From 611b6034699a5ad4fe2954c53c84670881de4103 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Sun, 3 May 2026 15:38:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20Google=20OIDC=20account-takeover?= =?UTF-8?q?=20(CVE-2023-7028=20class)=20=E2=80=94=20email=5Fverified=20gat?= =?UTF-8?q?e=20+=20sub=20as=20user=5Fid=20+=20id=5Ftoken=20cross-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus Cross-cutting audit found a classic OIDC account-takeover hole in kei-auth-google::verify(). Same class as the public Booking.com / Slack / GitLab pattern. Root cause: verify() accepted info.email from userinfo response as user_id WITHOUT checking info.email_verified. A Google Workspace admin can mint accounts with arbitrary unverified email aliases. Attacker then OAuth-flows into the relying party using a victim's email as their alias and gets a session bound to that user_id. No email verification = no auth. Fix in 3 layers (defense in depth): 1. email_verified GATE - client.rs: UserInfo gains email_verified: bool with #[serde(default)] — absent field defaults to false (fail-closed). - error.rs: new Error::EmailNotVerified variant. - provider.rs::verify(): rejects with EmailNotVerified before any session is built when email_verified != true. 2. sub AS PRIMARY user_id - provider.rs::verify(): user_id = info.sub (Google's stable account id), NOT info.email. Email is now mutable metadata only. Email reassignment in Google Workspace cannot redirect an existing user_id binding. 3. id_token.sub CROSS-CHECK - id_token.rs (new, 104 LOC): JWT-claims-only extract_sub() — parses base64-payload without signature verification (signature verification against Google JWKS is a documented follow-up atomar). - provider.rs::verify(): when TokenResponse.id_token is present, decode claims and require id_token.sub == userinfo.sub. New Error::IdSubMismatch + IdTokenMalformed variants. - This adds defense against a forged userinfo response even though signature is not yet verified. Constructor Pattern compliance: provider.rs split into provider.rs (181 LOC) + verify_helpers.rs (114 LOC, with unpack_challenge / check_state / enforce_email_verified / cross_check_id_token_sub helpers). All files <200 LOC, all functions <30 LOC. Tests added: tests/google_security_regression.rs (164 LOC, 5 dedicated CVE-2023-7028 regression tests). All 26 tests pass: - verify_rejects_unverified_email - verify_rejects_missing_email_verified_field - verify_uses_sub_not_email_as_user_id - verify_rejects_id_token_sub_mismatch - verify_accepts_matching_id_token_sub cargo check --workspace clean. cargo test -p kei-auth-google: 26/26 pass. Follow-up: JWT signature verification against Google's JWKS endpoint with kid-based key cache + RS256/ES256 — separate atomar (~150 LOC). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_rust/kei-auth-google/src/client.rs | 12 ++ .../_rust/kei-auth-google/src/error.rs | 27 +++ .../_rust/kei-auth-google/src/id_token.rs | 104 +++++++++++ _primitives/_rust/kei-auth-google/src/lib.rs | 3 + .../_rust/kei-auth-google/src/provider.rs | 54 +++--- .../kei-auth-google/src/verify_helpers.rs | 114 ++++++++++++ .../tests/google_client_smoke.rs | 22 +++ .../tests/google_security_regression.rs | 164 ++++++++++++++++++ .../kei-auth-google/tests/google_smoke.rs | 129 +++++++------- 9 files changed, 537 insertions(+), 92 deletions(-) create mode 100644 _primitives/_rust/kei-auth-google/src/id_token.rs create mode 100644 _primitives/_rust/kei-auth-google/src/verify_helpers.rs create mode 100644 _primitives/_rust/kei-auth-google/tests/google_security_regression.rs diff --git a/_primitives/_rust/kei-auth-google/src/client.rs b/_primitives/_rust/kei-auth-google/src/client.rs index 6a695a0..8436a06 100644 --- a/_primitives/_rust/kei-auth-google/src/client.rs +++ b/_primitives/_rust/kei-auth-google/src/client.rs @@ -30,11 +30,23 @@ pub struct TokenResponse { } /// Userinfo response (OIDC core §5.3.2 — only the fields we surface). +/// +/// `email_verified` is **load-bearing for security**: a Google Workspace +/// admin can mint accounts with arbitrary unverified email aliases, and +/// a relying party that trusts `email` without checking the verified +/// flag is vulnerable to the CVE-2023-7028 class of account-takeover +/// (Booking.com / Slack / GitLab). Always pair the `email` field with +/// the verified flag at the call site. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserInfo { pub sub: String, #[serde(default)] pub email: String, + /// OIDC `email_verified` boolean. Defaults to `false` when the + /// provider omits the claim — that matches the safe interpretation + /// (refuse rather than trust). + #[serde(default)] + pub email_verified: bool, #[serde(default)] pub name: String, } diff --git a/_primitives/_rust/kei-auth-google/src/error.rs b/_primitives/_rust/kei-auth-google/src/error.rs index 7fc258f..6f42cfe 100644 --- a/_primitives/_rust/kei-auth-google/src/error.rs +++ b/_primitives/_rust/kei-auth-google/src/error.rs @@ -39,6 +39,24 @@ pub enum Error { /// Configuration mismatch (env var unset, both URLs absent, etc.). #[error("config: {0}")] Config(String), + + /// Google account email is not verified — refusing authentication. + /// CVE-2023-7028 class: a Workspace admin can mint accounts with + /// arbitrary unverified email aliases. We treat the absence (or + /// `false`) of `email_verified` as fail-closed. + #[error("Google account email is not verified — refusing authentication")] + EmailNotVerified, + + /// `id_token.sub` from the token endpoint disagrees with + /// `userinfo.sub`. Fail-closed: we cannot tell which identity + /// the user actually consented to. + #[error("id_token sub mismatches userinfo sub — refusing authentication")] + IdSubMismatch, + + /// `id_token` was syntactically malformed (not three segments, + /// base64url-decode failed, or JSON claims unparsable). + #[error("id_token malformed: {0}")] + IdTokenMalformed(String), } pub type Result = std::result::Result; @@ -65,6 +83,15 @@ impl From for kei_runtime_core::Error { Error::Dna(s) => kei_runtime_core::Error::Provider(format!("dna: {s}")), Error::Serde(e) => kei_runtime_core::Error::Serde(e), Error::Config(s) => kei_runtime_core::Error::Config(s), + Error::EmailNotVerified => kei_runtime_core::Error::Auth( + "google email not verified".into(), + ), + Error::IdSubMismatch => kei_runtime_core::Error::Auth( + "google id_token sub mismatch".into(), + ), + Error::IdTokenMalformed(s) => kei_runtime_core::Error::Auth( + format!("google id_token malformed: {s}"), + ), } } } diff --git a/_primitives/_rust/kei-auth-google/src/id_token.rs b/_primitives/_rust/kei-auth-google/src/id_token.rs new file mode 100644 index 0000000..2753aaa --- /dev/null +++ b/_primitives/_rust/kei-auth-google/src/id_token.rs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! +//! ID-token claim extraction for Google OIDC. +//! +//! **Scope (deliberate, narrow).** This module decodes the *claims* +//! payload of a JWT — the middle base64url segment — and surfaces the +//! `sub` field. It does **not** verify the JWT signature against +//! Google's JWKS. Signature verification is a follow-up (load JWKS +//! over HTTPS, cache by `kid`, run RS256/ES256). Until then, the +//! `id_token.sub` is treated as a defence-in-depth cross-check +//! against the userinfo `sub` (the token came from a TLS-validated +//! token endpoint, but a malicious userinfo response could still +//! ship a different `sub` if the access token leaked). +//! +//! See RFC 7519 §3 (JWT compact serialization) and OIDC Core §2 +//! (id_token claims). +//! +//! [VERIFIED: https://datatracker.ietf.org/doc/html/rfc7519] + +use crate::error::{Error, Result}; +use base64::Engine as _; +use serde::Deserialize; + +/// Minimal projection of the OIDC id_token claims payload. +#[derive(Debug, Clone, Deserialize)] +pub struct IdTokenClaims { + /// Stable Google account identifier; matches userinfo `sub`. + pub sub: String, +} + +/// Parse the **claims** segment of a JWT and decode `sub`. +/// +/// Returns [`Error::IdTokenMalformed`] if the token is not three +/// segments, base64url-decode fails, or the JSON lacks `sub`. +/// +/// **Does not** verify the JWT signature — see module-level docs. +pub fn extract_sub(id_token: &str) -> Result { + let claims_b64 = jwt_claims_segment(id_token)?; + let claims_json = decode_b64url(claims_b64)?; + let claims: IdTokenClaims = serde_json::from_slice(&claims_json) + .map_err(|e| Error::IdTokenMalformed(format!("claims json: {e}")))?; + Ok(claims.sub) +} + +/// Pull the middle (claims) segment of a JWT compact serialization. +fn jwt_claims_segment(id_token: &str) -> Result<&str> { + let mut parts = id_token.split('.'); + let _header = parts.next() + .ok_or_else(|| Error::IdTokenMalformed("missing header".into()))?; + let claims = parts.next() + .ok_or_else(|| Error::IdTokenMalformed("missing claims".into()))?; + let _sig = parts.next() + .ok_or_else(|| Error::IdTokenMalformed("missing signature".into()))?; + if parts.next().is_some() { + return Err(Error::IdTokenMalformed("too many segments".into())); + } + Ok(claims) +} + +/// base64url-no-pad decode (RFC 7515 §2). Tolerant of optional padding. +fn decode_b64url(input: &str) -> Result> { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(input) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(input)) + .map_err(|e| Error::IdTokenMalformed(format!("b64: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_jwt(claims_json: &str) -> String { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(br#"{"alg":"RS256","typ":"JWT"}"#); + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(claims_json.as_bytes()); + format!("{header}.{claims}.fake-sig") + } + + #[test] + fn extract_sub_happy_path() { + let jwt = make_jwt(r#"{"sub":"1234567890","email":"a@b.c"}"#); + assert_eq!(extract_sub(&jwt).unwrap(), "1234567890"); + } + + #[test] + fn extract_sub_rejects_two_segment_token() { + let err = extract_sub("only.two").unwrap_err(); + assert!(format!("{err}").contains("id_token")); + } + + #[test] + fn extract_sub_rejects_garbage_claims() { + let jwt = "header.@@@@.sig"; + assert!(extract_sub(jwt).is_err()); + } + + #[test] + fn extract_sub_rejects_missing_sub_field() { + let jwt = make_jwt(r#"{"email":"x@y.z"}"#); + assert!(extract_sub(&jwt).is_err()); + } +} diff --git a/_primitives/_rust/kei-auth-google/src/lib.rs b/_primitives/_rust/kei-auth-google/src/lib.rs index 5e628a5..3e79e61 100644 --- a/_primitives/_rust/kei-auth-google/src/lib.rs +++ b/_primitives/_rust/kei-auth-google/src/lib.rs @@ -40,10 +40,13 @@ pub mod client; pub mod error; +pub mod id_token; pub mod pkce; pub mod provider; +mod verify_helpers; pub use client::{GoogleAuthClient, TokenResponse, UserInfo}; pub use error::{Error, Result}; +pub use id_token::{extract_sub as extract_id_token_sub, IdTokenClaims}; pub use pkce::pkce_challenge; pub use provider::GoogleAuthProvider; diff --git a/_primitives/_rust/kei-auth-google/src/provider.rs b/_primitives/_rust/kei-auth-google/src/provider.rs index 5fd5a44..641ae14 100644 --- a/_primitives/_rust/kei-auth-google/src/provider.rs +++ b/_primitives/_rust/kei-auth-google/src/provider.rs @@ -3,17 +3,35 @@ //! //! [`GoogleAuthProvider`] — `AuthProvider` impl over Google OAuth 2.0 + //! OIDC userinfo. Builds an [`AuthSession`] whose `user_id` is the OIDC -//! `email` (with `sub` available via the userinfo result if needed). +//! `sub` claim (Google's stable account-id; emails can change). +//! +//! ## Security model +//! +//! - **`email_verified` gate.** `verify()` rejects any userinfo response +//! with `email_verified == false`. CVE-2023-7028 class: Google +//! Workspace tenants can mint accounts with arbitrary unverified +//! email aliases. Trusting `email` without the verified flag is +//! account-takeover-equivalent. +//! - **`sub` as user_id.** `info.email` is exposed only as metadata; +//! the primary identifier is `info.sub` (Google's `255-byte stable +//! account identifier`). Email is mutable; sub is not. +//! - **`id_token.sub` cross-check.** When the token endpoint returns +//! an `id_token`, we decode its claims and verify `sub` matches the +//! userinfo response. Defence in depth against a forged userinfo. +//! *Note:* JWT signature verification (RS256 against Google's JWKS) +//! is a follow-up — the current code parses claims only. //! //! `provider_name = "google"`. `is_passwordless = true`. use crate::client::{GoogleAuthClient, DEFAULT_AUTH_URL}; -use crate::error::{Error, Result}; +use crate::error::Result; use crate::pkce::{pkce_challenge, url_encode}; +use crate::verify_helpers::{ + check_state, cross_check_id_token_sub, enforce_email_verified, unpack_challenge, +}; use async_trait::async_trait; use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider, AuthSession}; use kei_runtime_core::{Dna, DnaBuilder, HasDna}; -use subtle::ConstantTimeEq; use std::time::{SystemTime, UNIX_EPOCH}; /// Default scope set: OIDC profile + email. @@ -87,35 +105,20 @@ impl AuthProvider for GoogleAuthProvider { } async fn verify(&self, c: &AuthChallenge) -> kei_runtime_core::Result { - let (code, state, expected_state, code_verifier) = match c { - AuthChallenge::OAuthCode { - 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}" - ))); - } - _ => return Err(kei_runtime_core::Error::from(Error::MissingState)), - }; + let (code, state, expected_state, code_verifier) = unpack_challenge(c)?; check_state(state, expected_state)?; 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)?; + enforce_email_verified(&info)?; + cross_check_id_token_sub(&token, &info)?; let session_dna = build_session_dna(state)?; - let now_ms = now_ms(); - let expires_unix_ms = now_ms.saturating_add(token.expires_in.saturating_mul(1000)); - let user_id = if !info.email.is_empty() { info.email } else { info.sub }; + let expires_unix_ms = now_ms().saturating_add(token.expires_in.saturating_mul(1000)); Ok(AuthSession { dna: session_dna, parent_dna: self.dna.clone(), - user_id, + user_id: info.sub, expires_unix_ms, user_agent: None, }) @@ -124,11 +127,6 @@ impl AuthProvider for GoogleAuthProvider { async fn revoke(&self, _session: &Dna) -> kei_runtime_core::Result<()> { Ok(()) } } -fn check_state(got: &str, expected: &str) -> kei_runtime_core::Result<()> { - let ok: bool = got.as_bytes().ct_eq(expected.as_bytes()).into(); - if !ok { Err(kei_runtime_core::Error::CsrfStateMismatch) } else { Ok(()) } -} - fn build_session_dna(state: &str) -> kei_runtime_core::Result { DnaBuilder::new("session") .caps(["UI"]) diff --git a/_primitives/_rust/kei-auth-google/src/verify_helpers.rs b/_primitives/_rust/kei-auth-google/src/verify_helpers.rs new file mode 100644 index 0000000..a752b7a --- /dev/null +++ b/_primitives/_rust/kei-auth-google/src/verify_helpers.rs @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! +//! Pure helpers extracted from [`crate::provider`]. Each one is a +//! single-responsibility check used inside `verify()` — split out so +//! the provider file stays under the 200-LOC Constructor Pattern bound +//! and so the security-critical predicates are unit-testable in +//! isolation (no HTTP, no async). + +use crate::client::{TokenResponse, UserInfo}; +use crate::error::Error; +use crate::id_token::extract_sub as extract_id_token_sub; +use kei_runtime_core::traits::auth::AuthChallenge; +use subtle::ConstantTimeEq; + +/// Pull `(code, state, expected_state, code_verifier)` out of an +/// [`AuthChallenge::OAuthCode`] for `provider == "google"`. +pub(crate) fn unpack_challenge<'a>( + c: &'a AuthChallenge, +) -> kei_runtime_core::Result<(&'a str, &'a str, &'a str, Option<&'a str>)> { + match c { + AuthChallenge::OAuthCode { + provider, code, state, expected_state, code_verifier, + } if provider == "google" => Ok(( + code.as_str(), + state.as_str(), + expected_state.as_str(), + code_verifier.as_deref(), + )), + AuthChallenge::OAuthCode { provider, .. } => Err(kei_runtime_core::Error::Auth( + format!("wrong provider for google: {provider}"), + )), + _ => Err(kei_runtime_core::Error::from(Error::MissingState)), + } +} + +/// Constant-time CSRF-state compare. Returns +/// [`kei_runtime_core::Error::CsrfStateMismatch`] on disagreement. +pub(crate) fn check_state(got: &str, expected: &str) -> kei_runtime_core::Result<()> { + let ok: bool = got.as_bytes().ct_eq(expected.as_bytes()).into(); + if !ok { + Err(kei_runtime_core::Error::CsrfStateMismatch) + } else { + Ok(()) + } +} + +/// Reject userinfo where `email_verified` is absent / false. +/// +/// CVE-2023-7028 class fix: Google Workspace admins can mint accounts +/// with arbitrary unverified email aliases. Trusting `email` without +/// the verified flag is account-takeover-equivalent. +pub(crate) fn enforce_email_verified(info: &UserInfo) -> kei_runtime_core::Result<()> { + if !info.email_verified { + return Err(kei_runtime_core::Error::from(Error::EmailNotVerified)); + } + Ok(()) +} + +/// If `token.id_token` is `Some`, decode its claims and require +/// `id_token.sub == info.sub`. Skipped (Ok) when absent. Signature +/// verification is a follow-up; this is defence-in-depth against a +/// forged userinfo response. +pub(crate) fn cross_check_id_token_sub( + token: &TokenResponse, + info: &UserInfo, +) -> kei_runtime_core::Result<()> { + let Some(id_token) = token.id_token.as_deref() else { + return Ok(()); + }; + let id_sub = extract_id_token_sub(id_token) + .map_err(kei_runtime_core::Error::from)?; + if id_sub != info.sub { + return Err(kei_runtime_core::Error::from(Error::IdSubMismatch)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ui(email_verified: bool, sub: &str) -> UserInfo { + UserInfo { + sub: sub.into(), + email: "x@y.z".into(), + email_verified, + name: "X".into(), + } + } + + #[test] + fn enforce_email_verified_passes_when_true() { + assert!(enforce_email_verified(&ui(true, "abc")).is_ok()); + } + + #[test] + fn enforce_email_verified_rejects_false() { + let err = enforce_email_verified(&ui(false, "abc")).unwrap_err(); + assert!(format!("{err}").contains("not verified")); + } + + #[test] + fn cross_check_no_id_token_is_ok() { + let tok = TokenResponse { access_token: "t".into(), expires_in: 0, id_token: None }; + assert!(cross_check_id_token_sub(&tok, &ui(true, "abc")).is_ok()); + } + + #[test] + fn check_state_constant_time_ok() { + assert!(check_state("abc", "abc").is_ok()); + assert!(check_state("abc", "abd").is_err()); + } +} diff --git a/_primitives/_rust/kei-auth-google/tests/google_client_smoke.rs b/_primitives/_rust/kei-auth-google/tests/google_client_smoke.rs index bed4cb0..1bdfa8a 100644 --- a/_primitives/_rust/kei-auth-google/tests/google_client_smoke.rs +++ b/_primitives/_rust/kei-auth-google/tests/google_client_smoke.rs @@ -52,6 +52,7 @@ async fn userinfo_200_returns_email_and_sub() { .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "sub": "1234567890", "email": "alice@example.com", + "email_verified": true, "name": "Alice" }))) .expect(1) @@ -62,9 +63,30 @@ async fn userinfo_200_returns_email_and_sub() { let info = client.userinfo("ya29.a0AfH-test").await.unwrap(); assert_eq!(info.sub, "1234567890"); assert_eq!(info.email, "alice@example.com"); + assert!(info.email_verified); assert_eq!(info.name, "Alice"); } +#[tokio::test] +async fn userinfo_omits_email_verified_defaults_to_false() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/userinfo")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "sub": "abc", + "email": "x@y.z", + "name": "X" + }))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let info = client.userinfo("any").await.unwrap(); + // serde_default safe interpretation: absent ⇒ false ⇒ provider rejects. + assert!(!info.email_verified); +} + #[tokio::test] async fn exchange_code_400_returns_api_error() { let server = MockServer::start().await; diff --git a/_primitives/_rust/kei-auth-google/tests/google_security_regression.rs b/_primitives/_rust/kei-auth-google/tests/google_security_regression.rs new file mode 100644 index 0000000..f7eb564 --- /dev/null +++ b/_primitives/_rust/kei-auth-google/tests/google_security_regression.rs @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! +//! CVE-2023-7028 class regression tests for `GoogleAuthProvider`. +//! +//! Booking.com / Slack / GitLab were all hit by the same pattern: an +//! OIDC relying-party trusted `userinfo.email` without checking +//! `email_verified`, allowing a Workspace admin to mint accounts with +//! arbitrary unverified email aliases and sign in as any user. +//! +//! These tests ensure `verify()`: +//! 1. refuses `email_verified == false` +//! 2. refuses absent `email_verified` +//! 3. uses `sub` (not `email`) as `user_id` +//! 4. cross-checks `id_token.sub == userinfo.sub` when an `id_token` +//! is returned, and rejects mismatch +//! 5. accepts the happy path when both are equal + +use base64::Engine as _; +use kei_auth_google::{GoogleAuthClient, GoogleAuthProvider}; +use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider}; +use serde_json::json; +use wiremock::matchers::{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() +} + +fn challenge() -> AuthChallenge { + AuthChallenge::OAuthCode { + provider: "google".into(), + code: "c".into(), + state: "s".into(), + expected_state: "s".into(), + code_verifier: None, + } +} + +fn make_jwt_with_sub(sub: &str) -> String { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(br#"{"alg":"RS256","typ":"JWT"}"#); + let claims_json = format!(r#"{{"sub":"{sub}","aud":"client-id-xyz"}}"#); + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(claims_json.as_bytes()); + format!("{header}.{claims}.fake-sig-not-verified-yet") +} + +async fn mock_token_no_id(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "tok", + "expires_in": 1800, + "id_token": null + }))) + .mount(server) + .await; +} + +async fn mock_token_with_id(server: &MockServer, id_token: String) { + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "tok", + "expires_in": 1800, + "id_token": id_token + }))) + .mount(server) + .await; +} + +async fn mock_userinfo(server: &MockServer, body: serde_json::Value) { + Mock::given(method("GET")) + .and(path("/userinfo")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(server) + .await; +} + +#[tokio::test] +async fn verify_rejects_unverified_email() { + let server = MockServer::start().await; + mock_token_no_id(&server).await; + mock_userinfo(&server, json!({ + "sub": "attacker-sub", + "email": "victim@target.example", + "email_verified": false, + "name": "Attacker" + })).await; + let provider = GoogleAuthProvider::new(client_for(&server), None).unwrap(); + let err = provider.verify(&challenge()).await.unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("not verified") || msg.contains("email"), + "expected email-not-verified error, got: {msg}" + ); +} + +#[tokio::test] +async fn verify_rejects_missing_email_verified_field() { + let server = MockServer::start().await; + mock_token_no_id(&server).await; + mock_userinfo(&server, json!({ + "sub": "abc", + "email": "x@y.z", + "name": "Default" + })).await; + let provider = GoogleAuthProvider::new(client_for(&server), None).unwrap(); + assert!(provider.verify(&challenge()).await.is_err()); +} + +#[tokio::test] +async fn verify_uses_sub_not_email_as_user_id() { + let server = MockServer::start().await; + mock_token_no_id(&server).await; + mock_userinfo(&server, json!({ + "sub": "stable-google-account-id-12345", + "email": "alice@example.com", + "email_verified": true, + "name": "Alice" + })).await; + let provider = GoogleAuthProvider::new(client_for(&server), None).unwrap(); + let session = provider.verify(&challenge()).await.unwrap(); + assert_eq!(session.user_id, "stable-google-account-id-12345"); + assert_ne!(session.user_id, "alice@example.com"); +} + +#[tokio::test] +async fn verify_rejects_id_token_sub_mismatch() { + let server = MockServer::start().await; + mock_token_with_id(&server, make_jwt_with_sub("ATTACKER-SUB")).await; + mock_userinfo(&server, json!({ + "sub": "VICTIM-SUB", + "email": "v@example.com", + "email_verified": true, + "name": "Victim" + })).await; + let provider = GoogleAuthProvider::new(client_for(&server), None).unwrap(); + let err = provider.verify(&challenge()).await.unwrap_err(); + assert!(format!("{err}").contains("sub"), "expected sub-mismatch error"); +} + +#[tokio::test] +async fn verify_accepts_matching_id_token_sub() { + let server = MockServer::start().await; + mock_token_with_id(&server, make_jwt_with_sub("happy-sub")).await; + mock_userinfo(&server, json!({ + "sub": "happy-sub", + "email": "h@e.io", + "email_verified": true, + "name": "Happy" + })).await; + let provider = GoogleAuthProvider::new(client_for(&server), None).unwrap(); + let session = provider.verify(&challenge()).await.unwrap(); + assert_eq!(session.user_id, "happy-sub"); +} diff --git a/_primitives/_rust/kei-auth-google/tests/google_smoke.rs b/_primitives/_rust/kei-auth-google/tests/google_smoke.rs index 556352d..8fcd05c 100644 --- a/_primitives/_rust/kei-auth-google/tests/google_smoke.rs +++ b/_primitives/_rust/kei-auth-google/tests/google_smoke.rs @@ -2,10 +2,13 @@ // Copyright 2026 //! //! Wiremock smoke tests for `GoogleAuthProvider`. No live HTTP. +//! +//! CVE-2023-7028 class regressions live in +//! `tests/google_security_regression.rs`. use kei_auth_google::{GoogleAuthClient, GoogleAuthProvider}; -use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider}; -use serde_json::json; +use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider, AuthSession}; +use serde_json::{json, Value}; use wiremock::matchers::{body_string_contains, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -20,42 +23,62 @@ fn client_for(server: &MockServer) -> GoogleAuthClient { .unwrap() } +fn challenge(state: &str, code_verifier: Option<&str>) -> AuthChallenge { + AuthChallenge::OAuthCode { + provider: "google".into(), + code: "code".into(), + state: state.into(), + expected_state: state.into(), + code_verifier: code_verifier.map(str::to_string), + } +} + +async fn mock_token(server: &MockServer, body: Value) { + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(server) + .await; +} + +async fn mock_userinfo(server: &MockServer, body: Value) { + Mock::given(method("GET")) + .and(path("/userinfo")) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(server) + .await; +} + +async fn run_verify( + server: &MockServer, + state: &str, + code_verifier: Option<&str>, +) -> kei_runtime_core::Result { + let provider = GoogleAuthProvider::new(client_for(server), None).unwrap(); + provider.verify(&challenge(state, code_verifier)).await +} + #[tokio::test] async fn verify_end_to_end_builds_auth_session() { let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "access_token": "tok", - "expires_in": 1800, - "id_token": null - }))) - .expect(1) - .mount(&server) - .await; + mock_token(&server, json!({ + "access_token": "tok", "expires_in": 1800, "id_token": null + })).await; Mock::given(method("GET")) .and(path("/userinfo")) .and(header("authorization", "Bearer tok")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "sub": "999", - "email": "bob@example.com", - "name": "Bob" + "sub": "999", "email": "bob@example.com", + "email_verified": true, "name": "Bob" }))) .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-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"); + let session = run_verify(&server, "csrf-state-xyz", None).await.unwrap(); + // Post CVE-2023-7028 fix: user_id is the OIDC `sub`, not the email. + assert_eq!(session.user_id, "999"); assert_eq!(session.dna.role(), "session"); assert!(session.dna.caps().contains("UI")); assert_eq!(session.parent_dna.role(), "primitive"); @@ -69,8 +92,8 @@ async fn issue_challenge_rejects_non_oauth() { "http://t/x", "http://u/x", "cid", "secret", "http://r/cb", ).unwrap(); let provider = GoogleAuthProvider::new(client, None).unwrap(); - let challenge = AuthChallenge::MagicLink { email: "a@b.c".into() }; - assert!(provider.issue_challenge(&challenge).await.is_err()); + let c = AuthChallenge::MagicLink { email: "a@b.c".into() }; + assert!(provider.issue_challenge(&c).await.is_err()); } #[tokio::test] @@ -79,33 +102,28 @@ async fn verify_rejects_wrong_provider() { "http://t/x", "http://u/x", "cid", "secret", "http://r/cb", ).unwrap(); let provider = GoogleAuthProvider::new(client, None).unwrap(); - let challenge = AuthChallenge::OAuthCode { + let c = AuthChallenge::OAuthCode { provider: "github".into(), code: "x".into(), state: "y".into(), expected_state: "y".into(), code_verifier: None, }; - assert!(provider.verify(&challenge).await.is_err()); + assert!(provider.verify(&c).await.is_err()); } #[tokio::test] async fn verify_rejects_csrf_state_mismatch() { let server = MockServer::start().await; - let client = GoogleAuthClient::with_urls( - format!("{}/token", server.uri()), - format!("{}/userinfo", server.uri()), - "cid", "secret", "https://example.com/cb", - ).unwrap(); - let provider = GoogleAuthProvider::new(client, None).unwrap(); - let challenge = AuthChallenge::OAuthCode { + let provider = GoogleAuthProvider::new(client_for(&server), None).unwrap(); + let c = AuthChallenge::OAuthCode { provider: "google".into(), code: "code".into(), state: "got-this-state".into(), expected_state: "expected-state".into(), code_verifier: None, }; - let err = provider.verify(&challenge).await.unwrap_err(); + let err = provider.verify(&c).await.unwrap_err(); assert!( format!("{err}").contains("CSRF"), "expected CSRF error, got: {err}" @@ -119,33 +137,16 @@ async fn verify_sends_code_verifier_when_challenge_carries_some() { .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 + "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"); + mock_userinfo(&server, json!({ + "sub": "pkce-sub", "email": "pkce@example.com", + "email_verified": true, "name": "PKCE" + })).await; + let session = run_verify(&server, "st", Some("my-pkce-verifier")).await.unwrap(); + // Post-fix: user_id == OIDC `sub`, not email. + assert_eq!(session.user_id, "pkce-sub"); }