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