alibi_plugins/oauth_provider/
introspection.rs1use 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 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}