fix(auth): Google OIDC account-takeover (CVE-2023-7028 class) — email_verified gate + sub as user_id + id_token cross-check

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) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-05-03 15:38:53 +08:00
parent c0d900a943
commit 611b603469
9 changed files with 537 additions and 92 deletions

View file

@ -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,
}

View file

@ -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<T> = std::result::Result<T, Error>;
@ -65,6 +83,15 @@ impl From<Error> 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}"),
),
}
}
}

View file

@ -0,0 +1,104 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! 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<String> {
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<Vec<u8>> {
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());
}
}

View file

@ -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;

View file

@ -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<AuthSession> {
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<Dna> {
DnaBuilder::new("session")
.caps(["UI"])

View file

@ -0,0 +1,114 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! 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());
}
}

View file

@ -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;

View file

@ -0,0 +1,164 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! 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");
}

View file

@ -2,10 +2,13 @@
// Copyright 2026 <author org>
//!
//! 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<AuthSession> {
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");
}