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>
97 lines
3.5 KiB
Rust
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}"),
|
|
),
|
|
}
|
|
}
|
|
}
|