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 serde_json::{Value, json};
7
8impl OAuthProviderPlugin {
9    /// Validate access authority and return its RFC 7662 payload.
10    /// Unknown tokens are errors; recognized but inactive authority returns `active: false`.
11    pub async fn validate_access_token<S: AuthSchema>(
12        &self,
13        ctx: &AuthContext<S>,
14        token: &str,
15        client_id: Option<&str>,
16    ) -> Result<Value, EndpointError> {
17        if !self.config.disable_jwt_plugin
18            && let Some(jwt) = ctx.extensions.get::<crate::jwt::JwtPlugin>()
19            && let Some(mut claims) = jwt
20                .verify_signed_payload_with_optional_kid(token, None, ctx)
21                .await?
22        {
23            let now = serde_json::Number::from(chrono::Utc::now().timestamp())
24                .as_f64()
25                .ok_or_else(|| AuthError::internal("Invalid token validation time"))?;
26            let issuer = issuer(ctx, self.config.disable_jwt_plugin);
27            if claims.get("iss").and_then(Value::as_str) != Some(issuer.as_str()) {
28                return Err(AuthError::internal("unexpected \"iss\" claim value").into());
29            }
30            for field in ["exp", "nbf", "iat"] {
31                if claims.get(field).is_some_and(|value| !value.is_number()) {
32                    return Ok(json!({"active":false}));
33                }
34            }
35            if claims
36                .get("nbf")
37                .and_then(Value::as_f64)
38                .is_some_and(|nbf| nbf > now)
39            {
40                return Err(AuthError::internal("unexpected \"nbf\" claim value").into());
41            }
42            if claims
43                .get("exp")
44                .and_then(Value::as_f64)
45                .is_some_and(|exp| exp <= now)
46            {
47                return Ok(json!({"active":false}));
48            }
49            let Some(resources) = audience_values(claims.get("aud")) else {
50                return Ok(json!({"active":false}));
51            };
52            if !audiences_allowed(ctx, &resources).await? {
53                return Ok(json!({"active":false}));
54            }
55            let Some(issuing_id) = claims
56                .get("azp")
57                .and_then(Value::as_str)
58                .filter(|id| !id.is_empty())
59                .map(str::to_owned)
60            else {
61                return Ok(json!({"active":false}));
62            };
63            let Some(client) = self.get_client(ctx, &issuing_id).await? else {
64                return Ok(json!({"active":false}));
65            };
66            if client.metadata.get("disabled") == Some(&Value::Bool(true))
67                || !authorized(ctx, client_id, &issuing_id, &resources).await?
68            {
69                return Ok(json!({"active":false}));
70            }
71            if let Some(sid) = claims.get("sid").and_then(Value::as_str)
72                && ctx
73                    .database
74                    .get_session_by_id(sid)
75                    .await?
76                    .is_none_or(|session| session.expires_at() < chrono::Utc::now())
77            {
78                return Ok(json!({"active":false}));
79            }
80            _ = claims.insert("client_id".into(), json!(issuing_id));
81            _ = claims.insert("active".into(), json!(true));
82            _ = claims.insert(
83                "token_type".into(),
84                json!(if claims
85                    .get("cnf")
86                    .and_then(|cnf| cnf.get("jkt"))
87                    .and_then(Value::as_str)
88                    .is_some_and(|jkt| !jkt.is_empty())
89                {
90                    "DPoP"
91                } else {
92                    "Bearer"
93                }),
94            );
95            return Ok(Value::Object(claims));
96        }
97        if !self.config.disable_jwt_plugin && compact_jwt(token) {
98            return Err(AuthError::internal("JWT verification failed").into());
99        }
100        let hash = self.hash_token(token, "access_token").await?;
101        let Some(row) = ctx
102            .database
103            .get_oauth_token(OAuthTokenKind::Access, &hash)
104            .await?
105        else {
106            return Err(invalid_access_token());
107        };
108        let Some(row) = super::userinfo::live_access_row(row, ctx, self).await? else {
109            return Ok(json!({"active":false}));
110        };
111        let resources = row
112            .metadata
113            .get("resources")
114            .and_then(Value::as_array)
115            .map(|values| {
116                values
117                    .iter()
118                    .filter_map(Value::as_str)
119                    .map(str::to_owned)
120                    .collect::<Vec<_>>()
121            })
122            .unwrap_or_default();
123        if !audiences_allowed(ctx, &resources).await?
124            || !authorized(ctx, client_id, &row.client_id, &resources).await?
125        {
126            return Ok(json!({"active":false}));
127        }
128        let scopes = row
129            .metadata
130            .get("scopes")
131            .and_then(Value::as_array)
132            .map(|values| {
133                values
134                    .iter()
135                    .filter_map(Value::as_str)
136                    .collect::<Vec<_>>()
137                    .join(" ")
138            });
139        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()});
140        let mut payload = payload
141            .as_object()
142            .cloned()
143            .ok_or_else(|| AuthError::internal("Invalid access-token payload"))?;
144        payload.extend(confirmation_claims(row.metadata.get("confirmation")));
145        if let Some(scope) = scopes {
146            _ = payload.insert("scope".into(), json!(scope));
147        }
148        if let Some(user_id) = row.user_id.as_deref()
149            && let Some(user) = ctx.database.get_user_by_id(user_id).await?
150        {
151            _ = payload.insert("sub".into(), json!(ctx.user_view(&user).id));
152        }
153        if let Some(sid) = row.session_id {
154            _ = payload.insert("sid".into(), json!(sid));
155        }
156        if !resources.is_empty() {
157            let mut audiences = resources;
158            let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
159            if scopes_contain(&row.metadata, "openid") && !audiences.contains(&userinfo) {
160                audiences.push(userinfo);
161            }
162            _ = payload.insert(
163                "aud".into(),
164                match audiences.as_slice() {
165                    [one] => json!(one),
166                    _ => json!(audiences),
167                },
168            );
169        }
170        Ok(Value::Object(payload))
171    }
172    /// Require recognized and active authority, preserving the reference Bearer challenge.
173    pub async fn require_active_access_token<S: AuthSchema>(
174        &self,
175        ctx: &AuthContext<S>,
176        token: &str,
177        client_id: Option<&str>,
178    ) -> Result<Value, EndpointError> {
179        let payload = self.validate_access_token(ctx, token, client_id).await?;
180        if payload.get("active") == Some(&Value::Bool(true)) {
181            Ok(payload)
182        } else {
183            Err(invalid_access_token())
184        }
185    }
186}
187// Opaque access and refresh projections share the same sender-constraint
188// policy. Preserve truthy untrusted confirmation shapes without treating
189// mTLS, arrays, or empty jkt values as DPoP.
190pub(super) fn confirmation_claims(confirmation: Option<&Value>) -> serde_json::Map<String, Value> {
191    let token_type = if confirmation
192        .and_then(crate::oauth2::get_confirmation_jkt)
193        .is_some()
194    {
195        "DPoP"
196    } else {
197        "Bearer"
198    };
199    let mut claims = serde_json::Map::from_iter([("token_type".into(), json!(token_type))]);
200    if let Some(confirmation) = confirmation
201        .filter(|value| alibi_core::utils::json::JsValue::from((**value).clone()).is_truthy())
202    {
203        _ = claims.insert("cnf".into(), confirmation.clone());
204    }
205    claims
206}
207fn base_url<S: AuthSchema>(ctx: &AuthContext<S>) -> String {
208    format!(
209        "{}{}",
210        ctx.config.base_url.trim_end_matches('/'),
211        ctx.config.base_path
212    )
213}
214fn issuer<S: AuthSchema>(ctx: &AuthContext<S>, disabled: bool) -> String {
215    if !disabled
216        && let Some(value) = ctx
217            .extensions
218            .get::<crate::jwt::JwtDiscoveryOptions>()
219            .and_then(|jwt| jwt.issuer.clone())
220    {
221        return value;
222    }
223    base_url(ctx)
224}
225fn audience_values(value: Option<&Value>) -> Option<Vec<String>> {
226    match value {
227        None => Some(Vec::new()),
228        Some(Value::String(value)) => Some(vec![value.clone()]),
229        Some(Value::Array(values)) => values
230            .iter()
231            .map(|value| value.as_str().map(str::to_owned))
232            .collect(),
233        _ => None,
234    }
235}
236async fn audiences_allowed<S: AuthSchema>(
237    ctx: &AuthContext<S>,
238    audiences: &[String],
239) -> AuthResult<bool> {
240    if audiences.len() > 64 {
241        return Ok(false);
242    }
243    let userinfo = format!("{}/oauth2/userinfo", base_url(ctx));
244    for audience in audiences {
245        if audience != &userinfo && ctx.database.get_oauth_resource(audience).await?.is_none() {
246            return Ok(false);
247        }
248    }
249    Ok(true)
250}
251async fn authorized<S: AuthSchema>(
252    ctx: &AuthContext<S>,
253    caller: Option<&str>,
254    issuer: &str,
255    resources: &[String],
256) -> AuthResult<bool> {
257    if caller.is_none_or(|caller| caller.is_empty() || caller == issuer) {
258        return Ok(true);
259    }
260    let links = ctx
261        .database
262        .list_oauth_client_resources(caller.unwrap_or_default())
263        .await?;
264    Ok(links
265        .iter()
266        .any(|link| resources.contains(&link.resource_id)))
267}
268fn scopes_contain(metadata: &Value, scope: &str) -> bool {
269    metadata
270        .get("scopes")
271        .and_then(Value::as_array)
272        .is_some_and(|values| values.iter().any(|value| value == scope))
273}
274fn invalid_access_token() -> EndpointError {
275    let mut headers = alibi_core::Headers::new();
276    _ = headers.insert(
277        "WWW-Authenticate",
278        "Bearer error=\"invalid_token\", error_description=\"Invalid access token\"",
279    );
280    EndpointError {
281        error: AuthError::Api {
282            status: 401,
283            code: None,
284            message: String::new(),
285        },
286        headers: Some(headers),
287        body: Some(Box::new(alibi_core::utils::json::JsValue::from(
288            json!({"error":"invalid_token","error_description":"Invalid access token"}),
289        ))),
290        body_absent: false,
291    }
292}
293
294fn compact_jwt(token: &str) -> bool {
295    use base64::Engine as _;
296    let parts = token.split('.').collect::<Vec<_>>();
297    let [header, payload, signature] = parts.as_slice() else {
298        return false;
299    };
300    let decode = |text: &str| {
301        base64::engine::general_purpose::URL_SAFE_NO_PAD
302            .decode(text)
303            .ok()
304            .and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
305    };
306    decode(header).is_some_and(|value| value.get("alg").is_some_and(Value::is_string))
307        && decode(payload).is_some_and(|value| value.is_object())
308        && !signature.is_empty()
309}