diff --git a/_primitives/_rust/kei-auth-apple/src/provider.rs b/_primitives/_rust/kei-auth-apple/src/provider.rs index 13b4c1f..e160163 100644 --- a/_primitives/_rust/kei-auth-apple/src/provider.rs +++ b/_primitives/_rust/kei-auth-apple/src/provider.rs @@ -106,13 +106,13 @@ impl AuthProvider for AppleAuthProvider { async fn verify(&self, c: &AuthChallenge) -> CoreResult { let (code, state, expected_state, code_verifier) = match c { AuthChallenge::OAuthCode { - provider, code, state, expected_state, - } if provider == "apple" => { - // code_verifier is not threaded through AuthChallenge; - // callers pass it via the exchange directly if desired. - // Here we use None as the challenge only carries state. - (code.as_str(), state.as_str(), expected_state.as_str(), None::<&str>) - } + provider, code, state, expected_state, code_verifier, + } if provider == "apple" => ( + code.as_str(), + state.as_str(), + expected_state.as_str(), + code_verifier.as_deref(), + ), AuthChallenge::OAuthCode { provider, .. } => { return Err(kei_runtime_core::Error::Auth(format!( "wrong provider: expected apple, got {provider}" diff --git a/_primitives/_rust/kei-auth-apple/tests/apple_client_smoke.rs b/_primitives/_rust/kei-auth-apple/tests/apple_client_smoke.rs new file mode 100644 index 0000000..c9ee377 --- /dev/null +++ b/_primitives/_rust/kei-auth-apple/tests/apple_client_smoke.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +// +//! Wiremock smoke tests for `AppleAuthClient` HTTP layer. No live HTTP. + +#[allow(dead_code)] +mod helpers; +use helpers::{sign_id_token, token_response_body}; + +use kei_auth_apple::{AppleAuthClient, Error}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[tokio::test] +async fn token_endpoint_200_returns_token_response() { + let server = MockServer::start().await; + let id_token = sign_id_token( + r#"{"sub":"001234.abc","email":"x@y.example","iss":"https://appleid.apple.com","aud":"com.example.web"}"#, + ); + Mock::given(method("POST")) + .and(path("/auth/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(token_response_body(&id_token)), + ) + .mount(&server) + .await; + let token_url = format!("{}/auth/token", server.uri()); + let c = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let resp = c.exchange_code("auth-code-123", None).await.unwrap(); + assert_eq!(resp.access_token, "at-1234"); + assert_eq!(resp.expires_in, 3600); + assert_eq!(resp.id_token, id_token); + assert_eq!(resp.refresh_token.as_deref(), Some("rt-5678")); +} + +#[tokio::test] +async fn token_endpoint_400_maps_to_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/token")) + .respond_with( + ResponseTemplate::new(400) + .set_body_string("{\"error\":\"invalid_grant\"}"), + ) + .mount(&server) + .await; + let token_url = format!("{}/auth/token", server.uri()); + let c = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let err = c.exchange_code("bad-code", None).await.unwrap_err(); + assert!(matches!(err, Error::Api(_)), "expected Api(_), got {err:?}"); +} diff --git a/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs b/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs index 7caa3d4..e842644 100644 --- a/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs +++ b/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs @@ -1,67 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 // -//! `wiremock`-driven smoke tests for [`AppleAuthClient`] + -//! [`AppleAuthProvider`]. No live calls to appleid.apple.com. +//! Wiremock smoke tests for `AppleAuthProvider`. No live calls to appleid.apple.com. mod helpers; use helpers::{sign_id_token, token_response_body, TEST_JWKS_JSON}; -use kei_auth_apple::{AppleAuthClient, AppleAuthProvider, Error}; +use kei_auth_apple::{AppleAuthClient, AppleAuthProvider}; use kei_runtime_core::HasDna; use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider}; -use wiremock::matchers::{method, path}; +use wiremock::matchers::{body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -// ── Client-level tests ──────────────────────────────────────────────────────── - -#[tokio::test] -async fn token_endpoint_200_returns_token_response() { - let server = MockServer::start().await; - let id_token = sign_id_token( - r#"{"sub":"001234.abc","email":"x@y.example","iss":"https://appleid.apple.com","aud":"com.example.web"}"#, - ); - Mock::given(method("POST")) - .and(path("/auth/token")) - .respond_with( - ResponseTemplate::new(200).set_body_json(token_response_body(&id_token)), - ) - .mount(&server) - .await; - let token_url = format!("{}/auth/token", server.uri()); - let c = AppleAuthClient::with_url( - token_url, "com.example.web", "JWT-CS", "https://app.example/cb", - ) - .unwrap(); - let resp = c.exchange_code("auth-code-123", None).await.unwrap(); - assert_eq!(resp.access_token, "at-1234"); - assert_eq!(resp.expires_in, 3600); - assert_eq!(resp.id_token, id_token); - assert_eq!(resp.refresh_token.as_deref(), Some("rt-5678")); -} - -#[tokio::test] -async fn token_endpoint_400_maps_to_api_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/auth/token")) - .respond_with( - ResponseTemplate::new(400) - .set_body_string("{\"error\":\"invalid_grant\"}"), - ) - .mount(&server) - .await; - let token_url = format!("{}/auth/token", server.uri()); - let c = AppleAuthClient::with_url( - token_url, "com.example.web", "JWT-CS", "https://app.example/cb", - ) - .unwrap(); - let err = c.exchange_code("bad-code", None).await.unwrap_err(); - assert!(matches!(err, Error::Api(_)), "expected Api(_), got {err:?}"); -} - -// ── Provider-level tests ────────────────────────────────────────────────────── - #[tokio::test] async fn provider_verify_end_to_end_returns_session_with_sub_user_id() { let server = MockServer::start().await; @@ -86,6 +36,7 @@ async fn provider_verify_end_to_end_returns_session_with_sub_user_id() { code: "auth-code-123".into(), state: "csrf-token".into(), expected_state: "csrf-token".into(), + code_verifier: None, }; let session = provider.verify(&challenge).await.unwrap(); assert_eq!(session.user_id, "001999.zzz"); @@ -109,6 +60,7 @@ async fn provider_verify_csrf_mismatch_rejected() { code: "code".into(), state: "DIFFERENT".into(), expected_state: "EXPECTED".into(), + code_verifier: None, }; let err = provider.verify(&challenge).await.unwrap_err(); assert!( @@ -140,6 +92,7 @@ async fn jwt_decode_rejects_malformed_id_token() { code: "auth-code-123".into(), state: "csrf".into(), expected_state: "csrf".into(), + code_verifier: None, }; let err = provider.verify(&challenge).await.unwrap_err(); let msg = format!("{err}"); @@ -163,7 +116,40 @@ async fn provider_rejects_non_apple_oauth_code() { code: "x".into(), state: "y".into(), expected_state: "y".into(), + code_verifier: None, }; let err = provider.verify(&challenge).await.unwrap_err(); assert!(format!("{err}").contains("wrong provider")); } + +#[tokio::test] +async fn verify_sends_code_verifier_when_challenge_carries_some() { + let server = MockServer::start().await; + let id_token = sign_id_token( + r#"{"sub":"pkce-sub","email":"pkce@apple.example","iss":"https://appleid.apple.com","aud":"com.example.web"}"#, + ); + Mock::given(method("POST")) + .and(path("/auth/token")) + .and(body_string_contains("code_verifier=apple-pkce-verifier")) + .respond_with( + ResponseTemplate::new(200).set_body_json(token_response_body(&id_token)), + ) + .expect(1) + .mount(&server) + .await; + let token_url = format!("{}/auth/token", server.uri()); + let client = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let provider = AppleAuthProvider::new(client, TEST_JWKS_JSON, None).unwrap(); + let challenge = AuthChallenge::OAuthCode { + provider: "apple".into(), + code: "auth-code-pkce".into(), + state: "st".into(), + expected_state: "st".into(), + code_verifier: Some("apple-pkce-verifier".into()), + }; + let session = provider.verify(&challenge).await.unwrap(); + assert_eq!(session.user_id, "pkce-sub"); +} diff --git a/_primitives/_rust/kei-auth-google/src/lib.rs b/_primitives/_rust/kei-auth-google/src/lib.rs index ec13300..5e628a5 100644 --- a/_primitives/_rust/kei-auth-google/src/lib.rs +++ b/_primitives/_rust/kei-auth-google/src/lib.rs @@ -30,6 +30,7 @@ //! code: "".into(), //! state: "".into(), //! expected_state: "".into(), +//! code_verifier: Some("".into()), //! }; //! let session = provider.verify(&challenge).await?; //! # let _ = session; diff --git a/_primitives/_rust/kei-auth-google/src/provider.rs b/_primitives/_rust/kei-auth-google/src/provider.rs index e1e72e9..5fd5a44 100644 --- a/_primitives/_rust/kei-auth-google/src/provider.rs +++ b/_primitives/_rust/kei-auth-google/src/provider.rs @@ -87,10 +87,15 @@ impl AuthProvider for GoogleAuthProvider { } async fn verify(&self, c: &AuthChallenge) -> kei_runtime_core::Result { - let (code, state, expected_state) = match c { + let (code, state, expected_state, code_verifier) = match c { AuthChallenge::OAuthCode { - provider, code, state, expected_state, - } if provider == "google" => (code.as_str(), state.as_str(), expected_state.as_str()), + provider, code, state, expected_state, code_verifier, + } if provider == "google" => ( + code.as_str(), + state.as_str(), + expected_state.as_str(), + code_verifier.as_deref(), + ), AuthChallenge::OAuthCode { provider, .. } => { return Err(kei_runtime_core::Error::Auth(format!( "wrong provider for google: {provider}" @@ -99,7 +104,7 @@ impl AuthProvider for GoogleAuthProvider { _ => return Err(kei_runtime_core::Error::from(Error::MissingState)), }; check_state(state, expected_state)?; - let token = self.client.exchange_code(code, None).await + let token = self.client.exchange_code(code, code_verifier).await .map_err(kei_runtime_core::Error::from)?; let info = self.client.userinfo(&token.access_token).await .map_err(kei_runtime_core::Error::from)?; diff --git a/_primitives/_rust/kei-auth-google/tests/google_client_smoke.rs b/_primitives/_rust/kei-auth-google/tests/google_client_smoke.rs new file mode 100644 index 0000000..bed4cb0 --- /dev/null +++ b/_primitives/_rust/kei-auth-google/tests/google_client_smoke.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! +//! Wiremock smoke tests for `GoogleAuthClient` HTTP layer. No live HTTP. + +use kei_auth_google::GoogleAuthClient; +use serde_json::json; +use wiremock::matchers::{body_string_contains, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn client_for(server: &MockServer) -> GoogleAuthClient { + GoogleAuthClient::with_urls( + format!("{}/token", server.uri()), + format!("{}/userinfo", server.uri()), + "client-id-xyz", + "client-secret-xyz", + "https://example.com/cb", + ) + .unwrap() +} + +#[tokio::test] +async fn token_endpoint_200_returns_access_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("grant_type=authorization_code")) + .and(body_string_contains("code=abc123")) + .and(body_string_contains("client_id=client-id-xyz")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "ya29.a0AfH-test", + "expires_in": 3600, + "id_token": "eyJ.fake.jwt" + }))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let token = client.exchange_code("abc123", None).await.unwrap(); + assert_eq!(token.access_token, "ya29.a0AfH-test"); + assert_eq!(token.expires_in, 3600); + assert_eq!(token.id_token.as_deref(), Some("eyJ.fake.jwt")); +} + +#[tokio::test] +async fn userinfo_200_returns_email_and_sub() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/userinfo")) + .and(header("authorization", "Bearer ya29.a0AfH-test")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "sub": "1234567890", + "email": "alice@example.com", + "name": "Alice" + }))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let info = client.userinfo("ya29.a0AfH-test").await.unwrap(); + assert_eq!(info.sub, "1234567890"); + assert_eq!(info.email, "alice@example.com"); + assert_eq!(info.name, "Alice"); +} + +#[tokio::test] +async fn exchange_code_400_returns_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error": "invalid_grant", + "error_description": "Bad code" + }))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let err = client.exchange_code("bad-code", None).await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("api"), "expected api variant, got {msg}"); + assert!(msg.contains("400"), "expected status 400 in message, got {msg}"); +} diff --git a/_primitives/_rust/kei-auth-google/tests/google_smoke.rs b/_primitives/_rust/kei-auth-google/tests/google_smoke.rs index 13215e9..556352d 100644 --- a/_primitives/_rust/kei-auth-google/tests/google_smoke.rs +++ b/_primitives/_rust/kei-auth-google/tests/google_smoke.rs @@ -1,8 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 //! -//! Wiremock smoke tests for `kei-auth-google`. No live HTTP — every -//! assertion is local to the test process. +//! Wiremock smoke tests for `GoogleAuthProvider`. No live HTTP. use kei_auth_google::{GoogleAuthClient, GoogleAuthProvider}; use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider}; @@ -21,52 +20,6 @@ fn client_for(server: &MockServer) -> GoogleAuthClient { .unwrap() } -#[tokio::test] -async fn token_endpoint_200_returns_access_token() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .and(body_string_contains("grant_type=authorization_code")) - .and(body_string_contains("code=abc123")) - .and(body_string_contains("client_id=client-id-xyz")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "access_token": "ya29.a0AfH-test", - "expires_in": 3600, - "id_token": "eyJ.fake.jwt" - }))) - .expect(1) - .mount(&server) - .await; - - let client = client_for(&server); - let token = client.exchange_code("abc123", None).await.unwrap(); - assert_eq!(token.access_token, "ya29.a0AfH-test"); - assert_eq!(token.expires_in, 3600); - assert_eq!(token.id_token.as_deref(), Some("eyJ.fake.jwt")); -} - -#[tokio::test] -async fn userinfo_200_returns_email_and_sub() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/userinfo")) - .and(header("authorization", "Bearer ya29.a0AfH-test")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "sub": "1234567890", - "email": "alice@example.com", - "name": "Alice" - }))) - .expect(1) - .mount(&server) - .await; - - let client = client_for(&server); - let info = client.userinfo("ya29.a0AfH-test").await.unwrap(); - assert_eq!(info.sub, "1234567890"); - assert_eq!(info.email, "alice@example.com"); - assert_eq!(info.name, "Alice"); -} - #[tokio::test] async fn verify_end_to_end_builds_auth_session() { let server = MockServer::start().await; @@ -99,6 +52,7 @@ async fn verify_end_to_end_builds_auth_session() { code: "code-xyz".into(), state: "csrf-state-xyz".into(), expected_state: "csrf-state-xyz".into(), + code_verifier: None, }; let session = provider.verify(&challenge).await.unwrap(); assert_eq!(session.user_id, "bob@example.com"); @@ -109,26 +63,6 @@ async fn verify_end_to_end_builds_auth_session() { assert!(session.expires_unix_ms > 0); } -#[tokio::test] -async fn exchange_code_400_returns_api_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(400).set_body_json(json!({ - "error": "invalid_grant", - "error_description": "Bad code" - }))) - .expect(1) - .mount(&server) - .await; - - let client = client_for(&server); - let err = client.exchange_code("bad-code", None).await.unwrap_err(); - let msg = format!("{err}"); - assert!(msg.contains("api"), "expected api variant, got {msg}"); - assert!(msg.contains("400"), "expected status 400 in message, got {msg}"); -} - #[tokio::test] async fn issue_challenge_rejects_non_oauth() { let client = GoogleAuthClient::with_urls( @@ -150,6 +84,7 @@ async fn verify_rejects_wrong_provider() { code: "x".into(), state: "y".into(), expected_state: "y".into(), + code_verifier: None, }; assert!(provider.verify(&challenge).await.is_err()); } @@ -168,6 +103,7 @@ async fn verify_rejects_csrf_state_mismatch() { code: "code".into(), state: "got-this-state".into(), expected_state: "expected-state".into(), + code_verifier: None, }; let err = provider.verify(&challenge).await.unwrap_err(); assert!( @@ -175,3 +111,41 @@ async fn verify_rejects_csrf_state_mismatch() { "expected CSRF error, got: {err}" ); } + +#[tokio::test] +async fn verify_sends_code_verifier_when_challenge_carries_some() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("code_verifier=my-pkce-verifier")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "tok-pkce", + "expires_in": 900, + "id_token": null + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/userinfo")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "sub": "pkce-sub", + "email": "pkce@example.com", + "name": "PKCE" + }))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let provider = GoogleAuthProvider::new(client, None).unwrap(); + let challenge = AuthChallenge::OAuthCode { + provider: "google".into(), + code: "code-pkce".into(), + state: "st".into(), + expected_state: "st".into(), + code_verifier: Some("my-pkce-verifier".into()), + }; + let session = provider.verify(&challenge).await.unwrap(); + assert_eq!(session.user_id, "pkce@example.com"); +} diff --git a/_primitives/_rust/kei-runtime-core/src/secrets.rs b/_primitives/_rust/kei-runtime-core/src/secrets.rs index a649167..85a57bc 100644 --- a/_primitives/_rust/kei-runtime-core/src/secrets.rs +++ b/_primitives/_rust/kei-runtime-core/src/secrets.rs @@ -10,12 +10,15 @@ //! The value is exposed only via [`SecretString::expose`], forcing callers //! to be explicit about accessing the secret. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; use std::fmt; /// A string whose `Debug` impl is redacted and whose `Drop` zeroes memory. -#[derive(Clone, Serialize, Deserialize)] -#[serde(transparent)] +/// +/// `Serialize` emits the literal `""` so that parent structs +/// that derive `Serialize` never accidentally leak the secret value. +/// Use [`SecretString::expose`] to access the real value explicitly. +#[derive(Clone, Deserialize)] pub struct SecretString(String); impl SecretString { @@ -30,6 +33,16 @@ impl SecretString { } } +/// Serializes as the literal `""` — never the secret value. +/// +/// This prevents accidental secret leaks when a parent struct that holds a +/// `SecretString` field also derives `Serialize`. +impl Serialize for SecretString { + fn serialize(&self, serializer: S) -> std::result::Result { + serializer.serialize_str("") + } +} + /// Always prints `` — never the secret value. impl fmt::Debug for SecretString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -78,4 +91,11 @@ mod tests { let b = a.clone(); assert_eq!(a.expose(), b.expose()); } + + #[test] + fn serialize_emits_redacted_literal() { + let s = SecretString::new("secret123"); + let json = serde_json::to_string(&s).expect("serialize must not fail"); + assert_eq!(json, "\"\""); + } } diff --git a/_primitives/_rust/kei-runtime-core/src/traits/auth.rs b/_primitives/_rust/kei-runtime-core/src/traits/auth.rs index e79d372..2b42b6a 100644 --- a/_primitives/_rust/kei-runtime-core/src/traits/auth.rs +++ b/_primitives/_rust/kei-runtime-core/src/traits/auth.rs @@ -25,11 +25,18 @@ pub enum AuthChallenge { /// `expected_state` — the nonce generated when the auth URL was built; /// must equal `state` (verified via constant-time comparison in each /// provider's `verify()` impl). + /// + /// `code_verifier` — the plain PKCE verifier (RFC 7636) originally passed + /// to `build_auth_url`. Store this alongside `state` in the session-store + /// when building the auth URL; pass it back here at callback time so + /// `verify()` can thread it through to the token exchange endpoint. + /// `None` retains legacy "no PKCE" behavior. OAuthCode { provider: String, code: String, state: String, expected_state: String, + code_verifier: Option, }, SshKeySig { key_id: String, signature: String }, }