KeiSeiKit-1.0/_primitives/_rust/kei-auth-google/src/error.rs
Parfii-bot 611b603469 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>
2026-05-03 15:38:53 +08:00

97 lines
3.5 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! Error types for `kei-auth-google`. Maps cleanly into
//! [`kei_runtime_core::Error`] so the provider can fulfil
//! [`kei_runtime_core::traits::auth::AuthProvider`].
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
/// Transport-level reqwest failure (connect, TLS, decode).
#[error("http: {0}")]
Http(String),
/// Google API returned a non-success status with a body we surface
/// verbatim (token endpoint 400, userinfo 401, etc.).
#[error("api: {0}")]
Api(String),
/// Caller passed a non-OAuthCode challenge OR omitted the `state` ⇄ code
/// pairing required by the verify path.
#[error("missing state")]
MissingState,
/// Userinfo lookup returned 404 or the requested resource is absent.
#[error("not found: {0}")]
NotFound(String),
/// DNA composition failed (only possible if scope/body inputs violate
/// the wire format — should never trip in practice).
#[error("dna: {0}")]
Dna(String),
/// Underlying serde decode failure on a JSON body Google returned.
#[error("serde: {0}")]
Serde(#[from] serde_json::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>;
impl From<reqwest::Error> for Error {
fn from(e: reqwest::Error) -> Self {
Error::Http(e.to_string())
}
}
impl From<kei_runtime_core::DnaError> for Error {
fn from(e: kei_runtime_core::DnaError) -> Self {
Error::Dna(e.to_string())
}
}
impl From<Error> for kei_runtime_core::Error {
fn from(e: Error) -> Self {
match e {
Error::Http(s) => kei_runtime_core::Error::Network(s),
Error::Api(s) => kei_runtime_core::Error::Provider(s),
Error::MissingState => kei_runtime_core::Error::Auth("missing state".into()),
Error::NotFound(s) => kei_runtime_core::Error::NotFound(s),
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}"),
),
}
}
}