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