Skip to main content

soma_auth/
token.rs

1use axum::extract::{ConnectInfo, Form, State};
2use axum::{
3    Json,
4    http::{HeaderMap, StatusCode},
5    response::{IntoResponse, Response},
6};
7use base64::Engine;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use sha2::{Digest, Sha256};
10use std::net::SocketAddr;
11use subtle::ConstantTimeEq;
12use tracing::{info, warn};
13
14use crate::error::AuthError;
15use crate::jwt::AccessClaims;
16use crate::state::AuthState;
17use crate::token_client_auth;
18use crate::types::AuthorizationCodeRow;
19use crate::types::{RefreshTokenRow, TokenRequest, TokenResponse};
20use crate::util::{
21    apply_no_store, duration_secs_usize, expires_at, fingerprint, now_unix, oauth_error_response,
22    random_token, remote_ip, timestamp_usize,
23};
24
25pub async fn token(
26    State(state): State<AuthState>,
27    ConnectInfo(addr): ConnectInfo<SocketAddr>,
28    headers: HeaderMap,
29    Form(mut request): Form<TokenRequest>,
30) -> Response {
31    // Rate-limit before any parsing or client resolution. Client
32    // authentication now runs ahead of grant work, and for a CIMD-shaped
33    // `client_id` that means an outbound metadata fetch - reachable without a
34    // valid authorization code. Unlimited, that turns `/token` into both a
35    // self-DoS (queued 5s-timeout fetches) and an unauthenticated trigger for
36    // outbound requests to attacker-chosen hosts. `/authorize` and `/register`
37    // have always guarded this way; `/token` never called its own limiter.
38    if let Err(error) = state.check_token_rate_limit(remote_ip(addr)).await {
39        return TokenEndpointError::Auth(error).into_response();
40    }
41    if let Err(error) = token_client_auth::normalize_client_credentials(&headers, &mut request) {
42        return TokenEndpointError::Auth(error).into_response();
43    }
44    info!(
45        grant_type = %request.grant_type,
46        client_id = request.client_id.as_deref().unwrap_or("<missing>"),
47        requested_resource = request.resource.as_deref().unwrap_or("<default>"),
48        "oauth token request received"
49    );
50
51    match dispatch_grant(state, request).await {
52        Ok(response) => response.into_response(),
53        Err(error) => error.into_response(),
54    }
55}
56
57async fn dispatch_grant(
58    state: AuthState,
59    request: TokenRequest,
60) -> Result<TokenResponseWithCache, TokenEndpointError> {
61    let response = match request.grant_type.as_str() {
62        "authorization_code" => {
63            authenticate_client(&state, &request).await?;
64            authorization_code_grant(state, request).await
65        }
66        "refresh_token" => {
67            authenticate_client(&state, &request).await?;
68            refresh_token_grant(state, request).await
69        }
70        // Authenticated inside `machine_grant`, before any token material is
71        // minted - so every arm of this table authenticates first, even though
72        // only the two delegation arms say so explicitly.
73        "client_credentials" | token_client_auth::JWT_BEARER_GRANT_TYPE => {
74            machine_client_grant(state, request).await
75        }
76        other => {
77            warn!(grant_type = %other, "oauth token rejected: unsupported grant type");
78            return Err(TokenEndpointError::UnsupportedGrantType(other.to_string()));
79        }
80    };
81    response
82        .map(|response| TokenResponseWithCache(Json(response)))
83        .map_err(TokenEndpointError::Auth)
84}
85
86/// RFC 6749 section 3.2.1 client authentication for the user-delegation
87/// grants, run *before* any grant-specific work so an unauthenticated request
88/// can never consume a single-use authorization code.
89///
90/// Public clients (`token_endpoint_auth_method = "none"`, which is what
91/// dynamic registration and CIMD produce by default) present no secret and no
92/// assertion and are accepted exactly as they were before this check existed.
93/// Confidential clients must satisfy the method they registered.
94async fn authenticate_client(state: &AuthState, request: &TokenRequest) -> Result<(), AuthError> {
95    let client_id = request
96        .client_id
97        .as_deref()
98        .ok_or_else(|| AuthError::Validation("missing `client_id` parameter".to_string()))?;
99    if recorded_public_client(state, request, client_id).await? {
100        return authenticate_recorded_public_client(request);
101    }
102    token_client_auth::authenticate_oauth_client(
103        state,
104        client_id,
105        request.client_secret.as_deref(),
106        request.client_assertion_type.as_deref(),
107        request.client_assertion.as_deref(),
108    )
109    .await
110}
111
112/// Whether the grant being redeemed was issued to a client that registered
113/// `token_endpoint_auth_method = "none"`, as recorded on the grant itself.
114///
115/// This is the whole point of storing the method at issuance. Resolving the
116/// client instead means, for a CIMD-shaped (`https://...`) `client_id`, a live
117/// metadata fetch — so a valid, unrevoked refresh token failed with
118/// `invalid_client` for as long as the client's own metadata host was
119/// unreachable (up to the 60s negative-cache window, repeating until it came
120/// back). A public client presents no credentials to check against that
121/// document, so the fetch buys nothing for it.
122///
123/// Only the public case is short-circuited. `private_key_jwt` still resolves:
124/// its JWKS must be read fresh because the client may have rotated keys, and
125/// such a client is presenting an assertion anyway, so the fetch is inherent.
126/// A `None` record (legacy row, or a client that could not be resolved at
127/// issuance) also resolves — unknown never means public.
128///
129/// Machine clients are excluded: their credentials come from server config,
130/// not from a registration record, and `authenticate_oauth_client` checks them
131/// first. Skipping it for a config-declared client id would drop that check.
132async fn recorded_public_client(
133    state: &AuthState,
134    request: &TokenRequest,
135    client_id: &str,
136) -> Result<bool, AuthError> {
137    if state
138        .config
139        .machine_clients
140        .iter()
141        .any(|client| client.client_id == client_id)
142    {
143        return Ok(false);
144    }
145    let recorded = match request.grant_type.as_str() {
146        "authorization_code" => match request.code.as_deref() {
147            Some(code) => state.store.auth_code_client_auth_method(code).await?,
148            None => None,
149        },
150        "refresh_token" => match request.refresh_token.as_deref() {
151            Some(token) => state.store.refresh_token_client_auth_method(token).await?,
152            None => None,
153        },
154        _ => None,
155    };
156    Ok(recorded.as_deref() == Some("none"))
157}
158
159/// The public-client half of `authenticate_oauth_client`, decided locally.
160///
161/// Byte-for-byte the same verdict that arm reaches — a client registered with
162/// `token_endpoint_auth_method = "none"` that presents a `client_secret` or a
163/// `client_assertion` is rejected — just without the client resolution that
164/// produced the method, which the grant already told us.
165fn authenticate_recorded_public_client(request: &TokenRequest) -> Result<(), AuthError> {
166    if request.client_secret.is_some() || request.client_assertion.is_some() {
167        warn!(
168            grant_type = %request.grant_type,
169            "oauth token rejected: public client presented client credentials"
170        );
171        return Err(token_client_auth::invalid_client());
172    }
173    Ok(())
174}
175
176/// `client_credentials` / JWT-bearer machine grant. The client acts for
177/// itself, so the token's subject is the client id and no refresh token is
178/// issued (RFC 6749 section 4.4.3): the client can always re-authenticate.
179async fn machine_client_grant(
180    state: AuthState,
181    request: TokenRequest,
182) -> Result<TokenResponse, AuthError> {
183    let grant = token_client_auth::machine_grant(&state, &request).await?;
184    info!(
185        grant_type = %request.grant_type,
186        client_id = %grant.client_id,
187        resource = %grant.resource,
188        scope = %grant.scope,
189        "oauth machine grant authenticated client"
190    );
191    build_token_response(
192        &state,
193        grant.client_id,
194        grant.subject,
195        grant.resource,
196        grant.scope,
197        None,
198    )
199}
200
201enum TokenEndpointError {
202    Auth(AuthError),
203    UnsupportedGrantType(String),
204}
205
206impl From<AuthError> for TokenEndpointError {
207    fn from(error: AuthError) -> Self {
208        Self::Auth(error)
209    }
210}
211
212impl TokenEndpointError {
213    fn oauth_error(&self) -> &'static str {
214        match self {
215            Self::Auth(AuthError::InvalidGrant(_)) => "invalid_grant",
216            Self::Auth(AuthError::InvalidScope(_)) => "invalid_scope",
217            Self::UnsupportedGrantType(_) => "unsupported_grant_type",
218            Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
219                "invalid_client"
220            }
221            Self::Auth(AuthError::RateLimited { .. }) => "temporarily_unavailable",
222            Self::Auth(AuthError::Validation(_)) => "invalid_request",
223            Self::Auth(
224                AuthError::Config(_)
225                | AuthError::Storage(_)
226                | AuthError::Network(_)
227                | AuthError::Server(_)
228                | AuthError::Decode(_)
229                | AuthError::InsecurePermissions { .. },
230            ) => "server_error",
231        }
232    }
233
234    fn log_kind(&self) -> &'static str {
235        match self {
236            Self::Auth(error) => error.kind(),
237            Self::UnsupportedGrantType(_) => "unsupported_grant_type",
238        }
239    }
240
241    fn status(&self) -> StatusCode {
242        match self {
243            Self::Auth(
244                AuthError::InvalidGrant(_) | AuthError::InvalidScope(_) | AuthError::Validation(_),
245            )
246            | Self::UnsupportedGrantType(_) => StatusCode::BAD_REQUEST,
247            Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
248                StatusCode::UNAUTHORIZED
249            }
250            Self::Auth(AuthError::RateLimited { .. }) => StatusCode::TOO_MANY_REQUESTS,
251            Self::Auth(
252                AuthError::Config(_)
253                | AuthError::Storage(_)
254                | AuthError::Network(_)
255                | AuthError::Server(_)
256                | AuthError::Decode(_)
257                | AuthError::InsecurePermissions { .. },
258            ) => StatusCode::INTERNAL_SERVER_ERROR,
259        }
260    }
261
262    fn description(&self) -> String {
263        match self {
264            Self::Auth(error) => error.to_string(),
265            Self::UnsupportedGrantType(grant_type) => {
266                format!("unsupported grant_type `{grant_type}`")
267            }
268        }
269    }
270
271    fn retry_after_ms(&self) -> Option<u64> {
272        match self {
273            Self::Auth(AuthError::RateLimited { retry_after_ms, .. }) => Some(*retry_after_ms),
274            _ => None,
275        }
276    }
277}
278
279impl IntoResponse for TokenEndpointError {
280    fn into_response(self) -> Response {
281        oauth_error_response(
282            self.status(),
283            self.oauth_error(),
284            self.description(),
285            self.log_kind(),
286            self.retry_after_ms(),
287        )
288    }
289}
290
291struct TokenResponseWithCache(Json<TokenResponse>);
292
293impl IntoResponse for TokenResponseWithCache {
294    fn into_response(self) -> Response {
295        apply_no_store(self.0.into_response())
296    }
297}
298
299async fn authorization_code_grant(
300    state: AuthState,
301    request: TokenRequest,
302) -> Result<TokenResponse, AuthError> {
303    let requested_resource = request
304        .resource
305        .as_deref()
306        .map(str::trim)
307        .filter(|value| !value.is_empty())
308        .map(|value| value.trim_end_matches('/').to_string());
309    crate::authorize::validate_resource(&state, request.resource.as_deref())?;
310    let code = require_field(request.code, "code")?;
311    let client_id = require_field(request.client_id, "client_id")?;
312    let redirect_uri = require_field(request.redirect_uri, "redirect_uri")?;
313    let code_verifier = require_field(request.code_verifier, "code_verifier")?;
314    let auth_code_id = fingerprint(&code);
315    info!(
316        grant_type = "authorization_code",
317        client_id = %client_id,
318        auth_code_id = %auth_code_id,
319        redirect_uri = %redirect_uri,
320        requested_resource = requested_resource.as_deref().unwrap_or("<authorization-code-resource>"),
321        "oauth authorization_code grant redeeming local code"
322    );
323
324    let row = state.store.redeem_auth_code(&code).await.map_err(|error| {
325        warn!(
326            auth_code_id = %auth_code_id,
327            client_id = %client_id,
328            error = %error,
329            "oauth token rejected: authorization code is invalid, expired, or already redeemed"
330        );
331        error
332    })?;
333    validate_authorization_code_row(
334        &row,
335        &client_id,
336        &redirect_uri,
337        &code_verifier,
338        &auth_code_id,
339    )?;
340    if let Some(requested_resource) = requested_resource
341        && requested_resource != row.resource
342    {
343        warn!(
344            auth_code_id = %auth_code_id,
345            requested_resource = %requested_resource,
346            stored_resource = %row.resource,
347            "oauth token rejected: resource does not match authorization code"
348        );
349        return Err(AuthError::InvalidGrant(
350            "resource does not match the authorization code".to_string(),
351        ));
352    }
353
354    let refresh_token = if let Some(provider_refresh_token) = row.provider_refresh_token {
355        let refresh_token = random_token(24)?;
356        let created_at = now_unix();
357        state
358            .store
359            .upsert_refresh_token(RefreshTokenRow {
360                refresh_token: refresh_token.clone(),
361                client_id: row.client_id.clone(),
362                subject: row.subject.clone(),
363                resource: row.resource.clone(),
364                scope: row.scope.clone(),
365                provider: row.provider.clone(),
366                provider_refresh_token: Some(provider_refresh_token),
367                created_at,
368                expires_at: expires_at(
369                    created_at,
370                    state.config.refresh_token_ttl,
371                    &format!("{}_AUTH_REFRESH_TOKEN_TTL_SECS", state.config.env_prefix),
372                )?,
373                // The refresh token inherits the authorization code's contract
374                // so later refreshes authenticate the same way this exchange
375                // did, with no client resolution in between.
376                token_endpoint_auth_method: row.token_endpoint_auth_method.clone(),
377            })
378            .await?;
379        info!(
380            grant_type = "authorization_code",
381            client_id = %row.client_id,
382            auth_code_id = %auth_code_id,
383            subject_id = %fingerprint(&row.subject),
384            resource = %row.resource,
385            scope = %row.scope,
386            "oauth authorization_code grant issued lab access token and refresh token"
387        );
388        Some(refresh_token)
389    } else {
390        info!(
391            grant_type = "authorization_code",
392            client_id = %row.client_id,
393            auth_code_id = %auth_code_id,
394            subject_id = %fingerprint(&row.subject),
395            resource = %row.resource,
396            scope = %row.scope,
397            "oauth authorization_code grant issued lab access token without refresh token"
398        );
399        None
400    };
401
402    let resource = if row.resource.trim().is_empty() {
403        crate::metadata::canonical_resource_url(&state)
404    } else {
405        row.resource
406    };
407    build_token_response(
408        &state,
409        row.client_id,
410        row.subject,
411        resource,
412        row.scope,
413        refresh_token,
414    )
415}
416
417async fn refresh_token_grant(
418    state: AuthState,
419    request: TokenRequest,
420) -> Result<TokenResponse, AuthError> {
421    let requested_resource = request
422        .resource
423        .as_deref()
424        .map(str::trim)
425        .filter(|value| !value.is_empty())
426        .map(|value| crate::authorize::validate_resource(&state, Some(value)))
427        .transpose()?;
428    let client_id = require_field(request.client_id, "client_id")?;
429    let refresh_token = require_field(request.refresh_token, "refresh_token")?;
430    let refresh_token_id = fingerprint(&refresh_token);
431    info!(
432        grant_type = "refresh_token",
433        client_id = %client_id,
434        refresh_token_id = %refresh_token_id,
435        requested_resource = requested_resource.as_deref().unwrap_or("<refresh-token-resource>"),
436        "oauth refresh_token grant received"
437    );
438    let stored = state
439        .store
440        .find_refresh_token(&refresh_token)
441        .await?
442        .ok_or_else(|| {
443            warn!(
444                refresh_token_id = %refresh_token_id,
445                client_id = %client_id,
446                "oauth token rejected: unknown or expired refresh token"
447            );
448            AuthError::InvalidGrant("unknown refresh_token".to_string())
449        })?;
450    if stored.client_id != client_id {
451        warn!(
452            refresh_token_id = %refresh_token_id,
453            requested_client_id = %client_id,
454            stored_client_id = %stored.client_id,
455            "oauth token rejected: client_id does not match refresh token"
456        );
457        return Err(AuthError::InvalidGrant(
458            "client_id does not match the refresh token".to_string(),
459        ));
460    }
461    let stored_resource = if stored.resource.trim().is_empty() {
462        crate::metadata::canonical_resource_url(&state)
463    } else {
464        stored.resource.clone()
465    };
466    if let Some(requested_resource) = requested_resource
467        && requested_resource != stored_resource
468    {
469        warn!(
470            refresh_token_id = %refresh_token_id,
471            requested_resource = %requested_resource,
472            stored_resource = %stored_resource,
473            "oauth token rejected: resource does not match refresh token"
474        );
475        return Err(AuthError::InvalidGrant(
476            "resource does not match the refresh token".to_string(),
477        ));
478    }
479
480    let Some(provider_refresh_token) = stored.provider_refresh_token.clone() else {
481        warn!(
482            refresh_token_id = %refresh_token_id,
483            client_id = %stored.client_id,
484            "oauth token rejected: refresh token is not backed by an upstream refresh token"
485        );
486        return Err(AuthError::InvalidGrant(
487            "refresh token is not backed by an upstream refresh token".to_string(),
488        ));
489    };
490
491    // Refresh upstream before consuming the local token. If the provider or
492    // id-token verification fails, the client can retry the same local
493    // refresh token instead of being stranded with an unreturned replacement.
494    let provider = state.provider(&stored.provider)?;
495    // Defense-in-depth: GitHubProvider::exchange_code never sets
496    // provider_refresh_token, so a `refresh_tokens` row naming
497    // `provider = "github"` with a non-null `provider_refresh_token` should
498    // be unreachable through normal flows — but the DB layer doesn't enforce
499    // that invariant, and a hand-inserted or corrupted row would otherwise
500    // silently reach `GitHubProvider::refresh`'s unconditional error. Fail
501    // loudly and clearly here instead, at the actual choke point, in both
502    // debug and release builds.
503    if provider.provider_id() == "github" {
504        return Err(AuthError::Server(
505            "refresh token names provider `github`, which never issues upstream refresh \
506             tokens and does not support token refresh — this refresh token row should be \
507             unreachable; the underlying GitHub OAuth App requires the user to \
508             re-authenticate once their local soma-issued refresh token expires"
509                .to_string(),
510        ));
511    }
512    let exchange = provider.refresh(&provider_refresh_token).await?;
513
514    let refreshed_expires_at = expires_at(
515        now_unix(),
516        state.config.refresh_token_ttl,
517        &format!("{}_AUTH_REFRESH_TOKEN_TTL_SECS", state.config.env_prefix),
518    )?;
519    let subject =
520        crate::oauth_provider::namespaced_subject(provider.provider_id(), &exchange.subject);
521    let next_provider_refresh_token = exchange
522        .refresh_token
523        .clone()
524        .unwrap_or_else(|| provider_refresh_token.clone());
525    // Re-apply admin elevation in case this refresh token was originally
526    // issued before elevation was wired in, or before the user's email was
527    // on the allowlist.  elevate_scope_for_allowed_user is idempotent — if
528    // the scope already contains the admin token it is left unchanged.
529    let elevated_scope = crate::authorize::elevate_scope_for_allowed_user(
530        &stored.scope,
531        &state.config.default_scope,
532    );
533
534    state
535        .store
536        .upsert_refresh_token(RefreshTokenRow {
537            refresh_token: refresh_token.clone(),
538            client_id: stored.client_id.clone(),
539            subject: subject.clone(),
540            resource: stored_resource.clone(),
541            scope: elevated_scope.clone(),
542            provider: stored.provider.clone(),
543            provider_refresh_token: Some(next_provider_refresh_token),
544            created_at: stored.created_at,
545            expires_at: refreshed_expires_at,
546            // Preserved verbatim across the rewrite: the grant's contract does
547            // not change just because it was refreshed.
548            token_endpoint_auth_method: stored.token_endpoint_auth_method.clone(),
549        })
550        .await?;
551
552    info!(
553        grant_type = "refresh_token",
554        client_id = %stored.client_id,
555        refresh_token_id = %refresh_token_id,
556        subject_id = %fingerprint(&subject),
557        provider = provider.provider_id(),
558        resource = %stored_resource,
559        scope = %elevated_scope,
560        "oauth refresh_token grant refreshed stable local token and issued new access token"
561    );
562
563    build_token_response(
564        &state,
565        stored.client_id,
566        subject,
567        stored_resource,
568        elevated_scope,
569        Some(refresh_token),
570    )
571}
572
573fn build_token_response(
574    state: &AuthState,
575    client_id: String,
576    subject: String,
577    resource: String,
578    scope: String,
579    refresh_token: Option<String>,
580) -> Result<TokenResponse, AuthError> {
581    let issuer = crate::metadata::public_base_url(state);
582    let now = timestamp_usize(now_unix(), "current unix timestamp")?;
583    let access_token_ttl = duration_secs_usize(
584        state.config.access_token_ttl,
585        &format!("{}_AUTH_ACCESS_TOKEN_TTL_SECS", state.config.env_prefix),
586    )?;
587    let subject_id = fingerprint(&subject);
588    let access_token = state.signing_keys.issue_access_token(&AccessClaims {
589        iss: issuer,
590        sub: subject.clone(),
591        aud: resource.clone(),
592        exp: now.checked_add(access_token_ttl).ok_or_else(|| {
593            AuthError::Config(format!(
594                "{}_AUTH_ACCESS_TOKEN_TTL_SECS exceeds supported range",
595                state.config.env_prefix
596            ))
597        })?,
598        iat: now,
599        jti: random_token(18)?,
600        scope: scope.clone(),
601        azp: client_id.clone(),
602    })?;
603    info!(
604        client_id = %client_id,
605        subject_id = %subject_id,
606        resource = %resource,
607        scope = %scope,
608        expires_in_secs = state.config.access_token_ttl.as_secs(),
609        refresh_token_issued = refresh_token.is_some(),
610        "oauth token response minted access token"
611    );
612    Ok(TokenResponse {
613        access_token,
614        token_type: "Bearer".to_string(),
615        expires_in: state.config.access_token_ttl.as_secs(),
616        refresh_token,
617        scope,
618    })
619}
620
621fn require_field(value: Option<String>, field: &str) -> Result<String, AuthError> {
622    value.ok_or_else(|| AuthError::Validation(format!("missing `{field}` parameter")))
623}
624
625fn pkce_challenge(code_verifier: &str) -> String {
626    URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()))
627}
628
629fn validate_authorization_code_row(
630    row: &AuthorizationCodeRow,
631    client_id: &str,
632    redirect_uri: &str,
633    code_verifier: &str,
634    auth_code_id: &str,
635) -> Result<(), AuthError> {
636    if row.client_id != client_id {
637        warn!(
638            auth_code_id = %auth_code_id,
639            requested_client_id = %client_id,
640            stored_client_id = %row.client_id,
641            "oauth token rejected: client_id does not match authorization code"
642        );
643        return Err(AuthError::InvalidGrant(
644            "client_id does not match the authorization code".to_string(),
645        ));
646    }
647    if row.redirect_uri != redirect_uri {
648        warn!(
649            auth_code_id = %auth_code_id,
650            requested_redirect_uri = %redirect_uri,
651            stored_redirect_uri = %row.redirect_uri,
652            "oauth token rejected: redirect_uri does not match authorization code"
653        );
654        return Err(AuthError::InvalidGrant(
655            "redirect_uri does not match the authorization code".to_string(),
656        ));
657    }
658    if row.code_challenge_method != "S256" {
659        warn!(
660            auth_code_id = %auth_code_id,
661            code_challenge_method = %row.code_challenge_method,
662            "oauth token rejected: unsupported PKCE method on authorization code"
663        );
664        return Err(AuthError::InvalidGrant(
665            "authorization code uses an unsupported PKCE method".to_string(),
666        ));
667    }
668    if !bool::from(
669        pkce_challenge(code_verifier)
670            .as_bytes()
671            .ct_eq(row.code_challenge.as_bytes()),
672    ) {
673        warn!(
674            auth_code_id = %auth_code_id,
675            client_id = %row.client_id,
676            "oauth token rejected: code_verifier did not match authorization code"
677        );
678        return Err(AuthError::InvalidGrant(
679            "code_verifier does not match the authorization code".to_string(),
680        ));
681    }
682    Ok(())
683}
684
685#[cfg(test)]
686mod tests {
687    use axum::body::Body;
688    use axum::http::{Request, StatusCode, header};
689    use base64::Engine as _;
690    use ed25519_dalek::pkcs8::EncodePrivateKey as _;
691    use jsonwebtoken::dangerous::insecure_decode;
692    use tower::util::ServiceExt;
693    use url::Url;
694    use wiremock::matchers::{method, path};
695    use wiremock::{Mock, MockServer, ResponseTemplate};
696
697    use axum::Router;
698    use axum::extract::connect_info::MockConnectInfo;
699    use std::net::SocketAddr;
700
701    use crate::config::MachineClientConfig;
702    use crate::google::GoogleProvider;
703
704    // `oneshot` bypasses the live `into_make_service_with_connect_info` layer,
705    // so `/token`'s rate-limit `ConnectInfo<SocketAddr>` extractor would be
706    // missing and every request would 500. Wrap the real router with a mock
707    // peer address, matching the helper in `authorize.rs`.
708    fn router(state: AuthState) -> Router {
709        crate::routes::router(state)
710            .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 9002))))
711    }
712    use crate::state::AuthState;
713
714    use super::super::authorize::tests::{
715        test_auth_config, test_auth_state_with_config, test_auth_state_with_mock_google,
716        test_auth_state_with_registered_client,
717    };
718
719    async fn test_auth_state_with_failing_google_refresh() -> AuthState {
720        let state = test_auth_state_with_registered_client().await;
721        let server = Box::leak(Box::new(MockServer::start().await));
722        Mock::given(method("POST"))
723            .and(path("/token"))
724            .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
725                "error": "temporarily_unavailable"
726            })))
727            .mount(server)
728            .await;
729        let google = GoogleProvider::new(
730            "client-id".to_string(),
731            "client-secret".to_string(),
732            Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
733        )
734        .unwrap()
735        .with_endpoints(
736            server.uri().parse::<Url>().unwrap(),
737            server.uri().parse::<Url>().unwrap().join("/token").unwrap(),
738        );
739        AuthState::for_tests(
740            (*state.config).clone(),
741            state.store.clone(),
742            (*state.signing_keys).clone(),
743            AuthState::google_only_providers(google),
744        )
745    }
746
747    #[tokio::test]
748    async fn token_endpoint_mints_lab_jwt_and_refresh_token() {
749        let state = test_auth_state_with_registered_client().await;
750        seed_authorization_code(&state).await;
751        let app = router(state);
752        let response = app
753            .oneshot(
754                Request::builder()
755                    .method("POST")
756                    .uri("/token")
757                    .header(
758                        header::CONTENT_TYPE,
759                        "application/x-www-form-urlencoded",
760                    )
761                    .body(Body::from("grant_type=authorization_code&code=lab-code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"))
762                    .unwrap(),
763            )
764            .await
765            .unwrap();
766        assert_eq!(response.status(), StatusCode::OK);
767        assert_eq!(
768            response
769                .headers()
770                .get(header::CACHE_CONTROL)
771                .and_then(|value| value.to_str().ok()),
772            Some("no-store")
773        );
774        assert_eq!(
775            response
776                .headers()
777                .get(header::PRAGMA)
778                .and_then(|value| value.to_str().ok()),
779            Some("no-cache")
780        );
781        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
782            .await
783            .unwrap();
784        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
785        assert!(json["access_token"].is_string());
786        assert!(json["refresh_token"].is_string());
787        let access_token = json["access_token"].as_str().expect("access token string");
788        let claims = insecure_decode::<crate::jwt::AccessClaims>(access_token)
789            .expect("decode access token")
790            .claims;
791        assert_eq!(claims.aud, "https://lab.example.com/mcp");
792    }
793
794    #[tokio::test]
795    async fn token_endpoint_omits_refresh_token_without_upstream_refresh_capability() {
796        let state = test_auth_state_with_registered_client().await;
797        seed_authorization_code_without_provider_refresh(&state).await;
798        let app = router(state);
799        let response = app
800            .oneshot(
801                Request::builder()
802                    .method("POST")
803                    .uri("/token")
804                    .header(
805                        header::CONTENT_TYPE,
806                        "application/x-www-form-urlencoded",
807                    )
808                    .body(Body::from("grant_type=authorization_code&code=lab-code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"))
809                    .unwrap(),
810            )
811            .await
812            .unwrap();
813        assert_eq!(response.status(), StatusCode::OK);
814        assert_eq!(
815            response
816                .headers()
817                .get(header::CACHE_CONTROL)
818                .and_then(|value| value.to_str().ok()),
819            Some("no-store")
820        );
821        assert_eq!(
822            response
823                .headers()
824                .get(header::PRAGMA)
825                .and_then(|value| value.to_str().ok()),
826            Some("no-cache")
827        );
828        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
829            .await
830            .unwrap();
831        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
832        assert!(json["access_token"].is_string());
833        assert!(json.get("refresh_token").is_none());
834    }
835
836    #[tokio::test]
837    async fn token_endpoint_redeems_authorization_code_once() {
838        let state = test_auth_state_with_registered_client().await;
839        seed_authorization_code(&state).await;
840        let app = router(state);
841        let (a, b) = tokio::join!(
842            app.clone().oneshot(
843                Request::builder()
844                    .method("POST")
845                    .uri("/token")
846                    .header(
847                        header::CONTENT_TYPE,
848                        "application/x-www-form-urlencoded",
849                    )
850                    .body(Body::from("grant_type=authorization_code&code=lab-code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"))
851                    .unwrap()
852            ),
853            app.oneshot(
854                Request::builder()
855                    .method("POST")
856                    .uri("/token")
857                    .header(
858                        header::CONTENT_TYPE,
859                        "application/x-www-form-urlencoded",
860                    )
861                    .body(Body::from("grant_type=authorization_code&code=lab-code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"))
862                    .unwrap()
863            )
864        );
865        let a = a.unwrap();
866        let b = b.unwrap();
867        assert!(a.status() == StatusCode::OK || b.status() == StatusCode::OK);
868        assert!(a.status() == StatusCode::BAD_REQUEST || b.status() == StatusCode::BAD_REQUEST);
869    }
870
871    #[tokio::test]
872    async fn token_endpoint_rejects_expired_authorization_code() {
873        let state = test_auth_state_with_registered_client().await;
874        seed_authorization_code_with_expiry(&state, crate::util::now_unix() - 1).await;
875        let app = router(state);
876        let response = app
877            .oneshot(
878                Request::builder()
879                    .method("POST")
880                    .uri("/token")
881                    .header(
882                        header::CONTENT_TYPE,
883                        "application/x-www-form-urlencoded",
884                    )
885                    .body(Body::from("grant_type=authorization_code&code=lab-code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"))
886                    .unwrap(),
887            )
888            .await
889            .unwrap();
890        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
891        assert_eq!(
892            response
893                .headers()
894                .get(header::CACHE_CONTROL)
895                .and_then(|value| value.to_str().ok()),
896            Some("no-store")
897        );
898        assert_eq!(
899            response
900                .headers()
901                .get(header::PRAGMA)
902                .and_then(|value| value.to_str().ok()),
903            Some("no-cache")
904        );
905    }
906
907    #[tokio::test]
908    async fn token_endpoint_errors_use_oauth_error_shape() {
909        let state = test_auth_state_with_registered_client().await;
910        let app = router(state);
911        let response = app
912            .oneshot(
913                Request::builder()
914                    .method("POST")
915                    .uri("/token")
916                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
917                    .body(Body::from(
918                        "grant_type=refresh_token&refresh_token=missing&client_id=client",
919                    ))
920                    .unwrap(),
921            )
922            .await
923            .unwrap();
924        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
925        assert_eq!(
926            response
927                .headers()
928                .get(header::CACHE_CONTROL)
929                .and_then(|value| value.to_str().ok()),
930            Some("no-store")
931        );
932        assert_eq!(
933            response
934                .headers()
935                .get(header::PRAGMA)
936                .and_then(|value| value.to_str().ok()),
937            Some("no-cache")
938        );
939        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
940            .await
941            .unwrap();
942        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
943        assert_eq!(json["error"], "invalid_grant");
944        assert_eq!(json["error_description"], "unknown refresh_token");
945        assert!(json.get("kind").is_none());
946        assert!(json.get("message").is_none());
947    }
948
949    #[tokio::test]
950    async fn token_endpoint_unsupported_grant_type_uses_oauth_error_shape() {
951        let state = test_auth_state_with_registered_client().await;
952        let app = router(state);
953        let response = app
954            .oneshot(
955                Request::builder()
956                    .method("POST")
957                    .uri("/token")
958                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
959                    .body(Body::from("grant_type=password&client_id=client"))
960                    .unwrap(),
961            )
962            .await
963            .unwrap();
964        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
965        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
966            .await
967            .unwrap();
968        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
969        assert_eq!(json["error"], "unsupported_grant_type");
970        assert_eq!(
971            json["error_description"],
972            "unsupported grant_type `password`"
973        );
974    }
975
976    #[tokio::test]
977    async fn token_endpoint_refresh_grant_sets_cache_headers() {
978        let state = test_auth_state_with_mock_google().await;
979        state
980            .store
981            .upsert_refresh_token(crate::types::RefreshTokenRow {
982                refresh_token: "refresh-token".to_string(),
983                client_id: "client".to_string(),
984                subject: "google-subject-123".to_string(),
985                resource: "https://lab.example.com/mcp".to_string(),
986                scope: "lab".to_string(),
987                provider: "google".to_string(),
988                provider_refresh_token: Some("provider-refresh".to_string()),
989                created_at: crate::util::now_unix() - 60,
990                expires_at: crate::util::now_unix() + 3600,
991                token_endpoint_auth_method: None,
992            })
993            .await
994            .unwrap();
995        let app = router(state);
996        let response = app
997            .oneshot(
998                Request::builder()
999                    .method("POST")
1000                    .uri("/token")
1001                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1002                    .body(Body::from(
1003                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
1004                    ))
1005                    .unwrap(),
1006            )
1007            .await
1008            .unwrap();
1009        assert_eq!(response.status(), StatusCode::OK);
1010        assert_eq!(
1011            response
1012                .headers()
1013                .get(header::CACHE_CONTROL)
1014                .and_then(|value| value.to_str().ok()),
1015            Some("no-store")
1016        );
1017        assert_eq!(
1018            response
1019                .headers()
1020                .get(header::PRAGMA)
1021                .and_then(|value| value.to_str().ok()),
1022            Some("no-cache")
1023        );
1024    }
1025
1026    #[tokio::test]
1027    async fn token_endpoint_refresh_grant_preserves_stored_resource_when_omitted() {
1028        let state = test_auth_state_with_mock_google().await;
1029        state
1030            .store
1031            .upsert_refresh_token(crate::types::RefreshTokenRow {
1032                refresh_token: "refresh-token".to_string(),
1033                client_id: "client".to_string(),
1034                subject: "google-subject-123".to_string(),
1035                resource: "https://mcp.example.com/syslog".to_string(),
1036                scope: "mcp:read mcp:write".to_string(),
1037                provider: "google".to_string(),
1038                provider_refresh_token: Some("provider-refresh".to_string()),
1039                created_at: crate::util::now_unix() - 60,
1040                expires_at: crate::util::now_unix() + 3600,
1041                token_endpoint_auth_method: None,
1042            })
1043            .await
1044            .unwrap();
1045        let app = router(state);
1046        let response = app
1047            .oneshot(
1048                Request::builder()
1049                    .method("POST")
1050                    .uri("/token")
1051                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1052                    .body(Body::from(
1053                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
1054                    ))
1055                    .unwrap(),
1056            )
1057            .await
1058            .unwrap();
1059        assert_eq!(response.status(), StatusCode::OK);
1060        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1061            .await
1062            .unwrap();
1063        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1064        let access_token = json["access_token"].as_str().expect("access token string");
1065        let claims = insecure_decode::<crate::jwt::AccessClaims>(access_token)
1066            .expect("decode access token")
1067            .claims;
1068        assert_eq!(claims.aud, "https://mcp.example.com/syslog");
1069        assert_eq!(claims.scope, "mcp:read mcp:write lab:admin");
1070    }
1071
1072    #[tokio::test]
1073    async fn token_endpoint_rejects_mismatched_resource_parameter() {
1074        let state = test_auth_state_with_registered_client().await;
1075        seed_authorization_code(&state).await;
1076        let app = router(state);
1077        let response = app
1078            .oneshot(
1079                Request::builder()
1080                    .method("POST")
1081                    .uri("/token")
1082                    .header(
1083                        header::CONTENT_TYPE,
1084                        "application/x-www-form-urlencoded",
1085                    )
1086                    .body(Body::from("grant_type=authorization_code&code=lab-code&client_id=client&resource=https%3A%2F%2Fother.example.com%2Fmcp&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"))
1087                    .unwrap(),
1088            )
1089            .await
1090            .unwrap();
1091        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1092    }
1093
1094    #[tokio::test]
1095    async fn token_endpoint_rejects_expired_refresh_token() {
1096        let state = test_auth_state_with_registered_client().await;
1097        state
1098            .store
1099            .upsert_refresh_token(crate::types::RefreshTokenRow {
1100                refresh_token: "refresh-token".to_string(),
1101                client_id: "client".to_string(),
1102                subject: "google-subject-123".to_string(),
1103                resource: "https://lab.example.com/mcp".to_string(),
1104                scope: "lab".to_string(),
1105                provider: "google".to_string(),
1106                provider_refresh_token: Some("provider-refresh".to_string()),
1107                created_at: crate::util::now_unix() - 3600,
1108                expires_at: crate::util::now_unix() - 1,
1109                token_endpoint_auth_method: None,
1110            })
1111            .await
1112            .unwrap();
1113        let app = router(state);
1114        let response = app
1115            .oneshot(
1116                Request::builder()
1117                    .method("POST")
1118                    .uri("/token")
1119                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1120                    .body(Body::from(
1121                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
1122                    ))
1123                    .unwrap(),
1124            )
1125            .await
1126            .unwrap();
1127        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1128        assert_eq!(
1129            response
1130                .headers()
1131                .get(header::CACHE_CONTROL)
1132                .and_then(|value| value.to_str().ok()),
1133            Some("no-store")
1134        );
1135        assert_eq!(
1136            response
1137                .headers()
1138                .get(header::PRAGMA)
1139                .and_then(|value| value.to_str().ok()),
1140            Some("no-cache")
1141        );
1142    }
1143
1144    #[tokio::test]
1145    async fn token_endpoint_rejects_refresh_token_client_mismatch() {
1146        let state = test_auth_state_with_registered_client().await;
1147        // `other-client` must be a *registered* public client, otherwise the
1148        // request is rejected by client authentication (invalid_client) before
1149        // reaching the refresh-token/client binding check this test covers.
1150        state
1151            .store
1152            .register_client(crate::types::RegisteredClient {
1153                client_id: "other-client".to_string(),
1154                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
1155                created_at: crate::util::now_unix(),
1156                token_endpoint_auth_method: "none".to_string(),
1157                jwks: None,
1158            })
1159            .await
1160            .unwrap();
1161        state
1162            .store
1163            .upsert_refresh_token(crate::types::RefreshTokenRow {
1164                refresh_token: "refresh-token".to_string(),
1165                client_id: "client".to_string(),
1166                subject: "google-subject-123".to_string(),
1167                resource: "https://lab.example.com/mcp".to_string(),
1168                scope: "lab".to_string(),
1169                provider: "google".to_string(),
1170                provider_refresh_token: Some("provider-refresh".to_string()),
1171                created_at: crate::util::now_unix() - 60,
1172                expires_at: crate::util::now_unix() + 3600,
1173                token_endpoint_auth_method: None,
1174            })
1175            .await
1176            .unwrap();
1177        let app = router(state);
1178        let response = app
1179            .oneshot(
1180                Request::builder()
1181                    .method("POST")
1182                    .uri("/token")
1183                    .header(
1184                        header::CONTENT_TYPE,
1185                        "application/x-www-form-urlencoded",
1186                    )
1187                    .body(Body::from(
1188                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=other-client",
1189                    ))
1190                    .unwrap(),
1191            )
1192            .await
1193            .unwrap();
1194        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1195        assert_eq!(
1196            response
1197                .headers()
1198                .get(header::CACHE_CONTROL)
1199                .and_then(|value| value.to_str().ok()),
1200            Some("no-store")
1201        );
1202        assert_eq!(
1203            response
1204                .headers()
1205                .get(header::PRAGMA)
1206                .and_then(|value| value.to_str().ok()),
1207            Some("no-cache")
1208        );
1209    }
1210
1211    #[tokio::test]
1212    async fn token_endpoint_rejects_refresh_token_without_upstream_refresh_capability() {
1213        let state = test_auth_state_with_registered_client().await;
1214        state
1215            .store
1216            .upsert_refresh_token(crate::types::RefreshTokenRow {
1217                refresh_token: "refresh-token".to_string(),
1218                client_id: "client".to_string(),
1219                subject: "google-subject-123".to_string(),
1220                resource: "https://lab.example.com/mcp".to_string(),
1221                scope: "lab".to_string(),
1222                provider: "google".to_string(),
1223                provider_refresh_token: None,
1224                created_at: crate::util::now_unix() - 60,
1225                expires_at: crate::util::now_unix() + 3600,
1226                token_endpoint_auth_method: None,
1227            })
1228            .await
1229            .unwrap();
1230        let app = router(state);
1231        let response = app
1232            .oneshot(
1233                Request::builder()
1234                    .method("POST")
1235                    .uri("/token")
1236                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1237                    .body(Body::from(
1238                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
1239                    ))
1240                    .unwrap(),
1241            )
1242            .await
1243            .unwrap();
1244        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1245    }
1246
1247    async fn seed_authorization_code(state: &AuthState) {
1248        seed_authorization_code_with_expiry(state, 4_102_444_800).await;
1249    }
1250
1251    async fn seed_authorization_code_without_provider_refresh(state: &AuthState) {
1252        state
1253            .store
1254            .insert_auth_code(crate::types::AuthorizationCodeRow {
1255                code: "lab-code".to_string(),
1256                client_id: "client".to_string(),
1257                subject: "google-subject-123".to_string(),
1258                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
1259                resource: "https://lab.example.com/mcp".to_string(),
1260                scope: "lab".to_string(),
1261                provider: "google".to_string(),
1262                code_challenge: super::pkce_challenge("verifier"),
1263                code_challenge_method: "S256".to_string(),
1264                provider_refresh_token: None,
1265                created_at: 1_700_000_000,
1266                expires_at: 4_102_444_800,
1267                token_endpoint_auth_method: None,
1268            })
1269            .await
1270            .unwrap();
1271    }
1272
1273    async fn seed_authorization_code_with_expiry(state: &AuthState, expires_at: i64) {
1274        state
1275            .store
1276            .insert_auth_code(crate::types::AuthorizationCodeRow {
1277                code: "lab-code".to_string(),
1278                client_id: "client".to_string(),
1279                subject: "google-subject-123".to_string(),
1280                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
1281                resource: "https://lab.example.com/mcp".to_string(),
1282                scope: "lab".to_string(),
1283                provider: "google".to_string(),
1284                code_challenge: super::pkce_challenge("verifier"),
1285                code_challenge_method: "S256".to_string(),
1286                provider_refresh_token: Some("provider-refresh".to_string()),
1287                created_at: 1_700_000_000,
1288                expires_at,
1289                token_endpoint_auth_method: None,
1290            })
1291            .await
1292            .unwrap();
1293    }
1294
1295    #[tokio::test]
1296    async fn refresh_grant_preserves_local_token_on_success() {
1297        let state = test_auth_state_with_mock_google().await;
1298        state
1299            .store
1300            .upsert_refresh_token(crate::types::RefreshTokenRow {
1301                refresh_token: "original-token".to_string(),
1302                client_id: "client".to_string(),
1303                subject: "google-subject-123".to_string(),
1304                resource: String::new(),
1305                scope: "lab".to_string(),
1306                provider: "google".to_string(),
1307                provider_refresh_token: Some("provider-refresh".to_string()),
1308                created_at: crate::util::now_unix() - 60,
1309                expires_at: crate::util::now_unix() + 3600,
1310                token_endpoint_auth_method: None,
1311            })
1312            .await
1313            .unwrap();
1314        let app = router(state.clone());
1315        let response = app
1316            .oneshot(
1317                Request::builder()
1318                    .method("POST")
1319                    .uri("/token")
1320                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1321                    .body(Body::from(
1322                        "grant_type=refresh_token&refresh_token=original-token&client_id=client",
1323                    ))
1324                    .unwrap(),
1325            )
1326            .await
1327            .unwrap();
1328        assert_eq!(response.status(), StatusCode::OK);
1329        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1330            .await
1331            .unwrap();
1332        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1333        let new_token = json["refresh_token"].as_str().expect("refresh_token");
1334        assert_eq!(
1335            new_token, "original-token",
1336            "local token must remain stable"
1337        );
1338        assert!(
1339            state
1340                .store
1341                .find_refresh_token("original-token")
1342                .await
1343                .unwrap()
1344                .is_some(),
1345            "local refresh token must remain usable after successful refresh"
1346        );
1347    }
1348
1349    #[tokio::test]
1350    async fn refresh_grant_preserves_original_token_when_upstream_refresh_fails() {
1351        let state = test_auth_state_with_failing_google_refresh().await;
1352        state
1353            .store
1354            .upsert_refresh_token(crate::types::RefreshTokenRow {
1355                refresh_token: "recoverable-token".to_string(),
1356                client_id: "client".to_string(),
1357                subject: "google-subject-123".to_string(),
1358                resource: String::new(),
1359                scope: "lab".to_string(),
1360                provider: "google".to_string(),
1361                provider_refresh_token: Some("provider-refresh".to_string()),
1362                created_at: crate::util::now_unix() - 60,
1363                expires_at: crate::util::now_unix() + 3600,
1364                token_endpoint_auth_method: None,
1365            })
1366            .await
1367            .unwrap();
1368        let app = router(state.clone());
1369
1370        let response = app
1371            .oneshot(
1372                Request::builder()
1373                    .method("POST")
1374                    .uri("/token")
1375                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1376                    .body(Body::from(
1377                        "grant_type=refresh_token&refresh_token=recoverable-token&client_id=client",
1378                    ))
1379                    .unwrap(),
1380            )
1381            .await
1382            .unwrap();
1383
1384        assert_ne!(response.status(), StatusCode::OK);
1385        assert!(
1386            state
1387                .store
1388                .find_refresh_token("recoverable-token")
1389                .await
1390                .unwrap()
1391                .is_some(),
1392            "local refresh token must remain usable after upstream refresh failure"
1393        );
1394    }
1395
1396    #[tokio::test]
1397    async fn refresh_grant_elevates_stale_scope_to_admin() {
1398        // Simulate a refresh token that was issued before elevation was wired in,
1399        // storing only the base scope ("lab") without "lab:admin".  The refresh
1400        // grant must re-apply elevate_scope_for_allowed_user so the new access
1401        // token carries "lab:admin".
1402        let state = test_auth_state_with_mock_google().await;
1403        state
1404            .store
1405            .upsert_refresh_token(crate::types::RefreshTokenRow {
1406                refresh_token: "stale-token".to_string(),
1407                client_id: "client".to_string(),
1408                subject: "google-subject-123".to_string(),
1409                resource: String::new(),
1410                scope: "lab".to_string(), // stale — no lab:admin
1411                provider: "google".to_string(),
1412                provider_refresh_token: Some("provider-refresh".to_string()),
1413                created_at: crate::util::now_unix() - 60,
1414                expires_at: crate::util::now_unix() + 3600,
1415                token_endpoint_auth_method: None,
1416            })
1417            .await
1418            .unwrap();
1419        let app = router(state.clone());
1420        let response = app
1421            .oneshot(
1422                Request::builder()
1423                    .method("POST")
1424                    .uri("/token")
1425                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1426                    .body(Body::from(
1427                        "grant_type=refresh_token&refresh_token=stale-token&client_id=client",
1428                    ))
1429                    .unwrap(),
1430            )
1431            .await
1432            .unwrap();
1433        assert_eq!(response.status(), StatusCode::OK);
1434        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1435            .await
1436            .unwrap();
1437        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1438        // Decode the access token and verify the scope was elevated.
1439        let access_token = json["access_token"].as_str().expect("access_token");
1440        let claims = state
1441            .signing_keys
1442            .validate_access_token_with_issuer(
1443                access_token,
1444                "https://lab.example.com/mcp",
1445                "https://lab.example.com",
1446            )
1447            .expect("access token must be valid");
1448        let scopes: Vec<&str> = claims.scope.split_whitespace().collect();
1449        assert!(
1450            scopes.contains(&"lab:admin"),
1451            "elevated access token must contain lab:admin, got: {:?}",
1452            scopes
1453        );
1454    }
1455
1456    #[tokio::test]
1457    async fn refresh_grant_allows_reuse_of_stable_local_token() {
1458        let state = test_auth_state_with_mock_google().await;
1459        state
1460            .store
1461            .upsert_refresh_token(crate::types::RefreshTokenRow {
1462                refresh_token: "once-only-token".to_string(),
1463                client_id: "client".to_string(),
1464                subject: "google-subject-123".to_string(),
1465                resource: String::new(),
1466                scope: "lab".to_string(),
1467                provider: "google".to_string(),
1468                provider_refresh_token: Some("provider-refresh".to_string()),
1469                created_at: crate::util::now_unix() - 60,
1470                expires_at: crate::util::now_unix() + 3600,
1471                token_endpoint_auth_method: None,
1472            })
1473            .await
1474            .unwrap();
1475        let app = router(state);
1476        let first = app
1477            .clone()
1478            .oneshot(
1479                Request::builder()
1480                    .method("POST")
1481                    .uri("/token")
1482                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1483                    .body(Body::from(
1484                        "grant_type=refresh_token&refresh_token=once-only-token&client_id=client",
1485                    ))
1486                    .unwrap(),
1487            )
1488            .await
1489            .unwrap();
1490        assert_eq!(first.status(), StatusCode::OK);
1491        let replay = app
1492            .oneshot(
1493                Request::builder()
1494                    .method("POST")
1495                    .uri("/token")
1496                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1497                    .body(Body::from(
1498                        "grant_type=refresh_token&refresh_token=once-only-token&client_id=client",
1499                    ))
1500                    .unwrap(),
1501            )
1502            .await
1503            .unwrap();
1504        assert_eq!(
1505            replay.status(),
1506            StatusCode::OK,
1507            "same local refresh token must be reusable across client restarts"
1508        );
1509    }
1510
1511    #[tokio::test]
1512    async fn authorization_code_grant_never_mints_a_refresh_token_for_a_github_login() {
1513        let state = test_auth_state_with_registered_client().await;
1514        state
1515            .store
1516            .insert_auth_code(crate::types::AuthorizationCodeRow {
1517                code: "github-code".to_string(),
1518                client_id: "client".to_string(),
1519                subject: "github:9182310".to_string(),
1520                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
1521                resource: "https://lab.example.com/mcp".to_string(),
1522                scope: "lab".to_string(),
1523                provider: "github".to_string(),
1524                code_challenge: super::pkce_challenge("verifier"),
1525                code_challenge_method: "S256".to_string(),
1526                provider_refresh_token: None,
1527                created_at: crate::util::now_unix(),
1528                expires_at: crate::util::now_unix() + 300,
1529                token_endpoint_auth_method: None,
1530            })
1531            .await
1532            .unwrap();
1533        let app = router(state);
1534        let response = app
1535            .oneshot(
1536                Request::builder()
1537                    .method("POST")
1538                    .uri("/token")
1539                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1540                    .body(Body::from(
1541                        "grant_type=authorization_code&code=github-code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier",
1542                    ))
1543                    .unwrap(),
1544            )
1545            .await
1546            .unwrap();
1547        assert_eq!(response.status(), StatusCode::OK);
1548        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1549            .await
1550            .unwrap();
1551        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1552        assert!(
1553            json.get("refresh_token").is_none(),
1554            "github logins must never receive a local refresh token: {json}"
1555        );
1556    }
1557
1558    /// Engineering-review regression test: a deployment upgrades (backfilling
1559    /// pre-existing rows to `provider='google'`), then an operator removes a
1560    /// provider from config while an unexpired `refresh_tokens` row still
1561    /// names it. `state.provider(...)` must fail clearly (`AuthError::Validation`
1562    /// → `invalid_request` / 400), not panic or silently fall back to a
1563    /// different provider.
1564    #[tokio::test]
1565    async fn refresh_token_grant_fails_clearly_when_its_provider_is_no_longer_configured() {
1566        let state = test_auth_state_with_registered_client().await;
1567        state
1568            .store
1569            .upsert_refresh_token(crate::types::RefreshTokenRow {
1570                refresh_token: "orphaned-refresh".to_string(),
1571                client_id: "client".to_string(),
1572                subject: "authelia:some-user".to_string(),
1573                resource: "https://lab.example.com/mcp".to_string(),
1574                scope: "lab".to_string(),
1575                provider: "authelia".to_string(),
1576                provider_refresh_token: Some("upstream-refresh".to_string()),
1577                created_at: crate::util::now_unix(),
1578                expires_at: crate::util::now_unix() + 3600,
1579                token_endpoint_auth_method: None,
1580            })
1581            .await
1582            .unwrap();
1583        // `test_auth_state_with_registered_client` only configures Google —
1584        // "authelia" is intentionally never configured here, simulating an
1585        // operator who removed it after this token was issued.
1586        let app = router(state);
1587        let response = app
1588            .oneshot(
1589                Request::builder()
1590                    .method("POST")
1591                    .uri("/token")
1592                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1593                    .body(Body::from(
1594                        "grant_type=refresh_token&client_id=client&refresh_token=orphaned-refresh",
1595                    ))
1596                    .unwrap(),
1597            )
1598            .await
1599            .unwrap();
1600        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1601        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1602            .await
1603            .unwrap();
1604        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1605        assert_eq!(json["error"], "invalid_request");
1606    }
1607
1608    /// Defense-in-depth regression test: `GitHubProvider::exchange_code`
1609    /// never sets `provider_refresh_token`, so a `refresh_tokens` row naming
1610    /// `provider = "github"` with a non-null `provider_refresh_token` is
1611    /// unreachable through normal flows — but the DB layer doesn't enforce
1612    /// that invariant. Hand-insert exactly that row and confirm
1613    /// `refresh_token_grant` fails loudly with a clear server error instead
1614    /// of reaching `GitHubProvider::refresh`'s unconditional error.
1615    #[tokio::test]
1616    async fn refresh_token_grant_rejects_a_hand_inserted_github_row_with_a_provider_refresh_token()
1617    {
1618        let mut config = test_auth_config();
1619        config.github.client_id = "gh-client".to_string();
1620        config.github.client_secret = "gh-secret".to_string();
1621        config.github.scopes = vec!["read:user".to_string(), "user:email".to_string()];
1622        let state = test_auth_state_with_config(config).await;
1623        state
1624            .store
1625            .register_client(crate::types::RegisteredClient {
1626                client_id: "client".to_string(),
1627                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
1628                created_at: crate::util::now_unix(),
1629                token_endpoint_auth_method: "none".to_string(),
1630                jwks: None,
1631            })
1632            .await
1633            .unwrap();
1634        state
1635            .store
1636            .upsert_refresh_token(crate::types::RefreshTokenRow {
1637                refresh_token: "github-refresh".to_string(),
1638                client_id: "client".to_string(),
1639                subject: "github:9182310".to_string(),
1640                resource: "https://lab.example.com/mcp".to_string(),
1641                scope: "lab".to_string(),
1642                provider: "github".to_string(),
1643                provider_refresh_token: Some("hand-inserted-upstream-value".to_string()),
1644                created_at: crate::util::now_unix(),
1645                expires_at: crate::util::now_unix() + 3600,
1646                token_endpoint_auth_method: None,
1647            })
1648            .await
1649            .unwrap();
1650        let app = router(state);
1651        let response = app
1652            .oneshot(
1653                Request::builder()
1654                    .method("POST")
1655                    .uri("/token")
1656                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1657                    .body(Body::from(
1658                        "grant_type=refresh_token&client_id=client&refresh_token=github-refresh",
1659                    ))
1660                    .unwrap(),
1661            )
1662            .await
1663            .unwrap();
1664        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1665        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1666            .await
1667            .unwrap();
1668        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1669        assert_eq!(json["error"], "server_error");
1670    }
1671
1672    // --- client authentication + machine grants -------------------------
1673    //
1674    // These cover `token_client_auth`, which the token endpoint reaches
1675    // through `prepare_client_credentials`, `authenticate_client`, and
1676    // `machine_client_grant`.
1677
1678    /// Deterministic Ed25519 key standing in for a machine client's signing
1679    /// key. Test-only material: it authenticates nothing outside this module.
1680    fn client_assertion_signing_key() -> ed25519_dalek::SigningKey {
1681        ed25519_dalek::SigningKey::from_bytes(&[7u8; 32])
1682    }
1683
1684    const CLIENT_ASSERTION_KID: &str = "client-assertion-kid";
1685
1686    fn client_assertion_jwks() -> serde_json::Value {
1687        let public_key = client_assertion_signing_key().verifying_key();
1688        serde_json::json!({
1689            "keys": [{
1690                "kty": "OKP",
1691                "crv": "Ed25519",
1692                "alg": "EdDSA",
1693                "use": "sig",
1694                "kid": CLIENT_ASSERTION_KID,
1695                "x": base64::engine::general_purpose::URL_SAFE_NO_PAD
1696                    .encode(public_key.as_bytes()),
1697            }]
1698        })
1699    }
1700
1701    fn signed_client_assertion(client_id: &str, jti: &str) -> String {
1702        let now = crate::util::now_unix();
1703        let claims = serde_json::json!({
1704            "iss": client_id,
1705            "sub": client_id,
1706            "aud": "https://lab.example.com/token",
1707            "iat": now,
1708            "exp": now + 120,
1709            "jti": jti,
1710        });
1711        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
1712        header.kid = Some(CLIENT_ASSERTION_KID.to_string());
1713        let der = client_assertion_signing_key().to_pkcs8_der().unwrap();
1714        jsonwebtoken::encode(
1715            &header,
1716            &claims,
1717            &jsonwebtoken::EncodingKey::from_ed_der(der.as_bytes()),
1718        )
1719        .unwrap()
1720    }
1721
1722    fn secret_machine_client() -> MachineClientConfig {
1723        MachineClientConfig {
1724            client_id: "machine".to_string(),
1725            client_secret: Some("machine-secret".to_string()),
1726            jwks: None,
1727            scopes: vec!["lab".to_string()],
1728            resources: vec!["https://lab.example.com/mcp".to_string()],
1729        }
1730    }
1731
1732    fn assertion_machine_client() -> MachineClientConfig {
1733        MachineClientConfig {
1734            client_id: "assertion-machine".to_string(),
1735            client_secret: None,
1736            jwks: Some(client_assertion_jwks()),
1737            scopes: vec!["lab".to_string()],
1738            resources: vec!["https://lab.example.com/mcp".to_string()],
1739        }
1740    }
1741
1742    async fn machine_client_state(clients: Vec<MachineClientConfig>) -> AuthState {
1743        let mut config = test_auth_config();
1744        config.machine_clients = clients;
1745        test_auth_state_with_config(config).await
1746    }
1747
1748    fn basic_authorization(client_id: &str, client_secret: &str) -> String {
1749        format!(
1750            "Basic {}",
1751            base64::engine::general_purpose::STANDARD
1752                .encode(format!("{client_id}:{client_secret}"))
1753        )
1754    }
1755
1756    async fn post_token(
1757        state: &AuthState,
1758        body: String,
1759        authorization: Option<String>,
1760    ) -> (StatusCode, serde_json::Value) {
1761        let mut builder = Request::builder()
1762            .method("POST")
1763            .uri("/token")
1764            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
1765        if let Some(authorization) = authorization {
1766            builder = builder.header(header::AUTHORIZATION, authorization);
1767        }
1768        let response = router(state.clone())
1769            .oneshot(builder.body(Body::from(body)).unwrap())
1770            .await
1771            .unwrap();
1772        let status = response.status();
1773        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1774            .await
1775            .unwrap();
1776        let json = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
1777        (status, json)
1778    }
1779
1780    #[tokio::test]
1781    async fn client_credentials_grant_accepts_client_secret_basic() {
1782        let state = machine_client_state(vec![secret_machine_client()]).await;
1783        let (status, json) = post_token(
1784            &state,
1785            "grant_type=client_credentials".to_string(),
1786            Some(basic_authorization("machine", "machine-secret")),
1787        )
1788        .await;
1789        assert_eq!(status, StatusCode::OK, "{json}");
1790        assert_eq!(json["scope"], "lab");
1791        assert!(
1792            json.get("refresh_token").is_none(),
1793            "machine grants must not mint refresh tokens: {json}"
1794        );
1795        let claims = insecure_decode::<crate::jwt::AccessClaims>(
1796            json["access_token"].as_str().expect("access token"),
1797        )
1798        .expect("decode access token")
1799        .claims;
1800        assert_eq!(claims.sub, "machine");
1801        assert_eq!(claims.azp, "machine");
1802        assert_eq!(claims.aud, "https://lab.example.com/mcp");
1803    }
1804
1805    #[tokio::test]
1806    async fn client_credentials_grant_rejects_a_wrong_client_secret() {
1807        let state = machine_client_state(vec![secret_machine_client()]).await;
1808        let (status, json) = post_token(
1809            &state,
1810            "grant_type=client_credentials".to_string(),
1811            Some(basic_authorization("machine", "not-the-secret")),
1812        )
1813        .await;
1814        assert_eq!(status, StatusCode::UNAUTHORIZED);
1815        assert_eq!(json["error"], "invalid_client");
1816    }
1817
1818    #[tokio::test]
1819    async fn client_credentials_grant_rejects_basic_and_body_credentials_together() {
1820        let state = machine_client_state(vec![secret_machine_client()]).await;
1821        let (status, json) = post_token(
1822            &state,
1823            "grant_type=client_credentials&client_id=machine&client_secret=machine-secret"
1824                .to_string(),
1825            Some(basic_authorization("machine", "machine-secret")),
1826        )
1827        .await;
1828        assert_eq!(
1829            status,
1830            StatusCode::UNAUTHORIZED,
1831            "ambiguous credentials must never be resolved in the client's favour: {json}"
1832        );
1833        assert_eq!(json["error"], "invalid_client");
1834    }
1835
1836    #[tokio::test]
1837    async fn client_credentials_grant_rejects_scope_beyond_the_configured_grant() {
1838        let state = machine_client_state(vec![secret_machine_client()]).await;
1839        let (status, json) = post_token(
1840            &state,
1841            "grant_type=client_credentials&scope=lab%3Aadmin".to_string(),
1842            Some(basic_authorization("machine", "machine-secret")),
1843        )
1844        .await;
1845        assert_eq!(status, StatusCode::BAD_REQUEST);
1846        assert_eq!(json["error"], "invalid_scope");
1847    }
1848
1849    #[tokio::test]
1850    async fn client_credentials_grant_rejects_resource_beyond_the_configured_grant() {
1851        let state = machine_client_state(vec![secret_machine_client()]).await;
1852        let (status, json) = post_token(
1853            &state,
1854            "grant_type=client_credentials&resource=https%3A%2F%2Fother.example.com%2Fmcp"
1855                .to_string(),
1856            Some(basic_authorization("machine", "machine-secret")),
1857        )
1858        .await;
1859        assert_eq!(status, StatusCode::BAD_REQUEST);
1860        assert_eq!(json["error"], "invalid_scope");
1861    }
1862
1863    #[tokio::test]
1864    async fn client_credentials_grant_accepts_private_key_jwt_without_a_client_id_parameter() {
1865        let state = machine_client_state(vec![assertion_machine_client()]).await;
1866        let assertion = signed_client_assertion("assertion-machine", "assertion-jti-1");
1867        let (status, json) = post_token(
1868            &state,
1869            format!(
1870                "grant_type=client_credentials&client_assertion_type={}&client_assertion={assertion}",
1871                "urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
1872            ),
1873            None,
1874        )
1875        .await;
1876        assert_eq!(status, StatusCode::OK, "{json}");
1877        let claims = insecure_decode::<crate::jwt::AccessClaims>(
1878            json["access_token"].as_str().expect("access token"),
1879        )
1880        .expect("decode access token")
1881        .claims;
1882        assert_eq!(claims.sub, "assertion-machine");
1883    }
1884
1885    #[tokio::test]
1886    async fn client_credentials_grant_rejects_a_replayed_client_assertion() {
1887        let state = machine_client_state(vec![assertion_machine_client()]).await;
1888        let assertion = signed_client_assertion("assertion-machine", "replayed-jti");
1889        let body = format!(
1890            "grant_type=client_credentials&client_assertion_type={}&client_assertion={assertion}",
1891            "urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
1892        );
1893        let (first, json) = post_token(&state, body.clone(), None).await;
1894        assert_eq!(first, StatusCode::OK, "{json}");
1895        let (replay, json) = post_token(&state, body, None).await;
1896        assert_eq!(
1897            replay,
1898            StatusCode::UNAUTHORIZED,
1899            "a client assertion jti must be single-use: {json}"
1900        );
1901        assert_eq!(json["error"], "invalid_client");
1902    }
1903
1904    #[tokio::test]
1905    async fn client_credentials_grant_rejects_an_assertion_signed_by_an_unknown_key() {
1906        // Same claims, but the configured client authenticates with a shared
1907        // secret and publishes no JWKS at all.
1908        let state = machine_client_state(vec![secret_machine_client()]).await;
1909        let assertion = signed_client_assertion("machine", "foreign-key-jti");
1910        let (status, json) = post_token(
1911            &state,
1912            format!(
1913                "grant_type=client_credentials&client_assertion_type={}&client_assertion={assertion}",
1914                "urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
1915            ),
1916            None,
1917        )
1918        .await;
1919        assert_eq!(status, StatusCode::UNAUTHORIZED);
1920        assert_eq!(json["error"], "invalid_client");
1921    }
1922
1923    #[tokio::test]
1924    async fn jwt_bearer_grant_issues_a_machine_token_from_its_assertion() {
1925        let state = machine_client_state(vec![assertion_machine_client()]).await;
1926        let assertion = signed_client_assertion("assertion-machine", "jwt-bearer-jti");
1927        let (status, json) = post_token(
1928            &state,
1929            format!(
1930                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion={assertion}"
1931            ),
1932            None,
1933        )
1934        .await;
1935        assert_eq!(status, StatusCode::OK, "{json}");
1936        let claims = insecure_decode::<crate::jwt::AccessClaims>(
1937            json["access_token"].as_str().expect("access token"),
1938        )
1939        .expect("decode access token")
1940        .claims;
1941        assert_eq!(claims.sub, "assertion-machine");
1942        assert!(json.get("refresh_token").is_none(), "{json}");
1943    }
1944
1945    #[tokio::test]
1946    async fn jwt_bearer_grant_is_not_an_alias_for_client_credentials() {
1947        // Without an assertion the grant is rejected outright, so a machine
1948        // client cannot use it to launder a plain client_secret exchange past
1949        // whatever policy is attached to the JWT-bearer profile.
1950        let state = machine_client_state(vec![secret_machine_client()]).await;
1951        let (status, json) = post_token(
1952            &state,
1953            "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer".to_string(),
1954            Some(basic_authorization("machine", "machine-secret")),
1955        )
1956        .await;
1957        assert_eq!(status, StatusCode::BAD_REQUEST);
1958        assert_eq!(json["error"], "invalid_request");
1959        assert_eq!(json["error_description"], "missing `assertion` parameter");
1960    }
1961
1962    #[tokio::test]
1963    async fn jwt_bearer_grant_rejects_two_disagreeing_assertions() {
1964        let state = machine_client_state(vec![assertion_machine_client()]).await;
1965        let assertion = signed_client_assertion("assertion-machine", "grant-jti");
1966        let other = signed_client_assertion("assertion-machine", "credential-jti");
1967        let (status, json) = post_token(
1968            &state,
1969            format!(
1970                "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer\
1971                 &assertion={assertion}&client_assertion={other}"
1972            ),
1973            None,
1974        )
1975        .await;
1976        assert_eq!(status, StatusCode::UNAUTHORIZED);
1977        assert_eq!(json["error"], "invalid_client");
1978    }
1979
1980    #[tokio::test]
1981    async fn public_client_authorization_code_grant_is_unaffected_by_client_authentication() {
1982        // Regression guard for the whole point of `token_endpoint_auth_method
1983        // = "none"`: a public client still redeems its code with nothing but
1984        // PKCE, exactly as before client authentication was wired in.
1985        let state = test_auth_state_with_registered_client().await;
1986        seed_authorization_code(&state).await;
1987        let (status, json) = post_token(
1988            &state,
1989            "grant_type=authorization_code&code=lab-code&client_id=client\
1990             &redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"
1991                .to_string(),
1992            None,
1993        )
1994        .await;
1995        assert_eq!(status, StatusCode::OK, "{json}");
1996        assert!(json["access_token"].is_string());
1997    }
1998
1999    #[tokio::test]
2000    async fn public_client_authorization_code_grant_tolerates_an_empty_client_secret_field() {
2001        // Clients that emit `client_secret=` instead of omitting it are still
2002        // public clients; a blank field must not be read as a presented secret.
2003        let state = test_auth_state_with_registered_client().await;
2004        seed_authorization_code(&state).await;
2005        let (status, json) = post_token(
2006            &state,
2007            "grant_type=authorization_code&code=lab-code&client_id=client&client_secret=\
2008             &redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"
2009                .to_string(),
2010            None,
2011        )
2012        .await;
2013        assert_eq!(status, StatusCode::OK, "{json}");
2014    }
2015
2016    #[tokio::test]
2017    async fn public_client_authorization_code_grant_rejects_a_supplied_secret_without_burning_the_code()
2018     {
2019        let state = test_auth_state_with_registered_client().await;
2020        seed_authorization_code(&state).await;
2021        let (status, json) = post_token(
2022            &state,
2023            "grant_type=authorization_code&code=lab-code&client_id=client&client_secret=guess\
2024             &redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"
2025                .to_string(),
2026            None,
2027        )
2028        .await;
2029        assert_eq!(status, StatusCode::UNAUTHORIZED);
2030        assert_eq!(json["error"], "invalid_client");
2031
2032        // Client authentication runs before redemption, so the single-use code
2033        // must have survived the rejected attempt.
2034        let (status, json) = post_token(
2035            &state,
2036            "grant_type=authorization_code&code=lab-code&client_id=client\
2037             &redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"
2038                .to_string(),
2039            None,
2040        )
2041        .await;
2042        assert_eq!(
2043            status,
2044            StatusCode::OK,
2045            "a failed client authentication must not consume the code: {json}"
2046        );
2047    }
2048
2049    #[tokio::test]
2050    async fn refresh_grant_rejects_an_unregistered_client() {
2051        let state = test_auth_state_with_registered_client().await;
2052        state
2053            .store
2054            .upsert_refresh_token(crate::types::RefreshTokenRow {
2055                refresh_token: "refresh-token".to_string(),
2056                client_id: "ghost-client".to_string(),
2057                subject: "google-subject-123".to_string(),
2058                resource: "https://lab.example.com/mcp".to_string(),
2059                scope: "lab".to_string(),
2060                provider: "google".to_string(),
2061                provider_refresh_token: Some("provider-refresh".to_string()),
2062                created_at: crate::util::now_unix() - 60,
2063                expires_at: crate::util::now_unix() + 3600,
2064                token_endpoint_auth_method: None,
2065            })
2066            .await
2067            .unwrap();
2068        let (status, json) = post_token(
2069            &state,
2070            "grant_type=refresh_token&refresh_token=refresh-token&client_id=ghost-client"
2071                .to_string(),
2072            None,
2073        )
2074        .await;
2075        assert_eq!(status, StatusCode::UNAUTHORIZED);
2076        assert_eq!(json["error"], "invalid_client");
2077    }
2078
2079    /// `/token` must enforce its own rate limit. Client authentication runs
2080    /// ahead of grant work, and for a CIMD-shaped `client_id` that triggers an
2081    /// outbound metadata fetch - reachable with no valid authorization code.
2082    /// Unlimited, that is both a self-DoS and an unauthenticated trigger for
2083    /// outbound requests to attacker-chosen hosts. `check_token_rate_limit`
2084    /// existed and was documented for this endpoint but had no caller.
2085    #[tokio::test]
2086    async fn token_endpoint_is_rate_limited_after_configured_burst() {
2087        let mut config = test_auth_config();
2088        config.token_requests_per_minute = 1;
2089        let state = test_auth_state_with_config(config).await;
2090        let app = router(state);
2091
2092        let body = "grant_type=authorization_code&code=nope&client_id=client\
2093                    &redirect_uri=http://127.0.0.1:7777/callback&code_verifier=v";
2094
2095        let first = app
2096            .clone()
2097            .oneshot(
2098                Request::builder()
2099                    .method("POST")
2100                    .uri("/token")
2101                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2102                    .body(Body::from(body))
2103                    .unwrap(),
2104            )
2105            .await
2106            .unwrap();
2107        // The grant itself fails (no such code); what matters is that the
2108        // request was admitted rather than throttled.
2109        assert_ne!(first.status(), StatusCode::TOO_MANY_REQUESTS);
2110
2111        let second = app
2112            .oneshot(
2113                Request::builder()
2114                    .method("POST")
2115                    .uri("/token")
2116                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2117                    .body(Body::from(body))
2118                    .unwrap(),
2119            )
2120            .await
2121            .unwrap();
2122        assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS);
2123    }
2124
2125    // --- recorded token_endpoint_auth_method ----------------------------
2126    //
2127    // A CIMD `client_id` whose metadata host cannot be reached. Resolving it
2128    // is a DNS failure, so any code path that re-resolves this client at
2129    // `/token` fails; a path that authenticates from the grant's recorded
2130    // method does not. That contrast is what these tests measure.
2131    const UNREACHABLE_CIMD_CLIENT: &str = "https://unreachable-client.invalid/client.json";
2132
2133    fn form_encoded(value: &str) -> String {
2134        url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
2135    }
2136
2137    /// Seed a refresh token issued to `client_id` and recorded as having been
2138    /// granted under `method`. `None` is a row written before schema v5
2139    /// started recording one.
2140    async fn seed_recorded_refresh_token(
2141        state: &AuthState,
2142        refresh_token: &str,
2143        client_id: &str,
2144        method: Option<&str>,
2145    ) {
2146        state
2147            .store
2148            .upsert_refresh_token(crate::types::RefreshTokenRow {
2149                refresh_token: refresh_token.to_string(),
2150                client_id: client_id.to_string(),
2151                subject: "google-subject-123".to_string(),
2152                resource: "https://lab.example.com/mcp".to_string(),
2153                scope: "lab".to_string(),
2154                provider: "google".to_string(),
2155                provider_refresh_token: Some("provider-refresh".to_string()),
2156                created_at: crate::util::now_unix() - 60,
2157                expires_at: crate::util::now_unix() + 3600,
2158                token_endpoint_auth_method: method.map(str::to_string),
2159            })
2160            .await
2161            .unwrap();
2162    }
2163
2164    #[tokio::test]
2165    async fn refresh_grant_for_a_recorded_public_client_survives_an_unreachable_metadata_host() {
2166        // The bug this fixes: wiring client authentication into `/token` made
2167        // every refresh re-resolve the client, which for a CIMD `client_id` is
2168        // a live metadata fetch. A valid, unrevoked refresh token then failed
2169        // for as long as the client's own metadata host was down. A public
2170        // client presents no credentials to check against that document, so
2171        // the recorded method is enough and no fetch happens.
2172        let state = test_auth_state_with_mock_google().await;
2173        seed_recorded_refresh_token(
2174            &state,
2175            "public-cimd-token",
2176            UNREACHABLE_CIMD_CLIENT,
2177            Some("none"),
2178        )
2179        .await;
2180        let (status, json) = post_token(
2181            &state,
2182            format!(
2183                "grant_type=refresh_token&refresh_token=public-cimd-token&client_id={}",
2184                form_encoded(UNREACHABLE_CIMD_CLIENT)
2185            ),
2186            None,
2187        )
2188        .await;
2189        assert_eq!(
2190            status,
2191            StatusCode::OK,
2192            "a public client's refresh must not depend on its metadata host: {json}"
2193        );
2194        assert!(json["access_token"].is_string(), "{json}");
2195    }
2196
2197    #[tokio::test]
2198    async fn refresh_grant_for_a_row_without_a_recorded_method_still_resolves_the_client() {
2199        // The legacy half of the same scenario: a row issued before schema v5
2200        // records nothing, so `/token` resolves the client exactly as it did
2201        // before - unchanged behaviour, including this failure. NULL must
2202        // never be read as "public".
2203        let state = test_auth_state_with_mock_google().await;
2204        seed_recorded_refresh_token(&state, "legacy-token", UNREACHABLE_CIMD_CLIENT, None).await;
2205        let (status, json) = post_token(
2206            &state,
2207            format!(
2208                "grant_type=refresh_token&refresh_token=legacy-token&client_id={}",
2209                form_encoded(UNREACHABLE_CIMD_CLIENT)
2210            ),
2211            None,
2212        )
2213        .await;
2214        assert_eq!(status, StatusCode::BAD_REQUEST, "{json}");
2215        assert_eq!(json["error"], "invalid_request");
2216        assert!(
2217            json["error_description"]
2218                .as_str()
2219                .is_some_and(|description| description.contains("unreachable")),
2220            "the legacy path must still fail on client resolution: {json}"
2221        );
2222    }
2223
2224    #[tokio::test]
2225    async fn refresh_grant_rejects_a_recorded_public_client_presenting_a_client_secret() {
2226        // The fast path skips the fetch, not the rule: a client registered
2227        // with `token_endpoint_auth_method = "none"` that presents credentials
2228        // is rejected exactly as it is when the client is resolved.
2229        let state = test_auth_state_with_mock_google().await;
2230        seed_recorded_refresh_token(
2231            &state,
2232            "public-secret-token",
2233            UNREACHABLE_CIMD_CLIENT,
2234            Some("none"),
2235        )
2236        .await;
2237        let (status, json) = post_token(
2238            &state,
2239            format!(
2240                "grant_type=refresh_token&refresh_token=public-secret-token\
2241                 &client_id={}&client_secret=guess",
2242                form_encoded(UNREACHABLE_CIMD_CLIENT)
2243            ),
2244            None,
2245        )
2246        .await;
2247        assert_eq!(status, StatusCode::UNAUTHORIZED, "{json}");
2248        assert_eq!(json["error"], "invalid_client");
2249    }
2250
2251    #[tokio::test]
2252    async fn refresh_grant_rejects_a_recorded_public_client_presenting_a_client_assertion() {
2253        let state = test_auth_state_with_mock_google().await;
2254        seed_recorded_refresh_token(
2255            &state,
2256            "public-assertion-token",
2257            UNREACHABLE_CIMD_CLIENT,
2258            Some("none"),
2259        )
2260        .await;
2261        let assertion = signed_client_assertion(UNREACHABLE_CIMD_CLIENT, "public-assertion-jti");
2262        let (status, json) = post_token(
2263            &state,
2264            format!(
2265                "grant_type=refresh_token&refresh_token=public-assertion-token&client_id={}\
2266                 &client_assertion_type={}&client_assertion={assertion}",
2267                form_encoded(UNREACHABLE_CIMD_CLIENT),
2268                "urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
2269            ),
2270            None,
2271        )
2272        .await;
2273        assert_eq!(status, StatusCode::UNAUTHORIZED, "{json}");
2274        assert_eq!(json["error"], "invalid_client");
2275    }
2276
2277    /// A confidential client registered with `private_key_jwt`, whose JWKS
2278    /// lives in the registered-clients table.
2279    async fn state_with_private_key_jwt_client() -> AuthState {
2280        let state = test_auth_state_with_mock_google().await;
2281        state
2282            .store
2283            .register_client(crate::types::RegisteredClient {
2284                client_id: "jwt-client".to_string(),
2285                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
2286                created_at: crate::util::now_unix(),
2287                token_endpoint_auth_method: "private_key_jwt".to_string(),
2288                jwks: Some(client_assertion_jwks()),
2289            })
2290            .await
2291            .unwrap();
2292        state
2293    }
2294
2295    #[tokio::test]
2296    async fn refresh_grant_for_a_recorded_private_key_jwt_client_still_authenticates() {
2297        // Confidential clients are deliberately NOT short-circuited: their
2298        // JWKS must be read fresh because keys rotate, and they are presenting
2299        // an assertion anyway, so the resolution is inherent to the exchange.
2300        let state = state_with_private_key_jwt_client().await;
2301        seed_recorded_refresh_token(&state, "jwt-token", "jwt-client", Some("private_key_jwt"))
2302            .await;
2303        let assertion = signed_client_assertion("jwt-client", "refresh-assertion-jti");
2304        let (status, json) = post_token(
2305            &state,
2306            format!(
2307                "grant_type=refresh_token&refresh_token=jwt-token&client_id=jwt-client\
2308                 &client_assertion_type={}&client_assertion={assertion}",
2309                "urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer"
2310            ),
2311            None,
2312        )
2313        .await;
2314        assert_eq!(status, StatusCode::OK, "{json}");
2315        assert!(json["access_token"].is_string(), "{json}");
2316    }
2317
2318    #[tokio::test]
2319    async fn refresh_grant_for_a_recorded_private_key_jwt_client_rejects_a_missing_assertion() {
2320        let state = state_with_private_key_jwt_client().await;
2321        seed_recorded_refresh_token(
2322            &state,
2323            "jwt-bare-token",
2324            "jwt-client",
2325            Some("private_key_jwt"),
2326        )
2327        .await;
2328        let (status, json) = post_token(
2329            &state,
2330            "grant_type=refresh_token&refresh_token=jwt-bare-token&client_id=jwt-client"
2331                .to_string(),
2332            None,
2333        )
2334        .await;
2335        assert_eq!(status, StatusCode::UNAUTHORIZED, "{json}");
2336        assert_eq!(json["error"], "invalid_client");
2337    }
2338
2339    #[tokio::test]
2340    async fn authorization_code_grant_carries_the_recorded_method_onto_the_refresh_token() {
2341        // The recorded method has to survive the hop from authorization code
2342        // to refresh token, or the very first refresh falls back to resolving
2343        // the client and the fix only lasts one exchange.
2344        let state = test_auth_state_with_registered_client().await;
2345        state
2346            .store
2347            .insert_auth_code(crate::types::AuthorizationCodeRow {
2348                code: "recorded-code".to_string(),
2349                client_id: "client".to_string(),
2350                subject: "google-subject-123".to_string(),
2351                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
2352                resource: "https://lab.example.com/mcp".to_string(),
2353                scope: "lab".to_string(),
2354                provider: "google".to_string(),
2355                code_challenge: super::pkce_challenge("verifier"),
2356                code_challenge_method: "S256".to_string(),
2357                provider_refresh_token: Some("provider-refresh".to_string()),
2358                created_at: 1_700_000_000,
2359                expires_at: 4_102_444_800,
2360                token_endpoint_auth_method: Some("none".to_string()),
2361            })
2362            .await
2363            .unwrap();
2364        let (status, json) = post_token(
2365            &state,
2366            "grant_type=authorization_code&code=recorded-code&client_id=client\
2367             &redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier"
2368                .to_string(),
2369            None,
2370        )
2371        .await;
2372        assert_eq!(status, StatusCode::OK, "{json}");
2373        let refresh_token = json["refresh_token"].as_str().expect("refresh token");
2374        let row = state
2375            .store
2376            .find_refresh_token(refresh_token)
2377            .await
2378            .unwrap()
2379            .expect("refresh token row");
2380        assert_eq!(row.token_endpoint_auth_method.as_deref(), Some("none"));
2381    }
2382
2383    #[tokio::test]
2384    async fn refreshing_a_recorded_grant_preserves_its_method() {
2385        let state = test_auth_state_with_mock_google().await;
2386        seed_recorded_refresh_token(&state, "preserved-token", "client", Some("none")).await;
2387        let (status, json) = post_token(
2388            &state,
2389            "grant_type=refresh_token&refresh_token=preserved-token&client_id=client".to_string(),
2390            None,
2391        )
2392        .await;
2393        assert_eq!(status, StatusCode::OK, "{json}");
2394        let row = state
2395            .store
2396            .find_refresh_token("preserved-token")
2397            .await
2398            .unwrap()
2399            .expect("refresh token row");
2400        assert_eq!(
2401            row.token_endpoint_auth_method.as_deref(),
2402            Some("none"),
2403            "a refresh must not erase the contract the grant was issued under"
2404        );
2405    }
2406
2407    #[tokio::test]
2408    async fn a_recorded_public_method_never_bypasses_a_configured_machine_client() {
2409        // A config-declared machine client authenticates against server
2410        // config, not a registration record. Even if a stored grant claims
2411        // `none` for that client id, its configured credentials still apply.
2412        let mut config = test_auth_config();
2413        config.machine_clients = vec![MachineClientConfig {
2414            client_id: "client".to_string(),
2415            client_secret: Some("machine-secret".to_string()),
2416            jwks: None,
2417            scopes: vec!["lab".to_string()],
2418            resources: vec!["https://lab.example.com/mcp".to_string()],
2419        }];
2420        let state = test_auth_state_with_config(config).await;
2421        seed_recorded_refresh_token(&state, "machine-shadowed-token", "client", Some("none")).await;
2422        let (status, json) = post_token(
2423            &state,
2424            "grant_type=refresh_token&refresh_token=machine-shadowed-token&client_id=client"
2425                .to_string(),
2426            None,
2427        )
2428        .await;
2429        assert_eq!(status, StatusCode::UNAUTHORIZED, "{json}");
2430        assert_eq!(json["error"], "invalid_client");
2431    }
2432}