alibi_plugins/oauth_provider/
access_validation.rs1use super::OAuthProviderPlugin;
3use alibi_core::endpoint::EndpointError;
4use alibi_core::store::OAuthTokenKind;
5use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthSession};
6use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
7use serde_json::{Value, json};
8
9#[derive(Debug)]
10pub(super) struct AccessTokenValidation {
11 pub payload: Value,
12 pub requested_user_info_claims: Vec<String>,
13}
14impl From<Value> for AccessTokenValidation {
15 fn from(payload: Value) -> Self {
16 Self {
17 payload,
18 requested_user_info_claims: Vec::new(),
19 }
20 }
21}
22impl OAuthProviderPlugin {
23 pub async fn validate_access_token<S: AuthSchema>(
26 &self,
27 ctx: &AuthContext<S>,
28 token: &str,
29 client_id: Option<&str>,
30 ) -> Result<Value, EndpointError> {
31 Ok(self
32 .resolve_access_token_validation(ctx, token, client_id)
33 .await?
34 .payload)
35 }
36 async fn resolve_access_token_validation<S: AuthSchema>(
37 &self,
38 ctx: &AuthContext<S>,
39 token: &str,
40 client_id: Option<&str>,
41 ) -> Result<AccessTokenValidation, EndpointError> {
42 let decoded = self.decode_token_prefix(token, OAuthTokenKind::Access);
43 let prefixed_opaque = self
47 .config
48 .opaque_access_token_prefix
49 .as_deref()
50 .is_some_and(|prefix| !prefix.is_empty())
51 && token
52 .split('.')
53 .next()
54 .and_then(|header| URL_SAFE_NO_PAD.decode(header).ok())
55 .and_then(|header| serde_json::from_slice::<Value>(&header).ok())
56 .is_none_or(|header| !header.is_object());
57 let now = serde_json::Number::from(chrono::Utc::now().timestamp())
58 .as_f64()
59 .ok_or_else(|| AuthError::internal("Invalid token validation time"))?;
60 let expected_issuer = issuer(ctx, self.config.disable_jwt_plugin);
61 let verified = if !self.config.disable_jwt_plugin
66 && !prefixed_opaque
67 && let Some(jwt) = ctx.extensions.get::<crate::jwt::JwtPlugin>()
68 {
69 Box::pin(jwt.verify_signed_payload_with_optional_kid(token, None, ctx))
70 .await?
71 .filter(|claims| {
72 claims.get("iss").and_then(Value::as_str) == Some(expected_issuer.as_str())
73 && ["iat", "nbf", "exp"]
74 .iter()
75 .all(|field| claims.get(*field).is_none_or(Value::is_number))
76 && claims
77 .get("nbf")
78 .and_then(Value::as_f64)
79 .is_none_or(|nbf| nbf <= now)
80 })
81 } else {
82 None
83 };
84 if let Some(mut claims) = verified {
85 if claims
86 .get("exp")
87 .and_then(Value::as_f64)
88 .is_some_and(|exp| exp <= now)
89 {
90 return Ok(json!({"active":false}).into());
91 }
92 let Some(resources) = audience_values(claims.get("aud")) else {
93 return Ok(json!({"active":false}).into());
94 };
95 if !audiences_allowed(self, ctx, &resources).await? {
96 return Ok(json!({"active":false}).into());
97 }
98 let Some(issuing_id) = claims
99 .get("azp")
100 .and_then(Value::as_str)
101 .filter(|id| !id.is_empty())
102 .map(str::to_owned)
103 else {
104 return Ok(json!({"active":false}).into());
105 };
106 let Some(client) = self.get_client(ctx, &issuing_id).await? else {
107 return Ok(json!({"active":false}).into());
108 };
109 if client.metadata.get("disabled") == Some(&Value::Bool(true))
110 || !authorized(ctx, client_id, &issuing_id, &resources).await?
111 {
112 return Ok(json!({"active":false}).into());
113 }
114 if let Some(sid) = claims.get("sid").and_then(Value::as_str)
115 && ctx
116 .database
117 .get_session_by_id(sid)
118 .await?
119 .is_none_or(|session| session.expires_at() < chrono::Utc::now())
120 {
121 return Ok(json!({"active":false}).into());
122 }
123 _ = claims.insert("client_id".into(), json!(issuing_id));
124 _ = claims.insert("active".into(), json!(true));
125 _ = claims.insert(
126 "token_type".into(),
127 json!(if claims
128 .get("cnf")
129 .and_then(|cnf| cnf.get("jkt"))
130 .and_then(Value::as_str)
131 .is_some_and(|jkt| !jkt.is_empty())
132 {
133 "DPoP"
134 } else {
135 "Bearer"
136 }),
137 );
138 return Ok(Value::Object(claims).into());
139 }
140 let Some(token) = decoded else {
141 return Err(invalid_access_token());
142 };
143 let hash = self.hash_token(token, "access_token").await?;
144 let Some(row) = ctx
145 .database
146 .get_oauth_token(OAuthTokenKind::Access, &hash)
147 .await?
148 else {
149 return Err(invalid_access_token());
150 };
151 let Some((row, client)) = super::userinfo::live_access_row(row, ctx, self).await? else {
152 return Ok(json!({"active":false}).into());
153 };
154 let resources = row
155 .metadata
156 .get("resources")
157 .and_then(Value::as_array)
158 .map(|values| {
159 values
160 .iter()
161 .filter_map(Value::as_str)
162 .map(str::to_owned)
163 .collect::<Vec<_>>()
164 })
165 .unwrap_or_default();
166 if !audiences_allowed(self, ctx, &resources).await?
167 || !authorized(ctx, client_id, &row.client_id, &resources).await?
168 {
169 return Ok(json!({"active":false}).into());
170 }
171 let scopes = row
172 .metadata
173 .get("scopes")
174 .and_then(Value::as_array)
175 .map(|values| {
176 values
177 .iter()
178 .filter_map(Value::as_str)
179 .map(str::to_owned)
180 .collect::<Vec<_>>()
181 });
182 let mut params =
183 super::OAuthTokenIssueParams::new(client, scopes.clone().unwrap_or_default());
184 if let Some(user_id) = row.user_id.as_deref()
185 && let Some(user) = ctx.database.get_user_by_id(user_id).await?
186 {
187 params.user = Some(ctx.trusted_user_view(&user));
188 }
189 params.reference_id = row
190 .metadata
191 .get("referenceId")
192 .and_then(Value::as_str)
193 .map(str::to_owned);
194 params.resources = row
195 .metadata
196 .get("resources")
197 .and_then(Value::as_array)
198 .map(|_| resources.clone());
199 let mut resource_claims = serde_json::Map::new();
203 for resource in &resources {
204 if let Some(resource) = ctx.database.get_oauth_resource(resource).await?
205 && let Some(claims) = resource
206 .metadata
207 .get("customClaims")
208 .and_then(Value::as_object)
209 {
210 resource_claims.extend(claims.clone());
211 }
212 }
213 let claims = self
214 .resolve_access_token_claims(alibi_core::CallbackContext::new(ctx, None), ¶ms, None)
215 .await?;
216 let payload = json!({"active":true,"iss":issuer(ctx,self.config.disable_jwt_plugin),"client_id":row.client_id,"azp":row.client_id,"exp":row.expires_at.timestamp(),"iat":row.created_at.timestamp()});
217 let core = payload
218 .as_object()
219 .cloned()
220 .ok_or_else(|| AuthError::internal("Invalid access-token payload"))?;
221 let mut payload = super::extension_claims::compose_access_claims(claims, resource_claims);
222 payload.extend(core);
223 payload.extend(confirmation_claims(row.metadata.get("confirmation")));
224 if let Some(scope) = scopes {
225 _ = payload.insert("scope".into(), json!(scope.join(" ")));
226 }
227 if let Some(user) = params.user.as_ref() {
228 _ = payload.insert("sub".into(), json!(user.id));
229 }
230 if let Some(sid) = row.session_id {
231 _ = payload.insert("sid".into(), json!(sid));
232 }
233 if !resources.is_empty() {
234 let mut audiences = resources;
235 let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
236 if scopes_contain(&row.metadata, "openid") && !audiences.contains(&userinfo) {
237 audiences.push(userinfo);
238 }
239 _ = payload.insert(
240 "aud".into(),
241 match audiences.as_slice() {
242 [one] => json!(one),
243 _ => json!(audiences),
244 },
245 );
246 }
247 let requested_user_info_claims = row
248 .metadata
249 .get("requestedUserInfoClaims")
250 .and_then(Value::as_array)
251 .map(|values| {
252 values
253 .iter()
254 .filter_map(Value::as_str)
255 .map(str::to_owned)
256 .collect()
257 })
258 .unwrap_or_default();
259 Ok(AccessTokenValidation {
260 payload: Value::Object(payload),
261 requested_user_info_claims,
262 })
263 }
264 pub async fn require_active_access_token<S: AuthSchema>(
266 &self,
267 ctx: &AuthContext<S>,
268 token: &str,
269 client_id: Option<&str>,
270 ) -> Result<Value, EndpointError> {
271 Ok(self
272 .require_active_access_token_with_claims(ctx, token, client_id)
273 .await?
274 .payload)
275 }
276 pub(super) async fn require_active_access_token_with_claims<S: AuthSchema>(
277 &self,
278 ctx: &AuthContext<S>,
279 token: &str,
280 client_id: Option<&str>,
281 ) -> Result<AccessTokenValidation, EndpointError> {
282 let validation = self
283 .resolve_access_token_validation(ctx, token, client_id)
284 .await?;
285 if validation.payload.get("active") == Some(&Value::Bool(true)) {
286 Ok(validation)
287 } else {
288 Err(invalid_access_token())
289 }
290 }
291}
292pub(super) fn confirmation_claims(confirmation: Option<&Value>) -> serde_json::Map<String, Value> {
296 let token_type = if confirmation
297 .and_then(crate::oauth2::get_confirmation_jkt)
298 .is_some()
299 {
300 "DPoP"
301 } else {
302 "Bearer"
303 };
304 let mut claims = serde_json::Map::from_iter([("token_type".into(), json!(token_type))]);
305 if let Some(confirmation) = confirmation
306 .filter(|value| alibi_core::utils::json::JsValue::from((**value).clone()).is_truthy())
307 {
308 _ = claims.insert("cnf".into(), confirmation.clone());
309 }
310 claims
311}
312fn base_url<S: AuthSchema>(ctx: &AuthContext<S>) -> String {
313 format!(
314 "{}{}",
315 ctx.config.base_url.trim_end_matches('/'),
316 ctx.config.base_path
317 )
318}
319fn issuer<S: AuthSchema>(ctx: &AuthContext<S>, disabled: bool) -> String {
320 if !disabled
321 && let Some(value) = ctx
322 .extensions
323 .get::<crate::jwt::JwtDiscoveryOptions>()
324 .and_then(|jwt| jwt.issuer.clone())
325 {
326 return value;
327 }
328 base_url(ctx)
329}
330fn audience_values(value: Option<&Value>) -> Option<Vec<String>> {
331 match value {
332 None => Some(Vec::new()),
333 Some(Value::String(value)) => Some(vec![value.clone()]),
334 Some(Value::Array(values)) => values
335 .iter()
336 .map(|value| value.as_str().map(str::to_owned))
337 .collect(),
338 _ => None,
339 }
340}
341async fn audiences_allowed<S: AuthSchema>(
342 provider: &OAuthProviderPlugin,
343 ctx: &AuthContext<S>,
344 audiences: &[String],
345) -> AuthResult<bool> {
346 if audiences.len() > 64 {
347 return Ok(false);
348 }
349 let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
350 for audience in audiences {
351 if audience != &userinfo && provider.get_resource(ctx, audience).await?.is_none() {
352 return Ok(false);
353 }
354 }
355 Ok(true)
356}
357async fn authorized<S: AuthSchema>(
358 ctx: &AuthContext<S>,
359 caller: Option<&str>,
360 issuer: &str,
361 resources: &[String],
362) -> AuthResult<bool> {
363 if caller.is_none_or(|caller| caller.is_empty() || caller == issuer) {
364 return Ok(true);
365 }
366 let links = ctx
367 .database
368 .list_oauth_client_resources(caller.unwrap_or_default())
369 .await?;
370 Ok(links
371 .iter()
372 .any(|link| resources.contains(&link.resource_id)))
373}
374fn scopes_contain(metadata: &Value, scope: &str) -> bool {
375 metadata
376 .get("scopes")
377 .and_then(Value::as_array)
378 .is_some_and(|values| values.iter().any(|value| value == scope))
379}
380fn invalid_access_token() -> EndpointError {
381 let mut headers = alibi_core::Headers::new();
382 _ = headers.insert(
383 "WWW-Authenticate",
384 "Bearer error=\"invalid_token\", error_description=\"Invalid access token\"",
385 );
386 EndpointError {
387 error: AuthError::Api {
388 status: 401,
389 code: None,
390 message: String::new(),
391 },
392 headers: Some(headers),
393 body: Some(Box::new(alibi_core::utils::json::JsValue::from(
394 json!({"error":"invalid_token","error_description":"Invalid access token"}),
395 ))),
396 body_absent: false,
397 }
398}