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>
164 lines
5.8 KiB
Rust
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(_)));
|
|
}
|
|
}
|