Skip to main content

soma_auth/
token.rs

1use axum::extract::{Form, State};
2use axum::{
3    Json,
4    http::{HeaderValue, StatusCode, header},
5    response::{IntoResponse, Response},
6};
7use base64::Engine;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use sha2::{Digest, Sha256};
10use subtle::ConstantTimeEq;
11use tracing::{info, warn};
12
13use crate::error::{AuthError, AuthErrorKind};
14use crate::jwt::AccessClaims;
15use crate::state::AuthState;
16use crate::types::AuthorizationCodeRow;
17use crate::types::{RefreshTokenRow, TokenRequest, TokenResponse};
18use crate::util::{
19    duration_secs_usize, expires_at, fingerprint, now_unix, random_token, timestamp_usize,
20};
21
22pub async fn token(State(state): State<AuthState>, Form(request): Form<TokenRequest>) -> Response {
23    info!(
24        grant_type = %request.grant_type,
25        client_id = request.client_id.as_deref().unwrap_or("<missing>"),
26        requested_resource = request.resource.as_deref().unwrap_or("<default>"),
27        "oauth token request received"
28    );
29    let response: Result<TokenResponseWithCache, TokenEndpointError> =
30        match request.grant_type.as_str() {
31            "authorization_code" => authorization_code_grant(state, request)
32                .await
33                .map(|response| TokenResponseWithCache(Json(response)))
34                .map_err(TokenEndpointError::Auth),
35            "refresh_token" => refresh_token_grant(state, request)
36                .await
37                .map(|response| TokenResponseWithCache(Json(response)))
38                .map_err(TokenEndpointError::Auth),
39            other => {
40                warn!(grant_type = %other, "oauth token rejected: unsupported grant type");
41                Err(TokenEndpointError::UnsupportedGrantType(other.to_string()))
42            }
43        };
44
45    match response {
46        Ok(response) => response.into_response(),
47        Err(error) => error.into_response(),
48    }
49}
50
51enum TokenEndpointError {
52    Auth(AuthError),
53    UnsupportedGrantType(String),
54}
55
56impl TokenEndpointError {
57    fn oauth_error(&self) -> &'static str {
58        match self {
59            Self::Auth(AuthError::InvalidGrant(_)) => "invalid_grant",
60            Self::UnsupportedGrantType(_) => "unsupported_grant_type",
61            Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
62                "invalid_client"
63            }
64            Self::Auth(AuthError::RateLimited { .. }) => "temporarily_unavailable",
65            Self::Auth(AuthError::Validation(_)) => "invalid_request",
66            Self::Auth(
67                AuthError::Config(_)
68                | AuthError::Storage(_)
69                | AuthError::Network(_)
70                | AuthError::Server(_)
71                | AuthError::Decode(_)
72                | AuthError::InsecurePermissions { .. },
73            ) => "server_error",
74        }
75    }
76
77    fn log_kind(&self) -> &'static str {
78        match self {
79            Self::Auth(error) => error.kind(),
80            Self::UnsupportedGrantType(_) => "unsupported_grant_type",
81        }
82    }
83
84    fn status(&self) -> StatusCode {
85        match self {
86            Self::Auth(AuthError::InvalidGrant(_) | AuthError::Validation(_))
87            | Self::UnsupportedGrantType(_) => StatusCode::BAD_REQUEST,
88            Self::Auth(AuthError::AuthFailed(_) | AuthError::InvalidAccessToken) => {
89                StatusCode::UNAUTHORIZED
90            }
91            Self::Auth(AuthError::RateLimited { .. }) => StatusCode::TOO_MANY_REQUESTS,
92            Self::Auth(
93                AuthError::Config(_)
94                | AuthError::Storage(_)
95                | AuthError::Network(_)
96                | AuthError::Server(_)
97                | AuthError::Decode(_)
98                | AuthError::InsecurePermissions { .. },
99            ) => StatusCode::INTERNAL_SERVER_ERROR,
100        }
101    }
102
103    fn description(&self) -> String {
104        match self {
105            Self::Auth(error) => error.to_string(),
106            Self::UnsupportedGrantType(grant_type) => {
107                format!("unsupported grant_type `{grant_type}`")
108            }
109        }
110    }
111
112    fn retry_after_ms(&self) -> Option<u64> {
113        match self {
114            Self::Auth(AuthError::RateLimited { retry_after_ms, .. }) => Some(*retry_after_ms),
115            _ => None,
116        }
117    }
118}
119
120impl IntoResponse for TokenEndpointError {
121    fn into_response(self) -> Response {
122        let status = self.status();
123        let log_kind = self.log_kind();
124        let retry_after_ms = self.retry_after_ms();
125        let body = Json(serde_json::json!({
126            "error": self.oauth_error(),
127            "error_description": self.description(),
128        }));
129        let mut response = (status, body).into_response();
130        response.extensions_mut().insert(AuthErrorKind(log_kind));
131        if let Some(retry_after_ms) = retry_after_ms
132            && let Ok(value) = HeaderValue::from_str(&(retry_after_ms / 1_000).max(1).to_string())
133        {
134            response.headers_mut().insert(header::RETRY_AFTER, value);
135        }
136        apply_token_cache_headers(response)
137    }
138}
139
140struct TokenResponseWithCache(Json<TokenResponse>);
141
142impl IntoResponse for TokenResponseWithCache {
143    fn into_response(self) -> Response {
144        apply_token_cache_headers(self.0.into_response())
145    }
146}
147
148fn apply_token_cache_headers(mut response: Response) -> Response {
149    response
150        .headers_mut()
151        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
152    response
153        .headers_mut()
154        .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
155    response
156}
157
158async fn authorization_code_grant(
159    state: AuthState,
160    request: TokenRequest,
161) -> Result<TokenResponse, AuthError> {
162    let requested_resource = request
163        .resource
164        .as_deref()
165        .map(str::trim)
166        .filter(|value| !value.is_empty())
167        .map(|value| value.trim_end_matches('/').to_string());
168    crate::authorize::validate_resource(&state, request.resource.as_deref())?;
169    let code = require_field(request.code, "code")?;
170    let client_id = require_field(request.client_id, "client_id")?;
171    let redirect_uri = require_field(request.redirect_uri, "redirect_uri")?;
172    let code_verifier = require_field(request.code_verifier, "code_verifier")?;
173    let auth_code_id = fingerprint(&code);
174    info!(
175        grant_type = "authorization_code",
176        client_id = %client_id,
177        auth_code_id = %auth_code_id,
178        redirect_uri = %redirect_uri,
179        requested_resource = requested_resource.as_deref().unwrap_or("<authorization-code-resource>"),
180        "oauth authorization_code grant redeeming local code"
181    );
182
183    let row = state.store.redeem_auth_code(&code).await.map_err(|error| {
184        warn!(
185            auth_code_id = %auth_code_id,
186            client_id = %client_id,
187            error = %error,
188            "oauth token rejected: authorization code is invalid, expired, or already redeemed"
189        );
190        error
191    })?;
192    validate_authorization_code_row(
193        &row,
194        &client_id,
195        &redirect_uri,
196        &code_verifier,
197        &auth_code_id,
198    )?;
199    if let Some(requested_resource) = requested_resource
200        && requested_resource != row.resource
201    {
202        warn!(
203            auth_code_id = %auth_code_id,
204            requested_resource = %requested_resource,
205            stored_resource = %row.resource,
206            "oauth token rejected: resource does not match authorization code"
207        );
208        return Err(AuthError::InvalidGrant(
209            "resource does not match the authorization code".to_string(),
210        ));
211    }
212
213    let refresh_token = if let Some(provider_refresh_token) = row.provider_refresh_token {
214        let refresh_token = random_token(24)?;
215        let created_at = now_unix();
216        state
217            .store
218            .upsert_refresh_token(RefreshTokenRow {
219                refresh_token: refresh_token.clone(),
220                client_id: row.client_id.clone(),
221                subject: row.subject.clone(),
222                resource: row.resource.clone(),
223                scope: row.scope.clone(),
224                provider: row.provider.clone(),
225                provider_refresh_token: Some(provider_refresh_token),
226                created_at,
227                expires_at: expires_at(
228                    created_at,
229                    state.config.refresh_token_ttl,
230                    &format!("{}_AUTH_REFRESH_TOKEN_TTL_SECS", state.config.env_prefix),
231                )?,
232            })
233            .await?;
234        info!(
235            grant_type = "authorization_code",
236            client_id = %row.client_id,
237            auth_code_id = %auth_code_id,
238            subject_id = %fingerprint(&row.subject),
239            resource = %row.resource,
240            scope = %row.scope,
241            "oauth authorization_code grant issued lab access token and refresh token"
242        );
243        Some(refresh_token)
244    } else {
245        info!(
246            grant_type = "authorization_code",
247            client_id = %row.client_id,
248            auth_code_id = %auth_code_id,
249            subject_id = %fingerprint(&row.subject),
250            resource = %row.resource,
251            scope = %row.scope,
252            "oauth authorization_code grant issued lab access token without refresh token"
253        );
254        None
255    };
256
257    let resource = if row.resource.trim().is_empty() {
258        crate::metadata::canonical_resource_url(&state)
259    } else {
260        row.resource
261    };
262    build_token_response(
263        &state,
264        row.client_id,
265        row.subject,
266        resource,
267        row.scope,
268        refresh_token,
269    )
270}
271
272async fn refresh_token_grant(
273    state: AuthState,
274    request: TokenRequest,
275) -> Result<TokenResponse, AuthError> {
276    let requested_resource = request
277        .resource
278        .as_deref()
279        .map(str::trim)
280        .filter(|value| !value.is_empty())
281        .map(|value| crate::authorize::validate_resource(&state, Some(value)))
282        .transpose()?;
283    let client_id = require_field(request.client_id, "client_id")?;
284    let refresh_token = require_field(request.refresh_token, "refresh_token")?;
285    let refresh_token_id = fingerprint(&refresh_token);
286    info!(
287        grant_type = "refresh_token",
288        client_id = %client_id,
289        refresh_token_id = %refresh_token_id,
290        requested_resource = requested_resource.as_deref().unwrap_or("<refresh-token-resource>"),
291        "oauth refresh_token grant received"
292    );
293    let stored = state
294        .store
295        .find_refresh_token(&refresh_token)
296        .await?
297        .ok_or_else(|| {
298            warn!(
299                refresh_token_id = %refresh_token_id,
300                client_id = %client_id,
301                "oauth token rejected: unknown or expired refresh token"
302            );
303            AuthError::InvalidGrant("unknown refresh_token".to_string())
304        })?;
305    if stored.client_id != client_id {
306        warn!(
307            refresh_token_id = %refresh_token_id,
308            requested_client_id = %client_id,
309            stored_client_id = %stored.client_id,
310            "oauth token rejected: client_id does not match refresh token"
311        );
312        return Err(AuthError::InvalidGrant(
313            "client_id does not match the refresh token".to_string(),
314        ));
315    }
316    let stored_resource = if stored.resource.trim().is_empty() {
317        crate::metadata::canonical_resource_url(&state)
318    } else {
319        stored.resource.clone()
320    };
321    if let Some(requested_resource) = requested_resource
322        && requested_resource != stored_resource
323    {
324        warn!(
325            refresh_token_id = %refresh_token_id,
326            requested_resource = %requested_resource,
327            stored_resource = %stored_resource,
328            "oauth token rejected: resource does not match refresh token"
329        );
330        return Err(AuthError::InvalidGrant(
331            "resource does not match the refresh token".to_string(),
332        ));
333    }
334
335    let Some(provider_refresh_token) = stored.provider_refresh_token.clone() else {
336        warn!(
337            refresh_token_id = %refresh_token_id,
338            client_id = %stored.client_id,
339            "oauth token rejected: refresh token is not backed by an upstream refresh token"
340        );
341        return Err(AuthError::InvalidGrant(
342            "refresh token is not backed by an upstream refresh token".to_string(),
343        ));
344    };
345
346    // Refresh upstream before consuming the local token. If the provider or
347    // id-token verification fails, the client can retry the same local
348    // refresh token instead of being stranded with an unreturned replacement.
349    let provider = state.provider(&stored.provider)?;
350    // Defense-in-depth: GitHubProvider::exchange_code never sets
351    // provider_refresh_token, so a `refresh_tokens` row naming
352    // `provider = "github"` with a non-null `provider_refresh_token` should
353    // be unreachable through normal flows — but the DB layer doesn't enforce
354    // that invariant, and a hand-inserted or corrupted row would otherwise
355    // silently reach `GitHubProvider::refresh`'s unconditional error. Fail
356    // loudly and clearly here instead, at the actual choke point, in both
357    // debug and release builds.
358    if provider.provider_id() == "github" {
359        return Err(AuthError::Server(
360            "refresh token names provider `github`, which never issues upstream refresh \
361             tokens and does not support token refresh — this refresh token row should be \
362             unreachable; the underlying GitHub OAuth App requires the user to \
363             re-authenticate once their local soma-issued refresh token expires"
364                .to_string(),
365        ));
366    }
367    let exchange = provider.refresh(&provider_refresh_token).await?;
368
369    let refreshed_expires_at = expires_at(
370        now_unix(),
371        state.config.refresh_token_ttl,
372        &format!("{}_AUTH_REFRESH_TOKEN_TTL_SECS", state.config.env_prefix),
373    )?;
374    let subject =
375        crate::oauth_provider::namespaced_subject(provider.provider_id(), &exchange.subject);
376    let next_provider_refresh_token = exchange
377        .refresh_token
378        .clone()
379        .unwrap_or_else(|| provider_refresh_token.clone());
380    // Re-apply admin elevation in case this refresh token was originally
381    // issued before elevation was wired in, or before the user's email was
382    // on the allowlist.  elevate_scope_for_allowed_user is idempotent — if
383    // the scope already contains the admin token it is left unchanged.
384    let elevated_scope = crate::authorize::elevate_scope_for_allowed_user(
385        &stored.scope,
386        &state.config.default_scope,
387    );
388
389    state
390        .store
391        .upsert_refresh_token(RefreshTokenRow {
392            refresh_token: refresh_token.clone(),
393            client_id: stored.client_id.clone(),
394            subject: subject.clone(),
395            resource: stored_resource.clone(),
396            scope: elevated_scope.clone(),
397            provider: stored.provider.clone(),
398            provider_refresh_token: Some(next_provider_refresh_token),
399            created_at: stored.created_at,
400            expires_at: refreshed_expires_at,
401        })
402        .await?;
403
404    info!(
405        grant_type = "refresh_token",
406        client_id = %stored.client_id,
407        refresh_token_id = %refresh_token_id,
408        subject_id = %fingerprint(&subject),
409        provider = provider.provider_id(),
410        resource = %stored_resource,
411        scope = %elevated_scope,
412        "oauth refresh_token grant refreshed stable local token and issued new access token"
413    );
414
415    build_token_response(
416        &state,
417        stored.client_id,
418        subject,
419        stored_resource,
420        elevated_scope,
421        Some(refresh_token),
422    )
423}
424
425fn build_token_response(
426    state: &AuthState,
427    client_id: String,
428    subject: String,
429    resource: String,
430    scope: String,
431    refresh_token: Option<String>,
432) -> Result<TokenResponse, AuthError> {
433    let issuer = crate::metadata::public_base_url(state);
434    let now = timestamp_usize(now_unix(), "current unix timestamp")?;
435    let access_token_ttl = duration_secs_usize(
436        state.config.access_token_ttl,
437        &format!("{}_AUTH_ACCESS_TOKEN_TTL_SECS", state.config.env_prefix),
438    )?;
439    let subject_id = fingerprint(&subject);
440    let access_token = state.signing_keys.issue_access_token(&AccessClaims {
441        iss: issuer,
442        sub: subject.clone(),
443        aud: resource.clone(),
444        exp: now.checked_add(access_token_ttl).ok_or_else(|| {
445            AuthError::Config(format!(
446                "{}_AUTH_ACCESS_TOKEN_TTL_SECS exceeds supported range",
447                state.config.env_prefix
448            ))
449        })?,
450        iat: now,
451        jti: random_token(18)?,
452        scope: scope.clone(),
453        azp: client_id.clone(),
454    })?;
455    info!(
456        client_id = %client_id,
457        subject_id = %subject_id,
458        resource = %resource,
459        scope = %scope,
460        expires_in_secs = state.config.access_token_ttl.as_secs(),
461        refresh_token_issued = refresh_token.is_some(),
462        "oauth token response minted access token"
463    );
464    Ok(TokenResponse {
465        access_token,
466        token_type: "Bearer".to_string(),
467        expires_in: state.config.access_token_ttl.as_secs(),
468        refresh_token,
469        scope,
470    })
471}
472
473fn require_field(value: Option<String>, field: &str) -> Result<String, AuthError> {
474    value.ok_or_else(|| AuthError::Validation(format!("missing `{field}` parameter")))
475}
476
477fn pkce_challenge(code_verifier: &str) -> String {
478    URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()))
479}
480
481fn validate_authorization_code_row(
482    row: &AuthorizationCodeRow,
483    client_id: &str,
484    redirect_uri: &str,
485    code_verifier: &str,
486    auth_code_id: &str,
487) -> Result<(), AuthError> {
488    if row.client_id != client_id {
489        warn!(
490            auth_code_id = %auth_code_id,
491            requested_client_id = %client_id,
492            stored_client_id = %row.client_id,
493            "oauth token rejected: client_id does not match authorization code"
494        );
495        return Err(AuthError::InvalidGrant(
496            "client_id does not match the authorization code".to_string(),
497        ));
498    }
499    if row.redirect_uri != redirect_uri {
500        warn!(
501            auth_code_id = %auth_code_id,
502            requested_redirect_uri = %redirect_uri,
503            stored_redirect_uri = %row.redirect_uri,
504            "oauth token rejected: redirect_uri does not match authorization code"
505        );
506        return Err(AuthError::InvalidGrant(
507            "redirect_uri does not match the authorization code".to_string(),
508        ));
509    }
510    if row.code_challenge_method != "S256" {
511        warn!(
512            auth_code_id = %auth_code_id,
513            code_challenge_method = %row.code_challenge_method,
514            "oauth token rejected: unsupported PKCE method on authorization code"
515        );
516        return Err(AuthError::InvalidGrant(
517            "authorization code uses an unsupported PKCE method".to_string(),
518        ));
519    }
520    if !bool::from(
521        pkce_challenge(code_verifier)
522            .as_bytes()
523            .ct_eq(row.code_challenge.as_bytes()),
524    ) {
525        warn!(
526            auth_code_id = %auth_code_id,
527            client_id = %row.client_id,
528            "oauth token rejected: code_verifier did not match authorization code"
529        );
530        return Err(AuthError::InvalidGrant(
531            "code_verifier does not match the authorization code".to_string(),
532        ));
533    }
534    Ok(())
535}
536
537#[cfg(test)]
538mod tests {
539    use axum::body::Body;
540    use axum::http::{Request, StatusCode, header};
541    use jsonwebtoken::dangerous::insecure_decode;
542    use tower::util::ServiceExt;
543    use url::Url;
544    use wiremock::matchers::{method, path};
545    use wiremock::{Mock, MockServer, ResponseTemplate};
546
547    use crate::google::GoogleProvider;
548    use crate::routes::router;
549    use crate::state::AuthState;
550
551    use super::super::authorize::tests::{
552        test_auth_config, test_auth_state_with_config, test_auth_state_with_mock_google,
553        test_auth_state_with_registered_client,
554    };
555
556    async fn test_auth_state_with_failing_google_refresh() -> AuthState {
557        let state = test_auth_state_with_registered_client().await;
558        let server = Box::leak(Box::new(MockServer::start().await));
559        Mock::given(method("POST"))
560            .and(path("/token"))
561            .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
562                "error": "temporarily_unavailable"
563            })))
564            .mount(server)
565            .await;
566        let google = GoogleProvider::new(
567            "client-id".to_string(),
568            "client-secret".to_string(),
569            Url::parse("https://lab.example.com/auth/google/callback").unwrap(),
570        )
571        .unwrap()
572        .with_endpoints(
573            server.uri().parse::<Url>().unwrap(),
574            server.uri().parse::<Url>().unwrap().join("/token").unwrap(),
575        );
576        AuthState::for_tests(
577            (*state.config).clone(),
578            state.store.clone(),
579            (*state.signing_keys).clone(),
580            AuthState::google_only_providers(google),
581        )
582    }
583
584    #[tokio::test]
585    async fn token_endpoint_mints_lab_jwt_and_refresh_token() {
586        let state = test_auth_state_with_registered_client().await;
587        seed_authorization_code(&state).await;
588        let app = router(state);
589        let response = app
590            .oneshot(
591                Request::builder()
592                    .method("POST")
593                    .uri("/token")
594                    .header(
595                        header::CONTENT_TYPE,
596                        "application/x-www-form-urlencoded",
597                    )
598                    .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"))
599                    .unwrap(),
600            )
601            .await
602            .unwrap();
603        assert_eq!(response.status(), StatusCode::OK);
604        assert_eq!(
605            response
606                .headers()
607                .get(header::CACHE_CONTROL)
608                .and_then(|value| value.to_str().ok()),
609            Some("no-store")
610        );
611        assert_eq!(
612            response
613                .headers()
614                .get(header::PRAGMA)
615                .and_then(|value| value.to_str().ok()),
616            Some("no-cache")
617        );
618        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
619            .await
620            .unwrap();
621        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
622        assert!(json["access_token"].is_string());
623        assert!(json["refresh_token"].is_string());
624        let access_token = json["access_token"].as_str().expect("access token string");
625        let claims = insecure_decode::<crate::jwt::AccessClaims>(access_token)
626            .expect("decode access token")
627            .claims;
628        assert_eq!(claims.aud, "https://lab.example.com/mcp");
629    }
630
631    #[tokio::test]
632    async fn token_endpoint_omits_refresh_token_without_upstream_refresh_capability() {
633        let state = test_auth_state_with_registered_client().await;
634        seed_authorization_code_without_provider_refresh(&state).await;
635        let app = router(state);
636        let response = app
637            .oneshot(
638                Request::builder()
639                    .method("POST")
640                    .uri("/token")
641                    .header(
642                        header::CONTENT_TYPE,
643                        "application/x-www-form-urlencoded",
644                    )
645                    .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"))
646                    .unwrap(),
647            )
648            .await
649            .unwrap();
650        assert_eq!(response.status(), StatusCode::OK);
651        assert_eq!(
652            response
653                .headers()
654                .get(header::CACHE_CONTROL)
655                .and_then(|value| value.to_str().ok()),
656            Some("no-store")
657        );
658        assert_eq!(
659            response
660                .headers()
661                .get(header::PRAGMA)
662                .and_then(|value| value.to_str().ok()),
663            Some("no-cache")
664        );
665        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
666            .await
667            .unwrap();
668        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
669        assert!(json["access_token"].is_string());
670        assert!(json.get("refresh_token").is_none());
671    }
672
673    #[tokio::test]
674    async fn token_endpoint_redeems_authorization_code_once() {
675        let state = test_auth_state_with_registered_client().await;
676        seed_authorization_code(&state).await;
677        let app = router(state);
678        let (a, b) = tokio::join!(
679            app.clone().oneshot(
680                Request::builder()
681                    .method("POST")
682                    .uri("/token")
683                    .header(
684                        header::CONTENT_TYPE,
685                        "application/x-www-form-urlencoded",
686                    )
687                    .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"))
688                    .unwrap()
689            ),
690            app.oneshot(
691                Request::builder()
692                    .method("POST")
693                    .uri("/token")
694                    .header(
695                        header::CONTENT_TYPE,
696                        "application/x-www-form-urlencoded",
697                    )
698                    .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"))
699                    .unwrap()
700            )
701        );
702        let a = a.unwrap();
703        let b = b.unwrap();
704        assert!(a.status() == StatusCode::OK || b.status() == StatusCode::OK);
705        assert!(a.status() == StatusCode::BAD_REQUEST || b.status() == StatusCode::BAD_REQUEST);
706    }
707
708    #[tokio::test]
709    async fn token_endpoint_rejects_expired_authorization_code() {
710        let state = test_auth_state_with_registered_client().await;
711        seed_authorization_code_with_expiry(&state, crate::util::now_unix() - 1).await;
712        let app = router(state);
713        let response = app
714            .oneshot(
715                Request::builder()
716                    .method("POST")
717                    .uri("/token")
718                    .header(
719                        header::CONTENT_TYPE,
720                        "application/x-www-form-urlencoded",
721                    )
722                    .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"))
723                    .unwrap(),
724            )
725            .await
726            .unwrap();
727        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
728        assert_eq!(
729            response
730                .headers()
731                .get(header::CACHE_CONTROL)
732                .and_then(|value| value.to_str().ok()),
733            Some("no-store")
734        );
735        assert_eq!(
736            response
737                .headers()
738                .get(header::PRAGMA)
739                .and_then(|value| value.to_str().ok()),
740            Some("no-cache")
741        );
742    }
743
744    #[tokio::test]
745    async fn token_endpoint_errors_use_oauth_error_shape() {
746        let state = test_auth_state_with_registered_client().await;
747        let app = router(state);
748        let response = app
749            .oneshot(
750                Request::builder()
751                    .method("POST")
752                    .uri("/token")
753                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
754                    .body(Body::from(
755                        "grant_type=refresh_token&refresh_token=missing&client_id=client",
756                    ))
757                    .unwrap(),
758            )
759            .await
760            .unwrap();
761        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
762        assert_eq!(
763            response
764                .headers()
765                .get(header::CACHE_CONTROL)
766                .and_then(|value| value.to_str().ok()),
767            Some("no-store")
768        );
769        assert_eq!(
770            response
771                .headers()
772                .get(header::PRAGMA)
773                .and_then(|value| value.to_str().ok()),
774            Some("no-cache")
775        );
776        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
777            .await
778            .unwrap();
779        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
780        assert_eq!(json["error"], "invalid_grant");
781        assert_eq!(json["error_description"], "unknown refresh_token");
782        assert!(json.get("kind").is_none());
783        assert!(json.get("message").is_none());
784    }
785
786    #[tokio::test]
787    async fn token_endpoint_unsupported_grant_type_uses_oauth_error_shape() {
788        let state = test_auth_state_with_registered_client().await;
789        let app = router(state);
790        let response = app
791            .oneshot(
792                Request::builder()
793                    .method("POST")
794                    .uri("/token")
795                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
796                    .body(Body::from("grant_type=password&client_id=client"))
797                    .unwrap(),
798            )
799            .await
800            .unwrap();
801        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
802        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
803            .await
804            .unwrap();
805        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
806        assert_eq!(json["error"], "unsupported_grant_type");
807        assert_eq!(
808            json["error_description"],
809            "unsupported grant_type `password`"
810        );
811    }
812
813    #[tokio::test]
814    async fn token_endpoint_refresh_grant_sets_cache_headers() {
815        let state = test_auth_state_with_mock_google().await;
816        state
817            .store
818            .upsert_refresh_token(crate::types::RefreshTokenRow {
819                refresh_token: "refresh-token".to_string(),
820                client_id: "client".to_string(),
821                subject: "google-subject-123".to_string(),
822                resource: "https://lab.example.com/mcp".to_string(),
823                scope: "lab".to_string(),
824                provider: "google".to_string(),
825                provider_refresh_token: Some("provider-refresh".to_string()),
826                created_at: crate::util::now_unix() - 60,
827                expires_at: crate::util::now_unix() + 3600,
828            })
829            .await
830            .unwrap();
831        let app = router(state);
832        let response = app
833            .oneshot(
834                Request::builder()
835                    .method("POST")
836                    .uri("/token")
837                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
838                    .body(Body::from(
839                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
840                    ))
841                    .unwrap(),
842            )
843            .await
844            .unwrap();
845        assert_eq!(response.status(), StatusCode::OK);
846        assert_eq!(
847            response
848                .headers()
849                .get(header::CACHE_CONTROL)
850                .and_then(|value| value.to_str().ok()),
851            Some("no-store")
852        );
853        assert_eq!(
854            response
855                .headers()
856                .get(header::PRAGMA)
857                .and_then(|value| value.to_str().ok()),
858            Some("no-cache")
859        );
860    }
861
862    #[tokio::test]
863    async fn token_endpoint_refresh_grant_preserves_stored_resource_when_omitted() {
864        let state = test_auth_state_with_mock_google().await;
865        state
866            .store
867            .upsert_refresh_token(crate::types::RefreshTokenRow {
868                refresh_token: "refresh-token".to_string(),
869                client_id: "client".to_string(),
870                subject: "google-subject-123".to_string(),
871                resource: "https://mcp.example.com/syslog".to_string(),
872                scope: "mcp:read mcp:write".to_string(),
873                provider: "google".to_string(),
874                provider_refresh_token: Some("provider-refresh".to_string()),
875                created_at: crate::util::now_unix() - 60,
876                expires_at: crate::util::now_unix() + 3600,
877            })
878            .await
879            .unwrap();
880        let app = router(state);
881        let response = app
882            .oneshot(
883                Request::builder()
884                    .method("POST")
885                    .uri("/token")
886                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
887                    .body(Body::from(
888                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
889                    ))
890                    .unwrap(),
891            )
892            .await
893            .unwrap();
894        assert_eq!(response.status(), StatusCode::OK);
895        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
896            .await
897            .unwrap();
898        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
899        let access_token = json["access_token"].as_str().expect("access token string");
900        let claims = insecure_decode::<crate::jwt::AccessClaims>(access_token)
901            .expect("decode access token")
902            .claims;
903        assert_eq!(claims.aud, "https://mcp.example.com/syslog");
904        assert_eq!(claims.scope, "mcp:read mcp:write lab:admin");
905    }
906
907    #[tokio::test]
908    async fn token_endpoint_rejects_mismatched_resource_parameter() {
909        let state = test_auth_state_with_registered_client().await;
910        seed_authorization_code(&state).await;
911        let app = router(state);
912        let response = app
913            .oneshot(
914                Request::builder()
915                    .method("POST")
916                    .uri("/token")
917                    .header(
918                        header::CONTENT_TYPE,
919                        "application/x-www-form-urlencoded",
920                    )
921                    .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"))
922                    .unwrap(),
923            )
924            .await
925            .unwrap();
926        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
927    }
928
929    #[tokio::test]
930    async fn token_endpoint_rejects_expired_refresh_token() {
931        let state = test_auth_state_with_registered_client().await;
932        state
933            .store
934            .upsert_refresh_token(crate::types::RefreshTokenRow {
935                refresh_token: "refresh-token".to_string(),
936                client_id: "client".to_string(),
937                subject: "google-subject-123".to_string(),
938                resource: "https://lab.example.com/mcp".to_string(),
939                scope: "lab".to_string(),
940                provider: "google".to_string(),
941                provider_refresh_token: Some("provider-refresh".to_string()),
942                created_at: crate::util::now_unix() - 3600,
943                expires_at: crate::util::now_unix() - 1,
944            })
945            .await
946            .unwrap();
947        let app = router(state);
948        let response = app
949            .oneshot(
950                Request::builder()
951                    .method("POST")
952                    .uri("/token")
953                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
954                    .body(Body::from(
955                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
956                    ))
957                    .unwrap(),
958            )
959            .await
960            .unwrap();
961        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
962        assert_eq!(
963            response
964                .headers()
965                .get(header::CACHE_CONTROL)
966                .and_then(|value| value.to_str().ok()),
967            Some("no-store")
968        );
969        assert_eq!(
970            response
971                .headers()
972                .get(header::PRAGMA)
973                .and_then(|value| value.to_str().ok()),
974            Some("no-cache")
975        );
976    }
977
978    #[tokio::test]
979    async fn token_endpoint_rejects_refresh_token_client_mismatch() {
980        let state = test_auth_state_with_registered_client().await;
981        state
982            .store
983            .upsert_refresh_token(crate::types::RefreshTokenRow {
984                refresh_token: "refresh-token".to_string(),
985                client_id: "client".to_string(),
986                subject: "google-subject-123".to_string(),
987                resource: "https://lab.example.com/mcp".to_string(),
988                scope: "lab".to_string(),
989                provider: "google".to_string(),
990                provider_refresh_token: Some("provider-refresh".to_string()),
991                created_at: crate::util::now_unix() - 60,
992                expires_at: crate::util::now_unix() + 3600,
993            })
994            .await
995            .unwrap();
996        let app = router(state);
997        let response = app
998            .oneshot(
999                Request::builder()
1000                    .method("POST")
1001                    .uri("/token")
1002                    .header(
1003                        header::CONTENT_TYPE,
1004                        "application/x-www-form-urlencoded",
1005                    )
1006                    .body(Body::from(
1007                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=other-client",
1008                    ))
1009                    .unwrap(),
1010            )
1011            .await
1012            .unwrap();
1013        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1014        assert_eq!(
1015            response
1016                .headers()
1017                .get(header::CACHE_CONTROL)
1018                .and_then(|value| value.to_str().ok()),
1019            Some("no-store")
1020        );
1021        assert_eq!(
1022            response
1023                .headers()
1024                .get(header::PRAGMA)
1025                .and_then(|value| value.to_str().ok()),
1026            Some("no-cache")
1027        );
1028    }
1029
1030    #[tokio::test]
1031    async fn token_endpoint_rejects_refresh_token_without_upstream_refresh_capability() {
1032        let state = test_auth_state_with_registered_client().await;
1033        state
1034            .store
1035            .upsert_refresh_token(crate::types::RefreshTokenRow {
1036                refresh_token: "refresh-token".to_string(),
1037                client_id: "client".to_string(),
1038                subject: "google-subject-123".to_string(),
1039                resource: "https://lab.example.com/mcp".to_string(),
1040                scope: "lab".to_string(),
1041                provider: "google".to_string(),
1042                provider_refresh_token: None,
1043                created_at: crate::util::now_unix() - 60,
1044                expires_at: crate::util::now_unix() + 3600,
1045            })
1046            .await
1047            .unwrap();
1048        let app = router(state);
1049        let response = app
1050            .oneshot(
1051                Request::builder()
1052                    .method("POST")
1053                    .uri("/token")
1054                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1055                    .body(Body::from(
1056                        "grant_type=refresh_token&refresh_token=refresh-token&client_id=client",
1057                    ))
1058                    .unwrap(),
1059            )
1060            .await
1061            .unwrap();
1062        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1063    }
1064
1065    async fn seed_authorization_code(state: &AuthState) {
1066        seed_authorization_code_with_expiry(state, 4_102_444_800).await;
1067    }
1068
1069    async fn seed_authorization_code_without_provider_refresh(state: &AuthState) {
1070        state
1071            .store
1072            .insert_auth_code(crate::types::AuthorizationCodeRow {
1073                code: "lab-code".to_string(),
1074                client_id: "client".to_string(),
1075                subject: "google-subject-123".to_string(),
1076                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
1077                resource: "https://lab.example.com/mcp".to_string(),
1078                scope: "lab".to_string(),
1079                provider: "google".to_string(),
1080                code_challenge: super::pkce_challenge("verifier"),
1081                code_challenge_method: "S256".to_string(),
1082                provider_refresh_token: None,
1083                created_at: 1_700_000_000,
1084                expires_at: 4_102_444_800,
1085            })
1086            .await
1087            .unwrap();
1088    }
1089
1090    async fn seed_authorization_code_with_expiry(state: &AuthState, expires_at: i64) {
1091        state
1092            .store
1093            .insert_auth_code(crate::types::AuthorizationCodeRow {
1094                code: "lab-code".to_string(),
1095                client_id: "client".to_string(),
1096                subject: "google-subject-123".to_string(),
1097                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
1098                resource: "https://lab.example.com/mcp".to_string(),
1099                scope: "lab".to_string(),
1100                provider: "google".to_string(),
1101                code_challenge: super::pkce_challenge("verifier"),
1102                code_challenge_method: "S256".to_string(),
1103                provider_refresh_token: Some("provider-refresh".to_string()),
1104                created_at: 1_700_000_000,
1105                expires_at,
1106            })
1107            .await
1108            .unwrap();
1109    }
1110
1111    #[tokio::test]
1112    async fn refresh_grant_preserves_local_token_on_success() {
1113        let state = test_auth_state_with_mock_google().await;
1114        state
1115            .store
1116            .upsert_refresh_token(crate::types::RefreshTokenRow {
1117                refresh_token: "original-token".to_string(),
1118                client_id: "client".to_string(),
1119                subject: "google-subject-123".to_string(),
1120                resource: String::new(),
1121                scope: "lab".to_string(),
1122                provider: "google".to_string(),
1123                provider_refresh_token: Some("provider-refresh".to_string()),
1124                created_at: crate::util::now_unix() - 60,
1125                expires_at: crate::util::now_unix() + 3600,
1126            })
1127            .await
1128            .unwrap();
1129        let app = router(state.clone());
1130        let response = app
1131            .oneshot(
1132                Request::builder()
1133                    .method("POST")
1134                    .uri("/token")
1135                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1136                    .body(Body::from(
1137                        "grant_type=refresh_token&refresh_token=original-token&client_id=client",
1138                    ))
1139                    .unwrap(),
1140            )
1141            .await
1142            .unwrap();
1143        assert_eq!(response.status(), StatusCode::OK);
1144        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1145            .await
1146            .unwrap();
1147        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1148        let new_token = json["refresh_token"].as_str().expect("refresh_token");
1149        assert_eq!(
1150            new_token, "original-token",
1151            "local token must remain stable"
1152        );
1153        assert!(
1154            state
1155                .store
1156                .find_refresh_token("original-token")
1157                .await
1158                .unwrap()
1159                .is_some(),
1160            "local refresh token must remain usable after successful refresh"
1161        );
1162    }
1163
1164    #[tokio::test]
1165    async fn refresh_grant_preserves_original_token_when_upstream_refresh_fails() {
1166        let state = test_auth_state_with_failing_google_refresh().await;
1167        state
1168            .store
1169            .upsert_refresh_token(crate::types::RefreshTokenRow {
1170                refresh_token: "recoverable-token".to_string(),
1171                client_id: "client".to_string(),
1172                subject: "google-subject-123".to_string(),
1173                resource: String::new(),
1174                scope: "lab".to_string(),
1175                provider: "google".to_string(),
1176                provider_refresh_token: Some("provider-refresh".to_string()),
1177                created_at: crate::util::now_unix() - 60,
1178                expires_at: crate::util::now_unix() + 3600,
1179            })
1180            .await
1181            .unwrap();
1182        let app = router(state.clone());
1183
1184        let response = app
1185            .oneshot(
1186                Request::builder()
1187                    .method("POST")
1188                    .uri("/token")
1189                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1190                    .body(Body::from(
1191                        "grant_type=refresh_token&refresh_token=recoverable-token&client_id=client",
1192                    ))
1193                    .unwrap(),
1194            )
1195            .await
1196            .unwrap();
1197
1198        assert_ne!(response.status(), StatusCode::OK);
1199        assert!(
1200            state
1201                .store
1202                .find_refresh_token("recoverable-token")
1203                .await
1204                .unwrap()
1205                .is_some(),
1206            "local refresh token must remain usable after upstream refresh failure"
1207        );
1208    }
1209
1210    #[tokio::test]
1211    async fn refresh_grant_elevates_stale_scope_to_admin() {
1212        // Simulate a refresh token that was issued before elevation was wired in,
1213        // storing only the base scope ("lab") without "lab:admin".  The refresh
1214        // grant must re-apply elevate_scope_for_allowed_user so the new access
1215        // token carries "lab:admin".
1216        let state = test_auth_state_with_mock_google().await;
1217        state
1218            .store
1219            .upsert_refresh_token(crate::types::RefreshTokenRow {
1220                refresh_token: "stale-token".to_string(),
1221                client_id: "client".to_string(),
1222                subject: "google-subject-123".to_string(),
1223                resource: String::new(),
1224                scope: "lab".to_string(), // stale — no lab:admin
1225                provider: "google".to_string(),
1226                provider_refresh_token: Some("provider-refresh".to_string()),
1227                created_at: crate::util::now_unix() - 60,
1228                expires_at: crate::util::now_unix() + 3600,
1229            })
1230            .await
1231            .unwrap();
1232        let app = router(state.clone());
1233        let response = app
1234            .oneshot(
1235                Request::builder()
1236                    .method("POST")
1237                    .uri("/token")
1238                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1239                    .body(Body::from(
1240                        "grant_type=refresh_token&refresh_token=stale-token&client_id=client",
1241                    ))
1242                    .unwrap(),
1243            )
1244            .await
1245            .unwrap();
1246        assert_eq!(response.status(), StatusCode::OK);
1247        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1248            .await
1249            .unwrap();
1250        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1251        // Decode the access token and verify the scope was elevated.
1252        let access_token = json["access_token"].as_str().expect("access_token");
1253        let claims = state
1254            .signing_keys
1255            .validate_access_token_with_issuer(
1256                access_token,
1257                "https://lab.example.com/mcp",
1258                "https://lab.example.com",
1259            )
1260            .expect("access token must be valid");
1261        let scopes: Vec<&str> = claims.scope.split_whitespace().collect();
1262        assert!(
1263            scopes.contains(&"lab:admin"),
1264            "elevated access token must contain lab:admin, got: {:?}",
1265            scopes
1266        );
1267    }
1268
1269    #[tokio::test]
1270    async fn refresh_grant_allows_reuse_of_stable_local_token() {
1271        let state = test_auth_state_with_mock_google().await;
1272        state
1273            .store
1274            .upsert_refresh_token(crate::types::RefreshTokenRow {
1275                refresh_token: "once-only-token".to_string(),
1276                client_id: "client".to_string(),
1277                subject: "google-subject-123".to_string(),
1278                resource: String::new(),
1279                scope: "lab".to_string(),
1280                provider: "google".to_string(),
1281                provider_refresh_token: Some("provider-refresh".to_string()),
1282                created_at: crate::util::now_unix() - 60,
1283                expires_at: crate::util::now_unix() + 3600,
1284            })
1285            .await
1286            .unwrap();
1287        let app = router(state);
1288        let first = app
1289            .clone()
1290            .oneshot(
1291                Request::builder()
1292                    .method("POST")
1293                    .uri("/token")
1294                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1295                    .body(Body::from(
1296                        "grant_type=refresh_token&refresh_token=once-only-token&client_id=client",
1297                    ))
1298                    .unwrap(),
1299            )
1300            .await
1301            .unwrap();
1302        assert_eq!(first.status(), StatusCode::OK);
1303        let replay = app
1304            .oneshot(
1305                Request::builder()
1306                    .method("POST")
1307                    .uri("/token")
1308                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1309                    .body(Body::from(
1310                        "grant_type=refresh_token&refresh_token=once-only-token&client_id=client",
1311                    ))
1312                    .unwrap(),
1313            )
1314            .await
1315            .unwrap();
1316        assert_eq!(
1317            replay.status(),
1318            StatusCode::OK,
1319            "same local refresh token must be reusable across client restarts"
1320        );
1321    }
1322
1323    #[tokio::test]
1324    async fn authorization_code_grant_never_mints_a_refresh_token_for_a_github_login() {
1325        let state = test_auth_state_with_registered_client().await;
1326        state
1327            .store
1328            .insert_auth_code(crate::types::AuthorizationCodeRow {
1329                code: "github-code".to_string(),
1330                client_id: "client".to_string(),
1331                subject: "github:9182310".to_string(),
1332                redirect_uri: "http://127.0.0.1:7777/callback".to_string(),
1333                resource: "https://lab.example.com/mcp".to_string(),
1334                scope: "lab".to_string(),
1335                provider: "github".to_string(),
1336                code_challenge: super::pkce_challenge("verifier"),
1337                code_challenge_method: "S256".to_string(),
1338                provider_refresh_token: None,
1339                created_at: crate::util::now_unix(),
1340                expires_at: crate::util::now_unix() + 300,
1341            })
1342            .await
1343            .unwrap();
1344        let app = router(state);
1345        let response = app
1346            .oneshot(
1347                Request::builder()
1348                    .method("POST")
1349                    .uri("/token")
1350                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1351                    .body(Body::from(
1352                        "grant_type=authorization_code&code=github-code&client_id=client&redirect_uri=http://127.0.0.1:7777/callback&code_verifier=verifier",
1353                    ))
1354                    .unwrap(),
1355            )
1356            .await
1357            .unwrap();
1358        assert_eq!(response.status(), StatusCode::OK);
1359        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1360            .await
1361            .unwrap();
1362        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1363        assert!(
1364            json.get("refresh_token").is_none(),
1365            "github logins must never receive a local refresh token: {json}"
1366        );
1367    }
1368
1369    /// Engineering-review regression test: a deployment upgrades (backfilling
1370    /// pre-existing rows to `provider='google'`), then an operator removes a
1371    /// provider from config while an unexpired `refresh_tokens` row still
1372    /// names it. `state.provider(...)` must fail clearly (`AuthError::Validation`
1373    /// → `invalid_request` / 400), not panic or silently fall back to a
1374    /// different provider.
1375    #[tokio::test]
1376    async fn refresh_token_grant_fails_clearly_when_its_provider_is_no_longer_configured() {
1377        let state = test_auth_state_with_registered_client().await;
1378        state
1379            .store
1380            .upsert_refresh_token(crate::types::RefreshTokenRow {
1381                refresh_token: "orphaned-refresh".to_string(),
1382                client_id: "client".to_string(),
1383                subject: "authelia:some-user".to_string(),
1384                resource: "https://lab.example.com/mcp".to_string(),
1385                scope: "lab".to_string(),
1386                provider: "authelia".to_string(),
1387                provider_refresh_token: Some("upstream-refresh".to_string()),
1388                created_at: crate::util::now_unix(),
1389                expires_at: crate::util::now_unix() + 3600,
1390            })
1391            .await
1392            .unwrap();
1393        // `test_auth_state_with_registered_client` only configures Google —
1394        // "authelia" is intentionally never configured here, simulating an
1395        // operator who removed it after this token was issued.
1396        let app = router(state);
1397        let response = app
1398            .oneshot(
1399                Request::builder()
1400                    .method("POST")
1401                    .uri("/token")
1402                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1403                    .body(Body::from(
1404                        "grant_type=refresh_token&client_id=client&refresh_token=orphaned-refresh",
1405                    ))
1406                    .unwrap(),
1407            )
1408            .await
1409            .unwrap();
1410        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1411        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1412            .await
1413            .unwrap();
1414        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1415        assert_eq!(json["error"], "invalid_request");
1416    }
1417
1418    /// Defense-in-depth regression test: `GitHubProvider::exchange_code`
1419    /// never sets `provider_refresh_token`, so a `refresh_tokens` row naming
1420    /// `provider = "github"` with a non-null `provider_refresh_token` is
1421    /// unreachable through normal flows — but the DB layer doesn't enforce
1422    /// that invariant. Hand-insert exactly that row and confirm
1423    /// `refresh_token_grant` fails loudly with a clear server error instead
1424    /// of reaching `GitHubProvider::refresh`'s unconditional error.
1425    #[tokio::test]
1426    async fn refresh_token_grant_rejects_a_hand_inserted_github_row_with_a_provider_refresh_token()
1427    {
1428        let mut config = test_auth_config();
1429        config.github.client_id = "gh-client".to_string();
1430        config.github.client_secret = "gh-secret".to_string();
1431        config.github.scopes = vec!["read:user".to_string(), "user:email".to_string()];
1432        let state = test_auth_state_with_config(config).await;
1433        state
1434            .store
1435            .register_client(crate::types::RegisteredClient {
1436                client_id: "client".to_string(),
1437                redirect_uris: vec!["http://127.0.0.1:7777/callback".to_string()],
1438                created_at: crate::util::now_unix(),
1439            })
1440            .await
1441            .unwrap();
1442        state
1443            .store
1444            .upsert_refresh_token(crate::types::RefreshTokenRow {
1445                refresh_token: "github-refresh".to_string(),
1446                client_id: "client".to_string(),
1447                subject: "github:9182310".to_string(),
1448                resource: "https://lab.example.com/mcp".to_string(),
1449                scope: "lab".to_string(),
1450                provider: "github".to_string(),
1451                provider_refresh_token: Some("hand-inserted-upstream-value".to_string()),
1452                created_at: crate::util::now_unix(),
1453                expires_at: crate::util::now_unix() + 3600,
1454            })
1455            .await
1456            .unwrap();
1457        let app = router(state);
1458        let response = app
1459            .oneshot(
1460                Request::builder()
1461                    .method("POST")
1462                    .uri("/token")
1463                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1464                    .body(Body::from(
1465                        "grant_type=refresh_token&client_id=client&refresh_token=github-refresh",
1466                    ))
1467                    .unwrap(),
1468            )
1469            .await
1470            .unwrap();
1471        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1472        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1473            .await
1474            .unwrap();
1475        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1476        assert_eq!(json["error"], "server_error");
1477    }
1478}