KeiSeiKit-1.0/_primitives/_rust/kei-auth-apple/src/jwt.rs
Parfii-bot 8b0401b9db feat(auth): JWT verification + OAuth CSRF + PKCE + secret redaction
Group B — auth-crate security hardening (post-audit Sonnet test-retest 2026-05-02).

kei-auth-apple:
- jwt.rs: full ES256 JWKS signature verification (jsonwebtoken crate);
          validates iss == https://appleid.apple.com, aud == client_id, exp, iat;
          decode_id_token_unverified is now cfg(test)-only.
          Module docstring promised this since v0.1 — now actually implemented.
- claims.rs (new): IdTokenClaims + AudClaim extracted from jwt.rs.
- error.rs: JwtVerify, JwtDecode, MissingClaim variants.
- client.rs: client_secret_jwt: SecretString (was String); exchange_code accepts
              code_verifier: Option<&str> for PKCE.
- provider.rs: verify() does CSRF expected_state ConstantTimeEq + JWT verification;
                build_auth_url accepts state + verifier and emits PKCE code_challenge.
- tests/apple_smoke.rs + helpers/: 6 tests including malformed-JWT + non-Apple OAuth +
                                     400-mapping + provider_verify_csrf_mismatch_rejected.

kei-auth-google:
- pkce.rs (new): pkce_challenge + url_encode (RFC 7636 §B.1 test vector covered).
- client.rs: client_secret: SecretString; exchange_code accepts code_verifier.
- provider.rs: verify() rejects on state mismatch; build_auth_url emits S256 challenge.
- tests/google_smoke.rs: 7 tests including CSRF mismatch.

kei-auth:
- main.rs: resolve_token() supports stdin (-) and KEI_AUTH_TOKEN env. Token positional
            arg leaked via /proc/<pid>/cmdline + shell history; same fix that v0.14.1
            applied to --key.
- main.rs::key(): hard fail if KEI_AUTH_KEY len < 32 bytes (mirror of magiclink).
- tokens.rs::verify(): query_row(...).optional()? instead of .ok() — DB errors now
                        propagate instead of being swallowed as "token unknown".

kei-runtime-core:
- secrets.rs (new, 81 LOC): SecretString newtype with redacted Debug + zeroize-on-Drop.
                              Required by every auth crate that holds secret material.
- traits/auth.rs: AuthChallenge::Password.password is now SecretString;
                   OAuthCode { state, expected_state }.
- error.rs: CsrfStateMismatch variant.

Test results: 48 passed; 0 failed across kei-auth, kei-auth-apple, kei-auth-google,
kei-auth-magiclink, kei-runtime-core. cargo check --workspace clean.

Findings consensus: Apple JWT unverified + OAuth state CSRF appeared in all 3
audit waves (Wave-1 + Wave-A + Wave-B); PKCE absence + secret-derive-Debug appeared
only in Wave-A retest, would have been missed by single-pass audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:39:18 +08:00

164 lines
5.8 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//
//! Apple id_token verification — ES256 signature check against Apple JWKS.
//!
//! Production path: [`verify_id_token`] — verifies signature, validates
//! standard claims (`iss`, `aud`, `exp`, `iat`).
//!
//! Test-only path: [`decode_id_token_unverified`] — available only under
//! `#[cfg(test)]`; never present in production builds.
use crate::claims::IdTokenClaims;
use crate::error::{Error, Result};
use jsonwebtoken::{
decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation,
};
use std::time::{SystemTime, UNIX_EPOCH};
/// Verify an Apple id_token against the provided JWKS JSON, checking:
/// - ES256 signature against the matching `kid` in `jwks_json`.
/// - `iss == "https://appleid.apple.com"`.
/// - `aud` contains `client_id`.
/// - `exp > now` (not expired).
/// - `iat <= now` (not in the future).
///
/// `jwks_json` is the raw JSON body of Apple's public JWKS endpoint
/// (`https://appleid.apple.com/auth/keys`). The caller is responsible for
/// fetching and caching it.
pub fn verify_id_token(
token: &str,
jwks_json: &str,
client_id: &str,
) -> Result<IdTokenClaims> {
let header = decode_header(token)
.map_err(|e| Error::JwtVerify(format!("header: {e}")))?;
let kid = header
.kid
.ok_or_else(|| Error::JwtVerify("missing kid in JWT header".into()))?;
let jwks: JwkSet = serde_json::from_str(jwks_json)
.map_err(|e| Error::JwtVerify(format!("jwks json: {e}")))?;
let jwk = jwks
.find(&kid)
.ok_or_else(|| Error::JwtVerify(format!("kid {kid} not found in JWKS")))?;
let decoding_key = DecodingKey::from_jwk(jwk)
.map_err(|e| Error::JwtVerify(format!("decoding key: {e}")))?;
let mut validation = Validation::new(Algorithm::ES256);
validation.validate_exp = true;
validation.validate_aud = false; // we validate aud manually below
let data = decode::<IdTokenClaims>(token, &decoding_key, &validation)
.map_err(|e| Error::JwtVerify(format!("verify: {e}")))?;
validate_claims(&data.claims, client_id)?;
Ok(data.claims)
}
fn validate_claims(c: &IdTokenClaims, client_id: &str) -> Result<()> {
const APPLE_ISS: &str = "https://appleid.apple.com";
if c.iss != APPLE_ISS {
return Err(Error::JwtVerify(format!(
"iss mismatch: expected {APPLE_ISS}, got {}", c.iss
)));
}
if !c.aud.contains(client_id) {
return Err(Error::JwtVerify(format!(
"aud does not contain client_id {client_id}"
)));
}
let now = now_unix_secs();
if c.exp <= now {
return Err(Error::JwtVerify("token expired".into()));
}
if c.iat > now + 300 {
// 5-minute clock-skew tolerance
return Err(Error::JwtVerify("iat is in the future".into()));
}
if c.sub.is_empty() {
return Err(Error::MissingClaim("sub".into()));
}
Ok(())
}
fn now_unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Decode the claims segment of a JWT WITHOUT verifying the signature.
///
/// ONLY available under `#[cfg(test)]`. Production code MUST use
/// [`verify_id_token`] which validates the ES256 signature.
#[cfg(test)]
pub fn decode_id_token_unverified(jwt: &str) -> Result<IdTokenClaims> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
let mut parts = jwt.split('.');
let _header = parts
.next()
.ok_or_else(|| Error::JwtDecode("missing header segment".into()))?;
let payload = parts
.next()
.ok_or_else(|| Error::JwtDecode("missing payload segment".into()))?;
let _sig = parts
.next()
.ok_or_else(|| Error::JwtDecode("missing signature segment".into()))?;
if parts.next().is_some() {
return Err(Error::JwtDecode("more than 3 segments".into()));
}
let bytes = URL_SAFE_NO_PAD
.decode(payload.as_bytes())
.map_err(|e| Error::JwtDecode(format!("base64: {e}")))?;
let claims: IdTokenClaims = serde_json::from_slice(&bytes)
.map_err(|e| Error::JwtDecode(format!("json: {e}")))?;
if claims.sub.is_empty() {
return Err(Error::MissingClaim("sub".into()));
}
Ok(claims)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
fn b64(input: &str) -> String {
URL_SAFE_NO_PAD.encode(input.as_bytes())
}
fn make_jwt(header_b64: &str, payload_b64: &str, sig_b64: &str) -> String {
format!("{header_b64}.{payload_b64}.{sig_b64}")
}
#[test]
fn decode_unverified_valid() {
let header = b64("{\"alg\":\"ES256\"}");
let payload = b64(
"{\"sub\":\"001234.aabbcc\",\"email\":\"x@y.example\",\"exp\":9999999999,\"iss\":\"https://appleid.apple.com\"}",
);
let sig = b64("fake-sig");
let jwt = make_jwt(&header, &payload, &sig);
let claims = decode_id_token_unverified(&jwt).unwrap();
assert_eq!(claims.sub, "001234.aabbcc");
assert_eq!(claims.email.as_deref(), Some("x@y.example"));
assert_eq!(claims.exp, 9_999_999_999);
assert_eq!(claims.iss, "https://appleid.apple.com");
}
#[test]
fn decode_unverified_reject_two_segments() {
let header = b64("{\"alg\":\"ES256\"}");
let payload = b64("{\"sub\":\"x\"}");
let jwt = format!("{header}.{payload}");
let err = decode_id_token_unverified(&jwt).unwrap_err();
assert!(matches!(err, Error::JwtDecode(_)));
}
#[test]
fn decode_unverified_reject_invalid_base64() {
let jwt = "abc.!!!not-base64!!!.zzz";
let err = decode_id_token_unverified(jwt).unwrap_err();
assert!(matches!(err, Error::JwtDecode(_)));
}
}