Skip to main content

smbcloud_auth_sdk/
oidc.rs

1use {
2    base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD},
3    reqwest::{Client, Url},
4    serde::{Deserialize, Serialize},
5    sha2::{Digest, Sha256},
6    smbcloud_model::error_codes::{ErrorCode, ErrorResponse},
7    smbcloud_network::{environment::Environment, network},
8    uuid::Uuid,
9};
10
11const AUTHORIZE_PATH: &str = "v1/client/oauth/authorize";
12const TOKEN_PATH: &str = "v1/client/oauth/token";
13const USERINFO_PATH: &str = "v1/client/oauth/userinfo";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub 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)]
24pub struct CallbackPayload {
25    pub code: String,
26    pub state: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub 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/// OIDC userinfo claims returned by `GET /v1/client/oauth/userinfo`.
40///
41/// Only `sub` is always present; every other claim is gated by the scopes the
42/// access token carries (`profile` → name/preferred_username/role/updated_at,
43/// `email` → email/email_verified), so each is optional. `role` is the AuthUser
44/// role as its integer enum value; `updated_at` is a Unix timestamp (seconds).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct UserInfo {
47    pub sub: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub name: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub preferred_username: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub role: Option<i32>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub updated_at: Option<i64>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub email: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub email_verified: Option<bool>,
60}
61
62pub fn build_authorization_request(
63    env: Environment,
64    oidc_client_id: &str,
65    redirect_uri: String,
66) -> Result<AuthorizationRequest, ErrorResponse> {
67    let code_verifier = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
68    let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
69    let state = Uuid::new_v4().to_string();
70
71    let mut url =
72        issuer_base_url(env)
73            .join(AUTHORIZE_PATH)
74            .map_err(|err| ErrorResponse::Error {
75                error_code: ErrorCode::ParseError,
76                message: err.to_string(),
77            })?;
78
79    url.query_pairs_mut()
80        .append_pair("client_id", oidc_client_id)
81        .append_pair("redirect_uri", &redirect_uri)
82        .append_pair("response_type", "code")
83        .append_pair("scope", "openid profile email")
84        .append_pair("state", &state)
85        .append_pair("code_challenge", &code_challenge)
86        .append_pair("code_challenge_method", "S256");
87
88    Ok(AuthorizationRequest {
89        authorize_url: url.to_string(),
90        redirect_uri,
91        state,
92        code_verifier,
93    })
94}
95
96pub fn parse_callback_url(callback_url: &str) -> Result<CallbackPayload, ErrorResponse> {
97    let url = Url::parse(callback_url).map_err(|err| ErrorResponse::Error {
98        error_code: ErrorCode::ParseError,
99        message: err.to_string(),
100    })?;
101
102    let mut code = None;
103    let mut state = None;
104
105    for (key, value) in url.query_pairs() {
106        match key.as_ref() {
107            "code" => code = Some(value.into_owned()),
108            "state" => state = Some(value.into_owned()),
109            _ => {}
110        }
111    }
112
113    match (code, state) {
114        (Some(code), Some(state)) => Ok(CallbackPayload { code, state }),
115        _ => Err(ErrorResponse::Error {
116            error_code: ErrorCode::InvalidParams,
117            message: "Missing authorization code or state.".to_string(),
118        }),
119    }
120}
121
122pub async fn exchange_code(
123    env: Environment,
124    oidc_client_id: &str,
125    redirect_uri: &str,
126    code: &str,
127    code_verifier: &str,
128) -> Result<TokenResponse, ErrorResponse> {
129    let url = issuer_base_url(env)
130        .join(TOKEN_PATH)
131        .map_err(|err| ErrorResponse::Error {
132            error_code: ErrorCode::ParseError,
133            message: err.to_string(),
134        })?;
135
136    let builder = Client::new()
137        .post(url)
138        .form(&[
139            ("grant_type", "authorization_code"),
140            ("client_id", oidc_client_id),
141            ("code", code),
142            ("redirect_uri", redirect_uri),
143            ("code_verifier", code_verifier),
144        ])
145        .header("Accept", "application/json");
146
147    network::request(builder).await
148}
149
150pub async fn get_userinfo(
151    env: Environment,
152    access_token: &str,
153    tenant_id: Option<&str>,
154) -> Result<UserInfo, ErrorResponse> {
155    let url = issuer_base_url(env)
156        .join(USERINFO_PATH)
157        .map_err(|err| ErrorResponse::Error {
158            error_code: ErrorCode::ParseError,
159            message: err.to_string(),
160        })?;
161
162    let mut builder = Client::new()
163        .get(url)
164        .bearer_auth(access_token)
165        .header("Accept", "application/json");
166
167    if let Some(tenant_id) = tenant_id {
168        builder = builder.header("X-Smbcloud-Tenant-Id", tenant_id);
169    }
170
171    network::request(builder).await
172}
173
174fn issuer_base_url(env: Environment) -> Url {
175    Url::parse(&format!("{}://{}/", env.api_protocol(), env.api_host()))
176        .expect("valid smbcloud api base url")
177}