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:
parent
71f17337fe
commit
06ff2f8ed4
9 changed files with 537 additions and 92 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
_primitives/_rust/kei-auth-google/src/id_token.rs
Normal file
104
_primitives/_rust/kei-auth-google/src/id_token.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
114
_primitives/_rust/kei-auth-google/src/verify_helpers.rs
Normal file
114
_primitives/_rust/kei-auth-google/src/verify_helpers.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue