arcly_http_identity/oidc/
mod.rs1mod client;
16mod code;
17
18pub use client::{ClientAuth, OidcClient, OidcClientRegistry};
19pub use code::{AuthCode, AuthCodeStore, MemoryAuthCodeStore};
20
21use std::sync::Arc;
22
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use subtle::ConstantTimeEq;
26
27use arcly_http_core::auth::JwtService;
28
29use crate::error::{IdentityError, Result};
30use crate::identity::{new_id, IdentityService};
31use crate::store::UserStore;
32
33#[derive(Debug, Clone)]
35pub struct OidcConfig {
36 pub issuer: String,
38 pub jwks: serde_json::Value,
43 pub id_token_ttl_secs: u64,
45 pub code_ttl_secs: u64,
47}
48
49pub struct OidcProvider {
51 config: OidcConfig,
52 clients: Arc<OidcClientRegistry>,
53 codes: Arc<dyn AuthCodeStore>,
54 jwt: Arc<JwtService>,
55 users: Arc<dyn UserStore>,
56 identity: IdentityService,
57}
58
59#[derive(Debug, Clone, Deserialize)]
61pub struct AuthorizeRequest {
62 pub client_id: String,
63 pub redirect_uri: String,
64 pub response_type: String,
65 pub scope: String,
66 #[serde(default)]
67 pub state: Option<String>,
68 #[serde(default)]
69 pub nonce: Option<String>,
70 #[serde(default)]
71 pub code_challenge: Option<String>,
72 #[serde(default)]
73 pub code_challenge_method: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct OidcTokenResponse {
79 pub access_token: String,
80 pub id_token: String,
81 pub refresh_token: String,
82 pub token_type: &'static str,
83 pub expires_in: u64,
84 pub scope: String,
85}
86
87impl OidcProvider {
88 pub fn new(
89 config: OidcConfig,
90 clients: Arc<OidcClientRegistry>,
91 codes: Arc<dyn AuthCodeStore>,
92 jwt: Arc<JwtService>,
93 users: Arc<dyn UserStore>,
94 identity: IdentityService,
95 ) -> Self {
96 Self {
97 config,
98 clients,
99 codes,
100 jwt,
101 users,
102 identity,
103 }
104 }
105
106 pub fn discovery_document(&self) -> serde_json::Value {
108 let iss = &self.config.issuer;
109 serde_json::json!({
110 "issuer": iss,
111 "authorization_endpoint": format!("{iss}/oauth2/authorize"),
112 "token_endpoint": format!("{iss}/oauth2/token"),
113 "userinfo_endpoint": format!("{iss}/oauth2/userinfo"),
114 "jwks_uri": format!("{iss}/.well-known/jwks.json"),
115 "introspection_endpoint": format!("{iss}/oauth2/introspect"),
116 "revocation_endpoint": format!("{iss}/oauth2/revoke"),
117 "response_types_supported": ["code"],
118 "grant_types_supported": ["authorization_code", "refresh_token"],
119 "subject_types_supported": ["public"],
120 "id_token_signing_alg_values_supported": [self.jwt.algorithm_name()],
121 "scopes_supported": ["openid", "profile", "email", "offline_access"],
122 "token_endpoint_auth_methods_supported": ["client_secret_post", "none"],
123 "code_challenge_methods_supported": ["S256"],
124 })
125 }
126
127 pub fn jwks(&self) -> serde_json::Value {
129 self.config.jwks.clone()
130 }
131
132 pub async fn authorize(&self, req: &AuthorizeRequest, user_id: &str) -> Result<String> {
137 if req.response_type != "code" {
138 return Err(IdentityError::Forbidden);
139 }
140 let client = self
141 .clients
142 .get(&req.client_id)
143 .ok_or(IdentityError::Forbidden)?;
144 if !client.allows_redirect(&req.redirect_uri) {
145 return Err(IdentityError::Forbidden);
146 }
147 if client.requires_pkce()
149 && (req.code_challenge.is_none()
150 || req.code_challenge_method.as_deref() != Some("S256"))
151 {
152 return Err(IdentityError::Forbidden);
153 }
154
155 let code = new_id();
156 self.codes
157 .put(AuthCode {
158 code: code.clone(),
159 client_id: req.client_id.clone(),
160 user_id: user_id.to_owned(),
161 redirect_uri: req.redirect_uri.clone(),
162 scope: req.scope.clone(),
163 nonce: req.nonce.clone(),
164 code_challenge: req.code_challenge.clone(),
165 expires_at: JwtService::unix_now() + self.config.code_ttl_secs,
166 })
167 .await?;
168
169 let mut url = format!("{}?code={}", req.redirect_uri, code);
170 if let Some(state) = &req.state {
171 url.push_str(&format!("&state={state}"));
172 }
173 Ok(url)
174 }
175
176 pub async fn token(
179 &self,
180 code: &str,
181 redirect_uri: &str,
182 client_auth: &ClientAuth,
183 code_verifier: Option<&str>,
184 ) -> Result<OidcTokenResponse> {
185 let client = self
186 .clients
187 .authenticate(client_auth)
188 .ok_or(IdentityError::Forbidden)?;
189
190 let record = self
191 .codes
192 .take(code)
193 .await?
194 .ok_or(IdentityError::InvalidToken)?;
195
196 if record.client_id != client.client_id || record.redirect_uri != redirect_uri {
198 return Err(IdentityError::InvalidToken);
199 }
200 if JwtService::unix_now() >= record.expires_at {
201 return Err(IdentityError::InvalidToken);
202 }
203 if let Some(challenge) = &record.code_challenge {
205 let verifier = code_verifier.ok_or(IdentityError::InvalidToken)?;
206 if !pkce_matches(verifier, challenge) {
207 return Err(IdentityError::InvalidToken);
208 }
209 }
210
211 let user = self
212 .users
213 .find_by_id(&record.user_id)
214 .await?
215 .ok_or(IdentityError::NotFound)?;
216
217 let pair = self.identity.mint(&user).await?;
219 let id_token = self.mint_id_token(&user, &client.client_id, record.nonce.as_deref())?;
220
221 Ok(OidcTokenResponse {
222 access_token: pair.access_token,
223 id_token,
224 refresh_token: pair.refresh_token,
225 token_type: "Bearer",
226 expires_in: pair.expires_in,
227 scope: record.scope,
228 })
229 }
230
231 pub async fn userinfo(&self, access_token: &str) -> Result<serde_json::Value> {
233 let claims = self
234 .jwt
235 .decode_access(access_token)
236 .ok_or(IdentityError::InvalidToken)?;
237 let sub = claims
238 .get("sub")
239 .and_then(|v| v.as_str())
240 .ok_or(IdentityError::InvalidToken)?;
241 let user = self
242 .users
243 .find_by_id(sub)
244 .await?
245 .ok_or(IdentityError::NotFound)?;
246 Ok(serde_json::json!({
247 "sub": user.id,
248 "email": user.email,
249 "email_verified": user.email_verified,
250 "roles": user.roles,
251 }))
252 }
253
254 pub fn introspect(&self, token: &str) -> serde_json::Value {
256 match self.jwt.decode(token) {
257 Some(claims) => {
258 let mut obj = serde_json::Map::new();
259 obj.insert("active".into(), true.into());
260 for (k, v) in claims.iter() {
261 obj.insert(k.clone(), v.clone());
262 }
263 serde_json::Value::Object(obj)
264 }
265 None => serde_json::json!({ "active": false }),
266 }
267 }
268
269 pub async fn revoke(&self, token: &str) -> Result<()> {
272 self.identity.logout(token).await
273 }
274
275 fn mint_id_token(
276 &self,
277 user: &crate::model::Identity,
278 audience: &str,
279 nonce: Option<&str>,
280 ) -> Result<String> {
281 let now = JwtService::unix_now();
282 let mut claims = serde_json::Map::new();
283 claims.insert("iss".into(), self.config.issuer.clone().into());
284 claims.insert("sub".into(), user.id.clone().into());
285 claims.insert("aud".into(), audience.to_owned().into());
286 claims.insert("iat".into(), now.into());
287 claims.insert("exp".into(), (now + self.config.id_token_ttl_secs).into());
288 if let Some(email) = &user.email {
289 claims.insert("email".into(), email.clone().into());
290 claims.insert("email_verified".into(), user.email_verified.into());
291 }
292 if let Some(nonce) = nonce {
293 claims.insert("nonce".into(), nonce.to_owned().into());
294 }
295 self.jwt
296 .sign(&serde_json::Value::Object(claims))
297 .map_err(|e| IdentityError::Internal(e.to_string()))
298 }
299}
300
301fn pkce_matches(verifier: &str, challenge: &str) -> bool {
303 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
304 use base64::Engine;
305 let mut h = Sha256::new();
306 h.update(verifier.as_bytes());
307 let computed = URL_SAFE_NO_PAD.encode(h.finalize());
308 computed.as_bytes().ct_eq(challenge.as_bytes()).into()
309}