Skip to main content

alibi_plugins/oauth_provider/
introspection.rs

1//! RFC 7662 introspection over actually issued and protected durable tokens.
2use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::store::OAuthTokenKind;
6use alibi_core::utils::json::JsValue;
7use alibi_core::{AuthContext, AuthResult, AuthSchema, AuthSession};
8use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
9use serde_json::{Value, json};
10use sha2::{Digest as _, Sha256};
11impl OAuthProviderPlugin {
12    /// Inspect an access or refresh token using the introspecting client's credentials.
13    pub fn oauth2_introspect_endpoint(
14        input: &impl serde::Serialize,
15    ) -> AuthResult<ServerEndpoint<Value>> {
16        ServerEndpoint::new("oauth-provider", "oauth2Introspect").with_body(input)
17    }
18    pub(super) async fn introspect<S: AuthSchema>(
19        &self,
20        input: Option<&JsValue>,
21        req: &impl SessionRequest,
22        ctx: &AuthContext<S>,
23    ) -> AuthResult<EndpointResponse> {
24        let body = input
25            .map(JsValue::to_json_value)
26            .transpose()?
27            .unwrap_or_else(|| json!({}));
28        for (field, required) in [
29            ("client_id", false),
30            ("client_secret", false),
31            ("client_assertion", false),
32            ("client_assertion_type", false),
33            ("token", true),
34            ("token_type_hint", false),
35        ] {
36            if let Some(value) = body.get(field) {
37                if !value.is_string() {
38                    return error(
39                        400,
40                        "invalid_request",
41                        &format!(
42                            "{field} {}",
43                            if value.is_array() {
44                                "must not appear more than once"
45                            } else {
46                                "must be a string"
47                            }
48                        ),
49                    );
50                }
51            } else if required {
52                return error(400, "invalid_request", &format!("{field} is required"));
53            }
54        }
55        let credentials = super::credentials::extract(&body, req);
56        if credentials.client_id.is_empty() || credentials.secret.is_empty() {
57            return error(401, "invalid_client", "missing required credentials");
58        }
59        let raw = body
60            .get("token")
61            .and_then(Value::as_str)
62            .unwrap_or_default();
63        let token = raw
64            .strip_prefix("Bearer ")
65            .or_else(|| raw.strip_prefix("DPoP "))
66            .unwrap_or(raw);
67        if token.is_empty() {
68            return error(
69                400,
70                "invalid_request",
71                "missing a required token for introspection",
72            );
73        }
74        if let Err(response) = credentials.authenticate(ctx).await? {
75            return Ok(no_store(response));
76        }
77        let hint = body
78            .get("token_type_hint")
79            .and_then(Value::as_str)
80            .unwrap_or_default();
81        let kinds: &[OAuthTokenKind] = match hint {
82            "access_token" => &[OAuthTokenKind::Access],
83            "refresh_token" => &[OAuthTokenKind::Refresh],
84            _ => &[OAuthTokenKind::Access, OAuthTokenKind::Refresh],
85        };
86        let hash = URL_SAFE_NO_PAD.encode(Sha256::digest(token));
87        for kind in kinds {
88            let refresh = matches!(kind, OAuthTokenKind::Refresh);
89            let row = if refresh {
90                ctx.database.get_oauth_token(*kind, &hash).await?
91            } else {
92                super::userinfo::live_access(token, ctx).await?
93            };
94            let Some(row) = row else {
95                continue;
96            };
97            if row.expires_at < chrono::Utc::now()
98                || row.metadata.get("revoked").is_some_and(|v| !v.is_null())
99            {
100                return inactive();
101            }
102            let Some(client) = ctx.database.get_oauth_client(&row.client_id).await? else {
103                return inactive();
104            };
105            if client.metadata.get("disabled") == Some(&Value::Bool(true)) {
106                return inactive();
107            }
108            if row.client_id != credentials.client_id {
109                return inactive();
110            }
111            let user = if let Some(id) = row.user_id.as_deref() {
112                ctx.database.get_user_by_id(id).await?
113            } else {
114                None
115            };
116            let user_view = user.as_ref().map(|user| ctx.user_view(user));
117            let mut session_id = row.session_id.clone();
118            if let Some(id) = session_id.as_deref() {
119                let sessions = if let Some(user) = row.user_id.as_deref() {
120                    ctx.database.get_user_sessions(user).await?
121                } else {
122                    Vec::new()
123                };
124                if !sessions.iter().any(|session| {
125                    session.id().as_ref() == id && session.expires_at() >= chrono::Utc::now()
126                }) {
127                    if !refresh {
128                        return inactive();
129                    }
130                    session_id = None;
131                }
132            }
133            let base = format!(
134                "{}{}",
135                ctx.config.base_url.trim_end_matches('/'),
136                ctx.config.base_path
137            );
138            let jwt = ctx.extensions.get::<crate::jwt::JwtDiscoveryOptions>();
139            let issuer = if self.config.disable_jwt_plugin {
140                &base
141            } else {
142                jwt.as_ref()
143                    .and_then(|options| options.issuer.as_ref())
144                    .unwrap_or(&base)
145            };
146            let scopes = row
147                .metadata
148                .get("scopes")
149                .and_then(Value::as_array)
150                .map(|scopes| {
151                    scopes
152                        .iter()
153                        .filter_map(Value::as_str)
154                        .collect::<Vec<_>>()
155                        .join(" ")
156                });
157            let mut payload = json!({"active":true,"iss":issuer,"client_id":row.client_id,"exp":row.expires_at.timestamp(),"iat":row.created_at.timestamp(),"scope":scopes,"token_type":"Bearer"});
158            let fields = payload
159                .as_object_mut()
160                .ok_or_else(|| alibi_core::AuthError::internal("Invalid introspection payload"))?;
161            if let Some(user) = user_view {
162                _ = fields.insert("sub".into(), json!(user.id));
163            }
164            if let Some(id) = session_id {
165                _ = fields.insert("sid".into(), json!(id));
166            }
167            if !refresh {
168                _ = fields.insert("azp".into(), json!(row.client_id));
169            }
170            return Ok(no_store(EndpointResponse::json(&payload)?));
171        }
172        inactive()
173    }
174}
175fn no_store(response: EndpointResponse) -> EndpointResponse {
176    response
177        .with_header("Cache-Control", "no-store")
178        .with_header("Pragma", "no-cache")
179}
180fn inactive() -> AuthResult<EndpointResponse> {
181    Ok(no_store(EndpointResponse::json(&json!({"active":false}))?))
182}
183fn error(status: u16, code: &str, description: &str) -> AuthResult<EndpointResponse> {
184    super::clients::status_protocol_error(status, code, description).map(no_store)
185}