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