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>
181 lines
7.1 KiB
Rust
181 lines
7.1 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 <author org>
|
|
//!
|
|
//! [`GoogleAuthProvider`] — `AuthProvider` impl over Google OAuth 2.0 +
|
|
//! OIDC userinfo. Builds an [`AuthSession`] whose `user_id` is the OIDC
|
|
//! `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::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 std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
/// Default scope set: OIDC profile + email.
|
|
pub const DEFAULT_SCOPES: &str = "openid email profile";
|
|
|
|
/// `AuthProvider` for Google OAuth 2.0.
|
|
pub struct GoogleAuthProvider {
|
|
dna: Dna,
|
|
parent: Option<Dna>,
|
|
client: GoogleAuthClient,
|
|
}
|
|
|
|
impl GoogleAuthProvider {
|
|
/// Build a provider over a pre-configured client.
|
|
pub fn new(client: GoogleAuthClient, parent: Option<Dna>) -> Result<Self> {
|
|
let dna = DnaBuilder::new("primitive")
|
|
.caps(["PR", "AP", "GO"])
|
|
.scope("keiseikit.dev/primitives/kei-auth-google")
|
|
.body(b"google-oauth2")
|
|
.build()?;
|
|
Ok(Self { dna, parent, client })
|
|
}
|
|
|
|
/// Build the redirect URL for the Google OAuth 2.0 consent screen.
|
|
///
|
|
/// `state` — CSRF nonce. Store it server-side; compare against the
|
|
/// `expected_state` field of [`AuthChallenge::OAuthCode`] at callback.
|
|
///
|
|
/// `code_verifier` — plain PKCE verifier (RFC 7636). The challenge
|
|
/// (`BASE64URL(SHA256(verifier))`) is embedded in the URL. Pass the
|
|
/// same `code_verifier` back in [`AuthChallenge::OAuthCode`].
|
|
pub fn build_auth_url(&self, state: &str, code_verifier: &str) -> String {
|
|
let challenge = pkce_challenge(code_verifier);
|
|
let cid = url_encode(self.client.client_id());
|
|
let redir = url_encode(self.client.redirect_uri());
|
|
let scope = url_encode(DEFAULT_SCOPES);
|
|
let st = url_encode(state);
|
|
let cc = url_encode(&challenge);
|
|
format!(
|
|
"{base}?client_id={cid}&redirect_uri={redir}&response_type=code\
|
|
&scope={scope}&state={st}\
|
|
&code_challenge={cc}&code_challenge_method=S256",
|
|
base = DEFAULT_AUTH_URL,
|
|
)
|
|
}
|
|
|
|
/// Borrow the underlying client.
|
|
pub fn client(&self) -> &GoogleAuthClient { &self.client }
|
|
}
|
|
|
|
impl HasDna for GoogleAuthProvider {
|
|
fn dna(&self) -> &Dna { &self.dna }
|
|
fn parent_dna(&self) -> Option<&Dna> { self.parent.as_ref() }
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AuthProvider for GoogleAuthProvider {
|
|
fn provider_name(&self) -> &'static str { "google" }
|
|
fn is_passwordless(&self) -> bool { true }
|
|
|
|
async fn issue_challenge(&self, c: &AuthChallenge) -> kei_runtime_core::Result<()> {
|
|
match c {
|
|
AuthChallenge::OAuthCode { provider, .. } if provider == "google" => Ok(()),
|
|
AuthChallenge::OAuthCode { provider, .. } => Err(
|
|
kei_runtime_core::Error::Auth(format!("wrong provider for google: {provider}"))
|
|
),
|
|
_ => Err(kei_runtime_core::Error::Auth(
|
|
"google AuthProvider only accepts OAuthCode".into(),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn verify(&self, c: &AuthChallenge) -> kei_runtime_core::Result<AuthSession> {
|
|
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 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: info.sub,
|
|
expires_unix_ms,
|
|
user_agent: None,
|
|
})
|
|
}
|
|
|
|
async fn revoke(&self, _session: &Dna) -> kei_runtime_core::Result<()> { Ok(()) }
|
|
}
|
|
|
|
fn build_session_dna(state: &str) -> kei_runtime_core::Result<Dna> {
|
|
DnaBuilder::new("session")
|
|
.caps(["UI"])
|
|
.scope("keiseikit.dev/primitives/kei-auth-google/session")
|
|
.body(state.as_bytes())
|
|
.build()
|
|
.map_err(kei_runtime_core::Error::Dna)
|
|
}
|
|
|
|
fn now_ms() -> i64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_millis() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn provider_dna_carries_go_cap() {
|
|
let client = GoogleAuthClient::with_urls(
|
|
"http://t/x", "http://u/x", "cid", "secret", "http://r/cb",
|
|
).unwrap();
|
|
let provider = GoogleAuthProvider::new(client, None).unwrap();
|
|
assert_eq!(provider.provider_name(), "google");
|
|
assert!(provider.is_passwordless());
|
|
let caps = provider.dna().caps();
|
|
assert!(caps.contains("GO"), "expected GO in caps, got {caps}");
|
|
assert!(caps.contains("PR"));
|
|
assert!(caps.contains("AP"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_auth_url_has_required_params() {
|
|
let client = GoogleAuthClient::with_urls(
|
|
"http://t/x", "http://u/x", "my-cid", "secret",
|
|
"https://example.com/cb",
|
|
).unwrap();
|
|
let provider = GoogleAuthProvider::new(client, None).unwrap();
|
|
let url = provider.build_auth_url("xyz", "my-verifier-1234");
|
|
assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?"));
|
|
assert!(url.contains("client_id=my-cid"));
|
|
assert!(url.contains("response_type=code"));
|
|
assert!(url.contains("state=xyz"));
|
|
assert!(url.contains("scope=openid%20email%20profile"));
|
|
assert!(url.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcb"));
|
|
assert!(url.contains("code_challenge="));
|
|
assert!(url.contains("code_challenge_method=S256"));
|
|
}
|
|
}
|