| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347 |
- //! `test(authorize_validate)` suite: validation order + error-delivery matrix.
- use std::{collections::HashMap, time::Duration};
- use axum::http::{StatusCode, header};
- use base64::Engine as _;
- use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
- use ring::rand::SystemRandom;
- use ring::signature::{Ed25519KeyPair, KeyPair as _};
- use secrecy::SecretString;
- use time::OffsetDateTime;
- use uuid::Uuid;
- use super::{AuthorizeFailure, AuthorizeInput, validate};
- use crate::{
- config::DurabilityMode,
- crypto::pkce::challenge_from_verifier,
- db::{
- client_repo, client_repo::NewClient, redirect_repo, resource_repo, scope_repo,
- test_support::migrated_pool,
- },
- error::{AppError, OAuthErrorCode},
- keys::{KeyId, KeyRing},
- protocol::{
- cimd,
- client_auth::ClientAuthRequest,
- par::{ParContext, ParStores, push_authorization_request},
- },
- state::RedisConn,
- store::Store,
- test_support,
- };
- /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
- type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
- const ISSUER: &str = "https://auth.example.test";
- const REDIRECT: &str = "https://app.example.test/cb";
- const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
- /// Build a query map from pairs.
- fn query(pairs: &[(&str, &str)]) -> HashMap<String, String> {
- pairs
- .iter()
- .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
- .collect()
- }
- /// A minimal client fixture; `public` selects `token_endpoint_auth_method`.
- fn fixture(client_id: &str, public: bool) -> NewClient {
- NewClient {
- id: Uuid::new_v4(),
- client_id: client_id.to_owned(),
- client_secret_hash: None,
- client_secret_expires_at: None,
- client_name: "Authorize Test Client".to_owned(),
- grant_types: vec!["authorization_code".to_owned()],
- response_types: vec!["code".to_owned()],
- scope: "api:read".to_owned(),
- token_endpoint_auth_method: if public {
- "none".to_owned()
- } else {
- "client_secret_basic".to_owned()
- },
- jwks: None,
- jwks_uri: None,
- tls_client_auth_subject_dn: None,
- tls_client_auth_cert_thumbprint: None,
- request_uris: None,
- contacts: None,
- software_id: None,
- dpop_bound_access_tokens: false,
- access_token_format: "opaque".to_owned(),
- registration_access_token_hash: None,
- }
- }
- /// Register a client, one redirect URI, and the `api:read` catalog scope.
- async fn provision(
- pool: &sqlx::PgPool,
- keys: &KeyRing,
- client_id: &str,
- public: bool,
- ) -> TestResult {
- provision_at(pool, keys, client_id, public, REDIRECT).await
- }
- /// Register a client with an explicit redirect URI (encrypt-only at rest).
- async fn provision_at(
- pool: &sqlx::PgPool,
- keys: &KeyRing,
- client_id: &str,
- public: bool,
- uri: &str,
- ) -> TestResult {
- crate::db::client_repo::insert(pool, keys, false, &fixture(client_id, public)).await?;
- let (row, _) = redirect_repo::upsert(
- pool,
- keys,
- false,
- &redirect_repo::NewRedirectUri {
- id: Uuid::new_v4(),
- uri,
- kind: redirect_repo::RedirectKind::Authorize,
- description: "authorize test",
- created_by: None,
- },
- )
- .await?;
- redirect_repo::attach(pool, client_id, row.id, None).await?;
- scope_repo::upsert(pool, "api:read", "read api").await?;
- Ok(())
- }
- /// A never-dialed Redis connection: plain-query validation never touches PAR.
- fn offline_redis() -> TestResult<RedisConn> {
- let client = redis::Client::open("redis://127.0.0.1:1")?;
- let manager = redis::aio::ConnectionManager::new_lazy_with_config(
- client,
- redis::aio::ConnectionManagerConfig::new()
- .set_number_of_retries(0)
- .set_response_timeout(Some(Duration::from_millis(200)))
- .set_connection_timeout(Some(Duration::from_millis(200))),
- )?;
- Ok(RedisConn::Single(manager))
- }
- /// Store handle for the PAR lookup (unused on the plain-query path).
- fn test_store() -> Store {
- Store::from_parts(
- "oauth:{state}:".to_owned(),
- "oauth:{rl}:".to_owned(),
- DurabilityMode::WaitAof,
- )
- }
- /// Test PII key ring for the encrypted client projection.
- fn test_keys() -> TestResult<KeyRing> {
- Ok(KeyRing::from_parts(
- vec![(
- KeyId::new("v1"),
- SecretString::from("authorize-test-pepper"),
- )],
- vec![(KeyId::new("v1"), SecretString::from("authorize-test-totp"))],
- vec![(KeyId::new("v1"), SecretString::from("authorize-test-pii"))],
- )?)
- }
- /// An Ed25519 signer plus the matching inline JWKS for a JAR regression.
- struct TestSigner {
- encoding: EncodingKey,
- jwks: serde_json::Value,
- }
- impl TestSigner {
- /// Generate a fresh Ed25519 keypair and its one-key inline JWKS.
- fn generate(kid: &str) -> TestResult<Self> {
- let rng = SystemRandom::new();
- let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).map_err(|_| "ed25519 keygen failed")?;
- let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).map_err(|_| "ed25519 reload failed")?;
- let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pair.public_key().as_ref());
- Ok(Self {
- encoding: EncodingKey::from_ed_der(pkcs8.as_ref()),
- jwks: serde_json::json!({
- "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid }]
- }),
- })
- }
- /// Sign a request object bound to `client_id` and the test issuer.
- fn request_object(
- &self,
- kid: &str,
- client_id: &str,
- extra: serde_json::Value,
- ) -> TestResult<String> {
- let now = OffsetDateTime::now_utc().unix_timestamp();
- let mut claims = serde_json::json!({
- "iss": client_id,
- "aud": ISSUER,
- "exp": now + 300,
- "iat": now,
- });
- let object = claims
- .as_object_mut()
- .ok_or("request object claims must be an object")?;
- let extra = extra
- .as_object()
- .ok_or("request object extra claims must be an object")?;
- for (name, value) in extra {
- object.insert(name.clone(), value.clone());
- }
- let mut header = Header::new(Algorithm::EdDSA);
- header.kid = Some(kid.to_owned());
- Ok(encode(&header, &claims, &self.encoding)?)
- }
- }
- /// Register a client whose inline JWKS verifies signed request objects.
- async fn provision_signing(
- pool: &sqlx::PgPool,
- keys: &KeyRing,
- client_id: &str,
- jwks: serde_json::Value,
- ) -> TestResult {
- let mut new = fixture(client_id, true);
- new.jwks = Some(jwks);
- client_repo::insert(pool, keys, false, &new).await?;
- let (row, _) = redirect_repo::upsert(
- pool,
- keys,
- false,
- &redirect_repo::NewRedirectUri {
- id: Uuid::new_v4(),
- uri: REDIRECT,
- kind: redirect_repo::RedirectKind::Authorize,
- description: "authorize test",
- created_by: None,
- },
- )
- .await?;
- redirect_repo::attach(pool, client_id, row.id, None).await?;
- scope_repo::upsert(pool, "api:read", "read api").await?;
- Ok(())
- }
- /// A valid plain-query authorize map for the provisioned client.
- fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
- query(&[
- ("client_id", client_id),
- ("redirect_uri", REDIRECT),
- ("response_type", "code"),
- ("scope", "api:read"),
- ("code_challenge", challenge),
- ("code_challenge_method", "S256"),
- ("state", "xyz-state"),
- ])
- }
- /// A public client with plain query + PKCE proceeds with every field carried.
- #[tokio::test]
- async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-public", true).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let params = valid_query("authz-public", &challenge);
- let mut conn = offline_redis()?;
- let store = test_store();
- let validated = validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- .map_err(|failure| format!("valid request rejected: {}", failure.code()))?;
- assert_eq!(validated.client_id, "authz-public");
- assert_eq!(validated.redirect_uri, REDIRECT);
- assert_eq!(validated.scope.render(), "api:read");
- assert_eq!(validated.code_challenge, challenge);
- assert_eq!(validated.state.as_deref(), Some("xyz-state"));
- Ok(())
- }
- /// An unknown `client_id` shows the error page: 400, no `Location`.
- #[tokio::test]
- async fn authorize_validate_unknown_client_shows_page() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- let mut params = valid_query("authz-ghost", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
- params.remove("scope");
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("unknown client was accepted".into()),
- Err(failure) => {
- let response = failure.into_response();
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- assert!(response.headers().get(header::LOCATION).is_none());
- Ok(())
- }
- }
- }
- /// An unregistered `redirect_uri` shows the error page: 400, no `Location`.
- #[tokio::test]
- async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-redirect", true).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let mut params = valid_query("authz-redirect", &challenge);
- params.insert(
- "redirect_uri".to_owned(),
- "https://evil.example.test/cb".to_owned(),
- );
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("unregistered redirect was accepted".into()),
- Err(failure) => {
- assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
- let response = failure.into_response();
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- assert!(response.headers().get(header::LOCATION).is_none());
- Ok(())
- }
- }
- }
- /// `response_type=token` redirects with `unsupported_response_type` + `state`.
- #[tokio::test]
- async fn authorize_validate_token_response_type_redirects() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-rt", true).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let mut params = valid_query("authz-rt", &challenge);
- params.insert("response_type".to_owned(), "token".to_owned());
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("response_type=token was accepted".into()),
- Err(AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::UnsupportedResponseType);
- assert_eq!(state.as_deref(), Some("xyz-state"));
- assert_eq!(iss, ISSUER);
- let response = AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }
- .into_response();
- assert_eq!(response.status(), StatusCode::FOUND);
- let location = response
- .headers()
- .get(header::LOCATION)
- .and_then(|value| value.to_str().ok())
- .unwrap_or_default()
- .to_owned();
- assert!(location.contains("error=unsupported_response_type"));
- assert!(location.contains("state=xyz-state"));
- assert!(
- location.contains("iss=https%3A%2F%2Fauth.example.test"),
- "error redirect missing iss: {location}"
- );
- Ok(())
- }
- Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
- }
- }
- /// An unknown scope redirects with `invalid_scope` + `state` (no page).
- #[tokio::test]
- async fn authorize_validate_unknown_scope_redirects() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-scope", true).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let mut params = valid_query("authz-scope", &challenge);
- params.insert("scope".to_owned(), "admin:all".to_owned());
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("unknown scope was accepted".into()),
- Err(AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::InvalidScope);
- assert_eq!(state.as_deref(), Some("xyz-state"));
- assert_eq!(iss, ISSUER);
- let response = AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }
- .into_response();
- assert_eq!(response.status(), StatusCode::FOUND);
- let location = response
- .headers()
- .get(header::LOCATION)
- .and_then(|value| value.to_str().ok())
- .unwrap_or_default()
- .to_owned();
- assert!(location.contains("error=invalid_scope"));
- assert!(location.contains("state=xyz-state"));
- assert!(
- location.contains("iss=https%3A%2F%2Fauth.example.test"),
- "error redirect missing iss: {location}"
- );
- Ok(())
- }
- Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
- }
- }
- /// A confidential client with only plain query params is rejected with
- /// `invalid_request` (no PAR `request_uri`, no signed `request` object).
- #[tokio::test]
- async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-conf", false).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let params = valid_query("authz-conf", &challenge);
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("confidential plain-query request was accepted".into()),
- Err(failure) => {
- assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
- let response = failure.into_response();
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- assert!(response.headers().get(header::LOCATION).is_none());
- Ok(())
- }
- }
- }
- /// An exact registered redirect matches after decryption; a prefix/evil
- /// URI still fails closed; a corrupt `uri_enc` fails as 503 `PiiDecrypt`.
- #[tokio::test]
- async fn authorize_redirect_exact_match() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-exact", true).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let mut conn = offline_redis()?;
- let store = test_store();
- let params = valid_query("authz-exact", &challenge);
- let validated = validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- .map_err(|failure| format!("exact redirect rejected: {}", failure.code()))?;
- assert_eq!(validated.redirect_uri, REDIRECT);
- for hostile in [
- "https://evil.example.test/cb",
- "https://app.example.test/cb/extra",
- ] {
- let mut params = valid_query("authz-exact", &challenge);
- params.insert("redirect_uri".to_owned(), hostile.to_owned());
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => return Err(format!("hostile redirect accepted: {hostile}").into()),
- Err(failure) => {
- assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
- let response = failure.into_response();
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- assert!(response.headers().get(header::LOCATION).is_none());
- }
- }
- }
- let corrupt: &[u8] = b"corrupt-blob";
- sqlx::query("UPDATE redirect_uris SET uri_enc = $1")
- .bind(corrupt)
- .execute(&pool)
- .await
- .map_err(|error| format!("corrupt fixture failed: {error}"))?;
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("corrupt uri_enc accepted".into()),
- Err(AuthorizeFailure::Page(error)) => {
- assert!(matches!(error, AppError::PiiDecrypt));
- assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
- assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
- Ok(())
- }
- Err(other) => Err(format!("expected a PiiDecrypt page, got {}", other.code()).into()),
- }
- }
- /// A loopback registration matches a request with a random port (RFC 8252
- /// §7.3) after decryption; a different path still fails closed.
- #[tokio::test]
- async fn authorize_loopback_allows_port() -> TestResult {
- const LOOPBACK: &str = "http://127.0.0.1:8080/cb";
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision_at(&pool, &keys, "authz-loop", true, LOOPBACK).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let mut conn = offline_redis()?;
- let store = test_store();
- let mut params = valid_query("authz-loop", &challenge);
- params.insert(
- "redirect_uri".to_owned(),
- "http://127.0.0.1:59231/cb".to_owned(),
- );
- let validated = validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- .map_err(|failure| format!("loopback port rejected: {}", failure.code()))?;
- assert_eq!(validated.redirect_uri, "http://127.0.0.1:59231/cb");
- params.insert(
- "redirect_uri".to_owned(),
- "http://127.0.0.1:59231/other".to_owned(),
- );
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("loopback wrong-path accepted".into()),
- Err(failure) => {
- assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
- Ok(())
- }
- }
- }
- /// A missing PKCE challenge redirects with `invalid_request` (+ `state`).
- #[tokio::test]
- async fn authorize_validate_missing_challenge_redirects() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-pkce", true).await?;
- let mut params = valid_query("authz-pkce", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
- params.remove("code_challenge");
- params.remove("code_challenge_method");
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("challengeless request was accepted".into()),
- Err(AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::InvalidRequest);
- assert_eq!(state.as_deref(), Some("xyz-state"));
- assert_eq!(iss, ISSUER);
- Ok(())
- }
- Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
- }
- }
- /// A direct `Page(temporarily_unavailable)` renders 503 + `Retry-After`
- /// (fail-closed Postgres/PII outage pre-validation); `Page(server_error)`
- /// renders 500. Client-error pages stay 400.
- #[tokio::test]
- async fn authorize_page_outage_renders_503_with_retry_after() -> TestResult {
- let response = AuthorizeFailure::Page(AppError::temporarily_unavailable()).into_response();
- assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
- assert_eq!(
- response
- .headers()
- .get(header::RETRY_AFTER)
- .and_then(|value| value.to_str().ok()),
- Some("1")
- );
- assert!(response.headers().get(header::LOCATION).is_none());
- let response = AuthorizeFailure::Page(AppError::server_error()).into_response();
- assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
- assert!(response.headers().get(header::LOCATION).is_none());
- let response = AuthorizeFailure::Page(AppError::invalid_request("bad")).into_response();
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
- assert!(response.headers().get(header::LOCATION).is_none());
- Ok(())
- }
- /// A `post_logout` catalog outage after `redirect_uri` validation redirects
- /// (302) with `temporarily_unavailable`, per the error-delivery matrix.
- #[tokio::test]
- async fn authorize_post_logout_outage_redirects() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-post-logout", true).await?;
- let (row, _) = redirect_repo::upsert(
- &pool,
- &keys,
- false,
- &redirect_repo::NewRedirectUri {
- id: Uuid::new_v4(),
- uri: "https://app.example.test/signed-out",
- kind: redirect_repo::RedirectKind::PostLogout,
- description: "post-logout test",
- created_by: None,
- },
- )
- .await
- .map_err(|error| format!("post-logout fixture failed: {error}"))?;
- redirect_repo::attach(&pool, "authz-post-logout", row.id, None)
- .await
- .map_err(|error| format!("post-logout attach failed: {error}"))?;
- sqlx::query("UPDATE redirect_uris SET uri_enc = $1 WHERE kind = 'post_logout'")
- .bind(b"corrupt-blob".as_slice())
- .execute(&pool)
- .await
- .map_err(|error| format!("corrupt fixture failed: {error}"))?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let params = valid_query("authz-post-logout", &challenge);
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("post-logout outage was accepted".into()),
- Err(AuthorizeFailure::Redirect { target, error, .. }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
- assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
- let response = AuthorizeFailure::Redirect {
- target,
- error,
- state: None,
- iss: ISSUER.to_owned(),
- }
- .into_response();
- assert_eq!(response.status(), StatusCode::FOUND);
- Ok(())
- }
- Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
- }
- }
- /// draft-ietf-oauth-v2-1-16 §4.1.2.1 and §7.15.1.
- #[tokio::test]
- async fn authorize_error_iss() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-err-iss", true).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let mut params = valid_query("authz-err-iss", &challenge);
- params.insert("response_type".to_owned(), "token".to_owned());
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("response_type=token was accepted".into()),
- Err(AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::UnsupportedResponseType);
- assert_eq!(state.as_deref(), Some("xyz-state"));
- assert_eq!(iss, ISSUER);
- let response = AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }
- .into_response();
- assert_eq!(response.status(), StatusCode::FOUND);
- let location = response
- .headers()
- .get(header::LOCATION)
- .and_then(|value| value.to_str().ok())
- .unwrap_or_default()
- .to_owned();
- assert!(location.contains("error=unsupported_response_type"));
- assert!(location.contains("state=xyz-state"));
- assert!(
- location.contains("iss=https%3A%2F%2Fauth.example.test"),
- "error redirect missing iss: {location}"
- );
- Ok(())
- }
- Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
- }
- }
- /// A missing `response_type` redirects with `invalid_request` (+ `state`):
- /// `client_id` and `redirect_uri` are already validated here, so per
- /// draft-ietf-oauth-v2-1-16 §4.1.1 and §4.1.2.1 the error rides the redirect.
- #[tokio::test]
- async fn authorize_validate_missing_response_type_redirects() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-missing-rt", true).await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let mut conn = offline_redis()?;
- let store = test_store();
- for params in [
- {
- let mut q = valid_query("authz-missing-rt", &challenge);
- q.remove("response_type");
- q
- },
- {
- let mut q = valid_query("authz-missing-rt", &challenge);
- q.insert("response_type".to_owned(), String::new());
- q
- },
- ] {
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => return Err("missing response_type was accepted".into()),
- Err(AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::InvalidRequest);
- assert_eq!(state.as_deref(), Some("xyz-state"));
- assert_eq!(iss, ISSUER);
- let response = AuthorizeFailure::Redirect {
- target,
- error,
- state,
- iss,
- }
- .into_response();
- assert_eq!(response.status(), StatusCode::FOUND);
- let location = response
- .headers()
- .get(header::LOCATION)
- .and_then(|value| value.to_str().ok())
- .unwrap_or_default()
- .to_owned();
- assert!(location.contains("error=invalid_request"));
- assert!(location.contains("error_description="));
- assert!(location.contains("state=xyz-state"));
- assert!(
- location.contains("iss=https%3A%2F%2Fauth.example.test"),
- "error redirect missing iss: {location}"
- );
- }
- Err(other) => {
- return Err(format!("expected a redirect, got {}", other.code()).into());
- }
- }
- }
- Ok(())
- }
- /// Seed the CIMD document cache for `url`, so a resolve is served from Redis
- /// without a network fetch (a cache miss would fail against `.invalid`).
- async fn seed_cimd(
- conn: &mut RedisConn,
- prefix: &str,
- url: &str,
- document: &cimd::CimdDocument,
- ) -> TestResult {
- let payload = serde_json::to_string(document)?;
- redis::cmd("SET")
- .arg(cimd::cache_key(prefix, url))
- .arg(payload)
- .arg("EX")
- .arg(900)
- .query_async::<()>(conn)
- .await?;
- Ok(())
- }
- /// A minimal `none`-method CIMD document (CIMD draft §4.1).
- fn cimd_document(url: &str, redirect_uris: &[&str], scope: &str) -> cimd::CimdDocument {
- cimd::CimdDocument {
- client_id: url.to_owned(),
- redirect_uris: redirect_uris.iter().map(|uri| (*uri).to_owned()).collect(),
- token_endpoint_auth_method: Some("none".to_owned()),
- grant_types: Some(vec!["authorization_code".to_owned()]),
- scope: Some(scope.to_owned()),
- jwks: None,
- jwks_uri: None,
- client_name: None,
- dpop_bound_access_tokens: None,
- }
- }
- /// A shared-Redis connection plus its unique `{state}`/`{rl}` prefix pair.
- async fn shared_redis() -> TestResult<(RedisConn, String, String)> {
- let manager = test_support::redis_manager()
- .await
- .map_err(|error| format!("shared redis: {error}"))?;
- let (token_prefix, ratelimit_prefix) = test_support::unique_prefixes();
- Ok((RedisConn::Single(manager), token_prefix, ratelimit_prefix))
- }
- /// CIMD settings pointing at this test's cache prefix.
- fn cimd_settings(prefix: &str) -> super::CimdRequest<'_> {
- super::CimdRequest {
- token_prefix: prefix,
- max_bytes: 5_120,
- timeout: Duration::from_secs(2),
- }
- }
- /// A client metadata document client completes `/authorize` with no row and
- /// no catalog redirect; a redirect outside the document and a shared-secret
- /// document both fail closed (CIMD draft §4.1/§5).
- #[tokio::test]
- async fn authorize_accepts_cimd_public() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- scope_repo::upsert(&pool, "api:read", "read api").await?;
- let (mut conn, prefix, ratelimit) = shared_redis().await?;
- let store = Store::from_parts(prefix.clone(), ratelimit, DurabilityMode::WaitAof);
- let url = "https://client.invalid/cimd.json";
- seed_cimd(
- &mut conn,
- &prefix,
- url,
- &cimd_document(url, &["https://app.example.test/cb"], "api:read"),
- )
- .await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let params = query(&[
- ("client_id", url),
- ("redirect_uri", "https://app.example.test/cb"),
- ("response_type", "code"),
- ("scope", "api:read"),
- ("code_challenge", challenge.as_str()),
- ("code_challenge_method", "S256"),
- ("state", "xyz-state"),
- ]);
- let validated = validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: Some(cimd_settings(&prefix)),
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- .map_err(|failure| format!("CIMD request rejected: {}", failure.code()))?;
- assert_eq!(validated.client_id, url);
- assert_eq!(validated.redirect_uri, "https://app.example.test/cb");
- assert_eq!(validated.scope.render(), "api:read");
- assert_eq!(validated.state.as_deref(), Some("xyz-state"));
- // A registration-free client writes no row and attaches no catalog redirect.
- assert!(
- client_repo::find_active_by_client_id(&pool, &keys, url)
- .await?
- .is_none(),
- "CIMD must not persist a client row"
- );
- // A redirect outside the document is refused (untrusted: page, no Location).
- let mut mismatch = params.clone();
- mismatch.insert(
- "redirect_uri".to_owned(),
- "https://evil.example.test/cb".to_owned(),
- );
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: Some(cimd_settings(&prefix)),
- query: &mismatch,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => return Err("document redirect mismatch was accepted".into()),
- Err(failure) => assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest),
- }
- // A document declaring a shared-secret method is invalid metadata.
- let shared_url = "https://client.invalid/secret.json";
- let mut shared = cimd_document(shared_url, &["https://app.example.test/cb"], "api:read");
- shared.token_endpoint_auth_method = Some("client_secret_basic".to_owned());
- seed_cimd(&mut conn, &prefix, shared_url, &shared).await?;
- let mut shared_params = params.clone();
- shared_params.insert("client_id".to_owned(), shared_url.to_owned());
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: Some(cimd_settings(&prefix)),
- query: &shared_params,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("shared-secret CIMD document was accepted".into()),
- Err(failure) => {
- assert_eq!(failure.code(), OAuthErrorCode::InvalidClientMetadata);
- Ok(())
- }
- }
- }
- /// A document `http://localhost` redirect is accepted only while
- /// `allow_localhost_redirect` is on; the request's random port is matched by
- /// the RFC 8252 §7.3 loopback rule. With the flag off the document itself is
- /// invalid, because `http://localhost` is not a loopback host.
- #[tokio::test]
- async fn authorize_accepts_cimd_localhost_when_flag_on() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- scope_repo::upsert(&pool, "api:read", "read api").await?;
- let (mut conn, prefix, ratelimit) = shared_redis().await?;
- let store = Store::from_parts(prefix.clone(), ratelimit, DurabilityMode::WaitAof);
- let url = "https://client.invalid/loopback.json";
- seed_cimd(
- &mut conn,
- &prefix,
- url,
- &cimd_document(url, &["http://localhost/cb"], "api:read"),
- )
- .await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let params = query(&[
- ("client_id", url),
- ("redirect_uri", "http://localhost:51234/cb"),
- ("response_type", "code"),
- ("scope", "api:read"),
- ("code_challenge", challenge.as_str()),
- ("code_challenge_method", "S256"),
- ]);
- let validated = validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: true,
- cimd: Some(cimd_settings(&prefix)),
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- .map_err(|failure| {
- format!(
- "CIMD loopback rejected with the flag on: {}",
- failure.code()
- )
- })?;
- assert_eq!(validated.redirect_uri, "http://localhost:51234/cb");
- assert_eq!(validated.scope.render(), "api:read");
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: Some(cimd_settings(&prefix)),
- query: ¶ms,
- resources: Vec::new(),
- resource_indicators_enabled: false,
- })
- .await
- {
- Ok(_) => Err("CIMD localhost redirect was accepted with the flag off".into()),
- Err(failure) => {
- assert_eq!(failure.code(), OAuthErrorCode::InvalidRedirectUri);
- Ok(())
- }
- }
- }
- /// Two catalog resources presented as repeated query indicators validate and
- /// pin as a sorted deduped audience set on the validated request.
- #[tokio::test]
- async fn authorize_accepts_repeated_resource() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- provision(&pool, &keys, "authz-resource", true).await?;
- resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
- resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let params = valid_query("authz-resource", &challenge);
- let mut conn = offline_redis()?;
- let store = test_store();
- let validated = validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: vec![
- "https://b.example.test/".to_owned(),
- "https://a.example.test/".to_owned(),
- "https://b.example.test/".to_owned(),
- ],
- resource_indicators_enabled: true,
- })
- .await
- .map_err(|failure| format!("repeated resource rejected: {}", failure.code()))?;
- if validated.aud
- != vec![
- "https://a.example.test/".to_owned(),
- "https://b.example.test/".to_owned(),
- ]
- {
- return Err(format!("aud not pinned sorted deduped: {:?}", validated.aud).into());
- }
- Ok(())
- }
- /// A query `resource` appended to a PAR `request_uri` must not union into the
- /// pinned audience: the request rejects as `invalid_request_object` through
- /// the validated redirect (RFC 9101 §5.2, RFC 8707 §2.1).
- #[tokio::test]
- async fn authorize_rejects_query_resource_beside_par() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- let client_id = "authz-par-resource";
- provision(&pool, &keys, client_id, true).await?;
- resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
- resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
- let (mut conn, prefix, ratelimit) = shared_redis().await?;
- let store = Store::from_parts(prefix, ratelimit, DurabilityMode::WaitAof);
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let pushed_params = valid_query(client_id, &challenge);
- let auth = ClientAuthRequest {
- basic_header: None,
- client_id: Some(client_id),
- body_secret: None,
- assertion_type: None,
- assertion: None,
- peer_certs: None,
- };
- let ctx = ParContext {
- token_endpoint: "https://auth.example.test/token",
- now: OffsetDateTime::now_utc(),
- par_ttl: Duration::from_secs(90),
- allow_localhost_redirect: false,
- cimd: None,
- resource_indicators_enabled: true,
- };
- let pinned = ["https://a.example.test/".to_owned()];
- let pushed = {
- let mut stores = ParStores {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- };
- push_authorization_request(&mut stores, &ctx, &auth, &pushed_params, &pinned)
- .await
- .map_err(|error| format!("PAR push rejected: {}", error.code()))?
- };
- let appended = "https://b.example.test/".to_owned();
- let params = query(&[
- ("client_id", client_id),
- ("request_uri", pushed.request_uri.as_str()),
- ("resource", appended.as_str()),
- ]);
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: vec![appended],
- resource_indicators_enabled: true,
- })
- .await
- {
- Ok(validated) => Err(format!("PAR audience was widened to {:?}", validated.aud).into()),
- Err(AuthorizeFailure::Redirect { target, error, .. }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::InvalidRequestObject);
- Ok(())
- }
- Err(other) => Err(format!("expected invalid_request_object, got {}", other.code()).into()),
- }
- }
- /// A query `resource` beside an inline signed request object is rejected:
- /// the audience travels in the object, never the query (RFC 9101 §5.2).
- #[tokio::test]
- async fn authorize_rejects_query_resource_beside_inline_request() -> TestResult {
- let (_db, pool) = migrated_pool().await?;
- let keys = test_keys()?;
- let client_id = "authz-jar-resource";
- let signer = TestSigner::generate("jar-resource-key")?;
- provision_signing(&pool, &keys, client_id, signer.jwks.clone()).await?;
- resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
- let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
- let request = signer.request_object(
- "jar-resource-key",
- client_id,
- serde_json::json!({
- "response_type": "code",
- "redirect_uri": REDIRECT,
- "scope": "api:read",
- "code_challenge": challenge,
- "code_challenge_method": "S256",
- "state": "jar-state",
- }),
- )?;
- let appended = "https://b.example.test/".to_owned();
- let params = query(&[
- ("client_id", client_id),
- ("request", request.as_str()),
- ("resource", appended.as_str()),
- ]);
- let mut conn = offline_redis()?;
- let store = test_store();
- match validate(AuthorizeInput {
- pool: &pool,
- keys: &keys,
- conn: &mut conn,
- store: &store,
- issuer: ISSUER,
- allow_localhost_redirect: false,
- cimd: None,
- query: ¶ms,
- resources: vec![appended],
- resource_indicators_enabled: true,
- })
- .await
- {
- Ok(validated) => Err(format!("JAR pinned the query audience {:?}", validated.aud).into()),
- Err(AuthorizeFailure::Redirect { target, error, .. }) => {
- assert_eq!(target, REDIRECT);
- assert_eq!(error.code(), OAuthErrorCode::InvalidRequestObject);
- Ok(())
- }
- Err(other) => Err(format!("expected invalid_request_object, got {}", other.code()).into()),
- }
- }
|