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