Skip to main content

soma_auth/
authorize.rs

1use std::net::SocketAddr;
2
3use axum::extract::{ConnectInfo, Query, State};
4use axum::http::{HeaderValue, StatusCode, header};
5use axum::response::{IntoResponse, Redirect};
6use axum::{Json, response::Response};
7use base64::Engine;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use sha2::{Digest, Sha256};
10use tracing::{debug, info, warn};
11
12use crate::error::AuthError;
13use crate::oauth_provider::{AuthorizeUrlRequest, namespaced_subject};
14use crate::registration::resolve_client_redirect_uris;
15use crate::session::{append_set_cookie, build_browser_session_cookie, create_browser_session};
16use crate::state::AuthState;
17use crate::types::{
18    AuthorizationCodeRow, AuthorizationRequestRow, AuthorizeQuery, BrowserLoginQuery,
19    BrowserLoginStateRow, CallbackQuery, NativeAuthorizationResultRow, NativePollQuery,
20    NativePollResponse,
21};
22use crate::util::{expires_at, fingerprint, now_unix, random_token, remote_ip};
23
24const AUTH_REQUEST_TTL_SECS: i64 = 300;
25const NATIVE_SUCCESS_PAGE: &str = r#"<!doctype html><html><body style="font-family:sans-serif;background:#07131c;color:#e6f4fb;text-align:center;padding-top:4rem"><h2>Signed in</h2><p>You can close this tab and return to the app.</p></body></html>"#;
26const NATIVE_CALLBACK_EXPIRED_PAGE: &str = r#"<!doctype html><html><body style="font-family:sans-serif;background:#07131c;color:#e6f4fb;text-align:center;padding-top:4rem"><h2>Sign-in link expired</h2><p>Return to the app and start sign-in again.</p></body></html>"#;
27
28/// Enforces the configured email allowlist.
29///
30/// `email_verified` is enforced before the email comparison: without this guard,
31/// an attacker who presents someone else's unverified address through an
32/// upstream provider could bypass the allowlist.
33fn check_email_allowlist(
34    provider_id: &str,
35    email: Option<&str>,
36    email_verified: Option<bool>,
37    allowed_emails: &[String],
38) -> Result<(), AuthError> {
39    if allowed_emails.is_empty() {
40        return Ok(());
41    }
42    if email_verified != Some(true) {
43        warn!(
44            provider = provider_id,
45            "oauth callback rejected: provider did not return a verified email address"
46        );
47        return Err(AuthError::AuthFailed(format!(
48            "{provider_id} did not return a verified email address"
49        )));
50    }
51    let Some(e) = email else {
52        warn!(
53            provider = provider_id,
54            "oauth callback rejected: provider did not return an email address"
55        );
56        return Err(AuthError::AuthFailed(format!(
57            "{provider_id} did not return an email address"
58        )));
59    };
60    let trimmed = e.trim();
61    if allowed_emails
62        .iter()
63        .any(|a| a.eq_ignore_ascii_case(trimmed))
64    {
65        return Ok(());
66    }
67    warn!(
68        provider = provider_id,
69        email_id = %fingerprint(trimmed),
70        "oauth callback rejected: email not in allowed list"
71    );
72    Err(AuthError::AuthFailed(
73        "account is not permitted to access this gateway".to_string(),
74    ))
75}
76
77pub async fn browser_login(
78    State(state): State<AuthState>,
79    ConnectInfo(addr): ConnectInfo<SocketAddr>,
80    Query(query): Query<BrowserLoginQuery>,
81) -> Result<Response, AuthError> {
82    state.check_authorize_rate_limit(remote_ip(addr)).await?;
83    let return_to = sanitize_return_to(&state, query.return_to.as_deref());
84
85    let provider = match query.provider.as_deref() {
86        Some(id) => state.provider(id)?,
87        None if state.providers.len() > 1 => {
88            return Ok(render_provider_picker(&state, &return_to));
89        }
90        None => state.provider_or_default(None)?,
91    };
92
93    state.ensure_pending_oauth_state_capacity().await?;
94    let provider_code_verifier = random_token(32)?;
95    let provider_code_challenge =
96        URL_SAFE_NO_PAD.encode(Sha256::digest(provider_code_verifier.as_bytes()));
97    let request_state = random_token(24)?;
98    let oauth_state_id = fingerprint(&request_state);
99
100    state
101        .store
102        .insert_browser_login_state(BrowserLoginStateRow {
103            state: request_state.clone(),
104            return_to: return_to.clone(),
105            provider: provider.provider_id().to_string(),
106            provider_code_verifier,
107            created_at: now_unix(),
108            expires_at: now_unix() + AUTH_REQUEST_TTL_SECS,
109        })
110        .await?;
111
112    let location = provider.authorize_url(&AuthorizeUrlRequest {
113        state: request_state,
114        code_challenge: provider_code_challenge,
115        code_challenge_method: "S256".to_string(),
116        force_consent: true,
117    })?;
118    info!(
119        oauth_state_id = %oauth_state_id,
120        return_to = %return_to,
121        provider = provider.provider_id(),
122        "browser login redirected to upstream provider"
123    );
124
125    Ok((
126        StatusCode::FOUND,
127        [(header::LOCATION, location.to_string())],
128    )
129        .into_response())
130}
131
132/// Plain HTML provider-choice page shown by `browser_login` when the
133/// deployment has more than one provider configured and the request did
134/// not already say which one to use.
135///
136/// `return_to` is percent-encoded via `form_urlencoded` before
137/// interpolation. Verified against `url::form_urlencoded::byte_serialize`'s
138/// actual WHATWG `application/x-www-form-urlencoded` behavior: unreserved
139/// bytes are alphanumeric plus `*-._`, space becomes `+`, everything else
140/// (including `~`, which is NOT in the unreserved set for this encoder) is
141/// percent-encoded — so the real output charset is `[A-Za-z0-9*\-._+%]`.
142/// None of those characters can break out of a double-quoted HTML attribute,
143/// so no separate HTML-escaping step is needed for `return_to_encoded`.
144///
145/// `provider_id`/`provider_label(...)` are interpolated as plain text (not
146/// inside a percent-encoded query value), so they go through `html_escape`
147/// as defense-in-depth even though every value that reaches this function
148/// today is a hardcoded `&'static str` from a closed match — nothing about
149/// the escaping costs anything, and it removes "provider names are always
150/// compile-time literals" as a load-bearing invariant for this function's
151/// XSS-safety.
152fn render_provider_picker(state: &AuthState, return_to: &str) -> Response {
153    let return_to_encoded: String =
154        url::form_urlencoded::byte_serialize(return_to.as_bytes()).collect();
155    let login_path = &state.config.login_path;
156    let links: String = state
157        .providers
158        .values()
159        .map(|provider| {
160            let id = html_escape(provider.provider_id());
161            let label = html_escape(provider_label(provider.provider_id()));
162            format!(
163                r#"<li><a href="{login_path}?provider={id}&return_to={return_to_encoded}">Sign in with {label}</a></li>"#
164            )
165        })
166        .collect();
167    let body = format!(
168        r#"<!doctype html><html><body style="font-family:sans-serif;background:#07131c;color:#e6f4fb;text-align:center;padding-top:4rem"><h2>Sign in</h2><ul style="list-style:none;padding:0;font-size:1.1rem;line-height:2.5">{links}</ul></body></html>"#
169    );
170    let mut response = axum::response::Html(body).into_response();
171    response
172        .headers_mut()
173        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
174    response
175}
176
177fn provider_label(provider_id: &str) -> &'static str {
178    match provider_id {
179        "google" => "Google",
180        "authelia" => "Authelia",
181        "github" => "GitHub",
182        _ => "your identity provider",
183    }
184}
185
186fn html_escape(text: &str) -> String {
187    text.replace('&', "&amp;")
188        .replace('<', "&lt;")
189        .replace('>', "&gt;")
190        .replace('"', "&quot;")
191}
192
193pub async fn authorize(
194    State(state): State<AuthState>,
195    ConnectInfo(addr): ConnectInfo<SocketAddr>,
196    Query(query): Query<AuthorizeQuery>,
197) -> Result<Response, AuthError> {
198    state.check_authorize_rate_limit(remote_ip(addr)).await?;
199    state.ensure_pending_oauth_state_capacity().await?;
200    validate_response_type(&query.response_type)?;
201    let resource = validate_resource(&state, query.resource.as_deref())?;
202    let scope = validate_scope(&state, &resource, &query.scope)?;
203    let client_state_id = fingerprint(&query.state);
204    info!(
205        client_id = %query.client_id,
206        redirect_uri = %query.redirect_uri,
207        client_state_id = %client_state_id,
208        resource = %resource,
209        requested_scope = %query.scope,
210        normalized_scope = %scope,
211        "oauth authorize request received"
212    );
213    let redirect_uris =
214        resolve_client_redirect_uris(&state, &query.client_id, &client_state_id).await?;
215    if !redirect_uris.iter().any(|uri| uri == &query.redirect_uri) {
216        warn!(
217            client_id = %query.client_id,
218            redirect_uri = %query.redirect_uri,
219            client_state_id = %client_state_id,
220            "oauth authorize rejected: redirect URI does not match the registered/CIMD-allowlisted client"
221        );
222        return Err(AuthError::Validation(
223            "redirect_uri does not match the registered client".to_string(),
224        ));
225    }
226    if query.code_challenge_method != "S256" {
227        warn!(
228            client_id = %query.client_id,
229            client_state_id = %client_state_id,
230            code_challenge_method = %query.code_challenge_method,
231            "oauth authorize rejected: unsupported PKCE method"
232        );
233        return Err(AuthError::Validation(
234            "code_challenge_method must be S256".to_string(),
235        ));
236    }
237
238    let provider = state.provider_or_default(query.provider.as_deref())?;
239    let provider_code_verifier = random_token(32)?;
240    let provider_code_challenge =
241        URL_SAFE_NO_PAD.encode(Sha256::digest(provider_code_verifier.as_bytes()));
242    let request_state = random_token(24)?;
243    let oauth_state_id = fingerprint(&request_state);
244
245    state
246        .store
247        .insert_authorization_request(AuthorizationRequestRow {
248            state: request_state.clone(),
249            client_id: query.client_id.clone(),
250            redirect_uri: query.redirect_uri.clone(),
251            client_state: query.state.clone(),
252            resource: resource.clone(),
253            scope: scope.clone(),
254            provider: provider.provider_id().to_string(),
255            provider_code_verifier,
256            code_challenge: query.code_challenge.clone(),
257            code_challenge_method: query.code_challenge_method.clone(),
258            created_at: now_unix(),
259            expires_at: now_unix() + AUTH_REQUEST_TTL_SECS,
260        })
261        .await?;
262
263    // We don't know which upstream subject is about to sign in until they
264    // come back from the consent screen, so use "has this gateway ever
265    // minted a refresh token for THIS provider before" as a single-tenant
266    // proxy for "already granted." Scoped per-provider (not global) —
267    // otherwise a deployment where Google already has refresh tokens on file
268    // would skip forced consent on a user's very first Authelia or GitHub
269    // login, silently degrading that new provider's first session to no
270    // local refresh token (caught in engineering review; see Task 8's
271    // `has_any_refresh_token_for_provider`). Forcing full re-consent on
272    // every DCR client attempt (Raycast, Warp, etc.) adds an interactive
273    // round trip long enough for impatient clients to time out and retry
274    // before the human finishes clicking through it.
275    let force_consent = !state
276        .store
277        .has_any_refresh_token_for_provider(provider.provider_id())
278        .await?;
279    let location = provider.authorize_url(&AuthorizeUrlRequest {
280        state: request_state,
281        code_challenge: provider_code_challenge,
282        code_challenge_method: "S256".to_string(),
283        force_consent,
284    })?;
285    info!(
286        client_id = %query.client_id,
287        redirect_uri = %query.redirect_uri,
288        client_state_id = %client_state_id,
289        oauth_state_id = %oauth_state_id,
290        resource = %resource,
291        scope = %scope,
292        provider = provider.provider_id(),
293        "oauth authorize request redirected to upstream provider"
294    );
295    debug!(
296        client_id = %query.client_id,
297        oauth_state_id = %oauth_state_id,
298        location = %location,
299        "oauth authorize redirect URL generated"
300    );
301
302    Ok((
303        StatusCode::FOUND,
304        [(header::LOCATION, location.to_string())],
305    )
306        .into_response())
307}
308
309pub async fn callback(
310    State(state): State<AuthState>,
311    Query(query): Query<CallbackQuery>,
312) -> Result<Response, AuthError> {
313    let oauth_state_id = fingerprint(&query.state);
314    info!(
315        oauth_state_id = %oauth_state_id,
316        "oauth callback received"
317    );
318    if let Some(login) = state.store.take_browser_login_state(&query.state).await? {
319        let provider = state.provider(&login.provider)?;
320        let exchange = provider
321            .exchange_code(&query.code, &login.provider_code_verifier)
322            .await?;
323        let allowed = state.resolve_allowed_emails().await?;
324        check_email_allowlist(
325            provider.provider_id(),
326            exchange.email.as_deref(),
327            exchange.email_verified,
328            &allowed,
329        )?;
330        let subject = namespaced_subject(provider.provider_id(), &exchange.subject);
331        let session = create_browser_session(&state, subject, exchange.email).await?;
332        let mut response = Redirect::to(&login.return_to).into_response();
333        append_set_cookie(
334            &mut response,
335            &build_browser_session_cookie(&state, &session.session_id),
336        );
337        info!(
338            oauth_state_id = %oauth_state_id,
339            return_to = %login.return_to,
340            subject_id = %fingerprint(&session.subject),
341            provider = provider.provider_id(),
342            "browser login callback issued session cookie"
343        );
344        return Ok(response);
345    }
346
347    let request = state
348        .store
349        .take_authorization_request(&query.state)
350        .await
351        .map_err(|_| {
352            warn!(
353                oauth_state_id = %oauth_state_id,
354                "oauth callback rejected: authorization state is invalid or expired"
355            );
356            AuthError::InvalidGrant("authorization state is invalid or expired".to_string())
357        })?;
358    info!(
359        client_id = %request.client_id,
360        redirect_uri = %request.redirect_uri,
361        oauth_state_id = %oauth_state_id,
362        client_state_id = %fingerprint(&request.client_state),
363        resource = %request.resource,
364        scope = %request.scope,
365        "oauth callback state redeemed"
366    );
367    let provider = state.provider(&request.provider)?;
368    let exchange = provider
369        .exchange_code(&query.code, &request.provider_code_verifier)
370        .await?;
371
372    // RFC 9207: echo the issuer identifier on the authorization response (both
373    // success and error) so the client can detect authorization-server mix-up
374    // attacks. Matches the `issuer` advertised in authorization-server metadata.
375    let issuer = crate::metadata::public_base_url(&state);
376
377    // RFC 6749 §4.1.2.1: errors must redirect to the client's redirect_uri,
378    // not surface as a JSON HTTP error. The denial reason is sourced from the
379    // AuthError so we only log once (inside check_email_allowlist).
380    let allowed = state.resolve_allowed_emails().await?;
381    if let Err(denial) = check_email_allowlist(
382        provider.provider_id(),
383        exchange.email.as_deref(),
384        exchange.email_verified,
385        &allowed,
386    ) {
387        let mut redirect_target = url::Url::parse(&request.redirect_uri).map_err(|error| {
388            // Unreachable in practice: redirect_uri was validated against the
389            // client's registered URIs before being stored.
390            AuthError::Config(format!("failed to parse registered redirect_uri: {error}"))
391        })?;
392        redirect_target
393            .query_pairs_mut()
394            .append_pair("error", "access_denied")
395            .append_pair("error_description", &denial.to_string())
396            .append_pair("state", &request.client_state)
397            .append_pair("iss", &issuer);
398        return Ok(Redirect::to(redirect_target.as_str()).into_response());
399    }
400
401    let subject = namespaced_subject(provider.provider_id(), &exchange.subject);
402    let subject_id = fingerprint(&subject);
403    info!(
404        client_id = %request.client_id,
405        oauth_state_id = %oauth_state_id,
406        subject_id = %subject_id,
407        provider = provider.provider_id(),
408        has_provider_refresh_token = exchange.refresh_token.is_some(),
409        "oauth callback exchanged upstream code successfully"
410    );
411    let auth_code = random_token(24)?;
412    let auth_code_id = fingerprint(&auth_code);
413    // The user just passed `check_email_allowlist`, which IS the admin gate:
414    // operators are added to the allowlist explicitly to grant access. Elevate
415    // their scope to include `<default_scope>:admin` so MCP clients (which
416    // typically don't know to request elevated scopes) can call destructive
417    // gateway/setup actions without a separate flow. If they explicitly
418    // requested only the base scope, this is a no-op deny — they get admin.
419    let elevated_scope =
420        elevate_scope_for_allowed_user(&request.scope, &state.config.default_scope);
421    let request_client_id = request.client_id.clone();
422    let request_resource = request.resource.clone();
423    let request_scope = elevated_scope.clone();
424    state
425        .store
426        .insert_auth_code(AuthorizationCodeRow {
427            code: auth_code.clone(),
428            client_id: request.client_id,
429            subject,
430            redirect_uri: request.redirect_uri.clone(),
431            resource: request.resource,
432            scope: elevated_scope,
433            provider: provider.provider_id().to_string(),
434            code_challenge: request.code_challenge,
435            code_challenge_method: request.code_challenge_method,
436            provider_refresh_token: exchange.refresh_token,
437            created_at: now_unix(),
438            expires_at: expires_at(
439                now_unix(),
440                state.config.auth_code_ttl,
441                &format!("{}_AUTH_CODE_TTL_SECS", state.config.env_prefix),
442            )?,
443        })
444        .await?;
445    info!(
446        auth_code_id = %auth_code_id,
447        oauth_state_id = %oauth_state_id,
448        client_id = %request_client_id,
449        resource = %request_resource,
450        scope = %request_scope,
451        redirect_uri = %request.redirect_uri,
452        "oauth callback issued local authorization code"
453    );
454
455    // Native-flow clients (desktop/mobile apps with no loopback listener or
456    // custom URI scheme) register `redirect_uri = native_callback_endpoint` —
457    // our own HTTPS route — instead of a client-hosted URL. In that case there
458    // is no redirect target to send the browser back to: stash the code keyed
459    // by `state` for the client to retrieve via `/native/poll`, and show a
460    // plain "signed in" page directly.
461    let native_callback_endpoint = crate::metadata::native_callback_endpoint(&state);
462    if request.redirect_uri == native_callback_endpoint {
463        let now = now_unix();
464        state
465            .store
466            .insert_native_authorization_result(NativeAuthorizationResultRow {
467                state: request.client_state,
468                code: auth_code,
469                created_at: now,
470                expires_at: expires_at(
471                    now,
472                    state.config.auth_code_ttl,
473                    &format!("{}_AUTH_CODE_TTL_SECS", state.config.env_prefix),
474                )?,
475            })
476            .await?;
477        let mut response = axum::response::Html(NATIVE_SUCCESS_PAGE).into_response();
478        response
479            .headers_mut()
480            .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
481        debug!(
482            auth_code_id = %auth_code_id,
483            native_callback_endpoint = %native_callback_endpoint,
484            "oauth callback stored native authorization code for polling"
485        );
486        return Ok(response);
487    }
488
489    let redirect_uri = reqwest::Url::parse(&request.redirect_uri).map_err(|error| {
490        AuthError::Storage(format!(
491            "registered redirect_uri is not a valid URL: {error}"
492        ))
493    })?;
494    let mut redirect_uri = redirect_uri;
495    redirect_uri
496        .query_pairs_mut()
497        .append_pair("code", &auth_code)
498        .append_pair("state", &request.client_state)
499        .append_pair("iss", &issuer);
500    debug!(
501        auth_code_id = %auth_code_id,
502        redirect_uri = %redirect_uri,
503        "oauth callback redirecting client back to registered callback"
504    );
505
506    Ok(Redirect::to(redirect_uri.as_str()).into_response())
507}
508
509/// Direct-hit fallback for the registered native `redirect_uri`. In the real
510/// flow this path is never dereferenced by an actual browser redirect — the
511/// upstream provider's redirect target is always its own
512/// `/auth/<provider>/callback` (e.g. `/auth/google/callback`,
513/// `/auth/github/callback`), which detects a native-flow authorization
514/// request and short-circuits into stashing the code for `/native/poll`
515/// instead of redirecting here. This handler only answers a stray direct
516/// visit (e.g. a stale bookmark or a misconfigured client), so `state` is
517/// validated for URL-shape consistency but deliberately not looked up —
518/// there's nothing to correlate it against.
519pub async fn native_callback(Query(query): Query<NativePollQuery>) -> Result<Response, AuthError> {
520    let state_param = query.state.trim();
521    if state_param.is_empty() {
522        return Err(AuthError::Validation(
523            "missing `state` parameter".to_string(),
524        ));
525    }
526    let mut response = (
527        StatusCode::GONE,
528        axum::response::Html(NATIVE_CALLBACK_EXPIRED_PAGE),
529    )
530        .into_response();
531    response
532        .headers_mut()
533        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
534    Ok(response)
535}
536
537pub async fn native_poll(
538    State(state): State<AuthState>,
539    Query(query): Query<NativePollQuery>,
540) -> Result<Response, AuthError> {
541    let state_param = query.state.trim();
542    if state_param.is_empty() {
543        return Err(AuthError::Validation(
544            "missing `state` parameter".to_string(),
545        ));
546    }
547    let mut response = if let Some(row) = state
548        .store
549        .take_native_authorization_result(state_param)
550        .await?
551    {
552        Json(NativePollResponse {
553            code: Some(row.code),
554        })
555        .into_response()
556    } else {
557        (
558            StatusCode::ACCEPTED,
559            Json(NativePollResponse { code: None }),
560        )
561            .into_response()
562    };
563    response
564        .headers_mut()
565        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
566    Ok(response)
567}
568
569fn sanitize_return_to(state: &AuthState, requested: Option<&str>) -> String {
570    let Some(requested) = requested.map(str::trim).filter(|value| !value.is_empty()) else {
571        return "/".to_string();
572    };
573    if requested.starts_with('/') && !requested.starts_with("//") {
574        return requested.to_string();
575    }
576    let Some(public_url) = state.config.public_url.as_ref() else {
577        return "/".to_string();
578    };
579    let Ok(url) = reqwest::Url::parse(requested) else {
580        return "/".to_string();
581    };
582    if url.scheme() != public_url.scheme()
583        || url.host_str() != public_url.host_str()
584        || url.port_or_known_default() != public_url.port_or_known_default()
585    {
586        return "/".to_string();
587    }
588    let mut normalized = url.path().to_string();
589    if let Some(query) = url.query() {
590        normalized.push('?');
591        normalized.push_str(query);
592    }
593    if let Some(fragment) = url.fragment() {
594        normalized.push('#');
595        normalized.push_str(fragment);
596    }
597    normalized
598}
599
600fn validate_response_type(response_type: &str) -> Result<(), AuthError> {
601    if response_type == "code" {
602        Ok(())
603    } else {
604        warn!(
605            response_type = %response_type,
606            "oauth authorize rejected: unsupported response_type"
607        );
608        Err(AuthError::Validation(
609            "response_type must be `code`".to_string(),
610        ))
611    }
612}
613
614/// Add `<base>:admin` to `scope` if not already present, where `base` is the
615/// resource prefix of `default_scope` (everything before the first `:`).
616///
617/// For example, `default_scope = "syslog:read"` produces the admin scope
618/// `"syslog:admin"`, not `"syslog:read:admin"`.
619///
620/// Called after `check_email_allowlist` succeeds. Being on the allowlist IS
621/// the admin gate (operators add users explicitly), so the issued token
622/// carries the elevated scope regardless of what the OAuth client originally
623/// requested — most MCP clients use the default scope and have no way to
624/// negotiate `:admin` themselves.
625pub(crate) fn elevate_scope_for_allowed_user(scope: &str, default_scope: &str) -> String {
626    let base = default_scope.split(':').next().unwrap_or(default_scope);
627    let admin_scope = format!("{base}:admin");
628    let mut scopes: Vec<&str> = scope.split_whitespace().filter(|s| !s.is_empty()).collect();
629    // Always inject the default-brand admin scope (e.g. "lab:admin") for
630    // allowlisted users, even when the token is for a cross-brand protected
631    // route (e.g. "mcp:read mcp:write" for a cortex endpoint).  The JWT
632    // audience is still bound to the specific resource URL, so a cortex token
633    // carrying "lab:admin" cannot be presented to lab endpoints.  This lets
634    // authenticate_protected_route_request recognise the admin unconditionally
635    // without re-reading the allowlist at request time.
636    if !scopes.contains(&admin_scope.as_str()) {
637        scopes.push(admin_scope.as_str());
638    }
639    scopes.join(" ")
640}
641
642fn validate_scope(state: &AuthState, resource: &str, scope: &str) -> Result<String, AuthError> {
643    let canonical = crate::metadata::canonical_resource_url(state);
644    let supported = if resource.trim_end_matches('/') == canonical {
645        state.config.scopes_supported.clone()
646    } else {
647        state
648            .allowed_resource_scopes(resource)
649            .filter(|scopes| !scopes.is_empty())
650            .ok_or_else(|| {
651                AuthError::Validation(format!(
652                    "resource must be `{canonical}` or a configured protected MCP route"
653                ))
654            })?
655    };
656    let normalized = scope.trim();
657    if normalized.is_empty() {
658        if resource.trim_end_matches('/') == canonical {
659            let scope = state.config.default_scope.clone();
660            debug!(
661                resource = %resource,
662                scope = %scope,
663                "oauth authorize defaulted scope"
664            );
665            return Ok(scope);
666        }
667        let scope = supported.join(" ");
668        debug!(
669            resource = %resource,
670            scope = %scope,
671            "oauth authorize defaulted protected resource scope"
672        );
673        return Ok(scope);
674    }
675    let requested = normalized.split_whitespace().collect::<Vec<_>>();
676    if requested
677        .iter()
678        .all(|scope| supported.iter().any(|allowed| allowed == scope))
679    {
680        let scope = requested.join(" ");
681        debug!(
682            resource = %resource,
683            requested_scope = %normalized,
684            normalized_scope = %scope,
685            "oauth authorize scope accepted"
686        );
687        return Ok(scope);
688    }
689    warn!(
690        scope = %normalized,
691        resource = %resource,
692        supported_scopes = ?supported,
693        "oauth authorize rejected: unsupported scope"
694    );
695    Err(AuthError::Validation(format!(
696        "scope must be one of: {}",
697        supported.join(", ")
698    )))
699}
700
701pub(crate) fn validate_resource(
702    state: &AuthState,
703    requested: Option<&str>,
704) -> Result<String, AuthError> {
705    let canonical = crate::metadata::canonical_resource_url(state);
706    let Some(requested) = requested.map(str::trim).filter(|value| !value.is_empty()) else {
707        return Ok(canonical);
708    };
709    let requested = requested.trim_end_matches('/');
710    if requested == canonical || state.is_allowed_resource_url(requested) {
711        debug!(
712            requested_resource = %requested,
713            canonical_resource = %canonical,
714            protected_resource = requested != canonical,
715            "oauth resource accepted"
716        );
717        return Ok(requested.to_string());
718    }
719
720    warn!(
721        requested_resource = %requested,
722        expected_resource = %canonical,
723        "oauth request rejected: resource does not match an allowed MCP endpoint"
724    );
725    Err(AuthError::Validation(format!(
726        "resource must be `{canonical}` or a configured protected MCP route"
727    )))
728}
729
730#[cfg(test)]
731pub mod tests {
732    use axum::body::Body;
733    use axum::http::{Request, StatusCode, header};
734    use base64::Engine;
735    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
736    use rsa::RsaPrivateKey;
737    use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
738    use rsa::traits::PublicKeyParts;
739    use serde_json::json;
740    use tower::util::ServiceExt;
741    use url::Url;
742    use wiremock::matchers::{method, path};
743    use wiremock::{Mock, MockServer, ResponseTemplate};
744
745    use crate::config::{AuthConfig, AuthMode, GoogleConfig};
746    use crate::error::AuthError;
747    use crate::google::GoogleProvider;
748    use crate::redirect_uri::{host_pattern_matches, is_allowed_redirect_uri, wildcard_matches};
749    use crate::registration::{allowed_uris_from_cimd_document, allowlist_redirect_uris};
750    use crate::state::AuthState;
751    use crate::types::{AuthorizationRequestRow, NativeAuthorizationResultRow, RegisteredClient};
752
753    use crate::util::now_unix;
754
755    use axum::Router;
756    use axum::extract::connect_info::MockConnectInfo;
757    use std::net::SocketAddr;
758
759    // `oneshot` bypasses the live `into_make_service_with_connect_info` layer,
760    // so the rate-limit handlers' `ConnectInfo<SocketAddr>` extractor would be
761    // missing and every request would 500. Wrap the real router with a mock
762    // peer address; handlers that don't extract `ConnectInfo` ignore it.
763    fn router(state: AuthState) -> Router {
764        crate::routes::router(state)
765            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 9001))))
766    }
767
768    #[test]
769    fn allowlist_redirect_uris_keeps_only_patterns_that_pass_is_allowed_redirect_uri() {
770        let candidates = vec![
771            "http://127.0.0.1:7777/callback".to_string(), // loopback, always allowed
772            "https://attacker.evil/steal-code".to_string(), // not in any allowlist pattern
773            "https://callback.example.com/callback/node-a".to_string(), // matches pattern below
774        ];
775        let patterns = vec!["https://callback.example.com/callback/*".to_string()];
776        let allowed = allowlist_redirect_uris(&candidates, &patterns);
777        assert_eq!(
778            allowed,
779            vec![
780                "http://127.0.0.1:7777/callback".to_string(),
781                "https://callback.example.com/callback/node-a".to_string(),
782            ]
783        );
784    }
785
786    #[test]
787    fn allowlist_redirect_uris_returns_empty_when_nothing_matches() {
788        let candidates = vec!["https://attacker.evil/steal-code".to_string()];
789        let allowed = allowlist_redirect_uris(&candidates, &[]);
790        assert!(allowed.is_empty());
791    }
792
793    #[test]
794    fn allowed_uris_from_cimd_document_returns_the_allowlisted_subset() {
795        let document = crate::cimd::document::ClientMetadataDocument {
796            client_id: "https://app.example.com/client.json".to_string(),
797            client_name: "Example".to_string(),
798            redirect_uris: vec![
799                "http://127.0.0.1:3000/callback".to_string(),
800                "https://attacker.evil/steal-code".to_string(),
801            ],
802        };
803        let allowed = allowed_uris_from_cimd_document(
804            &document,
805            "https://app.example.com/client.json",
806            "state-id",
807            &[],
808        )
809        .expect("the loopback redirect_uri is allowed by default");
810        assert_eq!(allowed, vec!["http://127.0.0.1:3000/callback".to_string()]);
811    }
812
813    #[test]
814    fn allowed_uris_from_cimd_document_rejects_when_nothing_survives_the_allowlist() {
815        // The whole point of filtering CIMD-declared redirect_uris through
816        // the allowlist: a document that only declares an attacker-hosted
817        // target must be rejected outright, not silently trusted.
818        let document = crate::cimd::document::ClientMetadataDocument {
819            client_id: "https://app.example.com/client.json".to_string(),
820            client_name: "Example".to_string(),
821            redirect_uris: vec!["https://attacker.evil/steal-code".to_string()],
822        };
823        let err = allowed_uris_from_cimd_document(
824            &document,
825            "https://app.example.com/client.json",
826            "state-id",
827            &[],
828        )
829        .unwrap_err();
830        assert!(matches!(err, AuthError::Validation(_)));
831    }
832
833    #[tokio::test]
834    async fn authorize_rejects_a_cimd_client_id_that_targets_a_private_address() {
835        // A `client_id` shaped like a CIMD URL but pointing at a private
836        // address is rejected by the SSRF guard before any network I/O
837        // happens -- this proves the full wire-up (is_cimd_client_id
838        // routing, fetch_and_validate_client_metadata invocation, error
839        // mapping) end to end via a real /authorize HTTP request, without
840        // needing a reachable public HTTPS target.
841        let app = router(test_auth_state().await);
842        let response = app
843            .oneshot(
844                Request::builder()
845                    .uri("/authorize?response_type=code&client_id=https://127.0.0.1/client.json&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256")
846                    .body(Body::empty())
847                    .unwrap(),
848            )
849            .await
850            .unwrap();
851        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
852        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
853            .await
854            .unwrap();
855        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
856        // The response must carry only the generic message -- the detailed
857        // CimdError (which would reveal "resolved only to a private
858        // address" and leak internal-network-topology information to an
859        // anonymous caller) must never appear in the HTTP response body.
860        assert_eq!(
861            json["message"],
862            "client_id metadata document is invalid or unreachable"
863        );
864        let raw_body = String::from_utf8(body.to_vec()).unwrap();
865        assert!(!raw_body.contains("ssrf_blocked"), "{raw_body}");
866        assert!(!raw_body.contains("127.0.0.1"), "{raw_body}");
867        assert!(!raw_body.contains("private"), "{raw_body}");
868    }
869
870    #[tokio::test]
871    async fn register_accepts_public_dcr_and_enforces_loopback_redirects() {
872        let mut config = test_auth_config();
873        config.enable_dynamic_registration = true;
874        let app = router(test_auth_state_with_config(config).await);
875        let response = app
876            .clone()
877            .oneshot(
878                Request::builder()
879                    .method("POST")
880                    .uri("/register")
881                    .header(header::CONTENT_TYPE, "application/json")
882                    .body(Body::from(
883                        json!({
884                            "redirect_uris": ["http://127.0.0.1:7777/callback"]
885                        })
886                        .to_string(),
887                    ))
888                    .unwrap(),
889            )
890            .await
891            .unwrap();
892        assert_eq!(response.status(), StatusCode::OK);
893
894        let rejected = app
895            .oneshot(
896                Request::builder()
897                    .method("POST")
898                    .uri("/register")
899                    .header(header::CONTENT_TYPE, "application/json")
900                    .body(Body::from(
901                        json!({
902                            "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
903                        })
904                        .to_string(),
905                    ))
906                    .unwrap(),
907            )
908            .await
909            .unwrap();
910        assert_eq!(rejected.status(), StatusCode::BAD_REQUEST);
911    }
912
913    #[tokio::test]
914    async fn register_accepts_native_callback_endpoint_without_redirect_allowlist() {
915        let mut config = test_auth_config();
916        config.enable_dynamic_registration = true;
917        let state = test_auth_state_with_config(config).await;
918        let native_callback_endpoint = crate::metadata::native_callback_endpoint(&state);
919        let app = router(state);
920        let response = app
921            .oneshot(
922                Request::builder()
923                    .method("POST")
924                    .uri("/register")
925                    .header(header::CONTENT_TYPE, "application/json")
926                    .body(Body::from(
927                        json!({ "redirect_uris": [native_callback_endpoint] }).to_string(),
928                    ))
929                    .unwrap(),
930            )
931            .await
932            .unwrap();
933        assert_eq!(response.status(), StatusCode::OK);
934    }
935
936    #[tokio::test]
937    async fn register_rejects_native_callback_endpoint_smuggled_with_an_unsafe_redirect_uri() {
938        // The native-endpoint bypass in `register_client` is per-redirect_uri —
939        // confirm a registration that mixes the native endpoint with an
940        // otherwise-disallowed redirect_uri in the same request still fails
941        // validation for the whole request, rather than the native match
942        // short-circuiting the loop and letting the unsafe URI through.
943        let mut config = test_auth_config();
944        config.enable_dynamic_registration = true;
945        let state = test_auth_state_with_config(config).await;
946        let native_callback_endpoint = crate::metadata::native_callback_endpoint(&state);
947        let app = router(state);
948        let response = app
949            .oneshot(
950                Request::builder()
951                    .method("POST")
952                    .uri("/register")
953                    .header(header::CONTENT_TYPE, "application/json")
954                    .body(Body::from(
955                        json!({
956                            "redirect_uris": [
957                                native_callback_endpoint,
958                                "https://evil.example/callback",
959                            ]
960                        })
961                        .to_string(),
962                    ))
963                    .unwrap(),
964            )
965            .await
966            .unwrap();
967        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
968        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
969            .await
970            .unwrap();
971        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
972        assert_eq!(
973            json.get("error").and_then(|v| v.as_str()),
974            Some("invalid_redirect_uri"),
975            "must use the RFC 7591 error/error_description shape: {json}"
976        );
977        assert!(
978            json.get("error_description")
979                .and_then(|v| v.as_str())
980                .is_some_and(|s| !s.is_empty()),
981            "error_description must be present and non-empty: {json}"
982        );
983        assert!(json.get("kind").is_none());
984        assert!(json.get("message").is_none());
985    }
986
987    #[tokio::test]
988    async fn register_accepts_and_echoes_application_type() {
989        let mut config = test_auth_config();
990        config.enable_dynamic_registration = true;
991        let app = router(test_auth_state_with_config(config).await);
992        let response = app
993            .oneshot(
994                Request::builder()
995                    .method("POST")
996                    .uri("/register")
997                    .header(header::CONTENT_TYPE, "application/json")
998                    .body(Body::from(
999                        json!({
1000                            "redirect_uris": ["http://127.0.0.1:7777/callback"],
1001                            "application_type": "native"
1002                        })
1003                        .to_string(),
1004                    ))
1005                    .unwrap(),
1006            )
1007            .await
1008            .unwrap();
1009        assert_eq!(response.status(), StatusCode::OK);
1010        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1011            .await
1012            .unwrap();
1013        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1014        assert_eq!(
1015            json.get("application_type").and_then(|v| v.as_str()),
1016            Some("native"),
1017            "DCR response must echo the registered application_type: {json}"
1018        );
1019    }
1020
1021    #[tokio::test]
1022    async fn register_defaults_application_type_to_web_when_absent() {
1023        let mut config = test_auth_config();
1024        config.enable_dynamic_registration = true;
1025        let app = router(test_auth_state_with_config(config).await);
1026        let response = app
1027            .oneshot(
1028                Request::builder()
1029                    .method("POST")
1030                    .uri("/register")
1031                    .header(header::CONTENT_TYPE, "application/json")
1032                    .body(Body::from(
1033                        json!({
1034                            "redirect_uris": ["http://127.0.0.1:7777/callback"]
1035                        })
1036                        .to_string(),
1037                    ))
1038                    .unwrap(),
1039            )
1040            .await
1041            .unwrap();
1042        assert_eq!(response.status(), StatusCode::OK);
1043        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1044            .await
1045            .unwrap();
1046        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1047        assert_eq!(
1048            json.get("application_type").and_then(|v| v.as_str()),
1049            Some("web"),
1050            "absent application_type must default to web (OIDC default): {json}"
1051        );
1052    }
1053
1054    #[tokio::test]
1055    async fn register_rejects_invalid_application_type() {
1056        let mut config = test_auth_config();
1057        config.enable_dynamic_registration = true;
1058        let app = router(test_auth_state_with_config(config).await);
1059        let response = app
1060            .oneshot(
1061                Request::builder()
1062                    .method("POST")
1063                    .uri("/register")
1064                    .header(header::CONTENT_TYPE, "application/json")
1065                    .body(Body::from(
1066                        json!({
1067                            "redirect_uris": ["http://127.0.0.1:7777/callback"],
1068                            "application_type": "bogus"
1069                        })
1070                        .to_string(),
1071                    ))
1072                    .unwrap(),
1073            )
1074            .await
1075            .unwrap();
1076        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1077        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1078            .await
1079            .unwrap();
1080        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1081        assert_eq!(
1082            json.get("error").and_then(|v| v.as_str()),
1083            Some("invalid_client_metadata"),
1084            "must use the RFC 7591 error/error_description shape: {json}"
1085        );
1086        assert!(
1087            json.get("error_description")
1088                .and_then(|v| v.as_str())
1089                .is_some_and(|s| !s.is_empty()),
1090            "error_description must be present and non-empty: {json}"
1091        );
1092        assert!(json.get("kind").is_none());
1093        assert!(json.get("message").is_none());
1094    }
1095
1096    #[tokio::test]
1097    async fn native_poll_returns_202_with_no_code_for_an_unknown_state() {
1098        let app = router(test_auth_state().await);
1099        let response = app
1100            .oneshot(
1101                Request::builder()
1102                    .uri("/native/poll?state=never-issued")
1103                    .body(Body::empty())
1104                    .unwrap(),
1105            )
1106            .await
1107            .unwrap();
1108        assert_eq!(response.status(), StatusCode::ACCEPTED);
1109        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1110            .await
1111            .unwrap();
1112        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1113        assert!(json.get("code").is_none());
1114    }
1115
1116    #[tokio::test]
1117    async fn native_poll_rejects_missing_state() {
1118        let app = router(test_auth_state().await);
1119        let response = app
1120            .oneshot(
1121                Request::builder()
1122                    .uri("/native/poll?state=")
1123                    .body(Body::empty())
1124                    .unwrap(),
1125            )
1126            .await
1127            .unwrap();
1128        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1129    }
1130
1131    #[tokio::test]
1132    async fn native_poll_is_one_shot_and_returns_the_code_exactly_once() {
1133        let state = test_auth_state().await;
1134        state
1135            .store
1136            .insert_native_authorization_result(NativeAuthorizationResultRow {
1137                state: "poll-me".to_string(),
1138                code: "the-code".to_string(),
1139                created_at: now_unix(),
1140                expires_at: now_unix() + 300,
1141            })
1142            .await
1143            .unwrap();
1144        let app = router(state);
1145
1146        let first = app
1147            .clone()
1148            .oneshot(
1149                Request::builder()
1150                    .uri("/native/poll?state=poll-me")
1151                    .body(Body::empty())
1152                    .unwrap(),
1153            )
1154            .await
1155            .unwrap();
1156        assert_eq!(first.status(), StatusCode::OK);
1157        let body = axum::body::to_bytes(first.into_body(), usize::MAX)
1158            .await
1159            .unwrap();
1160        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1161        assert_eq!(json["code"], "the-code");
1162
1163        // Second poll for the same `state` must not still return the code —
1164        // `take_native_authorization_result` is a one-shot read-and-delete.
1165        let second = app
1166            .oneshot(
1167                Request::builder()
1168                    .uri("/native/poll?state=poll-me")
1169                    .body(Body::empty())
1170                    .unwrap(),
1171            )
1172            .await
1173            .unwrap();
1174        assert_eq!(second.status(), StatusCode::ACCEPTED);
1175    }
1176
1177    #[tokio::test]
1178    async fn native_callback_direct_hit_shows_expired_page_and_never_stores_a_code() {
1179        let app = router(test_auth_state().await);
1180        let response = app
1181            .oneshot(
1182                Request::builder()
1183                    .uri("/native/callback?state=whatever&code=attacker-supplied")
1184                    .body(Body::empty())
1185                    .unwrap(),
1186            )
1187            .await
1188            .unwrap();
1189        assert_eq!(response.status(), StatusCode::GONE);
1190    }
1191
1192    #[tokio::test]
1193    async fn insert_native_authorization_result_overwrites_on_state_collision() {
1194        // A retried /authorize with a reused `state` must not silently lose
1195        // the newer code — last-write-wins, not `DO NOTHING`.
1196        let state = test_auth_state().await;
1197        state
1198            .store
1199            .insert_native_authorization_result(NativeAuthorizationResultRow {
1200                state: "collide".to_string(),
1201                code: "first-code".to_string(),
1202                created_at: now_unix(),
1203                expires_at: now_unix() + 300,
1204            })
1205            .await
1206            .unwrap();
1207        state
1208            .store
1209            .insert_native_authorization_result(NativeAuthorizationResultRow {
1210                state: "collide".to_string(),
1211                code: "second-code".to_string(),
1212                created_at: now_unix(),
1213                expires_at: now_unix() + 300,
1214            })
1215            .await
1216            .unwrap();
1217        let fetched = state
1218            .store
1219            .take_native_authorization_result("collide")
1220            .await
1221            .unwrap()
1222            .expect("row should still be present");
1223        assert_eq!(fetched.code, "second-code");
1224    }
1225
1226    #[tokio::test]
1227    async fn callback_stores_native_flow_code_for_polling_instead_of_redirecting() {
1228        let native_state = test_auth_state_with_mock_google_native().await;
1229        let app = router(native_state);
1230        let response = app
1231            .clone()
1232            .oneshot(
1233                Request::builder()
1234                    .uri("/auth/google/callback?state=native-good-state&code=upstream-code")
1235                    .body(Body::empty())
1236                    .unwrap(),
1237            )
1238            .await
1239            .unwrap();
1240        // The native branch never redirects the browser — it shows a static
1241        // "signed in" page directly.
1242        assert_eq!(response.status(), StatusCode::OK);
1243        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1244            .await
1245            .unwrap();
1246        let body = String::from_utf8_lossy(&body);
1247        assert!(body.contains("Signed in"));
1248
1249        let poll = app
1250            .oneshot(
1251                Request::builder()
1252                    .uri("/native/poll?state=native-client-state")
1253                    .body(Body::empty())
1254                    .unwrap(),
1255            )
1256            .await
1257            .unwrap();
1258        assert_eq!(poll.status(), StatusCode::OK);
1259        let poll_body = axum::body::to_bytes(poll.into_body(), usize::MAX)
1260            .await
1261            .unwrap();
1262        let poll_json: serde_json::Value = serde_json::from_slice(&poll_body).unwrap();
1263        assert!(poll_json["code"].as_str().is_some());
1264    }
1265
1266    #[tokio::test]
1267    async fn register_accepts_allowed_non_loopback_redirect_patterns() {
1268        let mut config = test_auth_config();
1269        config.enable_dynamic_registration = true;
1270        config.allowed_client_redirect_uris =
1271            vec!["https://callback.example.com/callback/*".to_string()];
1272        let app = router(test_auth_state_with_config(config).await);
1273        let response = app
1274            .oneshot(
1275                Request::builder()
1276                    .method("POST")
1277                    .uri("/register")
1278                    .header(header::CONTENT_TYPE, "application/json")
1279                    .body(Body::from(
1280                        json!({
1281                            "redirect_uris": ["https://callback.example.com/callback/node-a"]
1282                        })
1283                        .to_string(),
1284                    ))
1285                    .unwrap(),
1286            )
1287            .await
1288            .unwrap();
1289        assert_eq!(response.status(), StatusCode::OK);
1290    }
1291
1292    #[tokio::test]
1293    async fn register_is_rate_limited_after_configured_burst() {
1294        let mut config = test_auth_config();
1295        config.enable_dynamic_registration = true;
1296        config.register_requests_per_minute = 1;
1297        let app = router(test_auth_state_with_config(config).await);
1298
1299        let first = app
1300            .clone()
1301            .oneshot(
1302                Request::builder()
1303                    .method("POST")
1304                    .uri("/register")
1305                    .header(header::CONTENT_TYPE, "application/json")
1306                    .body(Body::from(
1307                        json!({
1308                            "redirect_uris": ["http://127.0.0.1:7777/callback"]
1309                        })
1310                        .to_string(),
1311                    ))
1312                    .unwrap(),
1313            )
1314            .await
1315            .unwrap();
1316        assert_eq!(first.status(), StatusCode::OK);
1317
1318        let second = app
1319            .oneshot(
1320                Request::builder()
1321                    .method("POST")
1322                    .uri("/register")
1323                    .header(header::CONTENT_TYPE, "application/json")
1324                    .body(Body::from(
1325                        json!({
1326                            "redirect_uris": ["http://127.0.0.1:8888/callback"]
1327                        })
1328                        .to_string(),
1329                    ))
1330                    .unwrap(),
1331            )
1332            .await
1333            .unwrap();
1334        assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
1335    }
1336
1337    #[test]
1338    fn wildcard_redirect_patterns_support_leading_and_infix_matches() {
1339        assert!(wildcard_matches(
1340            "https://callback.example.com/callback/*",
1341            "https://callback.example.com/callback/node-a"
1342        ));
1343        assert!(wildcard_matches(
1344            "https://callback.*.com/callback/*",
1345            "https://callback.example.com/callback/node-a"
1346        ));
1347        assert!(!wildcard_matches("/callback", "/callback/extra"));
1348    }
1349
1350    #[test]
1351    fn host_patterns_support_full_label_wildcards_only() {
1352        assert!(host_pattern_matches(
1353            "callback.*.com",
1354            "callback.example.com"
1355        ));
1356        assert!(host_pattern_matches(
1357            "*.example.com",
1358            "callback.example.com"
1359        ));
1360        assert!(!host_pattern_matches(
1361            "callback.example.com*",
1362            "callback.example.com"
1363        ));
1364        assert!(!host_pattern_matches(
1365            "*.example.com",
1366            "callback.nested.example.com"
1367        ));
1368    }
1369
1370    #[test]
1371    fn wildcard_redirect_patterns_do_not_overmatch_similar_hosts() {
1372        assert!(!is_allowed_redirect_uri(
1373            "https://callback.example.com.evil.example/callback/node-a",
1374            &[String::from("https://callback.example.com/callback/*")]
1375        ));
1376        assert!(!is_allowed_redirect_uri(
1377            "https://callback.example.com.evil.example/callback",
1378            &[String::from("https://callback.example.com*")]
1379        ));
1380    }
1381
1382    #[test]
1383    fn native_app_scheme_redirect_uris_are_always_allowed() {
1384        // Native-app redirects (RFC 8252 §7.1) like `com.raycast:/oauth` or
1385        // `warp://mcp/oauth2callback` are scoped to whatever app the OS has
1386        // registered for that private-use scheme, so — like loopback — they
1387        // don't need a per-client allowlist entry.
1388        assert!(is_allowed_redirect_uri("com.raycast:/oauth", &[]));
1389        assert!(is_allowed_redirect_uri("warp://mcp/oauth2callback", &[]));
1390        assert!(is_allowed_redirect_uri(
1391            "com.raycast:/oauth",
1392            &[String::from("https://callback.example.com/callback/*")]
1393        ));
1394    }
1395
1396    #[test]
1397    fn script_executing_pseudo_schemes_are_never_auto_allowed() {
1398        assert!(!is_allowed_redirect_uri("javascript:alert(1)", &[]));
1399        assert!(!is_allowed_redirect_uri("data:text/html,evil", &[]));
1400        assert!(!is_allowed_redirect_uri("file:///etc/passwd", &[]));
1401    }
1402
1403    #[test]
1404    fn https_redirects_still_require_the_allowlist() {
1405        assert!(!is_allowed_redirect_uri(
1406            "https://evil.example/callback",
1407            &[String::from("https://callback.example.com/callback/*")]
1408        ));
1409        assert!(is_allowed_redirect_uri(
1410            "https://callback.example.com/callback/node-a",
1411            &[String::from("https://callback.example.com/callback/*")]
1412        ));
1413        assert!(is_allowed_redirect_uri(
1414            "https://chatgpt.com/connector/oauth/test-callback-id",
1415            &[String::from("https://chatgpt.com/connector/oauth/*")]
1416        ));
1417    }
1418
1419    #[test]
1420    fn all_https_redirect_pattern_allows_any_https_callback_only() {
1421        assert!(is_allowed_redirect_uri(
1422            "https://gemini.google.com/mcp/oauth/callback",
1423            &[String::from("https://*")]
1424        ));
1425        assert!(is_allowed_redirect_uri(
1426            "https://example.deeply.nested.client.invalid/path/callback?state=ok",
1427            &[String::from("https://*")]
1428        ));
1429        assert!(!is_allowed_redirect_uri(
1430            "http://example.deeply.nested.client.invalid/path/callback",
1431            &[String::from("https://*")]
1432        ));
1433    }
1434
1435    #[tokio::test]
1436    async fn authorize_persists_full_state_and_redirects_to_google() {
1437        let app = router(test_auth_state_with_registered_client().await);
1438        let response = app
1439            .oneshot(
1440                Request::builder()
1441                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256")
1442                    .body(Body::empty())
1443                    .unwrap(),
1444            )
1445            .await
1446            .unwrap();
1447        assert_eq!(response.status(), StatusCode::FOUND);
1448        let location = response
1449            .headers()
1450            .get(header::LOCATION)
1451            .unwrap()
1452            .to_str()
1453            .unwrap();
1454        assert!(location.contains("accounts.google.com"));
1455        assert!(location.contains("prompt=consent"));
1456    }
1457
1458    #[tokio::test]
1459    async fn authorize_omits_forced_consent_once_a_refresh_token_already_exists() {
1460        let state = test_auth_state_with_registered_client().await;
1461        state
1462            .store
1463            .upsert_refresh_token(crate::types::RefreshTokenRow {
1464                refresh_token: "existing-refresh".to_string(),
1465                client_id: "client".to_string(),
1466                subject: "google-user".to_string(),
1467                resource: "https://lab.example.com/mcp".to_string(),
1468                scope: "lab".to_string(),
1469                provider: "google".to_string(),
1470                provider_refresh_token: None,
1471                created_at: now_unix(),
1472                expires_at: now_unix() + 3600,
1473            })
1474            .await
1475            .unwrap();
1476        let app = router(state);
1477
1478        let response = app
1479            .oneshot(
1480                Request::builder()
1481                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256")
1482                    .body(Body::empty())
1483                    .unwrap(),
1484            )
1485            .await
1486            .unwrap();
1487        assert_eq!(response.status(), StatusCode::FOUND);
1488        let location = response
1489            .headers()
1490            .get(header::LOCATION)
1491            .unwrap()
1492            .to_str()
1493            .unwrap();
1494        assert!(location.contains("accounts.google.com"));
1495        assert!(!location.contains("prompt="));
1496    }
1497
1498    #[tokio::test]
1499    async fn authorize_accepts_configured_protected_resource_scopes() {
1500        let state = test_auth_state_with_registered_client().await;
1501        state.set_allowed_resource_scopes([(
1502            "https://mcp.example.com/syslog".to_string(),
1503            vec!["mcp:read".to_string(), "mcp:write".to_string()],
1504        )]);
1505        let app = router(state);
1506
1507        let response = app
1508            .oneshot(
1509                Request::builder()
1510                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&resource=https%3A%2F%2Fmcp.example.com%2Fsyslog&scope=mcp%3Aread%20mcp%3Awrite&code_challenge=pkce&code_challenge_method=S256")
1511                    .body(Body::empty())
1512                    .unwrap(),
1513            )
1514            .await
1515            .unwrap();
1516
1517        assert_eq!(response.status(), StatusCode::FOUND);
1518    }
1519
1520    #[tokio::test]
1521    async fn authorize_is_rate_limited_after_configured_burst() {
1522        let mut config = test_auth_config();
1523        config.authorize_requests_per_minute = 1;
1524        let state = test_auth_state_with_config(config).await;
1525        state
1526            .store
1527            .register_client(RegisteredClient {
1528                client_id: "client".to_string(),
1529                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
1530                created_at: now_unix(),
1531            })
1532            .await
1533            .unwrap();
1534        let app = router(state);
1535
1536        let first = app
1537            .clone()
1538            .oneshot(
1539                Request::builder()
1540                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256")
1541                    .body(Body::empty())
1542                    .unwrap(),
1543            )
1544            .await
1545            .unwrap();
1546        assert_eq!(first.status(), StatusCode::FOUND);
1547
1548        let second = app
1549            .oneshot(
1550                Request::builder()
1551                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=def&scope=lab&code_challenge=pkce&code_challenge_method=S256")
1552                    .body(Body::empty())
1553                    .unwrap(),
1554            )
1555            .await
1556            .unwrap();
1557        assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
1558    }
1559
1560    #[tokio::test]
1561    async fn browser_login_starts_upstream_flow_and_persists_return_to_state() {
1562        let state = test_auth_state().await;
1563        let app = router(state.clone());
1564        let response = app
1565            .oneshot(
1566                Request::builder()
1567                    .uri("/auth/login?return_to=%2Fgateways%2F%3Ftab%3Dlab")
1568                    .body(Body::empty())
1569                    .unwrap(),
1570            )
1571            .await
1572            .unwrap();
1573        assert_eq!(response.status(), StatusCode::FOUND);
1574        let location = Url::parse(
1575            response
1576                .headers()
1577                .get(header::LOCATION)
1578                .unwrap()
1579                .to_str()
1580                .unwrap(),
1581        )
1582        .unwrap();
1583        let upstream_state = location
1584            .query_pairs()
1585            .find(|(key, _)| key == "state")
1586            .map(|(_, value)| value.into_owned())
1587            .unwrap();
1588        let stored = state
1589            .store
1590            .take_browser_login_state(&upstream_state)
1591            .await
1592            .unwrap()
1593            .unwrap();
1594        assert_eq!(stored.return_to, "/gateways/?tab=lab");
1595    }
1596
1597    #[tokio::test]
1598    async fn browser_login_rejects_when_pending_oauth_state_cap_is_reached() {
1599        let mut config = test_auth_config();
1600        config.max_pending_oauth_states = 1;
1601        let state = test_auth_state_with_config(config).await;
1602        state
1603            .store
1604            .insert_browser_login_state(crate::types::BrowserLoginStateRow {
1605                state: "existing-login".to_string(),
1606                return_to: "/".to_string(),
1607                provider: "google".to_string(),
1608                provider_code_verifier: "provider-verifier".to_string(),
1609                created_at: now_unix(),
1610                expires_at: now_unix() + 300,
1611            })
1612            .await
1613            .unwrap();
1614
1615        let app = router(state);
1616        let response = app
1617            .oneshot(
1618                Request::builder()
1619                    .uri("/auth/login?return_to=%2Fgateways%2F")
1620                    .body(Body::empty())
1621                    .unwrap(),
1622            )
1623            .await
1624            .unwrap();
1625        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
1626    }
1627
1628    #[tokio::test]
1629    async fn callback_rejects_expired_or_mismatched_state() {
1630        let app = router(test_auth_state_with_mock_google().await);
1631        let response = app
1632            .oneshot(
1633                Request::builder()
1634                    .uri("/auth/google/callback?state=bad-state&code=upstream-code")
1635                    .body(Body::empty())
1636                    .unwrap(),
1637            )
1638            .await
1639            .unwrap();
1640        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1641    }
1642
1643    #[tokio::test]
1644    async fn browser_login_callback_sets_session_cookie_and_redirects_home() {
1645        let state = test_auth_state_with_mock_google().await;
1646        let app = router(state.clone());
1647        let login = app
1648            .clone()
1649            .oneshot(
1650                Request::builder()
1651                    .uri("/auth/login?return_to=%2Fgateways%2F")
1652                    .body(Body::empty())
1653                    .unwrap(),
1654            )
1655            .await
1656            .unwrap();
1657        let location = Url::parse(
1658            login
1659                .headers()
1660                .get(header::LOCATION)
1661                .unwrap()
1662                .to_str()
1663                .unwrap(),
1664        )
1665        .unwrap();
1666        let upstream_state = location
1667            .query_pairs()
1668            .find(|(key, _)| key == "state")
1669            .map(|(_, value)| value.into_owned())
1670            .unwrap();
1671
1672        let callback = app
1673            .oneshot(
1674                Request::builder()
1675                    .uri(format!(
1676                        "/auth/google/callback?state={upstream_state}&code=upstream-code"
1677                    ))
1678                    .body(Body::empty())
1679                    .unwrap(),
1680            )
1681            .await
1682            .unwrap();
1683        assert_eq!(callback.status(), StatusCode::SEE_OTHER);
1684        assert_eq!(
1685            callback.headers().get(header::LOCATION).unwrap(),
1686            "/gateways/"
1687        );
1688        let cookie = callback
1689            .headers()
1690            .get_all(header::SET_COOKIE)
1691            .iter()
1692            .find_map(|value| value.to_str().ok())
1693            .unwrap();
1694        assert!(cookie.contains("lab_session="));
1695    }
1696
1697    #[tokio::test]
1698    async fn oauth_client_callback_redirects_with_access_denied_when_email_not_in_allowlist() {
1699        let mut config = test_auth_config();
1700        config.admin_email = "allowed@example.com".to_string();
1701        let base_state = test_auth_state_with_config(config).await;
1702        base_state
1703            .store
1704            .register_client(RegisteredClient {
1705                client_id: "client".to_string(),
1706                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
1707                created_at: now_unix(),
1708            })
1709            .await
1710            .unwrap();
1711        // Pre-insert an authorization request (OAuth-client flow, not browser-login).
1712        base_state
1713            .store
1714            .insert_authorization_request(AuthorizationRequestRow {
1715                state: "good-state".to_string(),
1716                client_id: "client".to_string(),
1717                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
1718                client_state: "client-abc".to_string(),
1719                resource: "https://lab.example.com/mcp".to_string(),
1720                scope: "lab".to_string(),
1721                provider: "google".to_string(),
1722                provider_code_verifier: "provider-verifier".to_string(),
1723                code_challenge: "challenge".to_string(),
1724                code_challenge_method: "S256".to_string(),
1725                created_at: now_unix(),
1726                expires_at: now_unix() + 300,
1727            })
1728            .await
1729            .unwrap();
1730
1731        let server = Box::leak(Box::new(MockServer::start().await));
1732        Mock::given(method("POST"))
1733            .and(path("/token"))
1734            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1735                "access_token": "google-access-token",
1736                "refresh_token": "refresh-token",
1737                "expires_in": 3600,
1738                "id_token": signed_test_id_token(), // email=user@example.com, not in allowlist
1739            })))
1740            .mount(server)
1741            .await;
1742        Mock::given(method("GET"))
1743            .and(path("/certs"))
1744            .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
1745            .mount(server)
1746            .await;
1747
1748        let google = GoogleProvider::new(
1749            "client-id".to_string(),
1750            "client-secret".to_string(),
1751            Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
1752        )
1753        .unwrap()
1754        .with_endpoints(
1755            server.uri().parse::<Url>().unwrap(),
1756            server.uri().parse::<Url>().unwrap().join("/token").unwrap(),
1757        )
1758        .with_jwks_endpoint(server.uri().parse::<Url>().unwrap().join("/certs").unwrap());
1759
1760        let state = AuthState::for_tests(
1761            (*base_state.config).clone(),
1762            base_state.store.clone(),
1763            (*base_state.signing_keys).clone(),
1764            AuthState::google_only_providers(google),
1765        );
1766        let expected_iss = crate::metadata::public_base_url(&state);
1767        let app = router(state);
1768
1769        let response = app
1770            .oneshot(
1771                Request::builder()
1772                    .uri("/auth/google/callback?state=good-state&code=upstream-code")
1773                    .body(Body::empty())
1774                    .unwrap(),
1775            )
1776            .await
1777            .unwrap();
1778
1779        // Must redirect (not 401) with error=access_denied and the original client state.
1780        assert_eq!(response.status(), StatusCode::SEE_OTHER);
1781        let location = response
1782            .headers()
1783            .get(header::LOCATION)
1784            .unwrap()
1785            .to_str()
1786            .unwrap();
1787        let redirect = Url::parse(location).unwrap();
1788        let params: std::collections::HashMap<_, _> = redirect.query_pairs().collect();
1789        assert_eq!(
1790            params.get("error").map(|v| v.as_ref()),
1791            Some("access_denied")
1792        );
1793        assert_eq!(params.get("state").map(|v| v.as_ref()), Some("client-abc"));
1794        assert_eq!(
1795            params.get("iss").map(|v| v.as_ref()),
1796            Some(expected_iss.as_str()),
1797            "RFC 9207 iss must be present on the error response: {location}"
1798        );
1799    }
1800
1801    #[tokio::test]
1802    async fn browser_login_callback_rejects_email_not_in_allowlist() {
1803        let mut config = test_auth_config();
1804        // "allowed@example.com" is permitted; the mock id_token returns
1805        // "user@example.com" → callback must be denied with 401.
1806        config.admin_email = "allowed@example.com".to_string();
1807        let base_state = test_auth_state_with_config(config).await;
1808        base_state
1809            .store
1810            .register_client(RegisteredClient {
1811                client_id: "client".to_string(),
1812                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
1813                created_at: now_unix(),
1814            })
1815            .await
1816            .unwrap();
1817
1818        let server = Box::leak(Box::new(MockServer::start().await));
1819        Mock::given(method("POST"))
1820            .and(path("/token"))
1821            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1822                "access_token": "google-access-token",
1823                "refresh_token": "refresh-token",
1824                "expires_in": 3600,
1825                "id_token": signed_test_id_token(),
1826            })))
1827            .mount(server)
1828            .await;
1829        Mock::given(method("GET"))
1830            .and(path("/certs"))
1831            .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
1832            .mount(server)
1833            .await;
1834
1835        let google = GoogleProvider::new(
1836            "client-id".to_string(),
1837            "client-secret".to_string(),
1838            Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
1839        )
1840        .unwrap()
1841        .with_endpoints(
1842            server.uri().parse::<Url>().unwrap(),
1843            server.uri().parse::<Url>().unwrap().join("/token").unwrap(),
1844        )
1845        .with_jwks_endpoint(server.uri().parse::<Url>().unwrap().join("/certs").unwrap());
1846
1847        let state = AuthState::for_tests(
1848            (*base_state.config).clone(),
1849            base_state.store.clone(),
1850            (*base_state.signing_keys).clone(),
1851            AuthState::google_only_providers(google),
1852        );
1853        let app = router(state.clone());
1854
1855        let login = app
1856            .clone()
1857            .oneshot(
1858                Request::builder()
1859                    .uri("/auth/login?return_to=%2F")
1860                    .body(Body::empty())
1861                    .unwrap(),
1862            )
1863            .await
1864            .unwrap();
1865        let location = Url::parse(
1866            login
1867                .headers()
1868                .get(header::LOCATION)
1869                .unwrap()
1870                .to_str()
1871                .unwrap(),
1872        )
1873        .unwrap();
1874        let upstream_state = location
1875            .query_pairs()
1876            .find(|(key, _)| key == "state")
1877            .map(|(_, value)| value.into_owned())
1878            .unwrap();
1879
1880        let callback = app
1881            .oneshot(
1882                Request::builder()
1883                    .uri(format!(
1884                        "/auth/google/callback?state={upstream_state}&code=upstream-code"
1885                    ))
1886                    .body(Body::empty())
1887                    .unwrap(),
1888            )
1889            .await
1890            .unwrap();
1891        assert_eq!(callback.status(), StatusCode::UNAUTHORIZED);
1892    }
1893
1894    #[tokio::test]
1895    async fn authorize_rejects_missing_or_invalid_response_type() {
1896        let app = router(test_auth_state_with_registered_client().await);
1897        for uri in [
1898            "/authorize?client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256",
1899            "/authorize?response_type=token&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256",
1900        ] {
1901            let response = app
1902                .clone()
1903                .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1904                .await
1905                .unwrap();
1906            assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1907        }
1908    }
1909
1910    #[tokio::test]
1911    async fn validate_scope_accepts_supported_scopes_and_rejects_others() {
1912        let state = test_auth_state().await;
1913        let canonical = crate::metadata::canonical_resource_url(&state);
1914        // Empty scope falls back to configured default ("lab").
1915        assert_eq!(
1916            super::validate_scope(&state, &canonical, "").unwrap(),
1917            "lab"
1918        );
1919        // Base scope passes.
1920        assert_eq!(
1921            super::validate_scope(&state, &canonical, "lab").unwrap(),
1922            "lab"
1923        );
1924        // `:admin` is in `scopes_supported` by default — MCP clients can request
1925        // it explicitly. (Allowed-emails users also receive it implicitly via
1926        // elevate_scope_for_allowed_user at callback time.)
1927        assert_eq!(
1928            super::validate_scope(&state, &canonical, "lab:admin").unwrap(),
1929            "lab:admin"
1930        );
1931        // Anything not in scopes_supported is rejected.
1932        let err = super::validate_scope(&state, &canonical, "lab:write").unwrap_err();
1933        assert!(err.to_string().contains("lab"), "got: {err}");
1934    }
1935
1936    #[test]
1937    fn elevate_scope_adds_admin_when_missing() {
1938        assert_eq!(
1939            super::elevate_scope_for_allowed_user("lab", "lab"),
1940            "lab lab:admin"
1941        );
1942        // Already has admin → no duplication.
1943        assert_eq!(
1944            super::elevate_scope_for_allowed_user("lab lab:admin", "lab"),
1945            "lab lab:admin"
1946        );
1947        // Empty scope → just admin (rare; OAuth default normally fills `lab`).
1948        assert_eq!(
1949            super::elevate_scope_for_allowed_user("", "lab"),
1950            "lab:admin"
1951        );
1952        // Different brand prefix (syslog, axon, etc.) uses its own default.
1953        assert_eq!(
1954            super::elevate_scope_for_allowed_user("syslog", "syslog"),
1955            "syslog syslog:admin"
1956        );
1957        // default_scope with verb suffix (e.g. syslog:read) → admin uses base prefix only,
1958        // not syslog:read:admin.
1959        assert_eq!(
1960            super::elevate_scope_for_allowed_user("syslog:read", "syslog:read"),
1961            "syslog:read syslog:admin"
1962        );
1963        // Already has correct admin even when default_scope carries a suffix.
1964        assert_eq!(
1965            super::elevate_scope_for_allowed_user("syslog:read syslog:admin", "syslog:read"),
1966            "syslog:read syslog:admin"
1967        );
1968        // Cross-brand: protected route token (mcp:read mcp:write) for a lab
1969        // default_scope gets lab:admin injected so authenticate_protected_route_request
1970        // can recognise the admin without re-reading the allowlist.
1971        assert_eq!(
1972            super::elevate_scope_for_allowed_user("mcp:read mcp:write", "lab"),
1973            "mcp:read mcp:write lab:admin"
1974        );
1975        // Cross-brand already has admin → no duplication.
1976        assert_eq!(
1977            super::elevate_scope_for_allowed_user("mcp:read mcp:write lab:admin", "lab"),
1978            "mcp:read mcp:write lab:admin"
1979        );
1980    }
1981
1982    #[tokio::test]
1983    async fn authorize_rejects_invalid_scope() {
1984        let app = router(test_auth_state_with_registered_client().await);
1985        // `lab:write` is NOT in default scopes_supported; should be rejected.
1986        // (`lab:admin` IS in scopes_supported as of 2026-05; use a different
1987        // unsupported scope here.)
1988        let response = app
1989            .oneshot(
1990                Request::builder()
1991                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab:write&code_challenge=pkce&code_challenge_method=S256")
1992                    .body(Body::empty())
1993                    .unwrap(),
1994            )
1995            .await
1996            .unwrap();
1997        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1998    }
1999
2000    #[tokio::test]
2001    async fn authorize_rejects_mismatched_resource_parameter() {
2002        let app = router(test_auth_state_with_registered_client().await);
2003        let response = app
2004            .oneshot(
2005                Request::builder()
2006                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&resource=https://other.example.com/mcp&scope=lab&code_challenge=pkce&code_challenge_method=S256")
2007                    .body(Body::empty())
2008                    .unwrap(),
2009            )
2010            .await
2011            .unwrap();
2012        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2013    }
2014
2015    #[tokio::test]
2016    async fn callback_rejects_expired_state() {
2017        let state = test_auth_state_with_registered_client().await;
2018        state
2019            .store
2020            .insert_authorization_request(AuthorizationRequestRow {
2021                state: "expired-state".to_string(),
2022                client_id: "client".to_string(),
2023                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
2024                client_state: "client-state".to_string(),
2025                resource: "https://lab.example.com/mcp".to_string(),
2026                scope: "lab".to_string(),
2027                provider: "google".to_string(),
2028                provider_code_verifier: "provider-verifier".to_string(),
2029                code_challenge: "challenge".to_string(),
2030                code_challenge_method: "S256".to_string(),
2031                created_at: now_unix() - 300,
2032                expires_at: now_unix() - 1,
2033            })
2034            .await
2035            .unwrap();
2036        let app = router(state);
2037        let response = app
2038            .oneshot(
2039                Request::builder()
2040                    .uri("/auth/google/callback?state=expired-state&code=upstream-code")
2041                    .body(Body::empty())
2042                    .unwrap(),
2043            )
2044            .await
2045            .unwrap();
2046        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
2047    }
2048
2049    pub async fn test_auth_state() -> AuthState {
2050        test_auth_state_with_config(test_auth_config()).await
2051    }
2052
2053    pub async fn test_auth_state_with_config(config: AuthConfig) -> AuthState {
2054        AuthState::new(config).await.unwrap()
2055    }
2056
2057    pub(crate) fn test_auth_config() -> AuthConfig {
2058        let dir = Box::leak(Box::new(tempfile::tempdir().unwrap()));
2059        AuthConfig {
2060            mode: AuthMode::OAuth,
2061            public_url: Some(Url::parse("https://lab.example.com").unwrap()),
2062            sqlite_path: dir.path().join("auth.db"),
2063            key_path: dir.path().join("auth-jwt.pem"),
2064            bootstrap_secret: Some("bootstrap-secret".to_string()),
2065            enable_dynamic_registration: true,
2066            allowed_client_redirect_uris: Vec::new(),
2067            // Matches the mock id_token email returned by signed_test_id_token,
2068            // so happy-path callback tests pass the allowlist check.
2069            admin_email: "user@example.com".to_string(),
2070            google: GoogleConfig {
2071                client_id: "client-id".to_string(),
2072                client_secret: "client-secret".to_string(),
2073                callback_path: "/auth/google/callback".to_string(),
2074                scopes: vec![
2075                    "openid".to_string(),
2076                    "email".to_string(),
2077                    "profile".to_string(),
2078                ],
2079            },
2080            default_provider: "google".to_string(),
2081            ..AuthConfig::default()
2082        }
2083    }
2084
2085    pub async fn test_auth_state_with_registered_client() -> AuthState {
2086        let state = test_auth_state().await;
2087        state
2088            .store
2089            .register_client(RegisteredClient {
2090                client_id: "client".to_string(),
2091                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
2092                created_at: now_unix(),
2093            })
2094            .await
2095            .unwrap();
2096        state
2097    }
2098
2099    pub(crate) async fn test_auth_state_with_mock_google() -> AuthState {
2100        let state = test_auth_state_with_registered_client().await;
2101        let server = Box::leak(Box::new(MockServer::start().await));
2102        Mock::given(method("POST"))
2103            .and(path("/token"))
2104            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2105                "access_token": "google-access-token",
2106                "refresh_token": "refresh-token",
2107                "expires_in": 3600,
2108                "id_token": signed_test_id_token(),
2109            })))
2110            .mount(server)
2111            .await;
2112        Mock::given(method("GET"))
2113            .and(path("/certs"))
2114            .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
2115            .mount(server)
2116            .await;
2117        state
2118            .store
2119            .insert_authorization_request(AuthorizationRequestRow {
2120                state: "good-state".to_string(),
2121                client_id: "client".to_string(),
2122                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
2123                client_state: "client-state".to_string(),
2124                resource: "https://lab.example.com/mcp".to_string(),
2125                scope: "lab".to_string(),
2126                provider: "google".to_string(),
2127                provider_code_verifier: "provider-verifier".to_string(),
2128                code_challenge: "challenge".to_string(),
2129                code_challenge_method: "S256".to_string(),
2130                created_at: now_unix(),
2131                expires_at: now_unix() + 300,
2132            })
2133            .await
2134            .unwrap();
2135        let google = GoogleProvider::new(
2136            "client-id".to_string(),
2137            "client-secret".to_string(),
2138            Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
2139        )
2140        .unwrap()
2141        .with_endpoints(
2142            server.uri().parse::<Url>().unwrap(),
2143            server.uri().parse::<Url>().unwrap().join("/token").unwrap(),
2144        )
2145        .with_jwks_endpoint(server.uri().parse::<Url>().unwrap().join("/certs").unwrap());
2146        AuthState::for_tests(
2147            (*state.config).clone(),
2148            state.store.clone(),
2149            (*state.signing_keys).clone(),
2150            AuthState::google_only_providers(google),
2151        )
2152    }
2153
2154    /// Same mocked-Google harness as [`test_auth_state_with_mock_google`], but
2155    /// the pending authorization request's `redirect_uri` is the server's own
2156    /// `native_callback_endpoint` — exercising the native-flow branch of
2157    /// `callback()` instead of the normal client-redirect branch.
2158    async fn test_auth_state_with_mock_google_native() -> AuthState {
2159        let state = test_auth_state().await;
2160        let native_callback_endpoint = crate::metadata::native_callback_endpoint(&state);
2161        state
2162            .store
2163            .register_client(RegisteredClient {
2164                client_id: "native-client".to_string(),
2165                redirect_uris: vec![native_callback_endpoint.clone()],
2166                created_at: now_unix(),
2167            })
2168            .await
2169            .unwrap();
2170        let server = Box::leak(Box::new(MockServer::start().await));
2171        Mock::given(method("POST"))
2172            .and(path("/token"))
2173            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2174                "access_token": "google-access-token",
2175                "refresh_token": "refresh-token",
2176                "expires_in": 3600,
2177                "id_token": signed_test_id_token(),
2178            })))
2179            .mount(server)
2180            .await;
2181        Mock::given(method("GET"))
2182            .and(path("/certs"))
2183            .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
2184            .mount(server)
2185            .await;
2186        state
2187            .store
2188            .insert_authorization_request(AuthorizationRequestRow {
2189                state: "native-good-state".to_string(),
2190                client_id: "native-client".to_string(),
2191                redirect_uri: native_callback_endpoint,
2192                client_state: "native-client-state".to_string(),
2193                resource: "https://lab.example.com/mcp".to_string(),
2194                scope: "lab".to_string(),
2195                provider: "google".to_string(),
2196                provider_code_verifier: "provider-verifier".to_string(),
2197                code_challenge: "challenge".to_string(),
2198                code_challenge_method: "S256".to_string(),
2199                created_at: now_unix(),
2200                expires_at: now_unix() + 300,
2201            })
2202            .await
2203            .unwrap();
2204        let google = GoogleProvider::new(
2205            "client-id".to_string(),
2206            "client-secret".to_string(),
2207            Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
2208        )
2209        .unwrap()
2210        .with_endpoints(
2211            server.uri().parse::<Url>().unwrap(),
2212            server.uri().parse::<Url>().unwrap().join("/token").unwrap(),
2213        )
2214        .with_jwks_endpoint(server.uri().parse::<Url>().unwrap().join("/certs").unwrap());
2215        AuthState::for_tests(
2216            (*state.config).clone(),
2217            state.store.clone(),
2218            (*state.signing_keys).clone(),
2219            AuthState::google_only_providers(google),
2220        )
2221    }
2222
2223    fn signed_test_id_token() -> String {
2224        let claims = json!({
2225            "iss": "https://accounts.google.com",
2226            "aud": "client-id",
2227            "sub": "google-subject-123",
2228            "email": "user@example.com",
2229            "email_verified": true,
2230            "iat": now_unix() as usize,
2231            "exp": (now_unix() + 3600) as usize,
2232        });
2233        let mut header = Header::new(Algorithm::RS256);
2234        header.kid = Some("test-kid".to_string());
2235        encode(&header, &claims, &test_encoding_key()).unwrap()
2236    }
2237
2238    fn test_jwks() -> serde_json::Value {
2239        let key = test_rsa_key();
2240        let public_key = key.to_public_key();
2241        json!({
2242            "keys": [{
2243                "kid": "test-kid",
2244                "alg": "RS256",
2245                "kty": "RSA",
2246                "use": "sig",
2247                "n": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key.n_bytes()),
2248                "e": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key.e_bytes()),
2249            }]
2250        })
2251    }
2252
2253    fn test_rsa_key() -> RsaPrivateKey {
2254        RsaPrivateKey::from_pkcs8_pem(TEST_RSA_KEY_PEM).unwrap()
2255    }
2256
2257    fn test_encoding_key() -> EncodingKey {
2258        let pem = test_rsa_key().to_pkcs8_pem(LineEnding::LF).unwrap();
2259        EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap()
2260    }
2261
2262    const TEST_RSA_KEY_PEM: &str = r"-----BEGIN PRIVATE KEY-----
2263MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/Wa3MQnrNbKu9
2264H5+ZH30lrKV3+EJeuY0ofx3qMx73ax+ArHaPFHXq3PUAalSZ+UlBqRmX89DdzwWG
2265l5hqt3wzGjGe49zxhY5+nUUPLtRiI4JH0iEH4Bg3W9e9gWAAPjVemuYmZ57R9XOd
2266O1l0aI20mZiy4jeEN7Ls40I/pwyTcB22krOeHz13E1NzG+uDQnaMZkOKomRdTkKr
2267tiSETBcpacpIdyLtdc9lHR4LbcZtBH3aMosjmgae3uvQyks6ntj0UQZaKNYqNwNE
2268+GSOqQdtJeoWhps1IYjhc9wcfrlL69nn5U4FXwCcPzGOKXCOW45/BB4nr2WF2Bkq
2269N7iytDv/AgMBAAECggEABt1BtdUgsKPYWVV8FTMi+yoBWZdnUhyX6r78pL0mvDt0
2270itok+qcCP+WjSFuII2nk7d0SFPhjIsHdceGYTyO76d1jsE5+S4+9997ObmgAqHCb
2271qNXp521rkPjTeXHdrsSMh5NI9FG9SczjU92gLOPfSX5FEw24bh7NZWAVrVDhy5wn
2272BWAZow2kByQ2SLRitUJr+a1xF3UO3PgHLKdP0H0qZp9TCar3nzJxwMUyGJxOcd4f
2273mElyYNIsJtOBsIIoBsNh+aj5pSjOiuEZmfipbHuMWpjEwF1+UVH4iPXQugyKgFze
2274Gc8wy3aFlmA4dH2jbSzP3aIwiFUDgqsUrqdyEXVVeQKBgQD5/psH3uk3AOkRC/k/
2275P6cI5pwFG0rFRe3UgBJFqODnbTZR+0BwyTqf9kCZgi0nJIudCNyUF5utl8rkWdwE
2276s2s42NibGWTVyb5dabT+dHwP42jFljCxxbZw1D3GmP1mX0ybyXj0BOqWEpMHc76q
2277ZxzJFfML0FfyTxMVycukBL4bEwKBgQDD8m2Y5GvO17RJDeG6yPupTvWbcBaUTuwe
22780w9LOWSOYi3YPAIt7m6yE9XH9cWSFqXMoOAS5Lu1zUuBvwhZz3XAAeL9JpU2F/1V
2279DW7NiChNb7Np2X1dUHZTS5EmaAkok55uEMfA1N1FhsDfN+qCxVPITUszYwrPCu52
2280SMd4Nx5s5QKBgQDfK6woTZWyNYzaW+8IyIEL0BqN8HxCOZgD8MTfDNChqHwqmXpA
2281dVNxg3rNz0kRvW0pJcUMKzsdr/k++v0P8T+RwvszEmtS8sOPTpN16HTsFh3s7ZPQ
2282z2h7tuzjAqaMIh0YobXpWQ42JKS+rVQTePNYi9CpxjcMqAyokbnKVTWEowKBgFrB
22835/eAHVsh19RahKoyOzZRZztGsH6jC4S/d379J1E3skpMiSnjHQyIWWWTtZ4TtVnR
2284TdgSb8smOonvBJwsljqH5S4h98ylUeZaIW87WId9bFljrkhRY2zzPFjQqSVNMn2C
2285cjMjpRV189GwIYPOiB7nhiRYBIKfapII5bMNvJ7tAoGACMvtonFh25b7gB7j3Pep
2286LEH/fA5CRiOs7Plrt2Sv54wAup4Y6+HQ8i/KFOXIejEN9vfY1YRfyD5Ajc05zg90
2287uE8aLb5YtFvoaLAnc/A2ceW8sNxGgT5aPyLPUdmfSryAO4ayFDHmRlGFRsZtTUbn
2288Iy60nwnOxK6B5mZV2Cs+kv8=
2289-----END PRIVATE KEY-----";
2290
2291    /// Tests that exercise the merged allowlist path through real callback handlers.
2292    /// These verify that `resolve_allowed_emails` is correctly wired at both call
2293    /// sites (browser-login branch and oauth-client branch).
2294    mod merged_allowlist_callback_tests {
2295        use axum::body::Body;
2296        use axum::http::{Request, StatusCode, header};
2297        use serde_json::json;
2298        use tower::util::ServiceExt;
2299        use url::Url;
2300        use wiremock::matchers::{method, path};
2301        use wiremock::{Mock, MockServer, ResponseTemplate};
2302
2303        use super::{
2304            signed_test_id_token, test_auth_config, test_auth_state_with_config,
2305            test_auth_state_with_mock_google, test_jwks,
2306        };
2307        use crate::google::GoogleProvider;
2308        use crate::routes::router;
2309        use crate::state::AuthState;
2310        use crate::types::{AuthorizationRequestRow, BrowserLoginStateRow, RegisteredClient};
2311        use crate::util::now_unix;
2312
2313        /// Helper that mounts Google mock endpoints on a fresh server and builds
2314        /// an `AuthState` with that mock, reusing an existing base state's store
2315        /// and signing keys (so DB writes made to `base_state.store` are visible).
2316        async fn state_with_mock_google_from(base_state: &AuthState) -> AuthState {
2317            let server = Box::leak(Box::new(MockServer::start().await));
2318            Mock::given(method("POST"))
2319                .and(path("/token"))
2320                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2321                    "access_token": "google-access-token",
2322                    "refresh_token": "refresh-token",
2323                    "expires_in": 3600,
2324                    "id_token": signed_test_id_token(),
2325                })))
2326                .mount(server)
2327                .await;
2328            Mock::given(method("GET"))
2329                .and(path("/certs"))
2330                .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
2331                .mount(server)
2332                .await;
2333            let google = GoogleProvider::new(
2334                "client-id".to_string(),
2335                "client-secret".to_string(),
2336                Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
2337            )
2338            .unwrap()
2339            .with_endpoints(
2340                server.uri().parse::<Url>().unwrap(),
2341                server.uri().parse::<Url>().unwrap().join("/token").unwrap(),
2342            )
2343            .with_jwks_endpoint(server.uri().parse::<Url>().unwrap().join("/certs").unwrap());
2344            AuthState::for_tests(
2345                (*base_state.config).clone(),
2346                base_state.store.clone(),
2347                (*base_state.signing_keys).clone(),
2348                AuthState::google_only_providers(google),
2349            )
2350        }
2351
2352        /// The mock id_token always returns `user@example.com`. When admin is set
2353        /// to a *different* email and that address is added to `allowed_users`, the
2354        /// browser-login callback must succeed (DB row authorises the login).
2355        #[tokio::test]
2356        async fn browser_login_succeeds_for_allowlisted_non_admin_email() {
2357            let mut config = test_auth_config();
2358            // Set admin to something other than the id_token email.
2359            config.admin_email = "admin@example.com".to_string();
2360            let base_state = test_auth_state_with_config(config).await;
2361
2362            // Insert id_token email into allowed_users.
2363            base_state
2364                .store
2365                .add_allowed_user("user@example.com", "admin", now_unix())
2366                .await
2367                .unwrap();
2368
2369            let state = state_with_mock_google_from(&base_state).await;
2370
2371            // Seed the browser-login state row so the callback recognises the flow.
2372            state
2373                .store
2374                .insert_browser_login_state(BrowserLoginStateRow {
2375                    state: "browser-state".to_string(),
2376                    return_to: "/".to_string(),
2377                    provider: "google".to_string(),
2378                    provider_code_verifier: "provider-verifier".to_string(),
2379                    created_at: now_unix(),
2380                    expires_at: now_unix() + 300,
2381                })
2382                .await
2383                .unwrap();
2384
2385            let app = router(state);
2386            let response = app
2387                .oneshot(
2388                    Request::builder()
2389                        .uri("/auth/google/callback?state=browser-state&code=upstream-code")
2390                        .body(Body::empty())
2391                        .unwrap(),
2392                )
2393                .await
2394                .unwrap();
2395
2396            // Successful browser login → redirect with a Set-Cookie header (session).
2397            assert_eq!(response.status(), StatusCode::SEE_OTHER);
2398            assert!(response.headers().contains_key(header::SET_COOKIE));
2399        }
2400
2401        /// Admin email is always authorised even when the `allowed_users` table is
2402        /// empty (browser-login branch).
2403        #[tokio::test]
2404        async fn browser_login_succeeds_for_admin_when_allowed_users_is_empty() {
2405            // Default test config sets admin_email = "user@example.com", which
2406            // matches the id_token returned by signed_test_id_token.
2407            let base_state = test_auth_state_with_mock_google().await;
2408
2409            // Confirm no extra rows exist.
2410            assert!(
2411                base_state
2412                    .store
2413                    .list_allowed_users()
2414                    .await
2415                    .unwrap()
2416                    .is_empty()
2417            );
2418
2419            // Seed browser-login state.
2420            base_state
2421                .store
2422                .insert_browser_login_state(BrowserLoginStateRow {
2423                    state: "browser-state-2".to_string(),
2424                    return_to: "/".to_string(),
2425                    provider: "google".to_string(),
2426                    provider_code_verifier: "provider-verifier".to_string(),
2427                    created_at: now_unix(),
2428                    expires_at: now_unix() + 300,
2429                })
2430                .await
2431                .unwrap();
2432
2433            let app = router(base_state);
2434            let response = app
2435                .oneshot(
2436                    Request::builder()
2437                        .uri("/auth/google/callback?state=browser-state-2&code=upstream-code")
2438                        .body(Body::empty())
2439                        .unwrap(),
2440                )
2441                .await
2442                .unwrap();
2443
2444            assert_eq!(response.status(), StatusCode::SEE_OTHER);
2445            assert!(response.headers().contains_key(header::SET_COOKIE));
2446        }
2447
2448        /// The oauth-client callback must also succeed for a non-admin email that
2449        /// exists in `allowed_users`.
2450        #[tokio::test]
2451        async fn oauth_client_callback_succeeds_for_allowlisted_non_admin_email() {
2452            let mut config = test_auth_config();
2453            config.admin_email = "admin@example.com".to_string();
2454            let base_state = test_auth_state_with_config(config).await;
2455
2456            // Register a client.
2457            base_state
2458                .store
2459                .register_client(RegisteredClient {
2460                    client_id: "client".to_string(),
2461                    redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
2462                    created_at: now_unix(),
2463                })
2464                .await
2465                .unwrap();
2466
2467            // Add id_token email to allowed_users.
2468            base_state
2469                .store
2470                .add_allowed_user("user@example.com", "admin", now_unix())
2471                .await
2472                .unwrap();
2473
2474            let state = state_with_mock_google_from(&base_state).await;
2475
2476            // Seed an authorization request row.
2477            state
2478                .store
2479                .insert_authorization_request(AuthorizationRequestRow {
2480                    state: "oauth-state".to_string(),
2481                    client_id: "client".to_string(),
2482                    redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
2483                    client_state: "client-xyz".to_string(),
2484                    resource: "https://lab.example.com/mcp".to_string(),
2485                    scope: "lab".to_string(),
2486                    provider: "google".to_string(),
2487                    provider_code_verifier: "provider-verifier".to_string(),
2488                    code_challenge: "challenge".to_string(),
2489                    code_challenge_method: "S256".to_string(),
2490                    created_at: now_unix(),
2491                    expires_at: now_unix() + 300,
2492                })
2493                .await
2494                .unwrap();
2495
2496            let app = router(state);
2497            let response = app
2498                .oneshot(
2499                    Request::builder()
2500                        .uri("/auth/google/callback?state=oauth-state&code=upstream-code")
2501                        .body(Body::empty())
2502                        .unwrap(),
2503                )
2504                .await
2505                .unwrap();
2506
2507            // Success: redirect to client callback with `code` param (no `error`).
2508            assert_eq!(response.status(), StatusCode::SEE_OTHER);
2509            let location = response
2510                .headers()
2511                .get(header::LOCATION)
2512                .unwrap()
2513                .to_str()
2514                .unwrap();
2515            let redirect = Url::parse(location).unwrap();
2516            let params: std::collections::HashMap<_, _> = redirect.query_pairs().collect();
2517            assert!(
2518                params.contains_key("code"),
2519                "expected code in redirect: {location}"
2520            );
2521            assert!(
2522                !params.contains_key("error"),
2523                "unexpected error in redirect: {location}"
2524            );
2525        }
2526
2527        /// Email not in admin or allowed_users must be rejected in the browser-login
2528        /// branch (401 Unauthorized).
2529        #[tokio::test]
2530        async fn browser_login_rejects_email_absent_from_both_admin_and_db() {
2531            let mut config = test_auth_config();
2532            // Neither admin nor allowed_users contains "user@example.com" (the id_token email).
2533            config.admin_email = "admin@example.com".to_string();
2534            let base_state = test_auth_state_with_config(config).await;
2535
2536            let state = state_with_mock_google_from(&base_state).await;
2537
2538            state
2539                .store
2540                .insert_browser_login_state(BrowserLoginStateRow {
2541                    state: "browser-state-3".to_string(),
2542                    return_to: "/".to_string(),
2543                    provider: "google".to_string(),
2544                    provider_code_verifier: "provider-verifier".to_string(),
2545                    created_at: now_unix(),
2546                    expires_at: now_unix() + 300,
2547                })
2548                .await
2549                .unwrap();
2550
2551            let app = router(state);
2552            let response = app
2553                .oneshot(
2554                    Request::builder()
2555                        .uri("/auth/google/callback?state=browser-state-3&code=upstream-code")
2556                        .body(Body::empty())
2557                        .unwrap(),
2558                )
2559                .await
2560                .unwrap();
2561
2562            assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
2563        }
2564
2565        /// Admin also in the DB table must not appear twice (dedup check via
2566        /// resolve_allowed_emails, verified indirectly: the callback still succeeds
2567        /// and there is no panic from duplicate iteration).
2568        #[tokio::test]
2569        async fn admin_in_db_table_is_deduped_and_still_authorised() {
2570            // Default config: admin_email = "user@example.com".
2571            let base_state = test_auth_state_with_mock_google().await;
2572
2573            // Also add the admin email to allowed_users — this is the duplicate.
2574            base_state
2575                .store
2576                .add_allowed_user("user@example.com", "self", now_unix())
2577                .await
2578                .unwrap();
2579
2580            // Seed browser-login state.
2581            base_state
2582                .store
2583                .insert_browser_login_state(BrowserLoginStateRow {
2584                    state: "browser-state-4".to_string(),
2585                    return_to: "/".to_string(),
2586                    provider: "google".to_string(),
2587                    provider_code_verifier: "provider-verifier".to_string(),
2588                    created_at: now_unix(),
2589                    expires_at: now_unix() + 300,
2590                })
2591                .await
2592                .unwrap();
2593
2594            let app = router(base_state);
2595            let response = app
2596                .oneshot(
2597                    Request::builder()
2598                        .uri("/auth/google/callback?state=browser-state-4&code=upstream-code")
2599                        .body(Body::empty())
2600                        .unwrap(),
2601                )
2602                .await
2603                .unwrap();
2604
2605            // Must still succeed — dedup should not break the check.
2606            assert_eq!(response.status(), StatusCode::SEE_OTHER);
2607            assert!(response.headers().contains_key(header::SET_COOKIE));
2608        }
2609
2610        /// RFC 9207: the authorization success response MUST carry the `iss`
2611        /// parameter set to the authorization server's issuer identifier so the
2612        /// client can detect authorization-server mix-up attacks.
2613        #[tokio::test]
2614        async fn oauth_client_callback_includes_rfc9207_iss_on_success() {
2615            let mut config = test_auth_config();
2616            config.admin_email = "admin@example.com".to_string();
2617            let base_state = test_auth_state_with_config(config).await;
2618
2619            base_state
2620                .store
2621                .register_client(RegisteredClient {
2622                    client_id: "client".to_string(),
2623                    redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
2624                    created_at: now_unix(),
2625                })
2626                .await
2627                .unwrap();
2628            base_state
2629                .store
2630                .add_allowed_user("user@example.com", "admin", now_unix())
2631                .await
2632                .unwrap();
2633
2634            let state = state_with_mock_google_from(&base_state).await;
2635            state
2636                .store
2637                .insert_authorization_request(AuthorizationRequestRow {
2638                    state: "oauth-state".to_string(),
2639                    client_id: "client".to_string(),
2640                    redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
2641                    client_state: "client-xyz".to_string(),
2642                    resource: "https://lab.example.com/mcp".to_string(),
2643                    scope: "lab".to_string(),
2644                    provider: "google".to_string(),
2645                    provider_code_verifier: "provider-verifier".to_string(),
2646                    code_challenge: "challenge".to_string(),
2647                    code_challenge_method: "S256".to_string(),
2648                    created_at: now_unix(),
2649                    expires_at: now_unix() + 300,
2650                })
2651                .await
2652                .unwrap();
2653
2654            let expected_iss = crate::metadata::public_base_url(&state);
2655            let app = router(state);
2656            let response = app
2657                .oneshot(
2658                    Request::builder()
2659                        .uri("/auth/google/callback?state=oauth-state&code=upstream-code")
2660                        .body(Body::empty())
2661                        .unwrap(),
2662                )
2663                .await
2664                .unwrap();
2665
2666            assert_eq!(response.status(), StatusCode::SEE_OTHER);
2667            let location = response
2668                .headers()
2669                .get(header::LOCATION)
2670                .unwrap()
2671                .to_str()
2672                .unwrap();
2673            let redirect = Url::parse(location).unwrap();
2674            let params: std::collections::HashMap<_, _> = redirect.query_pairs().collect();
2675            assert_eq!(
2676                params.get("iss").map(|v| v.as_ref()),
2677                Some(expected_iss.as_str()),
2678                "RFC 9207 iss must equal the issuer identifier on success: {location}"
2679            );
2680        }
2681    }
2682
2683    mod allowlist_tests {
2684        use super::super::check_email_allowlist;
2685
2686        #[test]
2687        fn empty_allowlist_permits_any_email() {
2688            assert!(
2689                check_email_allowlist("google", Some("anyone@example.com"), Some(true), &[])
2690                    .is_ok()
2691            );
2692        }
2693
2694        #[test]
2695        fn empty_allowlist_permits_even_unverified_email() {
2696            // When no allowlist is configured, email_verified is not enforced.
2697            assert!(
2698                check_email_allowlist("google", Some("anyone@example.com"), Some(false), &[])
2699                    .is_ok()
2700            );
2701        }
2702
2703        #[test]
2704        fn matching_verified_email_is_permitted() {
2705            let list = vec!["alice@example.com".to_string()];
2706            assert!(
2707                check_email_allowlist("google", Some("alice@example.com"), Some(true), &list)
2708                    .is_ok()
2709            );
2710        }
2711
2712        #[test]
2713        fn matching_email_is_case_insensitive() {
2714            // Allowlist is pre-normalized to lowercase at config load.
2715            // Incoming email from Google may have any case.
2716            let list = vec!["alice@example.com".to_string()];
2717            assert!(
2718                check_email_allowlist("google", Some("Alice@Example.com"), Some(true), &list)
2719                    .is_ok()
2720            );
2721        }
2722
2723        #[test]
2724        fn non_matching_email_is_rejected() {
2725            let list = vec!["alice@example.com".to_string()];
2726            assert!(
2727                check_email_allowlist("google", Some("eve@example.com"), Some(true), &list)
2728                    .is_err()
2729            );
2730        }
2731
2732        #[test]
2733        fn unverified_email_is_rejected_even_when_in_allowlist() {
2734            let list = vec!["alice@example.com".to_string()];
2735            assert!(
2736                check_email_allowlist("google", Some("alice@example.com"), Some(false), &list)
2737                    .is_err()
2738            );
2739        }
2740
2741        #[test]
2742        fn missing_email_verified_claim_is_rejected_when_allowlist_is_set() {
2743            let list = vec!["alice@example.com".to_string()];
2744            assert!(
2745                check_email_allowlist("google", Some("alice@example.com"), None, &list).is_err()
2746            );
2747        }
2748
2749        #[test]
2750        fn none_email_is_rejected_when_allowlist_is_set() {
2751            let list = vec!["alice@example.com".to_string()];
2752            assert!(check_email_allowlist("google", None, Some(true), &list).is_err());
2753        }
2754    }
2755
2756    #[tokio::test]
2757    async fn browser_login_renders_a_picker_when_more_than_one_provider_is_configured() {
2758        let mut config = test_auth_config();
2759        config.github.client_id = "gh-client".to_string();
2760        config.github.client_secret = "gh-secret".to_string();
2761        // GitHubConfig::default() now provides default_github_scopes()
2762        // (["read:user", "user:email"]), which validate() requires — see
2763        // `impl Default for GitHubConfig` in config_providers.rs.
2764        let state = test_auth_state_with_config(config).await;
2765        let app = router(state);
2766        let response = app
2767            .oneshot(
2768                Request::builder()
2769                    .uri("/auth/login")
2770                    .body(Body::empty())
2771                    .unwrap(),
2772            )
2773            .await
2774            .unwrap();
2775        assert_eq!(response.status(), StatusCode::OK);
2776        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2777            .await
2778            .unwrap();
2779        let body = String::from_utf8_lossy(&body);
2780        assert!(body.contains("Sign in with Google"));
2781        assert!(body.contains("Sign in with GitHub"));
2782        assert!(body.contains("provider=google"));
2783        assert!(body.contains("provider=github"));
2784    }
2785
2786    #[tokio::test]
2787    async fn browser_login_skips_the_picker_and_redirects_directly_when_provider_is_given() {
2788        let mut config = test_auth_config();
2789        config.github.client_id = "gh-client".to_string();
2790        config.github.client_secret = "gh-secret".to_string();
2791        // GitHubConfig::default() now provides default_github_scopes()
2792        // (["read:user", "user:email"]), which validate() requires — see
2793        // `impl Default for GitHubConfig` in config_providers.rs.
2794        let state = test_auth_state_with_config(config).await;
2795        let app = router(state);
2796        let response = app
2797            .oneshot(
2798                Request::builder()
2799                    .uri("/auth/login?provider=github")
2800                    .body(Body::empty())
2801                    .unwrap(),
2802            )
2803            .await
2804            .unwrap();
2805        assert_eq!(response.status(), StatusCode::FOUND);
2806        let location = response
2807            .headers()
2808            .get(header::LOCATION)
2809            .unwrap()
2810            .to_str()
2811            .unwrap();
2812        assert!(location.contains("github.com/login/oauth/authorize"));
2813    }
2814
2815    #[tokio::test]
2816    async fn authorize_rejects_an_unknown_provider_query_param() {
2817        let app = router(test_auth_state_with_registered_client().await);
2818        let response = app
2819            .oneshot(
2820                Request::builder()
2821                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256&provider=okta")
2822                    .body(Body::empty())
2823                    .unwrap(),
2824            )
2825            .await
2826            .unwrap();
2827        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
2828    }
2829
2830    #[tokio::test]
2831    async fn authorize_scopes_force_consent_per_provider_not_globally() {
2832        // Regression test for the engineering-review finding: force_consent
2833        // must be scoped to the provider about to be used, not "has ANY
2834        // provider ever issued a refresh token." Seed a Google refresh
2835        // token, then confirm a fresh GitHub authorize() request still
2836        // forces consent even though a (Google) refresh token already
2837        // exists in the DB.
2838        let mut config = test_auth_config();
2839        config.github.client_id = "gh-client".to_string();
2840        config.github.client_secret = "gh-secret".to_string();
2841        // GitHubConfig::default() now provides default_github_scopes()
2842        // (["read:user", "user:email"]), which validate() requires — see
2843        // `impl Default for GitHubConfig` in config_providers.rs.
2844        let state = test_auth_state_with_config(config).await;
2845        state
2846            .store
2847            .register_client(RegisteredClient {
2848                client_id: "client".to_string(),
2849                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
2850                created_at: now_unix(),
2851            })
2852            .await
2853            .unwrap();
2854        state
2855            .store
2856            .upsert_refresh_token(crate::types::RefreshTokenRow {
2857                refresh_token: "existing-google-refresh".to_string(),
2858                client_id: "client".to_string(),
2859                subject: "google-user".to_string(),
2860                resource: "https://lab.example.com/mcp".to_string(),
2861                scope: "lab".to_string(),
2862                provider: "google".to_string(),
2863                provider_refresh_token: None,
2864                created_at: now_unix(),
2865                expires_at: now_unix() + 3600,
2866            })
2867            .await
2868            .unwrap();
2869        let app = router(state);
2870
2871        let response = app
2872            .oneshot(
2873                Request::builder()
2874                    .uri("/authorize?response_type=code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&state=abc&scope=lab&code_challenge=pkce&code_challenge_method=S256&provider=github")
2875                    .body(Body::empty())
2876                    .unwrap(),
2877            )
2878            .await
2879            .unwrap();
2880        assert_eq!(response.status(), StatusCode::FOUND);
2881        let location = response
2882            .headers()
2883            .get(header::LOCATION)
2884            .unwrap()
2885            .to_str()
2886            .unwrap();
2887        assert!(location.contains("github.com"));
2888        assert!(
2889            location.contains("prompt=consent"),
2890            "GitHub's first-ever authorize call must still force consent even though Google \
2891             already has a refresh token on file: {location}"
2892        );
2893    }
2894
2895    /// End-to-end round-trip proving Task 8's manually-renumbered SQL
2896    /// actually routes to the CORRECT provider's `exchange_code`, not just
2897    /// that the Rust side compiles. Wires two distinct mock upstream
2898    /// servers (one per provider) into the same `AuthState` and asserts
2899    /// only the provider named by `?provider=` at `/auth/login` time
2900    /// receives the token-exchange call.
2901    #[tokio::test]
2902    async fn browser_login_round_trip_calls_only_the_selected_providers_mock_server() {
2903        use crate::github::GitHubProvider;
2904
2905        let google_server = MockServer::start().await;
2906        Mock::given(method("POST"))
2907            .and(path("/token"))
2908            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2909                "access_token": "google-access-token",
2910                "refresh_token": "google-refresh-token",
2911                "expires_in": 3600,
2912                "id_token": signed_test_id_token(),
2913            })))
2914            .mount(&google_server)
2915            .await;
2916        Mock::given(method("GET"))
2917            .and(path("/certs"))
2918            .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks()))
2919            .mount(&google_server)
2920            .await;
2921
2922        let github_server = MockServer::start().await;
2923        Mock::given(method("POST"))
2924            .and(path("/login/oauth/access_token"))
2925            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2926                "access_token": "github-access-token",
2927                "scope": "read:user,user:email",
2928                "token_type": "bearer",
2929            })))
2930            .mount(&github_server)
2931            .await;
2932        Mock::given(method("GET"))
2933            .and(path("/user"))
2934            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2935                "id": 555, "login": "octocat", "email": "user@example.com",
2936            })))
2937            .mount(&github_server)
2938            .await;
2939        Mock::given(method("GET"))
2940            .and(path("/user/emails"))
2941            .respond_with(ResponseTemplate::new(200).set_body_json(json!([
2942                {"email": "user@example.com", "primary": true, "verified": true},
2943            ])))
2944            .mount(&github_server)
2945            .await;
2946
2947        let mut config = test_auth_config();
2948        config.github.client_id = "gh-client".to_string();
2949        config.github.client_secret = "gh-secret".to_string();
2950        // GitHubConfig::default() now provides default_github_scopes()
2951        // (["read:user", "user:email"]), which validate() requires — see
2952        // `impl Default for GitHubConfig` in config_providers.rs.
2953        let base_state = test_auth_state_with_config(config).await;
2954
2955        let google = GoogleProvider::new(
2956            "client-id".to_string(),
2957            "client-secret".to_string(),
2958            Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
2959        )
2960        .unwrap()
2961        .with_endpoints(
2962            google_server.uri().parse::<Url>().unwrap(),
2963            google_server
2964                .uri()
2965                .parse::<Url>()
2966                .unwrap()
2967                .join("/token")
2968                .unwrap(),
2969        )
2970        .with_jwks_endpoint(
2971            google_server
2972                .uri()
2973                .parse::<Url>()
2974                .unwrap()
2975                .join("/certs")
2976                .unwrap(),
2977        );
2978        let github = GitHubProvider::new(
2979            "gh-client".to_string(),
2980            "gh-secret".to_string(),
2981            Url::parse("https://lab.example.com/auth/github/callback").unwrap(),
2982        )
2983        .unwrap()
2984        .with_endpoints(
2985            github_server
2986                .uri()
2987                .parse::<Url>()
2988                .unwrap()
2989                .join("login/oauth/authorize")
2990                .unwrap(),
2991            github_server
2992                .uri()
2993                .parse::<Url>()
2994                .unwrap()
2995                .join("login/oauth/access_token")
2996                .unwrap(),
2997            github_server
2998                .uri()
2999                .parse::<Url>()
3000                .unwrap()
3001                .join("user")
3002                .unwrap(),
3003            github_server
3004                .uri()
3005                .parse::<Url>()
3006                .unwrap()
3007                .join("user/emails")
3008                .unwrap(),
3009        );
3010        let mut providers: std::collections::BTreeMap<
3011            String,
3012            std::sync::Arc<dyn crate::oauth_provider::OAuthProvider>,
3013        > = std::collections::BTreeMap::new();
3014        providers.insert("google".to_string(), std::sync::Arc::new(google));
3015        providers.insert("github".to_string(), std::sync::Arc::new(github));
3016        let state = AuthState::for_tests(
3017            (*base_state.config).clone(),
3018            base_state.store.clone(),
3019            (*base_state.signing_keys).clone(),
3020            providers,
3021        );
3022        let app = router(state);
3023
3024        // Start a browser login explicitly for GitHub.
3025        let login_response = app
3026            .clone()
3027            .oneshot(
3028                Request::builder()
3029                    .uri("/auth/login?provider=github")
3030                    .body(Body::empty())
3031                    .unwrap(),
3032            )
3033            .await
3034            .unwrap();
3035        assert_eq!(login_response.status(), StatusCode::FOUND);
3036        let location = Url::parse(
3037            login_response
3038                .headers()
3039                .get(header::LOCATION)
3040                .unwrap()
3041                .to_str()
3042                .unwrap(),
3043        )
3044        .unwrap();
3045        let upstream_state = location
3046            .query_pairs()
3047            .find(|(key, _)| key == "state")
3048            .map(|(_, value)| value.into_owned())
3049            .unwrap();
3050
3051        // Complete the callback with a fake upstream code.
3052        let callback_response = app
3053            .oneshot(
3054                Request::builder()
3055                    .uri(format!(
3056                        "/auth/github/callback?state={upstream_state}&code=upstream-code"
3057                    ))
3058                    .body(Body::empty())
3059                    .unwrap(),
3060            )
3061            .await
3062            .unwrap();
3063        assert_eq!(callback_response.status(), StatusCode::SEE_OTHER);
3064
3065        // Proves routing AND correct namespacing: the persisted session
3066        // subject must be `"github:<id>"`, not a bare `"<id>"` that could
3067        // collide with a Google subject sharing the same DB.
3068        let set_cookie = callback_response
3069            .headers()
3070            .get(header::SET_COOKIE)
3071            .and_then(|value| value.to_str().ok())
3072            .expect("callback must set a browser session cookie")
3073            .to_string();
3074        let session_id = set_cookie
3075            .split(';')
3076            .next()
3077            .and_then(|kv| kv.split_once('='))
3078            .map(|(_, value)| value.to_string())
3079            .expect("session cookie must have a `name=value` pair");
3080        let session = base_state
3081            .store
3082            .find_browser_session(&session_id)
3083            .await
3084            .unwrap()
3085            .expect("browser session must be persisted for the issued cookie");
3086        assert!(
3087            session.subject.starts_with("github:"),
3088            "expected a github-namespaced subject, got `{}`",
3089            session.subject
3090        );
3091
3092        let google_requests = google_server.received_requests().await.unwrap();
3093        let github_requests = github_server.received_requests().await.unwrap();
3094        assert!(
3095            google_requests.is_empty(),
3096            "selecting provider=github must never call Google's mock server: {google_requests:?}"
3097        );
3098        assert!(
3099            github_requests
3100                .iter()
3101                .any(|r| r.url.path() == "/login/oauth/access_token"),
3102            "expected GitHub's token endpoint to be called: {github_requests:?}"
3103        );
3104    }
3105}