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