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        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        // Validation has no issuance grant, session context or per-request
201        // claims. Re-read both application and resource claims only after all
202        // authority/liveness gates; do not re-apply new-issuance admission.
203        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), &params, 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    /// Require recognized and active authority, preserving the reference Bearer challenge.
266    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}
293// Opaque access and refresh projections share the same sender-constraint
294// policy. Preserve truthy untrusted confirmation shapes without treating
295// mTLS, arrays, or empty jkt values as DPoP.
296pub(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}