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