1use 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#[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 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 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}
64pub(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 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 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}