1use 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#[async_trait::async_trait]
13pub trait OAuthTokenGenerator: Send + Sync + std::fmt::Debug {
14 async fn generate(&self, params: &OAuthTokenIssueParams) -> AuthResult<String>;
15}
16
17#[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 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 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}
72pub(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}
93pub(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}
112pub(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 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 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}