Skip to main content

arcly_http_identity/oidc/
mod.rs

1//! OpenID Connect **Provider** — turn the app into an identity provider.
2//!
3//! This layer is transport-agnostic: it exposes pure async methods
4//! (`authorize`, `token`, `userinfo`, `discovery_document`, `jwks`) returning
5//! serde types. The app mounts them on whatever routes it likes (a reference
6//! controller lives in the example). Access/ID tokens are signed by the
7//! framework's [`JwtService`], which must be configured with an **asymmetric**
8//! algorithm (RS256/ES256) so the JWKS can publish a verify key — see
9//! Phase 0 in `docs/CIAM_PLAN.md`.
10//!
11//! Supported: Authorization Code + PKCE, refresh, token introspection
12//! (RFC 7662) and revocation (RFC 7009). Client-credentials is a small addition
13//! on top of [`OidcProvider::token`].
14
15mod 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/// Static provider configuration.
34#[derive(Debug, Clone)]
35pub struct OidcConfig {
36    /// The issuer URL (`iss`) — must exactly match the deployment's public base.
37    pub issuer: String,
38    /// The public JWKS document the provider serves at `/.well-known/jwks.json`.
39    /// Supplied by the app because deriving a JWK from a PEM requires an ASN.1
40    /// parser the framework deliberately does not link. Build it once from your
41    /// public key (e.g. with `josekit`/`jsonwebkey`) and hand it in here.
42    pub jwks: serde_json::Value,
43    /// Lifetime of issued `id_token`s in seconds.
44    pub id_token_ttl_secs: u64,
45    /// Authorization-code lifetime in seconds (short — 60s is typical).
46    pub code_ttl_secs: u64,
47}
48
49/// The OIDC provider. Provide via DI and mount its methods.
50pub 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/// Parsed `/authorize` request (already URL-decoded by the controller).
60#[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/// The `/token` exchange response (Authorization Code grant).
77#[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    /// The OIDC discovery document.
107    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    /// The JWKS document (verification keys).
128    pub fn jwks(&self) -> serde_json::Value {
129        self.config.jwks.clone()
130    }
131
132    /// Handle `/authorize` for an **already-authenticated** end user
133    /// (`user_id` established by the app's login session). Validates the client
134    /// and PKCE, mints a single-use authorization code, and returns the
135    /// redirect URL (with `code` + echoed `state`) to send the browser to.
136    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        // PKCE is mandatory for public clients.
148        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    /// Handle the `authorization_code` grant at `/token`: verify the code, PKCE
177    /// verifier, and client auth, then mint access + id + refresh tokens.
178    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        // Bind the code to the client + redirect it was issued for.
197        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        // PKCE verification.
204        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        // Access + refresh via the shared identity flow (keeps guards working).
218        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    /// The UserInfo response for a validated access token's subject.
232    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    /// RFC 7662 token introspection.
255    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    /// RFC 7009 revocation — revoke a refresh token's chain. Access tokens are
270    /// short-lived and stateless; introspection reflects their natural expiry.
271    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
301/// PKCE S256: `BASE64URL(SHA256(verifier)) == challenge`, constant-time.
302fn 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}