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