Skip to main content

soma_auth/
authorize.rs

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