Files
rustguac/src/oidc.rs
T
Dave Kempe b155ed7318 fix(oidc): allow client_secret from OIDC_CLIENT_SECRET env var alone
Closes #121.

OidcConfig.client_secret was a non-Optional String, so a config.toml with
no `client_secret = "..."` line failed TOML parsing with `missing field
client_secret` before the env-var override at the bottom of Config::load
could fill it in. The override path was effectively dead code unless
the operator put a placeholder in the file first.

  - client_secret is now Option<String> with #[serde(default)], so the
    field can be omitted from config.toml.
  - At the end of Config::load, if [oidc] is present, OIDC_CLIENT_SECRET
    fills the slot when set; if it's still empty after that, exit(1)
    with an actionable error pointing at both config.toml and the env
    file.
  - oidc.rs unwraps the Option at OidcState construction with a clear
    error string. The validation at config-load time means this should
    never trigger in normal use; it's defensive against a partially
    constructed config.
  - Debug impl now shows None / Some([REDACTED]) so secret-redaction
    behaviour is preserved either way.

Three new tests:
  - oidc_config_parses_without_client_secret  (the regression)
  - oidc_config_parses_with_client_secret
  - oidc_config_debug_redacts_client_secret
2026-05-06 15:57:26 +10:00

778 lines
29 KiB
Rust

//! OIDC authentication — login, callback, logout handlers.
use crate::config::OidcConfig;
use crate::db::{self, Db};
use axum::{
extract::{Query, State},
http::{header, StatusCode},
response::{AppendHeaders, IntoResponse, Redirect, Response},
Extension,
};
use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
AuthType, AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge,
PkceCodeVerifier, RedirectUrl, Scope, TokenResponse,
};
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
/// The concrete CoreClient type after from_provider_metadata + set_redirect_uri.
/// from_provider_metadata sets auth/token/userinfo to EndpointSet when present in metadata.
type OidcClient = CoreClient<
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>;
/// Pending OIDC flow entry: PKCE verifier, nonce, and creation timestamp.
type PendingFlows =
Arc<Mutex<std::collections::HashMap<String, (PkceCodeVerifier, Nonce, Instant)>>>;
/// Shared OIDC state initialized once at startup.
#[derive(Clone)]
pub struct OidcState {
pub client: OidcClient,
pub http_client: openidconnect::reqwest::Client,
pub config: OidcConfig,
/// Auth session TTL in seconds.
pub session_ttl_secs: u64,
/// Pending OIDC flows: state -> (pkce_verifier, nonce, created_at)
pub pending: PendingFlows,
}
/// Initialize OIDC client by discovering provider metadata.
pub async fn init_oidc(config: &OidcConfig, session_ttl_secs: u64) -> Result<OidcState, String> {
let mut builder = openidconnect::reqwest::ClientBuilder::new()
.redirect(openidconnect::reqwest::redirect::Policy::none());
if config.tls_skip_verify {
tracing::warn!(
"OIDC TLS certificate verification is DISABLED (tls_skip_verify = true). \
This exposes client_secret and tokens to MITM attacks — do NOT use in production."
);
builder = builder.danger_accept_invalid_certs(true);
}
if let Some(ref ca_path) = config.ca_cert {
let pem = std::fs::read(ca_path)
.map_err(|e| format!("Failed to read OIDC CA cert {}: {}", ca_path, e))?;
let cert = reqwest::tls::Certificate::from_pem(&pem)
.map_err(|e| format!("Failed to parse OIDC CA cert {}: {}", ca_path, e))?;
builder = builder.add_root_certificate(cert);
tracing::info!("OIDC TLS: added custom CA certificate from {}", ca_path);
}
let http_client = builder
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
let issuer_url = IssuerUrl::new(config.issuer_url.clone())
.map_err(|e| format!("Invalid issuer URL: {}", e))?;
let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client)
.await
.map_err(|e| friendly_discovery_error(&format!("{:?}", e)))?;
// client_secret is validated at config-load time when [oidc] is
// configured (Config::load), so reaching this point with None
// means we were called with a partially-constructed config; treat
// that as a programming error rather than a user-facing one.
let client_secret = config
.client_secret
.clone()
.ok_or_else(|| "OIDC client_secret missing at startup".to_string())?;
let client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(config.client_id.clone()),
Some(ClientSecret::new(client_secret)),
)
.set_auth_type(AuthType::RequestBody)
.set_redirect_uri(
RedirectUrl::new(config.redirect_uri.clone())
.map_err(|e| format!("Invalid redirect URI: {}", e))?,
);
Ok(OidcState {
client,
http_client,
config: config.clone(),
session_ttl_secs,
pending: Arc::new(Mutex::new(std::collections::HashMap::new())),
})
}
#[derive(Deserialize)]
pub struct LoginParams {
pub next: Option<String>,
}
/// GET /auth/login — redirect user to OIDC provider.
pub async fn login(State(oidc): State<OidcState>, Query(params): Query<LoginParams>) -> Response {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let mut auth_request = oidc
.client
.authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.set_pkce_challenge(pkce_challenge);
// Request any extra scopes configured (e.g. "groups")
for scope in &oidc.config.extra_scopes {
auth_request = auth_request.add_scope(Scope::new(scope.clone()));
}
let (auth_url, csrf_token, nonce) = auth_request.url();
// Store PKCE verifier + nonce keyed by CSRF state, and evict stale entries
let state_key = csrf_token.secret().clone();
let mut pending = oidc.pending.lock().await;
let cutoff = Instant::now() - std::time::Duration::from_secs(600);
pending.retain(|_, (_, _, created)| *created > cutoff);
pending.insert(state_key.clone(), (pkce_verifier, nonce, Instant::now()));
drop(pending);
// Set state in a cookie so we can verify on callback, then redirect
let state_cookie = format!(
"rustguac_oidc_state={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600",
state_key
);
let mut cookies = vec![(header::SET_COOKIE, state_cookie)];
// Store post-login redirect URL in a cookie if provided and safe
if let Some(ref next) = params.next {
if next.starts_with('/') && !next.starts_with("//") && !next.contains("://") {
let next_cookie = format!(
"rustguac_next={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600",
next
);
cookies.push((header::SET_COOKIE, next_cookie));
}
}
(
AppendHeaders(cookies),
Redirect::temporary(auth_url.as_str()),
)
.into_response()
}
#[derive(Deserialize)]
pub struct CallbackParams {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
}
/// GET /auth/callback — exchange code for tokens, create session.
pub async fn callback(
State(oidc): State<OidcState>,
Extension(database): Extension<Db>,
headers: axum::http::HeaderMap,
Query(params): Query<CallbackParams>,
) -> Response {
// Handle SSO error responses (e.g. user denied consent, timeout)
if let Some(ref err) = params.error {
let desc = params
.error_description
.as_deref()
.unwrap_or("unknown error");
tracing::warn!("OIDC callback error from provider: {} — {}", err, desc);
return axum::response::Redirect::to("/?sso_error=1").into_response();
}
let code = match params.code {
Some(c) => c,
None => {
tracing::warn!("OIDC callback missing 'code' parameter");
return axum::response::Redirect::to("/?sso_error=1").into_response();
}
};
let state = match params.state {
Some(s) => s,
None => {
tracing::warn!("OIDC callback missing 'state' parameter");
return axum::response::Redirect::to("/?sso_error=1").into_response();
}
};
// Verify the state cookie matches the state query parameter (binds flow to browser)
let state_cookie = extract_cookie_from_headers(&headers, "rustguac_oidc_state");
if state_cookie.as_deref() != Some(&state) {
tracing::warn!("OIDC callback state cookie mismatch");
return (
StatusCode::BAD_REQUEST,
axum::Json(json!({"error": "OIDC state cookie mismatch"})),
)
.into_response();
}
// Retrieve and remove the pending PKCE verifier
let pending_entry = oidc.pending.lock().await.remove(&state);
let (pkce_verifier, nonce, _created) = match pending_entry {
Some(entry) => entry,
None => {
return (
StatusCode::BAD_REQUEST,
axum::Json(json!({"error": "invalid or expired OIDC state"})),
)
.into_response();
}
};
// Exchange authorization code for tokens
let code_request = match oidc.client.exchange_code(AuthorizationCode::new(code)) {
Ok(req) => req,
Err(e) => {
tracing::error!("OIDC token endpoint not configured: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
axum::Json(json!({"error": "OIDC token endpoint not available"})),
)
.into_response();
}
};
let token_response = match code_request
.set_pkce_verifier(pkce_verifier)
.request_async(&oidc.http_client)
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::error!("OIDC token exchange failed: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
axum::Json(json!({"error": "OIDC token exchange failed"})),
)
.into_response();
}
};
// Extract and verify ID token
use openidconnect::core::{CoreIdToken, CoreIdTokenClaims};
let id_token: &CoreIdToken = match token_response.id_token() {
Some(t) => t,
None => {
return (
StatusCode::BAD_GATEWAY,
axum::Json(json!({"error": "no ID token in OIDC response"})),
)
.into_response();
}
};
let claims: &CoreIdTokenClaims = match id_token.claims(&oidc.client.id_token_verifier(), &nonce)
{
Ok(c) => c,
Err(e) => {
tracing::error!("OIDC ID token verification failed: {}", e);
return (
StatusCode::BAD_GATEWAY,
axum::Json(json!({"error": "ID token verification failed"})),
)
.into_response();
}
};
// Extract user info from claims
let subject = claims.subject().to_string();
let email: String = claims
.email()
.map(|e| e.to_string())
.unwrap_or_else(|| subject.clone());
let name: String = claims
.name()
.and_then(|n| n.get(None).map(|v| v.to_string()))
.unwrap_or_default();
// Extract group memberships from ID token JWT payload
let groups = extract_groups_from_jwt(&id_token.to_string(), &oidc.config.groups_claim);
if !groups.is_empty() {
tracing::info!(email = %email, groups = ?groups, "OIDC groups extracted");
let db_for_seen = database.clone();
let groups_for_seen = groups.clone();
let _ = tokio::task::spawn_blocking(move || {
if let Err(e) = db::upsert_seen_groups(&db_for_seen, &groups_for_seen) {
tracing::warn!(error = %e, "failed to persist seen OIDC groups");
}
})
.await;
}
// Resolve role from group-to-role mappings (highest matching wins).
// Returns Some(role) only if a mapping matched; None means keep existing role.
let db_for_role = database.clone();
let groups_for_role = groups.clone();
let mapped_role = match tokio::task::spawn_blocking(move || {
db::resolve_role_from_groups(&db_for_role, &groups_for_role)
})
.await
{
Ok(Ok(role)) => role,
_ => None,
};
// Upsert user in DB (sets default_role only on first login INSERT, not on subsequent updates)
let default_role = oidc.config.default_role.clone();
let db_clone = database.clone();
let email_clone = email.clone();
let name_clone = name.clone();
let subject_clone = subject.clone();
let user = match tokio::task::spawn_blocking(move || {
db::upsert_user(
&db_clone,
&email_clone,
&name_clone,
Some(&subject_clone),
&default_role,
&groups,
)
})
.await
{
Ok(Ok(user)) => user,
Ok(Err(e)) => {
tracing::error!("Failed to upsert user: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"error": "failed to create user"})),
)
.into_response();
}
Err(e) => {
tracing::error!("Spawn blocking failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"error": "internal error"})),
)
.into_response();
}
};
// If a group mapping matched, update the user's role to the mapped value
let effective_role = if let Some(ref role) = mapped_role {
let db_clone = database.clone();
let email_clone = email.clone();
let role_clone = role.clone();
let _ = tokio::task::spawn_blocking(move || {
db::set_user_role(&db_clone, &email_clone, &role_clone)
})
.await;
tracing::info!(email = %email, role = %role, "Role set from group mapping");
role.clone()
} else {
user.role.clone()
};
if user.disabled {
return (
StatusCode::FORBIDDEN,
axum::Json(json!({"error": "account is disabled"})),
)
.into_response();
}
// Create auth session
let user_id = user.id;
let ttl_secs = oidc.session_ttl_secs;
let db_clone = database.clone();
let session_token = match tokio::task::spawn_blocking(move || {
db::create_auth_session(&db_clone, user_id, ttl_secs)
})
.await
{
Ok(Ok(token)) => token,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
axum::Json(json!({"error": "failed to create session"})),
)
.into_response();
}
};
tracing::info!(email = %email, role = %effective_role, "OIDC login successful");
// Check for post-login redirect cookie
let redirect_to = extract_cookie_from_headers(&headers, "rustguac_next")
.filter(|n| n.starts_with('/') && !n.starts_with("//") && !n.contains("://"))
.unwrap_or_else(|| "/addressbook.html".to_string());
// Set session cookie and redirect; clear OIDC state and next cookies
let session_cookie = format!(
"rustguac_session={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={}",
session_token, ttl_secs
);
let clear_state_cookie =
"rustguac_oidc_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0".to_string();
let clear_next_cookie =
"rustguac_next=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0".to_string();
(
AppendHeaders([
(header::SET_COOKIE, session_cookie),
(header::SET_COOKIE, clear_state_cookie),
(header::SET_COOKIE, clear_next_cookie),
]),
Redirect::temporary(&redirect_to),
)
.into_response()
}
/// GET /auth/logout — clear session cookie and redirect to login.
pub async fn logout(
Extension(database): Extension<Db>,
request: axum::extract::Request,
) -> Response {
// Try to delete the session from DB
if let Some(token) = extract_cookie_value(&request, "rustguac_session") {
let db_clone = database.clone();
let _ =
tokio::task::spawn_blocking(move || db::delete_auth_session(&db_clone, &token)).await;
}
let clear_cookie =
"rustguac_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0".to_string();
(
[(header::SET_COOKIE, clear_cookie)],
Redirect::temporary("/"),
)
.into_response()
}
/// Extract group memberships from a JWT string by decoding the payload.
/// Accepts the raw JWT string to avoid openidconnect's complex generics.
/// Max OIDC groups kept per login. Caps `seen_groups` bloat + `oidc_groups`
/// column size if an IdP returns an absurdly large group list (misconfig or
/// compromise). Realistic AD deployments sit well under this.
const MAX_OIDC_GROUPS: usize = 64;
/// Max characters per group name. Longer names are truncated on the byte
/// boundary preserving UTF-8 (we drop trailing partial code points).
const MAX_OIDC_GROUP_LEN: usize = 256;
fn truncate_group_name(s: &str) -> String {
if s.len() <= MAX_OIDC_GROUP_LEN {
return s.to_string();
}
let mut end = MAX_OIDC_GROUP_LEN;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
fn extract_groups_from_jwt(token_str: &str, groups_claim: &str) -> Vec<String> {
use base64::Engine;
let parts: Vec<&str> = token_str.split('.').collect();
let payload = match parts.get(1) {
Some(p) => p,
None => return Vec::new(),
};
let bytes = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) {
Ok(b) => b,
Err(_) => return Vec::new(),
};
let claims: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let mut groups: Vec<String> = match claims.get(groups_claim) {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(truncate_group_name))
.collect(),
// Some OIDC providers send a plain string instead of an array when user is in one group
Some(serde_json::Value::String(s)) => vec![truncate_group_name(s)],
_ => Vec::new(),
};
if groups.len() > MAX_OIDC_GROUPS {
tracing::warn!(
total = groups.len(),
kept = MAX_OIDC_GROUPS,
"OIDC groups claim exceeds cap — truncating"
);
groups.truncate(MAX_OIDC_GROUPS);
}
groups
}
/// Extract a cookie value from request headers.
fn extract_cookie_value(request: &axum::extract::Request, name: &str) -> Option<String> {
extract_cookie_from_headers(request.headers(), name)
}
/// Extract a cookie value from a HeaderMap.
fn extract_cookie_from_headers(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
let c = c.trim();
if let Some(val) = c.strip_prefix(name) {
val.strip_prefix('=').map(|v| v.to_string())
} else {
None
}
})
})
}
/// Reshape the openidconnect crate's discovery error into something an
/// operator can act on without grepping Debug output.
///
/// The specific case we handle is the issuer-URI mismatch that OIDC
/// Discovery's spec demands (the `issuer` claim in the discovery document
/// must byte-for-byte match the URL the client used to fetch it). This
/// fires for any OIDC provider where the operator has a trailing slash
/// wrong or copy-pasted the authorisation URL instead of the issuer URL:
/// Keycloak, Authentik, Azure AD / Entra ID, Okta, Auth0, JumpCloud,
/// Google, and so on. The original library error gives the two URIs but
/// phrases "expected" from the validator's perspective, which is
/// exactly backwards for what the operator needs to do. We flip it
/// around and name the fix explicitly.
///
/// Every other discovery failure (network, TLS, JSON parse, wrong path)
/// falls through to the raw Debug string so we don't swallow useful
/// diagnostics.
fn friendly_discovery_error(raw: &str) -> String {
let (got, expected) = match parse_issuer_mismatch(raw) {
Some(pair) => pair,
None => return format!("OIDC discovery failed: {raw}"),
};
// `got` is what the provider actually advertises in its discovery
// document; `expected` is what the library was told by the config.
// The operator always needs to change `expected` to match `got`.
let headline;
let fix;
if got.trim_end_matches('/') == expected.trim_end_matches('/') {
// Slash-only mismatch — the overwhelmingly common case.
if expected.ends_with('/') && !got.ends_with('/') {
headline = "issuer_url has an extra trailing slash";
fix = "remove the trailing slash from issuer_url".to_string();
} else {
headline = "issuer_url is missing a trailing slash";
fix = "add a trailing slash to issuer_url".to_string();
}
} else {
// Genuinely different URLs (different host, different path, wrong
// URL copied from the provider console). Operator needs to see
// both sides and swap in the right one.
headline = "issuer_url does not match the provider's advertised value";
fix = format!("set issuer_url = \"{got}\" in your [oidc] config");
}
format!(
"OIDC discovery failed: {headline}.\n \
config: \"{expected}\"\n \
provider: \"{got}\"\n \
Fix: {fix}."
)
}
/// Extract (got, expected) from the openidconnect crate's issuer-mismatch
/// validation error. The error is Debug-formatted into the enclosing code
/// path and reliably contains:
/// unexpected issuer URI `GOT` (expected `EXPECTED`)
/// Returns None for any other shape so the caller falls through to the raw
/// error text.
fn parse_issuer_mismatch(raw: &str) -> Option<(String, String)> {
let tag = "unexpected issuer URI `";
let start = raw.find(tag)? + tag.len();
let after = &raw[start..];
let got_end = after.find('`')?;
let got = after[..got_end].to_string();
let rest = &after[got_end + 1..];
let expected_tag = "(expected `";
let expected_start = rest.find(expected_tag)? + expected_tag.len();
let expected_rest = &rest[expected_start..];
let expected_end = expected_rest.find('`')?;
let expected = expected_rest[..expected_end].to_string();
Some((got, expected))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
fn make_jwt(payload: &serde_json::Value) -> String {
let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(br#"{"alg":"none","typ":"JWT"}"#);
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(payload).unwrap());
format!("{}.{}.sig", header, payload)
}
#[test]
fn groups_missing_claim_returns_empty() {
let jwt = make_jwt(&serde_json::json!({"sub": "alice"}));
assert!(extract_groups_from_jwt(&jwt, "groups").is_empty());
}
#[test]
fn groups_malformed_jwt_returns_empty() {
assert!(extract_groups_from_jwt("not-a-jwt", "groups").is_empty());
assert!(extract_groups_from_jwt("onlyone", "groups").is_empty());
assert!(extract_groups_from_jwt("a.b", "groups").is_empty());
assert!(extract_groups_from_jwt("a.!notbase64!.c", "groups").is_empty());
}
#[test]
fn groups_array_passed_through() {
let jwt = make_jwt(&serde_json::json!({"groups": ["admins", "ops"]}));
let got = extract_groups_from_jwt(&jwt, "groups");
assert_eq!(got, vec!["admins".to_string(), "ops".to_string()]);
}
#[test]
fn groups_single_string_wrapped() {
let jwt = make_jwt(&serde_json::json!({"groups": "admins"}));
assert_eq!(extract_groups_from_jwt(&jwt, "groups"), vec!["admins"]);
}
#[test]
fn groups_non_string_values_filtered() {
let jwt = make_jwt(&serde_json::json!({"groups": ["ok", 42, null, {"nested": "x"}]}));
assert_eq!(extract_groups_from_jwt(&jwt, "groups"), vec!["ok"]);
}
#[test]
fn groups_array_over_cap_truncated() {
let many: Vec<String> = (0..MAX_OIDC_GROUPS + 50).map(|i| format!("g{i}")).collect();
let jwt = make_jwt(&serde_json::json!({"groups": many}));
let got = extract_groups_from_jwt(&jwt, "groups");
assert_eq!(got.len(), MAX_OIDC_GROUPS);
assert_eq!(got[0], "g0");
}
#[test]
fn groups_long_names_truncated_on_utf8_boundary() {
// Build a name with a multi-byte char straddling the 256-byte limit.
// '€' is 3 bytes. Pad with ASCII up to position 254 then add two '€' —
// naive byte-truncate at 256 would split the second '€'.
let mut name = "a".repeat(254);
name.push('€'); // bytes 254..257
name.push('€'); // bytes 257..260
let jwt = make_jwt(&serde_json::json!({"groups": [name.clone()]}));
let got = extract_groups_from_jwt(&jwt, "groups");
assert_eq!(got.len(), 1);
// Truncation must end on a valid UTF-8 boundary ≤ 256 bytes.
assert!(got[0].len() <= MAX_OIDC_GROUP_LEN);
assert!(std::str::from_utf8(got[0].as_bytes()).is_ok());
// And must not exceed the original.
assert!(name.starts_with(&got[0]));
}
#[test]
fn groups_short_names_pass_through_unchanged() {
let jwt = make_jwt(&serde_json::json!({"groups": ["alpha", "beta"]}));
assert_eq!(
extract_groups_from_jwt(&jwt, "groups"),
vec!["alpha", "beta"]
);
}
#[test]
fn groups_custom_claim_name_respected() {
let jwt = make_jwt(&serde_json::json!({"roles": ["r1", "r2"], "groups": ["g1"]}));
assert_eq!(extract_groups_from_jwt(&jwt, "roles"), vec!["r1", "r2"]);
assert_eq!(extract_groups_from_jwt(&jwt, "groups"), vec!["g1"]);
}
// ── Discovery error wrapping ────────────────────────────────────────
#[test]
fn parse_issuer_mismatch_extracts_both_uris() {
let raw = "Validation(\"unexpected issuer URI `https://oauth.jumpcloud.com` (expected `https://oauth.jumpcloud.com/`)\")";
let (got, expected) = parse_issuer_mismatch(raw).expect("should parse");
assert_eq!(got, "https://oauth.jumpcloud.com");
assert_eq!(expected, "https://oauth.jumpcloud.com/");
}
#[test]
fn parse_issuer_mismatch_handles_opposite_slash_direction() {
// JumpCloud's other endpoint: the mirror-image of the JumpCloud bug.
let raw = "Validation(\"unexpected issuer URI `https://oauth.id.jumpcloud.com/` (expected `https://oauth.id.jumpcloud.com`)\")";
let (got, expected) = parse_issuer_mismatch(raw).expect("should parse");
assert_eq!(got, "https://oauth.id.jumpcloud.com/");
assert_eq!(expected, "https://oauth.id.jumpcloud.com");
}
#[test]
fn parse_issuer_mismatch_returns_none_for_unrelated_errors() {
assert!(parse_issuer_mismatch("Request(reqwest::Error { ... })").is_none());
assert!(parse_issuer_mismatch("random string").is_none());
assert!(parse_issuer_mismatch("").is_none());
}
#[test]
fn friendly_discovery_error_extra_slash_in_config() {
// Config has trailing slash, provider advertises without.
let raw = "Validation(\"unexpected issuer URI `https://oauth.jumpcloud.com` (expected `https://oauth.jumpcloud.com/`)\")";
let msg = friendly_discovery_error(raw);
assert!(msg.contains("issuer_url has an extra trailing slash"));
assert!(msg.contains("config: \"https://oauth.jumpcloud.com/\""));
assert!(msg.contains("provider: \"https://oauth.jumpcloud.com\""));
assert!(msg.contains("Fix: remove the trailing slash from issuer_url"));
}
#[test]
fn friendly_discovery_error_missing_slash_in_config() {
// Provider advertises with trailing slash, config doesn't have one.
let raw = "Validation(\"unexpected issuer URI `https://auth.example.com/realms/corp/` (expected `https://auth.example.com/realms/corp`)\")";
let msg = friendly_discovery_error(raw);
assert!(msg.contains("issuer_url is missing a trailing slash"));
assert!(msg.contains("config: \"https://auth.example.com/realms/corp\""));
assert!(msg.contains("provider: \"https://auth.example.com/realms/corp/\""));
assert!(msg.contains("Fix: add a trailing slash to issuer_url"));
}
#[test]
fn friendly_discovery_error_different_urls_entirely() {
// Azure-AD-style: the v2.0 authority URL vs the actual issuer.
// Works for any provider where the operator copy-pasted a wrong URL.
let raw = "Validation(\"unexpected issuer URI `https://sts.windows.net/tenant-id/` (expected `https://login.microsoftonline.com/tenant-id/v2.0`)\")";
let msg = friendly_discovery_error(raw);
assert!(msg.contains("issuer_url does not match the provider's advertised value"));
assert!(msg.contains("config: \"https://login.microsoftonline.com/tenant-id/v2.0\""));
assert!(msg.contains("provider: \"https://sts.windows.net/tenant-id/\""));
assert!(msg.contains("Fix: set issuer_url = \"https://sts.windows.net/tenant-id/\""));
}
#[test]
fn friendly_discovery_error_passes_through_other_errors() {
let raw = "Request(NetworkError(connect_timeout))";
let msg = friendly_discovery_error(raw);
assert_eq!(
msg,
"OIDC discovery failed: Request(NetworkError(connect_timeout))"
);
}
}