@smbcloud / cli / commits / 260bef7

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(&params)
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 +}