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, is_endpoint_api_error};
4use alibi_core::session::SessionRequest;
5use alibi_core::store::OAuthTokenKind;
6use alibi_core::utils::json::JsValue;
7use alibi_core::{AuthContext, AuthError, 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 = crate::oauth2::strip_access_token_authorization_scheme(raw);
68        let token = token.as_str();
69        if token.is_empty() {
70            return error(
71                400,
72                "invalid_request",
73                "missing a required token for introspection",
74            );
75        }
76        let introspecting_client = match credentials.authenticate(ctx, self).await? {
77            Ok(client) => client,
78            Err(response) => return Ok(no_store(response)),
79        };
80        let hint = body
81            .get("token_type_hint")
82            .and_then(Value::as_str)
83            .unwrap_or_default();
84        let kinds: &[OAuthTokenKind] = match hint {
85            "access_token" => &[OAuthTokenKind::Access],
86            "refresh_token" => &[OAuthTokenKind::Refresh],
87            _ => &[OAuthTokenKind::Access, OAuthTokenKind::Refresh],
88        };
89        for kind in kinds {
90            let refresh = matches!(kind, OAuthTokenKind::Refresh);
91            if !refresh {
92                match self
93                    .validate_access_token(ctx, token, Some(&credentials.client_id))
94                    .await
95                {
96                    Ok(payload) => {
97                        let payload = self
98                            .resolve_introspection_subject(ctx, payload, &introspecting_client)
99                            .await?;
100                        return Ok(no_store(EndpointResponse::json(&payload)?));
101                    }
102                    Err(failure) if matches!(&failure.error, AuthError::Api { .. }) => {
103                        if hint != "access_token" {
104                            continue;
105                        }
106                        let inactive_error = failure.error.status_code() == 400
107                            || failure
108                                .body
109                                .as_deref()
110                                .and_then(|body| body.get("error"))
111                                .and_then(JsValue::as_str)
112                                == Some("invalid_token");
113                        if inactive_error {
114                            return inactive();
115                        }
116                        let mut response = EndpointResponse::error(failure.error);
117                        if failure.body_absent {
118                            response = response.without_error_body();
119                        } else if let Some(body) = failure.body {
120                            response = response.with_error_body(*body);
121                        }
122                        if let Some(headers) = failure.headers {
123                            response.merge_headers(headers);
124                        }
125                        return Ok(no_store(response));
126                    }
127                    Err(_) => {
128                        return Ok(no_store(
129                            EndpointResponse::error(AuthError::Api {
130                                status: 500,
131                                code: None,
132                                message: String::new(),
133                            })
134                            .without_error_body()
135                            .with_header("content-type", "application/json"),
136                        ));
137                    }
138                }
139            }
140            let decoded = match self.decode_refresh_token(token).await {
141                Ok(Some(decoded)) => decoded,
142                Ok(None) => continue,
143                Err(error) if is_endpoint_api_error(&error) => {
144                    if hint != "refresh_token" {
145                        continue;
146                    }
147                    if error.status_code() == 400 {
148                        return inactive();
149                    }
150                    return Ok(no_store(EndpointResponse::error(error)));
151                }
152                Err(_) => {
153                    return Ok(no_store(
154                        EndpointResponse::error(AuthError::Api {
155                            status: 500,
156                            code: None,
157                            message: String::new(),
158                        })
159                        .without_error_body()
160                        .with_header("content-type", "application/json"),
161                    ));
162                }
163            };
164            let row = ctx
165                .database
166                .get_oauth_token(
167                    *kind,
168                    &self.hash_token(&decoded.token, "refresh_token").await?,
169                )
170                .await?;
171            let Some(row) = row else {
172                continue;
173            };
174            if row.expires_at < chrono::Utc::now()
175                || row.metadata.get("revoked").is_some_and(|v| !v.is_null())
176            {
177                return inactive();
178            }
179            let client = if row.client_id == introspecting_client.client_id {
180                Some(introspecting_client.clone())
181            } else {
182                super::discovery::resolve(ctx, &row.client_id).await?
183            };
184            let Some(client) = client else {
185                return inactive();
186            };
187            if client.metadata.get("disabled") == Some(&Value::Bool(true)) {
188                return inactive();
189            }
190            if row.client_id != credentials.client_id {
191                return inactive();
192            }
193            let user = if let Some(id) = row.user_id.as_deref() {
194                ctx.database.get_user_by_id(id).await?
195            } else {
196                None
197            };
198            let user_view = user.as_ref().map(|user| ctx.user_view(user));
199            let mut session_id = row.session_id.clone();
200            if let Some(id) = session_id.as_deref() {
201                let sessions = if let Some(user) = row.user_id.as_deref() {
202                    ctx.database.get_user_sessions(user).await?
203                } else {
204                    Vec::new()
205                };
206                if !sessions.iter().any(|session| {
207                    session.id().as_ref() == id && session.expires_at() >= chrono::Utc::now()
208                }) {
209                    session_id = None;
210                }
211            }
212            let base = format!(
213                "{}{}",
214                ctx.config.base_url.trim_end_matches('/'),
215                ctx.config.base_path
216            );
217            let jwt = ctx.extensions.get::<crate::jwt::JwtDiscoveryOptions>();
218            let issuer = if self.config.disable_jwt_plugin {
219                &base
220            } else {
221                jwt.as_ref()
222                    .and_then(|options| options.issuer.as_ref())
223                    .unwrap_or(&base)
224            };
225            let scopes = row
226                .metadata
227                .get("scopes")
228                .and_then(Value::as_array)
229                .map(|scopes| {
230                    scopes
231                        .iter()
232                        .filter_map(Value::as_str)
233                        .collect::<Vec<_>>()
234                        .join(" ")
235                });
236            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});
237            let fields = payload
238                .as_object_mut()
239                .ok_or_else(|| alibi_core::AuthError::internal("Invalid introspection payload"))?;
240            fields.extend(super::access_validation::confirmation_claims(
241                row.metadata.get("confirmation"),
242            ));
243            if let Some(user) = user_view {
244                _ = fields.insert("sub".into(), json!(user.id));
245            }
246            if let Some(id) = session_id {
247                _ = fields.insert("sid".into(), json!(id));
248            }
249            let payload = self
250                .resolve_introspection_subject(ctx, payload, &introspecting_client)
251                .await?;
252            return Ok(no_store(EndpointResponse::json(&payload)?));
253        }
254        inactive()
255    }
256}
257fn no_store(response: EndpointResponse) -> EndpointResponse {
258    response
259        .with_header("Cache-Control", "no-store")
260        .with_header("Pragma", "no-cache")
261}
262fn inactive() -> AuthResult<EndpointResponse> {
263    Ok(no_store(EndpointResponse::json(&json!({"active":false}))?))
264}
265fn error(status: u16, code: &str, description: &str) -> AuthResult<EndpointResponse> {
266    super::clients::status_protocol_error(status, code, description).map(no_store)
267}