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#[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}