Add OIDC support and client credentials API to networking-account crate
- Add `oidc.rs` with OIDC authorization, token, and userinfo flows - Introduce `ClientCredentials` type and base URL builder for client-based API calls - Add `*_with_client` variants for login, logout, signup, me, and remove endpoints - Update dependencies: add `base64`, `serde`, and `url-builder` to workspace and crate
Seto committed
Mar 27, 2026 at 01:14 UTC
260bef72b84a089068cb1a7da4903e5239f96f8f
10 files changed
+313
Cargo.lock
+3
@@ -2169,13 +2169,16 @@ dependencies = [
2169
name = "smbcloud-networking-account"
2170
version = "0.3.30"
2171
dependencies = [
2172
+ "base64",
2173
"log",
2174
"openssl",
2175
"reqwest",
2176
+ "serde",
2177
"serde_json",
2178
"smbcloud-model",
2179
"smbcloud-network",
2180
"smbcloud-networking",
2181
+ "url-builder",
2182
"uuid",
2183
]
2184
crates/smbcloud-networking-account/Cargo.toml
+3
@@ -18,11 +18,14 @@ categories = [
18
readme = "README.md"
19
20
[dependencies]
21
+base64 = "0.22"
22
log = { workspace = true }
23
openssl = { version = "0.10", features = ["vendored"] }
24
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
25
+serde = { workspace = true, features = ["derive"] }
26
serde_json = { workspace = true }
27
smbcloud-model = { workspace = true }
28
smbcloud-network = { workspace = true }
29
smbcloud-networking = { workspace = true }
30
+url-builder = { workspace = true }
31
uuid = { version = "1", features = ["v4"] }
crates/smbcloud-networking-account/src/client_credentials.rs
new
+17
@@ -0,0 +1,17 @@
1
+use {smbcloud_network::environment::Environment, url_builder::URLBuilder};
2
+
3
+#[derive(Clone, Copy)]
4
+pub struct ClientCredentials<'a> {
5
+ pub app_id: &'a str,
6
+ pub app_secret: &'a str,
7
+}
8
+
9
+pub fn base_url_builder(env: Environment, client: ClientCredentials<'_>) -> URLBuilder {
10
+ let mut url_builder = URLBuilder::new();
11
+ url_builder
12
+ .set_protocol(&env.api_protocol())
13
+ .set_host(&env.api_host())
14
+ .add_param("client_id", client.app_id)
15
+ .add_param("client_secret", client.app_secret);
16
+ url_builder
17
+}
crates/smbcloud-networking-account/src/lib.rs
+2
@@ -1,8 +1,10 @@
1
pub mod check_email;
2
+pub mod client_credentials;
3
pub mod login;
4
pub mod logout;
5
pub mod me;
6
pub mod oauth;
7
+pub mod oidc;
8
pub mod remove;
9
pub mod resend_email_verification;
10
pub mod resend_reset_password_instruction;
crates/smbcloud-networking-account/src/login.rs
+26
@@ -1,4 +1,5 @@
1
use {
2
+ crate::client_credentials::{base_url_builder as tenant_base_url_builder, ClientCredentials},
3
reqwest::Client,
4
smbcloud_model::{
5
error_codes::ErrorResponse,
@@ -31,8 +32,33 @@ pub async fn login(
32
request_login(builder).await
33
}
34
35
+pub async fn login_with_client(
36
+ env: Environment,
37
+ client: ClientCredentials<'_>,
38
+ username: String,
39
+ password: String,
40
+) -> Result<AccountStatus, ErrorResponse> {
41
+ let login_params = LoginParams {
42
+ user: UserParam {
43
+ email: username,
44
+ password,
45
+ },
46
+ };
47
+ let builder = Client::new()
48
+ .post(build_login_url_with_client(env, client))
49
+ .json(&login_params)
50
+ .header("User-agent", client.app_id);
51
+ request_login(builder).await
52
+}
53
+
54
pub(crate) fn build_smb_login_url(env: Environment, client: (&SmbClient, &str)) -> String {
55
let mut url_builder = smb_base_url_builder(env, client);
56
url_builder.add_route(PATH_USERS_SIGN_IN);
57
url_builder.build()
58
}
59
+
60
+fn build_login_url_with_client(env: Environment, client: ClientCredentials<'_>) -> String {
61
+ let mut url_builder = tenant_base_url_builder(env, client);
62
+ url_builder.add_route("v1/client/users/sign_in");
63
+ url_builder.build()
64
+}
crates/smbcloud-networking-account/src/logout.rs
+38
@@ -1,4 +1,5 @@
1
use {
2
+ crate::client_credentials::{base_url_builder as tenant_base_url_builder, ClientCredentials},
3
reqwest::{Client, StatusCode},
4
smbcloud_model::error_codes::{ErrorCode, ErrorResponse},
5
smbcloud_network::environment::Environment,
@@ -38,8 +39,45 @@ pub async fn logout(
39
}
40
}
41
42
+pub async fn logout_with_client(
43
+ env: Environment,
44
+ client: ClientCredentials<'_>,
45
+ access_token: String,
46
+) -> Result<(), ErrorResponse> {
47
+ let response = match Client::new()
48
+ .delete(build_logout_url_with_client(env, client))
49
+ .header("Authorization", access_token)
50
+ .header("Accept", "application/json")
51
+ .header("Content-Type", "application/x-www-form-urlencoded")
52
+ .send()
53
+ .await
54
+ {
55
+ Ok(response) => response,
56
+ Err(_) => {
57
+ return Err(ErrorResponse::Error {
58
+ error_code: ErrorCode::Unknown,
59
+ message: ErrorCode::Unknown.message(None).to_string(),
60
+ });
61
+ }
62
+ };
63
+
64
+ match response.status() {
65
+ StatusCode::OK => Ok(()),
66
+ _ => Err(ErrorResponse::Error {
67
+ error_code: ErrorCode::Unauthorized,
68
+ message: ErrorCode::Unauthorized.message(None).to_string(),
69
+ }),
70
+ }
71
+}
72
+
73
fn build_smb_logout_url(env: Environment, client: (&SmbClient, &str)) -> String {
74
let mut url_builder = smb_base_url_builder(env, client);
75
url_builder.add_route(PATH_USERS_SIGN_OUT);
76
url_builder.build()
77
}
78
+
79
+fn build_logout_url_with_client(env: Environment, client: ClientCredentials<'_>) -> String {
80
+ let mut url_builder = tenant_base_url_builder(env, client);
81
+ url_builder.add_route("v1/client/users/sign_out");
82
+ url_builder.build()
83
+}
crates/smbcloud-networking-account/src/me.rs
+20
@@ -1,3 +1,4 @@
1
+use crate::client_credentials::{base_url_builder as tenant_base_url_builder, ClientCredentials};
2
use reqwest::Client;
3
use smbcloud_model::account::User;
4
use smbcloud_model::error_codes::ErrorResponse;
@@ -17,8 +18,27 @@ pub async fn me(
18
request(builder).await
19
}
20
21
+pub async fn me_with_client(
22
+ env: Environment,
23
+ client: ClientCredentials<'_>,
24
+ access_token: &str,
25
+) -> Result<User, ErrorResponse> {
26
+ let builder = Client::new()
27
+ .get(build_info_url_with_client(env, client))
28
+ .header("Authorization", access_token)
29
+ .header("Accept", "application/json")
30
+ .header("Content-Type", "application/x-www-form-urlencoded");
31
+ request(builder).await
32
+}
33
+
34
fn build_smb_info_url(env: Environment, client: (&SmbClient, &str)) -> String {
35
let mut url_builder = smb_base_url_builder(env, client);
36
url_builder.add_route(PATH_USERS_ME);
37
url_builder.build()
38
}
39
+
40
+fn build_info_url_with_client(env: Environment, client: ClientCredentials<'_>) -> String {
41
+ let mut url_builder = tenant_base_url_builder(env, client);
42
+ url_builder.add_route("v1/client/me");
43
+ url_builder.build()
44
+}
crates/smbcloud-networking-account/src/oidc.rs
new
+162
@@ -0,0 +1,162 @@
1
+use {
2
+ base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _},
3
+ openssl::sha::sha256,
4
+ reqwest::{Client, Url},
5
+ serde::{Deserialize, Serialize},
6
+ smbcloud_model::error_codes::{ErrorCode, ErrorResponse},
7
+ smbcloud_network::{environment::Environment, network},
8
+ uuid::Uuid,
9
+};
10
+
11
+const AUTHORIZE_PATH: &str = "oauth/authorize";
12
+const TOKEN_PATH: &str = "oauth/token";
13
+const USERINFO_PATH: &str = "oauth/userinfo";
14
+
15
+#[derive(Debug, Clone, Serialize, Deserialize)]
16
+pub struct AuthorizationRequest {
17
+ pub authorize_url: String,
18
+ pub redirect_uri: String,
19
+ pub state: String,
20
+ pub code_verifier: String,
21
+}
22
+
23
+#[derive(Debug, Clone, Serialize, Deserialize)]
24
+pub struct CallbackPayload {
25
+ pub code: String,
26
+ pub state: String,
27
+}
28
+
29
+#[derive(Debug, Clone, Serialize, Deserialize)]
30
+pub struct TokenResponse {
31
+ pub access_token: String,
32
+ pub token_type: String,
33
+ pub expires_in: Option<i32>,
34
+ pub refresh_token: Option<String>,
35
+ pub scope: Option<String>,
36
+ pub id_token: Option<String>,
37
+}
38
+
39
+#[derive(Debug, Clone, Serialize, Deserialize)]
40
+pub struct UserInfo {
41
+ pub sub: String,
42
+ pub email: Option<String>,
43
+ pub email_verified: Option<bool>,
44
+ pub tenant_id: Option<u64>,
45
+ pub tenant_slug: Option<String>,
46
+}
47
+
48
+pub fn build_authorization_request(
49
+ env: Environment,
50
+ oidc_client_id: &str,
51
+ redirect_uri: String,
52
+) -> Result<AuthorizationRequest, ErrorResponse> {
53
+ let code_verifier = format!(
54
+ "{}{}",
55
+ Uuid::new_v4().simple(),
56
+ Uuid::new_v4().simple()
57
+ );
58
+ let code_challenge = URL_SAFE_NO_PAD.encode(sha256(code_verifier.as_bytes()));
59
+ let state = Uuid::new_v4().to_string();
60
+
61
+ let mut url = issuer_base_url(env).join(AUTHORIZE_PATH).map_err(|err| ErrorResponse::Error {
62
+ error_code: ErrorCode::ParseError,
63
+ message: err.to_string(),
64
+ })?;
65
+
66
+ url.query_pairs_mut()
67
+ .append_pair("client_id", oidc_client_id)
68
+ .append_pair("redirect_uri", &redirect_uri)
69
+ .append_pair("response_type", "code")
70
+ .append_pair("scope", "openid profile email")
71
+ .append_pair("state", &state)
72
+ .append_pair("code_challenge", &code_challenge)
73
+ .append_pair("code_challenge_method", "S256");
74
+
75
+ Ok(AuthorizationRequest {
76
+ authorize_url: url.to_string(),
77
+ redirect_uri,
78
+ state,
79
+ code_verifier,
80
+ })
81
+}
82
+
83
+pub fn parse_callback_url(callback_url: &str) -> Result<CallbackPayload, ErrorResponse> {
84
+ let url = Url::parse(callback_url).map_err(|err| ErrorResponse::Error {
85
+ error_code: ErrorCode::ParseError,
86
+ message: err.to_string(),
87
+ })?;
88
+
89
+ let mut code = None;
90
+ let mut state = None;
91
+
92
+ for (key, value) in url.query_pairs() {
93
+ match key.as_ref() {
94
+ "code" => code = Some(value.into_owned()),
95
+ "state" => state = Some(value.into_owned()),
96
+ _ => {}
97
+ }
98
+ }
99
+
100
+ match (code, state) {
101
+ (Some(code), Some(state)) => Ok(CallbackPayload { code, state }),
102
+ _ => Err(ErrorResponse::Error {
103
+ error_code: ErrorCode::InvalidParams,
104
+ message: "Missing authorization code or state.".to_string(),
105
+ }),
106
+ }
107
+}
108
+
109
+pub async fn exchange_code(
110
+ env: Environment,
111
+ oidc_client_id: &str,
112
+ redirect_uri: &str,
113
+ code: &str,
114
+ code_verifier: &str,
115
+) -> Result<TokenResponse, ErrorResponse> {
116
+ let url = issuer_base_url(env).join(TOKEN_PATH).map_err(|err| ErrorResponse::Error {
117
+ error_code: ErrorCode::ParseError,
118
+ message: err.to_string(),
119
+ })?;
120
+
121
+ let builder = Client::new()
122
+ .post(url)
123
+ .form(&[
124
+ ("grant_type", "authorization_code"),
125
+ ("client_id", oidc_client_id),
126
+ ("code", code),
127
+ ("redirect_uri", redirect_uri),
128
+ ("code_verifier", code_verifier),
129
+ ])
130
+ .header("Accept", "application/json");
131
+
132
+ network::request(builder).await
133
+}
134
+
135
+pub async fn get_userinfo(
136
+ env: Environment,
137
+ access_token: &str,
138
+ tenant_id: Option<&str>,
139
+) -> Result<UserInfo, ErrorResponse> {
140
+ let url = issuer_base_url(env)
141
+ .join(USERINFO_PATH)
142
+ .map_err(|err| ErrorResponse::Error {
143
+ error_code: ErrorCode::ParseError,
144
+ message: err.to_string(),
145
+ })?;
146
+
147
+ let mut builder = Client::new()
148
+ .get(url)
149
+ .bearer_auth(access_token)
150
+ .header("Accept", "application/json");
151
+
152
+ if let Some(tenant_id) = tenant_id {
153
+ builder = builder.header("X-Smbcloud-Tenant-Id", tenant_id);
154
+ }
155
+
156
+ network::request(builder).await
157
+}
158
+
159
+fn issuer_base_url(env: Environment) -> Url {
160
+ Url::parse(&format!("{}://{}/", env.api_protocol(), env.api_host()))
161
+ .expect("valid smbcloud api base url")
162
+}
crates/smbcloud-networking-account/src/remove.rs
+19
@@ -1,4 +1,5 @@
1
use {
2
+ crate::client_credentials::{base_url_builder as tenant_base_url_builder, ClientCredentials},
3
reqwest::Client,
4
smbcloud_model::error_codes::ErrorResponse,
5
smbcloud_network::{environment::Environment, network::request},
@@ -17,8 +18,26 @@ pub async fn remove(
18
request(builder).await
19
}
20
21
+pub async fn remove_with_client(
22
+ env: Environment,
23
+ client: ClientCredentials<'_>,
24
+ access_token: &str,
25
+) -> Result<(), ErrorResponse> {
26
+ let builder = Client::new()
27
+ .delete(build_remove_url_with_client(env, client))
28
+ .header("Authorization", access_token)
29
+ .header("User-agent", client.app_id);
30
+ request(builder).await
31
+}
32
+
33
fn build_smb_signup_url(env: Environment, client: (&SmbClient, &str)) -> String {
34
let mut url_builder = smb_base_url_builder(env, client);
35
url_builder.add_route(PATH_USERS);
36
url_builder.build()
37
}
38
+
39
+fn build_remove_url_with_client(env: Environment, client: ClientCredentials<'_>) -> String {
40
+ let mut url_builder = tenant_base_url_builder(env, client);
41
+ url_builder.add_route("v1/client/me");
42
+ url_builder.build()
43
+}
crates/smbcloud-networking-account/src/signup.rs
+23
@@ -1,4 +1,5 @@
1
use {
2
+ crate::client_credentials::{base_url_builder as tenant_base_url_builder, ClientCredentials},
3
reqwest::Client,
4
smbcloud_model::{
5
error_codes::ErrorResponse,
@@ -24,8 +25,30 @@ pub async fn signup(
25
request(builder).await
26
}
27
28
+pub async fn signup_with_client(
29
+ env: Environment,
30
+ client: ClientCredentials<'_>,
31
+ email: String,
32
+ password: String,
33
+) -> Result<SignupResult, ErrorResponse> {
34
+ let params = SignupEmailParams {
35
+ user: SignupUserEmail { email, password },
36
+ };
37
+ let builder = Client::new()
38
+ .post(build_signup_url_with_client(env, client))
39
+ .json(¶ms)
40
+ .header("User-agent", client.app_id);
41
+ request(builder).await
42
+}
43
+
44
fn build_smb_signup_url(env: Environment, client: (&SmbClient, &str)) -> String {
45
let mut url_builder = smb_base_url_builder(env, client);
46
url_builder.add_route(PATH_USERS);
47
url_builder.build()
48
}
49
+
50
+fn build_signup_url_with_client(env: Environment, client: ClientCredentials<'_>) -> String {
51
+ let mut url_builder = tenant_base_url_builder(env, client);
52
+ url_builder.add_route("v1/client/users");
53
+ url_builder.build()
54
+}