Skip to main content

alibi_plugins/oauth_provider/
userinfo.rs

1//! Scoped user claims resolved from the real issued access-token authority.
2use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::store::{OAuthClient, OAuthToken};
6use alibi_core::utils::json::JsValue;
7use alibi_core::{AuthContext, AuthError, AuthResult, AuthSchema, AuthSession, HttpMethod};
8use serde_json::{Value, json};
9/// The shared access-token liveness gate used by public protocol consumers.
10pub(super) async fn live_access_row<S: AuthSchema>(
11    row: OAuthToken,
12    ctx: &AuthContext<S>,
13    provider: &OAuthProviderPlugin,
14) -> AuthResult<Option<(OAuthToken, OAuthClient)>> {
15    if row.expires_at < chrono::Utc::now()
16        || row.metadata.get("revoked").is_some_and(|v| !v.is_null())
17    {
18        return Ok(None);
19    }
20    let Some(client) = provider.get_client(ctx, &row.client_id).await? else {
21        return Ok(None);
22    };
23    if client.metadata.get("disabled") == Some(&Value::Bool(true)) {
24        return Ok(None);
25    }
26    if let Some(session) = row.session_id.as_deref() {
27        let sessions = if ctx.config.session.secondary_storage.is_some()
28            && ctx.config.session.store_in_database
29            && ctx.config.session.preserve_in_database
30            && !ctx.config.session.stateless
31        {
32            // Preserved SQL rows, not browser-cache membership, decide bound
33            // OAuth authority. A single-delete veto may already have evicted
34            // the cache while leaving this durable session live.
35            ctx.database
36                .get_session_by_id(session)
37                .await?
38                .into_iter()
39                .collect()
40        } else if let Some(user) = row.user_id.as_deref() {
41            ctx.database.get_user_sessions(user).await?
42        } else {
43            Vec::new()
44        };
45        if !sessions
46            .iter()
47            .any(|value| value.id().as_ref() == session && value.expires_at() >= chrono::Utc::now())
48        {
49            return Ok(None);
50        }
51    }
52    Ok(Some((row, client)))
53}
54impl OAuthProviderPlugin {
55    /// Read the current user claims authorized by an OAuth access token.
56    #[must_use]
57    pub fn oauth2_user_info_endpoint() -> ServerEndpoint<Value> {
58        ServerEndpoint::new("oauth-provider", "oauth2UserInfo")
59    }
60    pub(super) async fn user_info<S: AuthSchema>(
61        &self,
62        input: Option<&JsValue>,
63        req: &impl SessionRequest,
64        ctx: &AuthContext<S>,
65    ) -> AuthResult<EndpointResponse> {
66        let headers = req.session_headers();
67        let header = headers
68            .iter()
69            .find(|(name, _)| name.eq_ignore_ascii_case("authorization"))
70            .map(|(_, value)| value.as_str());
71        let authorization = crate::oauth2::parse_access_token_authorization(header);
72        let body_token = if req
73            .physical_request()
74            .is_some_and(|request| *request.method() == HttpMethod::Post)
75        {
76            input
77                .and_then(|value| value.get("access_token"))
78                .and_then(JsValue::as_str)
79                .filter(|value| !value.is_empty())
80        } else {
81            None
82        };
83        if authorization.is_some() && body_token.is_some() {
84            return error(
85                400,
86                "invalid_request",
87                "Multiple access token transport methods are not allowed",
88            );
89        }
90        let Some(authorization) = authorization.or_else(|| {
91            body_token.map(|token| crate::oauth2::AccessTokenAuthorization {
92                scheme: "Bearer".into(),
93                token: token.into(),
94            })
95        }) else {
96            return error(401, "invalid_request", "access token not found");
97        };
98        let validation = match self
99            .require_active_access_token_with_claims(ctx, &authorization.token, None)
100            .await
101        {
102            Ok(payload) => payload,
103            Err(error) => {
104                let mut response = EndpointResponse::error(error.error);
105                if let Some(body) = error.body {
106                    response = response.with_error_body(*body);
107                }
108                if let Some(headers) = error.headers {
109                    response.merge_headers(headers);
110                }
111                return Ok(no_store(response));
112            }
113        };
114        let payload = validation.payload;
115        let requested = validation.requested_user_info_claims;
116        if payload
117            .get("cnf")
118            .and_then(crate::oauth2::get_confirmation_jkt)
119            .is_some()
120            && req.physical_request().is_none()
121        {
122            return error(
123                401,
124                "invalid_token",
125                "DPoP-bound access token requires an HTTP request context",
126            );
127        }
128        let method = match req.physical_request().map(alibi_core::AuthRequest::method) {
129            Some(HttpMethod::Post) => "POST",
130            Some(HttpMethod::Put) => "PUT",
131            Some(HttpMethod::Patch) => "PATCH",
132            Some(HttpMethod::Delete) => "DELETE",
133            Some(HttpMethod::Options) => "OPTIONS",
134            Some(HttpMethod::Head) => "HEAD",
135            Some(HttpMethod::Get) | None => "GET",
136        };
137        let url = super::dpop::endpoint_url(ctx, req, "/oauth2/userinfo");
138        let replay = crate::oauth2::create_dpop_replay_store(ctx);
139        let mut binding =
140            crate::oauth2::EnforceDpopBindingParams::new(&payload, &authorization, method, &url);
141        binding.proof_jwt = super::dpop::proof_header(req);
142        binding.replay_store = Some(&replay);
143        binding.proof_max_age_seconds = self.config.dpop_proof_max_age_seconds;
144        binding.signing_algorithms = self.config.dpop_signing_algorithms.clone();
145        if let Err(failure) = crate::oauth2::enforce_dpop_binding(&binding).await {
146            if crate::oauth2::is_dpop_binding_error(&failure) {
147                return error(
148                    401,
149                    failure.code.as_deref().unwrap_or_default(),
150                    &failure.message,
151                );
152            }
153            return Err(AuthError::internal(failure.message));
154        }
155        let scopes = payload
156            .get("scope")
157            .and_then(Value::as_str)
158            .map(|scope| scope.split(' ').collect::<Vec<_>>())
159            .unwrap_or_default();
160        if !scopes.contains(&"openid") {
161            return error(400, "invalid_scope", "Missing required scope");
162        }
163        let Some(id) = payload
164            .get("sub")
165            .and_then(Value::as_str)
166            .filter(|value| !value.is_empty())
167        else {
168            return error(400, "invalid_request", "user not found");
169        };
170        let Some(user) = ctx.database.get_user_by_id(id).await? else {
171            return error(400, "invalid_request", "user not found");
172        };
173        let user = ctx.user_view(&user);
174        let client_id = payload
175            .get("client_id")
176            .or_else(|| payload.get("azp"))
177            .and_then(Value::as_str);
178        let client = if self
179            .config
180            .pairwise_secret
181            .as_ref()
182            .is_some_and(|secret| !secret.is_empty())
183            || self
184                .config
185                .extensions
186                .iter()
187                .any(|extension| extension.claims.user_info.is_some())
188        {
189            if let Some(client_id) = client_id {
190                self.get_client(ctx, client_id).await?
191            } else {
192                None
193            }
194        } else {
195            None
196        };
197        let subject = client.as_ref().map_or_else(
198            || Ok(user.id.clone()),
199            |client| self.resolve_subject(&user.id, client),
200        )?;
201        let mut claims = serde_json::Map::from_iter([("sub".into(), json!(subject))]);
202        let wanted = |name: &str, scope: &str| {
203            scopes.contains(&scope) || requested.iter().any(|claim| claim == name)
204        };
205        if let Some(name) = user.name.as_deref() {
206            if wanted("name", "profile") {
207                _ = claims.insert("name".into(), json!(name));
208            }
209            if wanted("given_name", "profile") || wanted("family_name", "profile") {
210                let parts = name
211                    .split(' ')
212                    .filter(|part| !part.is_empty())
213                    .collect::<Vec<_>>();
214                if let Some((family, given)) = parts.split_last()
215                    && !given.is_empty()
216                {
217                    if wanted("given_name", "profile") {
218                        _ = claims.insert("given_name".into(), json!(given.join(" ")));
219                    }
220                    if wanted("family_name", "profile") {
221                        _ = claims.insert("family_name".into(), json!(family));
222                    }
223                }
224            }
225        }
226        if wanted("picture", "profile")
227            && let Some(image) = user.image.as_ref()
228        {
229            _ = claims.insert("picture".into(), json!(image));
230        }
231        if wanted("email", "email")
232            && let Some(email) = user.email.as_ref()
233        {
234            _ = claims.insert("email".into(), json!(email));
235        }
236        if wanted("email_verified", "email") {
237            _ = claims.insert("email_verified".into(), json!(user.email_verified));
238        }
239        let scopes = scopes.into_iter().map(str::to_owned).collect::<Vec<_>>();
240        let input = super::OAuthUserInfoClaimsInput {
241            context: super::extension_claims::callback_context(ctx, req),
242            options: &self.config,
243            user: &user,
244            client: client.as_ref(),
245            scopes: &scopes,
246            payload: &payload,
247            requested_claims: &requested,
248        };
249        let extensions = self.collect_user_info_claims(input.clone()).await?;
250        super::extension_claims::merge_additive(&mut claims, extensions);
251        if let Some(contributor) = &self.config.custom_user_info_claims {
252            // Unlike extensions, the operator may replace ordinary claims,
253            // including Object.prototype names. JS pickClaims assigns into {},
254            // whose __proto__ setter does not create an own key for spreading.
255            let operator = contributor.contribute(input).await?;
256            claims.extend(operator.into_iter().filter(|(key, _)| key != "__proto__"));
257        }
258        // Subject presentation remains authoritative, including pairwise clients.
259        _ = claims.insert("sub".into(), json!(subject));
260        Ok(no_store(EndpointResponse::json(&claims)?))
261    }
262}
263fn no_store(response: EndpointResponse) -> EndpointResponse {
264    response
265        .with_header("Cache-Control", "no-store")
266        .with_header("Pragma", "no-cache")
267}
268fn error(status: u16, code: &str, description: &str) -> AuthResult<EndpointResponse> {
269    super::clients::status_protocol_error(status, code, description).map(no_store)
270}