Skip to main content

alibi_plugins/oauth_provider/
access_validation.rs

1//! Trusted server access-token validation without protocol client authentication.
2use 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    /// Validate access authority and return its RFC 7662 payload.
24    /// Unknown tokens are errors; recognized but inactive authority returns `active: false`.
25    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        // A prefix may introduce JWT separators. With prefix presentation in
44        // use, non-JWS headers must still reach opaque prefix/row validation,
45        // as they do after the reference's caught JWS-format error.
46        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        // A JWT this server cannot verify (bad signature, unknown key, or a
62        // failed `iss`, `iat` or `nbf` check) may still be a stored opaque
63        // token, so it falls through to the opaque lookup below. Only an
64        // expired, otherwise valid JWT is recognized as inactive.
65        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        // Validation has no issuance grant, session context or per-request
200        // claims. Re-read both application and resource claims only after all
201        // authority/liveness gates; do not re-apply new-issuance admission.
202        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), &params, 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    /// Require recognized and active authority, preserving the reference Bearer challenge.
265    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}
292// Opaque access and refresh projections share the same sender-constraint
293// policy. Preserve truthy untrusted confirmation shapes without treating
294// mTLS, arrays, or empty jkt values as DPoP.
295pub(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}