KeiSeiKit-1.0/_primitives/_rust/kei-auth-google/src/id_token.rs
Parfii-bot 06ff2f8ed4 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

104 lines
3.7 KiB
Rust

// 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());
}
}