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 let provider = state.provider(&stored.provider)?;
350 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 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 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(), 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 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 #[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 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 #[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}