Skip to main content

alibi_plugins/oauth_provider/
issuance.rs

1//! Shared trusted minting primitive; grant admission belongs to the caller.
2use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointCall, EndpointError, EndpointResponse, is_endpoint_api_error};
4use alibi_core::store::{CreateOAuthToken, OAuthClient, OAuthToken, OAuthTokenKind};
5use alibi_core::wire::UserView;
6use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthUser};
7use serde_json::{Value, json};
8
9/// Application-owned opaque access/refresh credentials. The admitted issuance
10/// params provide native context without additional user or client lookups.
11/// Empty output is preserved; only an absent configured generator uses randomness.
12#[async_trait::async_trait]
13pub trait OAuthTokenGenerator: Send + Sync + std::fmt::Debug {
14    async fn generate(&self, params: &OAuthTokenIssueParams) -> AuthResult<String>;
15}
16
17/// Inputs authorized by a trusted grant handler or companion endpoint.
18/// Supplying these does not repeat client authentication or scope admission.
19#[derive(Clone, Debug)]
20pub struct OAuthTokenIssueParams {
21    pub client: OAuthClient,
22    pub scopes: Vec<String>,
23    pub user: Option<UserView>,
24    pub reference_id: Option<String>,
25    pub session_id: Option<String>,
26    pub nonce: Option<String>,
27    pub auth_time: Option<chrono::DateTime<chrono::Utc>>,
28    pub resources: Option<Vec<String>>,
29    pub original_resources: Option<Vec<String>>,
30    /// Retained authority supplied by an already-authorized caller. Rotation
31    /// atomically claims its ID; endpoint scope/client admission is not repeated.
32    pub refresh_token: Option<OAuthToken>,
33    pub requested_user_info_claims: Option<Vec<String>>,
34    pub access_token_claims: Option<Value>,
35    pub id_token_claims: Option<Value>,
36    pub token_response: Option<Value>,
37    pub confirmation: Option<Value>,
38    /// Captured authorization data, including `query.dpop_jkt`.
39    pub verification_value: Option<Value>,
40    pub(crate) authorization_code_id: Option<String>,
41}
42impl OAuthTokenIssueParams {
43    pub fn new(client: OAuthClient, scopes: Vec<String>) -> Self {
44        Self {
45            client,
46            scopes,
47            user: None,
48            reference_id: None,
49            session_id: None,
50            nonce: None,
51            auth_time: None,
52            resources: None,
53            original_resources: None,
54            refresh_token: None,
55            requested_user_info_claims: None,
56            access_token_claims: None,
57            id_token_claims: None,
58            token_response: None,
59            confirmation: None,
60            verification_value: None,
61            authorization_code_id: None,
62        }
63    }
64}
65pub(super) fn random_token() -> String {
66    rand::RngExt::sample_iter(rand::rng(), rand::distr::Alphanumeric)
67        .map(char::from)
68        .filter(char::is_ascii_alphabetic)
69        .take(32)
70        .collect()
71}
72/// Optional protocol fields must not survive custom input when not issued.
73pub(super) fn response_fields(params: &OAuthTokenIssueParams) -> serde_json::Map<String, Value> {
74    let mut fields = params
75        .token_response
76        .as_ref()
77        .and_then(Value::as_object)
78        .cloned()
79        .unwrap_or_default();
80    for key in [
81        "access_token",
82        "expires_in",
83        "expires_at",
84        "token_type",
85        "refresh_token",
86        "scope",
87        "id_token",
88    ] {
89        _ = fields.remove(key);
90    }
91    fields
92}
93/// Ordinary issuance failures escape endpoint dispatch; only intentional API
94/// rejections publish an error envelope and OAuth cache headers.
95pub(super) fn error_response(error: EndpointError) -> AuthResult<EndpointResponse> {
96    if !is_endpoint_api_error(&error.error) {
97        return Err(error.error);
98    }
99    let mut response = EndpointResponse::error(error.error);
100    if error.body_absent {
101        response = response.without_error_body();
102    } else if let Some(body) = error.body {
103        response = response.with_error_body(*body);
104    }
105    if let Some(headers) = error.headers {
106        response.merge_headers(headers);
107    }
108    Ok(response
109        .with_header("Cache-Control", "no-store")
110        .with_header("Pragma", "no-cache"))
111}
112/// Preserve JavaScript number serialization, including integral default values.
113pub(super) fn number(value: f64) -> AuthResult<Value> {
114    serde_json::from_str(&value.to_string()).map_err(AuthError::from)
115}
116pub(super) fn expiration(issued: i64, lifetime: f64) -> AuthResult<Value> {
117    let issued = json!(issued)
118        .as_f64()
119        .ok_or_else(|| AuthError::internal("Invalid issuance time"))?;
120    number(issued + lifetime)
121}
122pub(super) fn expiration_date(value: &Value) -> AuthResult<chrono::DateTime<chrono::Utc>> {
123    let seconds = value
124        .as_f64()
125        .ok_or_else(|| AuthError::internal("Invalid token expiration"))?;
126    let millis = (seconds * 1000.0)
127        .trunc()
128        .to_string()
129        .parse::<i64>()
130        .map_err(|_| AuthError::internal("Invalid token expiration"))?;
131    chrono::DateTime::from_timestamp_millis(millis)
132        .ok_or_else(|| AuthError::internal("Invalid token expiration"))
133}
134pub(super) fn remaining(issued: i64, expires: &Value) -> AuthResult<Value> {
135    let issued = json!(issued)
136        .as_f64()
137        .ok_or_else(|| AuthError::internal("Invalid issuance time"))?;
138    let expires = expires
139        .as_f64()
140        .ok_or_else(|| AuthError::internal("Invalid token expiration"))?;
141    number(expires - issued)
142}
143pub(super) struct AccessTokenIssuance<'a> {
144    pub params: &'a OAuthTokenIssueParams,
145    pub issued: chrono::DateTime<chrono::Utc>,
146    pub expires: &'a Value,
147    pub policy: &'a super::resource_policy::ResourceAdmission,
148    pub refresh_id: Option<&'a str>,
149}
150fn token_metadata(p: &OAuthTokenIssueParams) -> serde_json::Map<String, Value> {
151    let mut metadata = serde_json::Map::from_iter([
152        ("scopes".into(), json!(p.scopes)),
153        ("resources".into(), json!(p.resources)),
154    ]);
155    for (key, value) in [
156        (
157            "referenceId",
158            p.reference_id.as_ref().map(|value| json!(value)),
159        ),
160        ("confirmation", p.confirmation.clone()),
161        (
162            "requestedUserInfoClaims",
163            p.requested_user_info_claims
164                .as_ref()
165                .filter(|v| !v.is_empty())
166                .map(|v| json!(v)),
167        ),
168    ] {
169        if let Some(value) = value {
170            _ = metadata.insert(key.into(), value);
171        }
172    }
173    metadata
174}
175impl OAuthProviderPlugin {
176    pub(super) fn access_expiration<T: AsRef<str>>(
177        &self,
178        issued: i64,
179        has_user: bool,
180        scopes: &[T],
181    ) -> AuthResult<Value> {
182        let lifetime = if has_user {
183            self.config.access_token_expires_in
184        } else {
185            self.config.m2m_access_token_expires_in
186        };
187        let mut result = expiration(issued, lifetime)?;
188        for scope in scopes {
189            let Some(configured) = self.config.scope_expirations.get(scope.as_ref()) else {
190                continue;
191            };
192            // The reference tests the configured value's JS truthiness before
193            // converting it: numeric zero/NaN are absent, a Date at epoch isn't.
194            if matches!(configured,crate::jwt::JwtExpiration::Numeric(value) if *value==0.0 || value.is_nan())
195            {
196                continue;
197            }
198            let candidate = configured.timestamp(Some(&json!(issued)));
199            let current = result
200                .as_f64()
201                .ok_or_else(|| AuthError::internal("Invalid scope expiration"))?;
202            let proposed = candidate
203                .as_f64()
204                .ok_or_else(|| AuthError::internal("Invalid scope expiration"))?;
205            if current >= proposed {
206                result = candidate;
207            }
208        }
209        Ok(result)
210    }
211    /// Mint authority for an already authorized caller. The grant is required,
212    /// but need not be registered: admission is the trusted caller's responsibility.
213    pub async fn issue_tokens<S: AuthSchema>(
214        &self,
215        ctx: &AuthContext<S>,
216        call: &EndpointCall,
217        grant_type: Option<&str>,
218        params: &OAuthTokenIssueParams,
219    ) -> Result<Value, EndpointError> {
220        if grant_type.is_none_or(str::is_empty) {
221            return Err(EndpointError {
222                error: AuthError::Api {
223                    status: 500,
224                    code: None,
225                    message: String::new(),
226                },
227                headers: None,
228                body: Some(Box::new(alibi_core::utils::json::JsValue::from(
229                    json!({"error":"server_error","error_description":"issueTokens requires a grant type; pass it to getOAuthProviderApi(ctx, opts, grantType)."}),
230                ))),
231                body_absent: false,
232            });
233        }
234        self.mint_tokens(ctx, call, params, grant_type.unwrap_or_default())
235            .await
236    }
237    pub(super) async fn create_access_token<S: AuthSchema>(
238        &self,
239        ctx: &AuthContext<S>,
240        req: &impl alibi_core::session::SessionRequest,
241        issuance: AccessTokenIssuance<'_>,
242    ) -> Result<String, EndpointError> {
243        let AccessTokenIssuance {
244            params: p,
245            issued,
246            expires: expires_claim,
247            policy,
248            refresh_id,
249        } = issuance;
250        let audiences = &policy.audiences;
251        let expires = expiration_date(expires_claim)?;
252        let user_id = p.user.as_ref().map(|user| user.id().as_ref().to_owned());
253        let mut metadata = token_metadata(p);
254        let access = if !self.config.disable_jwt_plugin && !audiences.is_empty() {
255            let base = format!(
256                "{}{}",
257                ctx.config.base_url.trim_end_matches('/'),
258                ctx.config.base_path
259            );
260            let audience = if audiences.len() == 1 {
261                json!(audiences.first())
262            } else {
263                json!(audiences)
264            };
265            let jwt = ctx
266                .extensions
267                .get::<crate::jwt::JwtPlugin>()
268                .ok_or_else(|| AuthError::config("jwt_config"))?;
269            let issuer = ctx
270                .extensions
271                .get::<crate::jwt::JwtDiscoveryOptions>()
272                .and_then(|o| o.issuer.clone())
273                .unwrap_or(base);
274            let claims = p
275                .access_token_claims
276                .as_ref()
277                .and_then(Value::as_object)
278                .cloned()
279                .unwrap_or_default();
280            let mut payload =
281                super::extension_claims::compose_access_claims(claims, policy.claims.clone());
282            for (key, value) in [
283                (
284                    "sub",
285                    json!(user_id.as_deref().unwrap_or(&p.client.client_id)),
286                ),
287                ("aud", audience),
288                ("client_id", json!(p.client.client_id)),
289                ("azp", json!(p.client.client_id)),
290                ("scope", json!(p.scopes.join(" "))),
291                ("iss", json!(issuer)),
292                ("iat", json!(issued.timestamp())),
293                ("exp", expires_claim.clone()),
294                ("jti", json!(random_token())),
295            ] {
296                _ = payload.insert(key.into(), value);
297            }
298            if let Some(sid) = &p.session_id {
299                _ = payload.insert("sid".into(), json!(sid));
300            }
301            if let Some(cnf) = &p.confirmation {
302                _ = payload.insert("cnf".into(), cnf.clone());
303            }
304            let options = crate::jwt::JwtSignOptions {
305                signing_algorithm: policy.signing_algorithm,
306                signing_key_id: policy.signing_key_id.clone(),
307                header: Some(serde_json::Map::from_iter([(
308                    "typ".into(),
309                    json!("at+jwt"),
310                )])),
311                ..Default::default()
312            };
313            jwt.sign_jwt(payload, &options, req.physical_request(), ctx)
314                .await?
315        } else {
316            let token = if let Some(generator) = &self.config.opaque_access_token_generator {
317                generator.generate(p).await?
318            } else {
319                random_token()
320            };
321            if let Some(id) = refresh_id {
322                _ = metadata.insert("refreshId".into(), json!(id));
323            }
324            _ = ctx
325                .database
326                .create_oauth_token(
327                    OAuthTokenKind::Access,
328                    CreateOAuthToken {
329                        token: self.hash_token(&token, "access_token").await?,
330                        client_id: p.client.client_id.clone(),
331                        user_id,
332                        session_id: p.session_id.clone(),
333                        authorization_code_id: p.authorization_code_id.clone(),
334                        metadata: Value::Object(metadata),
335                        created_at: issued,
336                        expires_at: expires,
337                    },
338                )
339                .await?;
340            self.encode_token_prefix(&token, OAuthTokenKind::Access)
341        };
342        Ok(access)
343    }
344    pub(super) async fn create_refresh_token<S: AuthSchema>(
345        &self,
346        ctx: &AuthContext<S>,
347        p: &OAuthTokenIssueParams,
348        policy: &super::resource_policy::ResourceAdmission,
349        issued: chrono::DateTime<chrono::Utc>,
350        requested: bool,
351    ) -> Result<Option<(String, String)>, EndpointError> {
352        let Some(user) = p.user.as_ref() else {
353            return Ok(None);
354        };
355        if !requested || !super::authentication::client_allows_grant(&p.client, "refresh_token") {
356            return Ok(None);
357        }
358        let token = if let Some(generator) = &self.config.refresh_token_generator {
359            generator.generate(p).await?
360        } else {
361            random_token()
362        };
363        let protected = self.hash_token(&token, "refresh_token").await?;
364        if let Some(original) = p.refresh_token.as_ref()
365            && !ctx
366                .database
367                .claim_oauth_refresh_rotation_by_id(
368                    &original.id,
369                    issued,
370                    super::refresh::replay_deadline(
371                        issued,
372                        self.config.refresh_token_reuse_interval,
373                    )?,
374                )
375                .await?
376        {
377            return Err(super::resource_policy::failure(
378                "invalid_grant",
379                "invalid refresh token",
380            ));
381        }
382        let mut metadata = token_metadata(p);
383        let resources = p
384            .refresh_token
385            .as_ref()
386            .and_then(|row| row.metadata.get("resources"))
387            .filter(|value| !value.is_null())
388            .cloned()
389            .unwrap_or_else(|| json!(p.original_resources.as_ref().or(p.resources.as_ref())));
390        _ = metadata.insert("resources".into(), resources);
391        _ = metadata.insert("authTime".into(), json!(p.auth_time));
392        let row = ctx
393            .database
394            .create_oauth_token(
395                OAuthTokenKind::Refresh,
396                CreateOAuthToken {
397                    token: protected,
398                    client_id: p.client.client_id.clone(),
399                    user_id: Some(user.id().as_ref().to_owned()),
400                    session_id: p.session_id.clone(),
401                    authorization_code_id: p.authorization_code_id.clone(),
402                    metadata: Value::Object(metadata),
403                    created_at: issued,
404                    expires_at: expiration_date(&policy.refresh_expiration(
405                        issued.timestamp(),
406                        self.config.refresh_token_expires_in,
407                    )?)?,
408                },
409            )
410            .await?;
411        Ok(Some((
412            row.id,
413            self.encode_refresh_token(&token, p.session_id.as_deref())?,
414        )))
415    }
416    pub(super) async fn mint_tokens<S: AuthSchema>(
417        &self,
418        ctx: &AuthContext<S>,
419        req: &impl alibi_core::session::SessionRequest,
420        p: &OAuthTokenIssueParams,
421        grant_type: &str,
422    ) -> Result<Value, EndpointError> {
423        let issued = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
424            .ok_or_else(|| AuthError::internal("Invalid token issue time"))?;
425        let expires_claim =
426            self.access_expiration(issued.timestamp(), p.user.is_some(), &p.scopes)?;
427        let resource_value = json!(p.resources);
428        let admission = self
429            .admit_resources(ctx, &p.client.client_id, &p.scopes, Some(&resource_value))
430            .await?;
431        let expires_claim = admission.access_expiration(issued.timestamp(), &expires_claim)?;
432        let refresh_requested = p.scopes.iter().any(|scope| scope == "offline_access")
433            || p.refresh_token
434                .as_ref()
435                .and_then(|row| row.metadata.get("scopes"))
436                .and_then(Value::as_array)
437                .is_some_and(|scopes| scopes.iter().any(|scope| scope == "offline_access"));
438        let mut resolved = p.clone();
439        resolved.scopes = admission.scopes.clone();
440        self.extend_id_token_claims(ctx, req, &mut resolved, grant_type)
441            .await?;
442        self.extend_token_response_fields(ctx, req, &mut resolved, grant_type)
443            .await?;
444        resolved.confirmation = match p.confirmation.as_ref().filter(|value| !value.is_null()) {
445            Some(confirmation) => Some(confirmation.clone()),
446            None => self.resolve_dpop_binding(ctx, req, p, &admission).await?,
447        };
448        if resolved.requested_user_info_claims.is_none() {
449            resolved.requested_user_info_claims = p
450                .refresh_token
451                .as_ref()
452                .and_then(|row| row.metadata.get("requestedUserInfoClaims"))
453                .and_then(Value::as_array)
454                .map(|values| {
455                    values
456                        .iter()
457                        .filter_map(Value::as_str)
458                        .map(str::to_owned)
459                        .collect()
460                });
461        }
462        if !self.config.disable_jwt_plugin && !admission.audiences.is_empty() {
463            self.extend_access_token_claims(ctx, req, &mut resolved, grant_type)
464                .await?;
465        }
466        let p = &resolved;
467        let refresh = self
468            .create_refresh_token(ctx, p, &admission, issued, refresh_requested)
469            .await?;
470        let rotated = refresh.is_some();
471        let access = self
472            .create_access_token(
473                ctx,
474                req,
475                AccessTokenIssuance {
476                    params: p,
477                    issued,
478                    expires: &expires_claim,
479                    policy: &admission,
480                    refresh_id: refresh.as_ref().map(|(id, _)| id.as_str()),
481                },
482            )
483            .await?;
484        let mut response = response_fields(p);
485        for (key, value) in [
486            ("access_token", json!(access)),
487            ("expires_in", remaining(issued.timestamp(), &expires_claim)?),
488            ("expires_at", expires_claim),
489            (
490                "token_type",
491                json!(if p
492                    .confirmation
493                    .as_ref()
494                    .and_then(crate::oauth2::get_confirmation_jkt)
495                    .is_some()
496                {
497                    "DPoP"
498                } else {
499                    "Bearer"
500                }),
501            ),
502            ("scope", json!(p.scopes.join(" "))),
503        ] {
504            _ = response.insert(key.into(), value);
505        }
506        if let Some((_, token)) = refresh {
507            _ = response.insert("refresh_token".into(), json!(token));
508        }
509        if p.user.is_some() && p.scopes.iter().any(|s| s == "openid") {
510            let proof = json!({"userId":p.user.as_ref().map(|u|u.id().as_ref().to_owned()),"sessionId":p.session_id,"authTime":p.auth_time.map(|t|t.timestamp_millis()),"query":{"nonce":p.nonce},"idTokenClaims":p.id_token_claims});
511            match Box::pin(self.issue_id_token(p, grant_type, &proof, &access, req, ctx)).await? {
512                Ok(Some(token)) => {
513                    _ = response.insert("id_token".into(), json!(token));
514                }
515                Ok(None) => {}
516                Err(response) => return Err(super::authentication::response_error(&response)?),
517            }
518        }
519        let response = Value::Object(response);
520        if rotated && let Some(original) = p.refresh_token.as_ref() {
521            let scopes = p.scopes.iter().map(String::as_str).collect::<Vec<_>>();
522            let request = super::refresh::replay_request(
523                &scopes,
524                &json!({"resource":p.resources}),
525                None,
526                p.confirmation.as_ref(),
527            );
528            self.store_rotation_replay(ctx, original, request, &response)
529                .await;
530        }
531        Ok(response)
532    }
533}