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 base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
9use serde_json::{Value, json};
10use sha2::{Digest as _, Sha256};
11impl OAuthProviderPlugin {
12 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}