Skip to main content

alibi_plugins/oauth_provider/
token.rs

1//! OAuth authorization-code token exchange and single-use grant lifecycle.
2use super::OAuthProviderPlugin;
3use alibi_core::endpoint::{EndpointResponse, ServerEndpoint};
4use alibi_core::session::SessionRequest;
5use alibi_core::store::{CreateOAuthToken, 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    /// Exchange an OAuth grant through the provider's public server operation.
13    pub fn oauth2_token_endpoint(
14        input: &impl serde::Serialize,
15    ) -> AuthResult<ServerEndpoint<Value>> {
16        ServerEndpoint::new("oauth-provider", "oauth2Token").with_body(input)
17    }
18    pub(super) async fn token<S: AuthSchema>(
19        &self,
20        input: Option<&JsValue>,
21        req: &impl SessionRequest,
22        ctx: &AuthContext<S>,
23    ) -> AuthResult<EndpointResponse> {
24        let body: Value = input
25            .map(JsValue::to_json_value)
26            .transpose()?
27            .unwrap_or_else(|| json!({}));
28        let text = |key: &str| body.get(key).and_then(Value::as_str).unwrap_or_default();
29        let grant = text("grant_type").trim();
30        if !self
31            .config
32            .grant_types
33            .iter()
34            .any(|supported| supported == grant)
35        {
36            return error(
37                400,
38                "unsupported_grant_type",
39                &format!("unsupported grant_type {grant}"),
40            );
41        }
42        // Each remaining grant receives its own issuance and lifecycle finding.
43        if grant != "authorization_code" {
44            return error(
45                400,
46                "invalid_request",
47                if text("client_id").is_empty() {
48                    "Missing required client_id"
49                } else if grant == "refresh_token" {
50                    "Missing a required refresh_token for refresh_token grant"
51                } else {
52                    "Missing required client credentials"
53                },
54            );
55        }
56        let credentials = super::credentials::extract(&body, req);
57        let client_id = credentials.client_id.clone();
58        let secret = &credentials.secret;
59        if client_id.is_empty() {
60            return error(400, "invalid_request", "client_id is required");
61        }
62        let code = text("code");
63        if code.is_empty() {
64            return error(400, "invalid_request", "code is required");
65        }
66        let verifier = text("code_verifier");
67        if secret.is_empty() && verifier.is_empty() {
68            return error(
69                400,
70                "invalid_request",
71                "Either code_verifier or client_secret is required",
72            );
73        }
74        let identifier = URL_SAFE_NO_PAD.encode(Sha256::digest(code));
75        let Some(proof) = ctx.verifications().consume(&identifier).await? else {
76            ctx.database
77                .delete_oauth_tokens_for_code(&identifier)
78                .await?;
79            return error(400, "invalid_grant", "invalid code");
80        };
81        let Ok(value) = serde_json::from_str::<Value>(proof.value()?) else {
82            return error(400, "invalid_grant", "malformed verification value");
83        };
84        if value.get("type").unwrap_or(&Value::Null) != "authorization_code"
85            || !value.get("query").unwrap_or(&Value::Null).is_object()
86        {
87            return error(400, "invalid_grant", "malformed verification value");
88        }
89        let query = &value.get("query").unwrap_or(&Value::Null);
90        if query.get("client_id").and_then(Value::as_str) != Some(client_id.as_str()) {
91            return error(400, "invalid_grant", "invalid client_id");
92        }
93        let bound = query
94            .get("redirect_uri")
95            .unwrap_or(&Value::Null)
96            .as_str()
97            .unwrap_or_default();
98        if !bound.is_empty() && text("redirect_uri").is_empty() {
99            return error(400, "invalid_request", "redirect_uri is required");
100        }
101        if bound != text("redirect_uri") {
102            return error(400, "invalid_grant", "redirect_uri mismatch");
103        }
104        let Some(scope) = query.get("scope").unwrap_or(&Value::Null).as_str() else {
105            return error(500, "invalid_scope", "verification scope unset");
106        };
107        let client = match credentials.authenticate(ctx).await? {
108            Ok(client) => client,
109            Err(response) => {
110                return Ok(response
111                    .with_header("Cache-Control", "no-store")
112                    .with_header("Pragma", "no-cache"));
113            }
114        };
115        let registered = client
116            .metadata
117            .get("token_endpoint_auth_method")
118            .and_then(Value::as_str)
119            .unwrap_or("client_secret_basic");
120        if client
121            .metadata
122            .get("grant_types")
123            .unwrap_or(&Value::Null)
124            .as_array()
125            .is_some_and(|grants| !grants.iter().any(|g| g == grant))
126        {
127            return error(
128                400,
129                "unauthorized_client",
130                "client is not authorized to use grant type authorization_code",
131            );
132        }
133        let ceiling = client
134            .metadata
135            .get("scope")
136            .unwrap_or(&Value::Null)
137            .as_str()
138            .unwrap_or_default()
139            .split(' ')
140            .collect::<Vec<_>>();
141        let invalid = scope
142            .split(' ')
143            .filter(|s| !ceiling.contains(s))
144            .collect::<Vec<_>>();
145        if !invalid.is_empty() {
146            return error(
147                400,
148                "invalid_scope",
149                &format!("The following scopes are invalid: {}", invalid.join(", ")),
150            );
151        }
152        let challenge = query
153            .get("code_challenge")
154            .unwrap_or(&Value::Null)
155            .as_str()
156            .unwrap_or_default();
157        if registered == "none" && verifier.is_empty() {
158            return error(400, "invalid_request", "PKCE is required for this client");
159        }
160        if !challenge.is_empty() && verifier.is_empty() {
161            return error(
162                401,
163                "invalid_request",
164                "code_verifier required because PKCE was used in authorization",
165            );
166        }
167        if challenge.is_empty() && !verifier.is_empty() {
168            return error(
169                401,
170                "invalid_request",
171                "code_verifier provided but PKCE was not used in authorization",
172            );
173        }
174        if !verifier.is_empty()
175            && (query.get("code_challenge_method").unwrap_or(&Value::Null) != "S256"
176                || URL_SAFE_NO_PAD.encode(Sha256::digest(verifier)) != challenge)
177        {
178            return error(401, "invalid_request", "code verification failed");
179        }
180        let user_id = value
181            .get("userId")
182            .unwrap_or(&Value::Null)
183            .as_str()
184            .unwrap_or_default();
185        if ctx.database.get_user_by_id(user_id).await?.is_none() {
186            return error(
187                400,
188                "invalid_user",
189                "missing user, user may have been deleted",
190            );
191        }
192        let session_id = value
193            .get("sessionId")
194            .unwrap_or(&Value::Null)
195            .as_str()
196            .unwrap_or_default();
197        let session = ctx
198            .database
199            .get_user_sessions(user_id)
200            .await?
201            .into_iter()
202            .find(|s| s.id().as_ref() == session_id);
203        if session.is_none_or(|s| s.expires_at() < chrono::Utc::now()) {
204            return error(400, "invalid_request", "session no longer exists");
205        }
206        let issued = chrono::DateTime::from_timestamp(chrono::Utc::now().timestamp(), 0)
207            .ok_or_else(|| alibi_core::AuthError::internal("Invalid token issue time"))?;
208        let expires = issued + chrono::Duration::seconds(3600);
209
210        let random_token = || {
211            rand::RngExt::sample_iter(rand::rng(), rand::distr::Alphanumeric)
212                .map(char::from)
213                .filter(char::is_ascii_alphabetic)
214                .take(32)
215                .collect::<String>()
216        };
217        let scopes = scope.split(' ').collect::<Vec<_>>();
218        let refresh_allowed = client
219            .metadata
220            .get("grant_types")
221            .and_then(Value::as_array)
222            .is_none_or(|grants| grants.iter().any(|g| g == "refresh_token"));
223        let resources = value
224            .get("resource")
225            .filter(|value| value.as_array().is_none_or(|values| !values.is_empty()))
226            .cloned()
227            .unwrap_or(Value::Null);
228        let refresh = if refresh_allowed && scopes.contains(&"offline_access") {
229            let token = random_token();
230            let auth_time = value
231                .get("authTime")
232                .and_then(Value::as_i64)
233                .and_then(chrono::DateTime::from_timestamp_millis);
234            let row = ctx.database.create_oauth_token(OAuthTokenKind::Refresh,CreateOAuthToken {
235                token:URL_SAFE_NO_PAD.encode(Sha256::digest(&token)),client_id:client_id.clone(),user_id:Some(user_id.into()),session_id:Some(session_id.into()),authorization_code_id:Some(identifier.clone()),
236                metadata:json!({"scopes":scopes,"resources":resources,"authTime":auth_time}),created_at:issued,expires_at:issued+chrono::Duration::seconds(2_592_000)
237            }).await?;
238            Some((row.id, token))
239        } else {
240            None
241        };
242        let token = random_token();
243        let mut metadata = json!({"scopes":scopes,"resources":resources});
244        if let Some((id, _)) = &refresh {
245            _ = metadata
246                .as_object_mut()
247                .ok_or_else(|| alibi_core::AuthError::internal("Invalid access-token metadata"))?
248                .insert("refreshId".into(), json!(id));
249        }
250        _ = ctx
251            .database
252            .create_oauth_token(
253                OAuthTokenKind::Access,
254                CreateOAuthToken {
255                    token: URL_SAFE_NO_PAD.encode(Sha256::digest(&token)),
256                    client_id: client_id.clone(),
257                    user_id: Some(user_id.into()),
258                    session_id: Some(session_id.into()),
259                    authorization_code_id: Some(identifier),
260                    metadata,
261                    created_at: issued,
262                    expires_at: expires,
263                },
264            )
265            .await?;
266        let mut response = json!({"access_token":token,"expires_in":3600,"expires_at":expires.timestamp(),"token_type":"Bearer","scope":scope});
267        if scopes.contains(&"openid") {
268            match Box::pin(self.issue_id_token(&client, &value, &token, req, ctx)).await? {
269                Ok(Some(token)) => {
270                    _ = response
271                        .as_object_mut()
272                        .ok_or_else(|| alibi_core::AuthError::internal("Invalid token response"))?
273                        .insert("id_token".into(), json!(token));
274                }
275                Ok(None) => {}
276                Err(response) => {
277                    return Ok(response
278                        .with_header("Cache-Control", "no-store")
279                        .with_header("Pragma", "no-cache"));
280                }
281            }
282        }
283        if let Some((_, token)) = refresh {
284            _ = response
285                .as_object_mut()
286                .ok_or_else(|| alibi_core::AuthError::internal("Invalid token response"))?
287                .insert("refresh_token".into(), json!(token));
288        }
289        Ok(EndpointResponse::json(&response)?
290            .with_header("Cache-Control", "no-store")
291            .with_header("Pragma", "no-cache"))
292    }
293}
294fn error(status: u16, code: &str, description: &str) -> AuthResult<EndpointResponse> {
295    super::clients::status_protocol_error(status, code, description).map(|r| {
296        r.with_header("Cache-Control", "no-store")
297            .with_header("Pragma", "no-cache")
298    })
299}