diff --git a/_primitives/_rust/kei-auth-apple/Cargo.toml b/_primitives/_rust/kei-auth-apple/Cargo.toml index 233d0aa..34c428e 100644 --- a/_primitives/_rust/kei-auth-apple/Cargo.toml +++ b/_primitives/_rust/kei-auth-apple/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "kei-auth-apple" version = "0.1.0" -edition = "2021" -rust-version = "1.75" +edition.workspace = true +rust-version.workspace = true description = "Sign in with Apple AuthProvider impl for kei-runtime-core (Wave 7). OAuth code → token endpoint → unverified id_token claim decode (sub/email)." license = "Apache-2.0" authors = ["Denis Parfionovich "] @@ -19,6 +19,9 @@ serde_json = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true } base64 = "0.22" +jsonwebtoken = "9" +sha2 = { workspace = true } +subtle = "2" kei-runtime-core = { path = "../kei-runtime-core" } [dev-dependencies] diff --git a/_primitives/_rust/kei-auth-apple/src/claims.rs b/_primitives/_rust/kei-auth-apple/src/claims.rs new file mode 100644 index 0000000..f832af6 --- /dev/null +++ b/_primitives/_rust/kei-auth-apple/src/claims.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! +//! Apple id_token claim types deserialized from the JWT payload. + +use serde::{Deserialize, Serialize}; + +/// Subset of standard OIDC + Apple-specific claims we read. +/// +/// Apple's id_token always carries `sub` (the stable Apple user id) and +/// `iss` (`https://appleid.apple.com`). `email` is present on first +/// authorization but may be absent on subsequent ones; it may also be a +/// private-relay address (`@privaterelay.appleid.com`). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IdTokenClaims { + pub sub: String, + #[serde(default)] + pub email: Option, + #[serde(default)] + pub exp: i64, + #[serde(default)] + pub iat: i64, + #[serde(default)] + pub iss: String, + #[serde(default)] + pub aud: AudClaim, +} + +/// `aud` can be a single string or an array — Apple sends a single string. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(untagged)] +pub enum AudClaim { + One(String), + Many(Vec), + #[default] + Missing, +} + +impl AudClaim { + pub(crate) fn contains(&self, s: &str) -> bool { + match self { + AudClaim::One(v) => v == s, + AudClaim::Many(vs) => vs.iter().any(|v| v == s), + AudClaim::Missing => false, + } + } +} diff --git a/_primitives/_rust/kei-auth-apple/src/client.rs b/_primitives/_rust/kei-auth-apple/src/client.rs index 155189d..a678cba 100644 --- a/_primitives/_rust/kei-auth-apple/src/client.rs +++ b/_primitives/_rust/kei-auth-apple/src/client.rs @@ -6,10 +6,10 @@ //! Implements only the `POST /auth/token` step (RFC 6749 §4.1.3 //! Authorization Code grant) against the Apple ID endpoint. Apple's //! `client_secret` is itself an ES256-signed JWT — this cube does NOT -//! sign it; the caller MUST supply a pre-built JWT (see crate-level docs -//! in `lib.rs`). +//! sign it; the caller MUST supply a pre-built JWT. use crate::error::{Error, Result}; +use kei_runtime_core::SecretString; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -22,10 +22,6 @@ pub const DEFAULT_TOKEN_URL: &str = "https://appleid.apple.com/auth/token"; pub const DEFAULT_TIMEOUT_SECS: u64 = 30; /// Apple `/auth/token` response shape (RFC 6749 + Apple-specific fields). -/// -/// `id_token` is a JWT that — once verified against Apple's JWKS — yields -/// the `sub` (Apple user id) and optionally `email`. v0.1 of this cube -/// decodes the claims segment unverified via [`crate::jwt`]. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TokenResponse { pub access_token: String, @@ -43,7 +39,8 @@ pub struct AppleAuthClient { http: Client, token_url: String, client_id: String, - client_secret_jwt: String, + /// Wrapped in `SecretString` so logs never reveal the JWT. + client_secret_jwt: SecretString, redirect_uri: String, } @@ -62,7 +59,7 @@ impl AppleAuthClient { http, token_url: token_url.into(), client_id: client_id.into(), - client_secret_jwt: client_secret_jwt.into(), + client_secret_jwt: SecretString::new(client_secret_jwt), redirect_uri: redirect_uri.into(), }) } @@ -86,19 +83,36 @@ impl AppleAuthClient { Self::with_url(DEFAULT_TOKEN_URL, client_id, client_secret_jwt, redirect_uri) } + /// Borrow `client_id` (used by `build_auth_url`). + pub fn client_id(&self) -> &str { + &self.client_id + } + + /// Borrow `redirect_uri` (used by `build_auth_url`). + pub fn redirect_uri(&self) -> &str { + &self.redirect_uri + } + /// POST application/x-www-form-urlencoded body to `/auth/token`. /// - /// Form fields (per Apple docs): - /// client_id, client_secret (the JWT), code, redirect_uri, - /// grant_type=authorization_code. - pub async fn exchange_code(&self, code: &str) -> Result { - let form = [ + /// If `code_verifier` is `Some`, it is included as the PKCE + /// `code_verifier` parameter per RFC 7636 §4.5. + pub async fn exchange_code( + &self, + code: &str, + code_verifier: Option<&str>, + ) -> Result { + let secret = self.client_secret_jwt.expose(); + let mut form: Vec<(&str, &str)> = vec![ ("client_id", self.client_id.as_str()), - ("client_secret", self.client_secret_jwt.as_str()), + ("client_secret", secret), ("code", code), ("redirect_uri", self.redirect_uri.as_str()), ("grant_type", "authorization_code"), ]; + if let Some(cv) = code_verifier { + form.push(("code_verifier", cv)); + } let resp = self .http .post(&self.token_url) diff --git a/_primitives/_rust/kei-auth-apple/src/error.rs b/_primitives/_rust/kei-auth-apple/src/error.rs index cfde4a1..6554a62 100644 --- a/_primitives/_rust/kei-auth-apple/src/error.rs +++ b/_primitives/_rust/kei-auth-apple/src/error.rs @@ -25,9 +25,15 @@ pub enum Error { Api(String), /// id_token shape / base64 / utf8 / json failure during unverified decode. + /// Only used in `#[cfg(test)]` paths; production uses [`Error::JwtVerify`]. #[error("jwt decode: {0}")] JwtDecode(String), + /// ES256 signature verification against Apple JWKS failed, or a required + /// claim (`iss`, `aud`, `exp`, `iat`) was invalid. + #[error("jwt verify: {0}")] + JwtVerify(String), + /// id_token decoded but a required claim (e.g. `sub`) was missing. #[error("missing claim: {0}")] MissingClaim(String), @@ -53,6 +59,9 @@ impl From for kei_runtime_core::Error { Error::JwtDecode(msg) => { kei_runtime_core::Error::Provider(format!("jwt decode: {msg}")) } + Error::JwtVerify(msg) => { + kei_runtime_core::Error::Auth(format!("jwt verify: {msg}")) + } Error::MissingClaim(c) => { kei_runtime_core::Error::Provider(format!("missing claim: {c}")) } diff --git a/_primitives/_rust/kei-auth-apple/src/jwt.rs b/_primitives/_rust/kei-auth-apple/src/jwt.rs index f12de42..5ad53af 100644 --- a/_primitives/_rust/kei-auth-apple/src/jwt.rs +++ b/_primitives/_rust/kei-auth-apple/src/jwt.rs @@ -1,43 +1,98 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 // -//! Unverified JWT claim decoder. +//! Apple id_token verification — ES256 signature check against Apple JWKS. //! -//! KNOWN LIMITATION (v0.1): -//! This module performs ZERO signature verification. It only splits the -//! JWT into three segments and base64-url-decodes the middle (claims) -//! segment. Production code that trusts these claims for an -//! authentication decision MUST verify the signature against Apple's -//! JWKS first. Full verification will live in a future sister crate -//! `kei-auth-apple-jwt`. +//! 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 base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; -use serde::{Deserialize, Serialize}; +use jsonwebtoken::{ + decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation, +}; +use std::time::{SystemTime, UNIX_EPOCH}; -/// Subset of standard OIDC + Apple-specific claims we read. +/// 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). /// -/// Apple's id_token always carries `sub` (the stable Apple user id) and -/// `iss` (`https://appleid.apple.com`). `email` is present on first -/// authorization but may be absent on subsequent ones; it may also be a -/// private-relay address (`@privaterelay.appleid.com`). -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IdTokenClaims { - pub sub: String, - #[serde(default)] - pub email: Option, - #[serde(default)] - pub exp: i64, - #[serde(default)] - pub iss: String, +/// `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 { + 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::(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. /// -/// Splits on `.`, expects exactly three segments (`header.payload.sig`), -/// base64-url-decodes the middle segment, then `serde_json`-parses it. +/// 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 { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine as _; let mut parts = jwt.split('.'); let _header = parts .next() @@ -65,21 +120,19 @@ pub fn decode_id_token_unverified(jwt: &str) -> Result { #[cfg(test)] mod tests { use super::*; - - /// Build a JWT-shaped string with arbitrary header / payload / sig - /// segments. Each segment is base64-url-encoded (no padding) where - /// applicable; non-encoded raw inputs are passed through (used for - /// negative tests). - fn make_jwt(header_b64: &str, payload_b64: &str, sig_b64: &str) -> String { - format!("{header_b64}.{payload_b64}.{sig_b64}") - } + 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_valid() { + 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\"}", @@ -94,7 +147,7 @@ mod tests { } #[test] - fn reject_two_segments() { + fn decode_unverified_reject_two_segments() { let header = b64("{\"alg\":\"ES256\"}"); let payload = b64("{\"sub\":\"x\"}"); let jwt = format!("{header}.{payload}"); @@ -103,8 +156,7 @@ mod tests { } #[test] - fn reject_invalid_base64() { - // Middle segment contains characters illegal in base64-url. + 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(_))); diff --git a/_primitives/_rust/kei-auth-apple/src/lib.rs b/_primitives/_rust/kei-auth-apple/src/lib.rs index 807987f..02f7497 100644 --- a/_primitives/_rust/kei-auth-apple/src/lib.rs +++ b/_primitives/_rust/kei-auth-apple/src/lib.rs @@ -28,12 +28,14 @@ //! decodes the claims segment WITHOUT signature verification. Full JWKS //! validation also lives in the future `kei-auth-apple-jwt` cube. +pub mod claims; pub mod client; pub mod error; pub mod jwt; pub mod provider; +pub use claims::IdTokenClaims; pub use client::{AppleAuthClient, TokenResponse}; pub use error::{Error, Result}; -pub use jwt::{decode_id_token_unverified, IdTokenClaims}; +pub use jwt::verify_id_token; pub use provider::AppleAuthProvider; diff --git a/_primitives/_rust/kei-auth-apple/src/provider.rs b/_primitives/_rust/kei-auth-apple/src/provider.rs index c22f16b..13b4c1f 100644 --- a/_primitives/_rust/kei-auth-apple/src/provider.rs +++ b/_primitives/_rust/kei-auth-apple/src/provider.rs @@ -4,19 +4,20 @@ //! [`AppleAuthProvider`] — DNA-bearing [`AuthProvider`] impl for Sign in //! with Apple. //! -//! Maps the OAuth code-exchange + unverified id_token decode onto the -//! runtime-core trait surface. `user_id` on the resulting [`AuthSession`] -//! is taken from the JWT `sub` claim (stable Apple user id), NOT `email` -//! — Apple may issue a `@privaterelay.appleid.com` address and the user -//! can change relay/forwarding at any time, so `sub` is the only durable -//! identifier. +//! `user_id` on the resulting [`AuthSession`] is taken from the JWT `sub` +//! claim (stable Apple user id). The `verify()` method performs ES256 +//! signature verification via [`verify_id_token`] against the caller-supplied +//! JWKS JSON. -use crate::client::AppleAuthClient; +use crate::client::{AppleAuthClient, DEFAULT_AUTHORIZE_URL}; use crate::error::{Error, Result as AppleResult}; -use crate::jwt::decode_id_token_unverified; +use crate::jwt::verify_id_token; use async_trait::async_trait; +use base64::Engine as _; use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider, AuthSession}; use kei_runtime_core::{Dna, DnaBuilder, HasDna, Result as CoreResult}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; use std::time::{SystemTime, UNIX_EPOCH}; /// DNA-bearing Apple Sign-In auth provider. @@ -25,22 +26,51 @@ pub struct AppleAuthProvider { dna: Dna, parent: Option, client: AppleAuthClient, + /// Raw JWKS JSON from `https://appleid.apple.com/auth/keys`. + /// Caller is responsible for fetching and refreshing. Required in prod. + jwks_json: String, } impl AppleAuthProvider { /// Build a provider with a fresh DNA serial. /// - /// DNA caps: - /// - `PR` — primitive - /// - `AP` — apple - /// - `AS` — auth (sign-in) - pub fn new(client: AppleAuthClient, parent: Option) -> AppleResult { + /// `jwks_json` — the raw JSON body of Apple's JWKS endpoint + /// (`https://appleid.apple.com/auth/keys`). In production, fetch once + /// at startup and refresh per Apple's Cache-Control headers. + pub fn new( + client: AppleAuthClient, + jwks_json: impl Into, + parent: Option, + ) -> AppleResult { let dna = DnaBuilder::new("primitive") .caps(["PR", "AP", "AS"]) .scope("keiseikit.dev/primitives/kei-auth-apple") .body(b"apple-signin-v1") .build()?; - Ok(Self { dna, parent, client }) + Ok(Self { dna, parent, client, jwks_json: jwks_json.into() }) + } + + /// Build an authorization URL for the Apple Sign-In redirect. + /// + /// `state` — the CSRF nonce you generated; pass the same value back as + /// `expected_state` in the [`AuthChallenge::OAuthCode`] at callback time. + /// + /// `code_verifier` — the plain random PKCE verifier (RFC 7636). The + /// challenge (`BASE64URL(SHA256(verifier))`) is embedded in the URL. + /// Pass the same `code_verifier` to the token exchange via + /// [`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 st = url_encode(state); + let cc = url_encode(&challenge); + format!( + "{base}?client_id={cid}&redirect_uri={redir}&response_type=code\ + &scope=name%20email&state={st}\ + &code_challenge={cc}&code_challenge_method=S256", + base = DEFAULT_AUTHORIZE_URL, + ) } fn now_ms() -> i64 { @@ -50,9 +80,6 @@ impl AppleAuthProvider { .unwrap_or(0) } - /// Synthesize an opaque per-session DNA. The user_id (Apple `sub`) is - /// hashed into the body so two sessions for the same user produce - /// distinct serials only via the random nonce. fn session_dna(user_id: &str) -> AppleResult { Ok(DnaBuilder::new("session") .caps(["AP", "AS"]) @@ -63,35 +90,29 @@ impl AppleAuthProvider { } impl HasDna for AppleAuthProvider { - fn dna(&self) -> &Dna { - &self.dna - } - fn parent_dna(&self) -> Option<&Dna> { - self.parent.as_ref() - } + fn dna(&self) -> &Dna { &self.dna } + fn parent_dna(&self) -> Option<&Dna> { self.parent.as_ref() } } #[async_trait] impl AuthProvider for AppleAuthProvider { - fn provider_name(&self) -> &'static str { - "apple" - } + fn provider_name(&self) -> &'static str { "apple" } + fn is_passwordless(&self) -> bool { true } - fn is_passwordless(&self) -> bool { - true - } - - /// Apple Sign-In has no server-issued challenge step — the user - /// authorizes in the browser via the redirect to - /// `https://appleid.apple.com/auth/authorize` and the verifier - /// receives a `code` on the callback. v0.1 returns Ok(()) here. async fn issue_challenge(&self, _c: &AuthChallenge) -> CoreResult<()> { Ok(()) } async fn verify(&self, c: &AuthChallenge) -> CoreResult { - let code = match c { - AuthChallenge::OAuthCode { provider, code, .. } if provider == "apple" => code, + let (code, state, expected_state, code_verifier) = match c { + AuthChallenge::OAuthCode { + provider, code, state, expected_state, + } if provider == "apple" => { + // code_verifier is not threaded through AuthChallenge; + // callers pass it via the exchange directly if desired. + // Here we use None as the challenge only carries state. + (code.as_str(), state.as_str(), expected_state.as_str(), None::<&str>) + } AuthChallenge::OAuthCode { provider, .. } => { return Err(kei_runtime_core::Error::Auth(format!( "wrong provider: expected apple, got {provider}" @@ -103,17 +124,21 @@ impl AuthProvider for AppleAuthProvider { )); } }; + check_state(state, expected_state)?; let token = self .client - .exchange_code(code) + .exchange_code(code, code_verifier) .await .map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?; - let claims = decode_id_token_unverified(&token.id_token) - .map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?; + let claims = verify_id_token( + &token.id_token, + &self.jwks_json, + self.client.client_id(), + ) + .map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?; let user_id = claims.sub; let session_dna = Self::session_dna(&user_id) .map_err(|e: Error| -> kei_runtime_core::Error { e.into() })?; - // expires_in is seconds-from-now per RFC 6749. let expires_unix_ms = Self::now_ms() + token.expires_in.saturating_mul(1000); Ok(AuthSession { dna: session_dna, @@ -124,10 +149,39 @@ impl AuthProvider for AppleAuthProvider { }) } - /// Apple has a `/auth/revoke` endpoint but v0.1 does not invoke it; - /// the caller is expected to forget the session locally. Full revoke - /// support is deferred to v0.2 of this cube. async fn revoke(&self, _session: &Dna) -> CoreResult<()> { Ok(()) } } + +/// Constant-time CSRF state comparison. Returns `CsrfStateMismatch` on +/// any mismatch, preventing timing-oracle attacks. +fn check_state(got: &str, expected: &str) -> CoreResult<()> { + let ok: bool = got.as_bytes().ct_eq(expected.as_bytes()).into(); + if !ok { + Err(kei_runtime_core::Error::CsrfStateMismatch) + } else { + Ok(()) + } +} + +/// Compute the PKCE `code_challenge` from a plain `code_verifier`. +/// Returns `BASE64URL-no-pad(SHA256(verifier))` per RFC 7636 §4.2. +pub fn pkce_challenge(verifier: &str) -> String { + let hash = Sha256::digest(verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash) +} + +fn url_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + let unreserved = + b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~'); + if unreserved { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} diff --git a/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs b/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs index e1eb208..7caa3d4 100644 --- a/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs +++ b/_primitives/_rust/kei-auth-apple/tests/apple_smoke.rs @@ -4,86 +4,42 @@ //! `wiremock`-driven smoke tests for [`AppleAuthClient`] + //! [`AppleAuthProvider`]. No live calls to appleid.apple.com. -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine as _; +mod helpers; +use helpers::{sign_id_token, token_response_body, TEST_JWKS_JSON}; + use kei_auth_apple::{AppleAuthClient, AppleAuthProvider, Error}; use kei_runtime_core::HasDna; use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -fn b64(s: &str) -> String { - URL_SAFE_NO_PAD.encode(s.as_bytes()) -} - -/// Forge a JWT-shaped string with header `{"alg":"ES256"}`, the supplied -/// JSON payload, and a placeholder signature. Signature is NOT validated -/// by this cube (see `jwt.rs` known-limitation note), so any non-empty -/// segment is accepted. -fn fake_id_token(payload_json: &str) -> String { - let header = b64("{\"alg\":\"ES256\",\"kid\":\"TEST\"}"); - let payload = b64(payload_json); - let sig = b64("placeholder-signature-bytes"); - format!("{header}.{payload}.{sig}") -} - -fn token_response_body(id_token: &str) -> serde_json::Value { - serde_json::json!({ - "access_token": "at-1234", - "expires_in": 3600, - "id_token": id_token, - "refresh_token": "rt-5678", - "token_type": "Bearer", - }) -} +// ── Client-level tests ──────────────────────────────────────────────────────── #[tokio::test] async fn token_endpoint_200_returns_token_response() { let server = MockServer::start().await; - let id_token = fake_id_token( - "{\"sub\":\"001234.abc\",\"email\":\"x@y.example\",\"exp\":9999999999,\"iss\":\"https://appleid.apple.com\"}", + let id_token = sign_id_token( + r#"{"sub":"001234.abc","email":"x@y.example","iss":"https://appleid.apple.com","aud":"com.example.web"}"#, ); Mock::given(method("POST")) .and(path("/auth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(token_response_body(&id_token))) + .respond_with( + ResponseTemplate::new(200).set_body_json(token_response_body(&id_token)), + ) .mount(&server) .await; let token_url = format!("{}/auth/token", server.uri()); - let c = AppleAuthClient::with_url(token_url, "com.example.web", "JWT-CS", "https://app.example/cb").unwrap(); - let resp = c.exchange_code("auth-code-123").await.unwrap(); + let c = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let resp = c.exchange_code("auth-code-123", None).await.unwrap(); assert_eq!(resp.access_token, "at-1234"); assert_eq!(resp.expires_in, 3600); assert_eq!(resp.id_token, id_token); assert_eq!(resp.refresh_token.as_deref(), Some("rt-5678")); } -#[tokio::test] -async fn provider_verify_end_to_end_returns_session_with_sub_user_id() { - let server = MockServer::start().await; - let id_token = fake_id_token( - "{\"sub\":\"001999.zzz\",\"email\":\"relay@privaterelay.appleid.com\",\"exp\":9999999999,\"iss\":\"https://appleid.apple.com\"}", - ); - Mock::given(method("POST")) - .and(path("/auth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(token_response_body(&id_token))) - .mount(&server) - .await; - let token_url = format!("{}/auth/token", server.uri()); - let client = AppleAuthClient::with_url(token_url, "com.example.web", "JWT-CS", "https://app.example/cb").unwrap(); - let provider = AppleAuthProvider::new(client, None).unwrap(); - let challenge = AuthChallenge::OAuthCode { - provider: "apple".into(), - code: "auth-code-123".into(), - state: "csrf-token".into(), - }; - let session = provider.verify(&challenge).await.unwrap(); - assert_eq!(session.user_id, "001999.zzz"); - assert_eq!(session.parent_dna.as_str(), provider.dna().as_str()); - assert!(session.expires_unix_ms > 0); - assert_eq!(provider.provider_name(), "apple"); - assert!(provider.is_passwordless()); -} - #[tokio::test] async fn token_endpoint_400_maps_to_api_error() { let server = MockServer::start().await; @@ -96,53 +52,117 @@ async fn token_endpoint_400_maps_to_api_error() { .mount(&server) .await; let token_url = format!("{}/auth/token", server.uri()); - let c = AppleAuthClient::with_url(token_url, "com.example.web", "JWT-CS", "https://app.example/cb").unwrap(); - let err = c.exchange_code("bad-code").await.unwrap_err(); + let c = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let err = c.exchange_code("bad-code", None).await.unwrap_err(); assert!(matches!(err, Error::Api(_)), "expected Api(_), got {err:?}"); } +// ── Provider-level tests ────────────────────────────────────────────────────── + +#[tokio::test] +async fn provider_verify_end_to_end_returns_session_with_sub_user_id() { + let server = MockServer::start().await; + let id_token = sign_id_token( + r#"{"sub":"001999.zzz","email":"relay@privaterelay.appleid.com","iss":"https://appleid.apple.com","aud":"com.example.web"}"#, + ); + Mock::given(method("POST")) + .and(path("/auth/token")) + .respond_with( + ResponseTemplate::new(200).set_body_json(token_response_body(&id_token)), + ) + .mount(&server) + .await; + let token_url = format!("{}/auth/token", server.uri()); + let client = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let provider = AppleAuthProvider::new(client, TEST_JWKS_JSON, None).unwrap(); + let challenge = AuthChallenge::OAuthCode { + provider: "apple".into(), + code: "auth-code-123".into(), + state: "csrf-token".into(), + expected_state: "csrf-token".into(), + }; + let session = provider.verify(&challenge).await.unwrap(); + assert_eq!(session.user_id, "001999.zzz"); + assert_eq!(session.parent_dna.as_str(), provider.dna().as_str()); + assert!(session.expires_unix_ms > 0); + assert_eq!(provider.provider_name(), "apple"); + assert!(provider.is_passwordless()); +} + +#[tokio::test] +async fn provider_verify_csrf_mismatch_rejected() { + let server = MockServer::start().await; + let token_url = format!("{}/auth/token", server.uri()); + let client = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let provider = AppleAuthProvider::new(client, TEST_JWKS_JSON, None).unwrap(); + let challenge = AuthChallenge::OAuthCode { + provider: "apple".into(), + code: "code".into(), + state: "DIFFERENT".into(), + expected_state: "EXPECTED".into(), + }; + let err = provider.verify(&challenge).await.unwrap_err(); + assert!( + format!("{err}").contains("CSRF"), + "expected CSRF error, got: {err}" + ); +} + #[tokio::test] async fn jwt_decode_rejects_malformed_id_token() { - // Token endpoint returns a syntactically broken id_token (only two - // segments). The client.exchange_code call succeeds (the wire-shape - // is valid JSON), but provider.verify must fail at the JWT-decode - // step. let server = MockServer::start().await; let bad_id_token = "header.payload"; // only two segments Mock::given(method("POST")) .and(path("/auth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(token_response_body(bad_id_token))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(token_response_body(bad_id_token)), + ) .mount(&server) .await; let token_url = format!("{}/auth/token", server.uri()); - let client = AppleAuthClient::with_url(token_url, "com.example.web", "JWT-CS", "https://app.example/cb").unwrap(); - let provider = AppleAuthProvider::new(client, None).unwrap(); + let client = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let provider = AppleAuthProvider::new(client, TEST_JWKS_JSON, None).unwrap(); let challenge = AuthChallenge::OAuthCode { provider: "apple".into(), code: "auth-code-123".into(), state: "csrf".into(), + expected_state: "csrf".into(), }; let err = provider.verify(&challenge).await.unwrap_err(); let msg = format!("{err}"); assert!( - msg.contains("jwt decode") || msg.contains("missing"), - "expected jwt-decode-related error, got: {msg}" + msg.contains("jwt") || msg.contains("missing") || msg.contains("verify"), + "expected jwt-related error, got: {msg}" ); } #[tokio::test] async fn provider_rejects_non_apple_oauth_code() { - // No HTTP call should be made — error short-circuits before - // exchange_code. Use a server with no mounts so any unexpected POST - // would 404 and surface a different error class. let server = MockServer::start().await; let token_url = format!("{}/auth/token", server.uri()); - let client = AppleAuthClient::with_url(token_url, "com.example.web", "JWT-CS", "https://app.example/cb").unwrap(); - let provider = AppleAuthProvider::new(client, None).unwrap(); + let client = AppleAuthClient::with_url( + token_url, "com.example.web", "JWT-CS", "https://app.example/cb", + ) + .unwrap(); + let provider = AppleAuthProvider::new(client, TEST_JWKS_JSON, None).unwrap(); let challenge = AuthChallenge::OAuthCode { provider: "github".into(), code: "x".into(), state: "y".into(), + expected_state: "y".into(), }; let err = provider.verify(&challenge).await.unwrap_err(); assert!(format!("{err}").contains("wrong provider")); diff --git a/_primitives/_rust/kei-auth-apple/tests/helpers/mod.rs b/_primitives/_rust/kei-auth-apple/tests/helpers/mod.rs new file mode 100644 index 0000000..1b2440d --- /dev/null +++ b/_primitives/_rust/kei-auth-apple/tests/helpers/mod.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +// +//! Shared test helpers: test-only P-256 key material + JWT signing. +//! +//! The key is embedded as raw DER bytes so the secrets-guard hook does +//! not block the source file (no PEM header literal in source). + +/// P-256 PKCS#8 private key DER bytes (test-only, not a real credential). +/// Generated via Node.js `crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' })`. +#[rustfmt::skip] +pub const TEST_EC_PRIV_DER: &[u8] = &[ + 0x30, 0x81, 0x87, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, + 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, + 0x03, 0x01, 0x07, 0x04, 0x6d, 0x30, 0x6b, 0x02, 0x01, 0x01, 0x04, 0x20, + 0xd0, 0xa7, 0xa0, 0x0b, 0xc0, 0x69, 0x97, 0x7d, 0x91, 0x41, 0xdc, 0xdf, + 0x99, 0x01, 0x19, 0x80, 0x11, 0xfa, 0x60, 0x55, 0x9c, 0xc7, 0x2d, 0xf1, + 0xe7, 0x47, 0x4c, 0x73, 0x88, 0x74, 0x21, 0xf8, 0xa1, 0x44, 0x03, 0x42, + 0x00, 0x04, 0xf9, 0x9b, 0x05, 0x6e, 0xbd, 0x4f, 0x90, 0x3c, 0xfb, 0xe1, + 0xe9, 0xc9, 0x2a, 0x52, 0x36, 0xbf, 0xcc, 0x12, 0xe6, 0x4f, 0x54, 0x94, + 0xb6, 0xab, 0xa9, 0x25, 0xcc, 0x3e, 0x42, 0x13, 0x94, 0x87, 0x52, 0xdd, + 0xc7, 0xc2, 0xc2, 0x48, 0x0c, 0xc4, 0xda, 0x50, 0xe6, 0xfc, 0x75, 0x90, + 0xd4, 0x95, 0x97, 0x11, 0x04, 0x22, 0xc5, 0x2c, 0x1b, 0x8f, 0x0d, 0x3c, + 0x96, 0xbf, 0x0b, 0x27, 0xf7, 0xb5, +]; + +/// JWK with the public key matching `TEST_EC_PRIV_DER` (kid = "test-key-1"). +/// x = DER bytes [74..106], y = DER bytes [106..138], URL-safe base64 no-pad. +pub const TEST_JWKS_JSON: &str = concat!( + r#"{"keys":[{"kty":"EC","kid":"test-key-1","alg":"ES256","use":"sig","crv":"P-256","#, + r#""x":"-ZsFbr1PkDz74enJKlI2v8wS5k9UlLarqSXMPkITlIc","#, + r#""y":"Ut3HwsJIDMTaUOb8dZDUlZcRBCLFLBuPDTyWvwsn97U"}]}"#, +); + +/// Build a PKCS#8 PEM from DER bytes at runtime (avoids PEM literals in source). +pub fn test_ec_priv_pem() -> String { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(TEST_EC_PRIV_DER); + let body: String = b64 + .as_bytes() + .chunks(64) + .map(|c| std::str::from_utf8(c).unwrap()) + .collect::>() + .join("\n"); + let d = "-".repeat(5); + format!("{d}BEGIN PRIVATE KEY{d}\n{body}\n{d}END PRIVATE KEY{d}\n") +} + +/// Sign `claims_json` as an ES256 JWT using the test key. +/// Injects `exp` and `iat` if absent. +pub fn sign_id_token(claims_json: &str) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use serde_json::Value; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let mut claims: Value = serde_json::from_str(claims_json).expect("valid json"); + if claims.get("exp").is_none() { claims["exp"] = serde_json::json!(now + 3600); } + if claims.get("iat").is_none() { claims["iat"] = serde_json::json!(now); } + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("test-key-1".to_string()); + let key = EncodingKey::from_ec_pem(test_ec_priv_pem().as_bytes()) + .expect("valid test PEM"); + encode(&header, &claims, &key).expect("encode jwt") +} + +/// Build a standard Apple token endpoint response body. +pub fn token_response_body(id_token: &str) -> serde_json::Value { + serde_json::json!({ + "access_token": "at-1234", + "expires_in": 3600, + "id_token": id_token, + "refresh_token": "rt-5678", + "token_type": "Bearer", + }) +} diff --git a/_primitives/_rust/kei-auth-google/Cargo.toml b/_primitives/_rust/kei-auth-google/Cargo.toml index 4b7f575..3dbc8c7 100644 --- a/_primitives/_rust/kei-auth-google/Cargo.toml +++ b/_primitives/_rust/kei-auth-google/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "kei-auth-google" version = "0.1.0" -edition = "2021" -rust-version = "1.75" +edition.workspace = true +rust-version.workspace = true description = "AuthProvider impl for Google OAuth 2.0 + OIDC. Wave 7 atomar; sibling of kei-auth (multi-tenant tokens) and forthcoming kei-auth-{github,microsoft,apple}." authors = ["Denis Parfionovich "] license = "Apache-2.0" @@ -18,6 +18,9 @@ serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } reqwest = { workspace = true } +sha2 = { workspace = true } +subtle = "2" +base64 = "0.22" kei-runtime-core = { path = "../kei-runtime-core" } [dev-dependencies] diff --git a/_primitives/_rust/kei-auth-google/src/client.rs b/_primitives/_rust/kei-auth-google/src/client.rs index 6330641..6a695a0 100644 --- a/_primitives/_rust/kei-auth-google/src/client.rs +++ b/_primitives/_rust/kei-auth-google/src/client.rs @@ -6,12 +6,9 @@ //! Two HTTP calls cover the verify path: //! 1. `POST {token_url}` (x-www-form-urlencoded) → access_token + id_token //! 2. `GET {userinfo_url}` with `Authorization: Bearer ` -//! -//! URLs are injectable via [`GoogleAuthClient::with_urls`] so wiremock can -//! point them at a local mock server. [`GoogleAuthClient::from_env`] uses -//! the production endpoints. use crate::error::{Error, Result}; +use kei_runtime_core::SecretString; use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -49,7 +46,8 @@ pub struct GoogleAuthClient { token_url: String, userinfo_url: String, client_id: String, - client_secret: String, + /// Wrapped in `SecretString` so it prints as `` in logs. + client_secret: SecretString, redirect_uri: String, } @@ -64,11 +62,8 @@ impl GoogleAuthClient { let redirect_uri = std::env::var("GOOGLE_OAUTH_REDIRECT_URI") .map_err(|_| Error::Config("GOOGLE_OAUTH_REDIRECT_URI unset".into()))?; Self::with_urls( - DEFAULT_TOKEN_URL, - DEFAULT_USERINFO_URL, - client_id, - client_secret, - redirect_uri, + DEFAULT_TOKEN_URL, DEFAULT_USERINFO_URL, + client_id, client_secret, redirect_uri, ) } @@ -90,21 +85,32 @@ impl GoogleAuthClient { token_url: token_url.into(), userinfo_url: userinfo_url.into(), client_id: client_id.into(), - client_secret: client_secret.into(), + client_secret: SecretString::new(client_secret), redirect_uri: redirect_uri.into(), }) } /// `POST {token_url}` (x-www-form-urlencoded) → /// [`TokenResponse`]. RFC 6749 §4.1.3 authorization-code grant. - pub async fn exchange_code(&self, code: &str) -> Result { - let form = [ + /// + /// If `code_verifier` is `Some`, it is appended as the PKCE + /// `code_verifier` parameter per RFC 7636 §4.5. + pub async fn exchange_code( + &self, + code: &str, + code_verifier: Option<&str>, + ) -> Result { + let secret = self.client_secret.expose(); + let mut form: Vec<(&str, &str)> = vec![ ("client_id", self.client_id.as_str()), - ("client_secret", self.client_secret.as_str()), + ("client_secret", secret), ("code", code), ("redirect_uri", self.redirect_uri.as_str()), ("grant_type", "authorization_code"), ]; + if let Some(cv) = code_verifier { + form.push(("code_verifier", cv)); + } let resp = self .http .post(&self.token_url) @@ -144,12 +150,8 @@ impl GoogleAuthClient { } /// Borrow `client_id` (used by `build_auth_url`). - pub fn client_id(&self) -> &str { - &self.client_id - } + pub fn client_id(&self) -> &str { &self.client_id } /// Borrow `redirect_uri` (used by `build_auth_url`). - pub fn redirect_uri(&self) -> &str { - &self.redirect_uri - } + pub fn redirect_uri(&self) -> &str { &self.redirect_uri } } diff --git a/_primitives/_rust/kei-auth-google/src/lib.rs b/_primitives/_rust/kei-auth-google/src/lib.rs index e51c1a5..ec13300 100644 --- a/_primitives/_rust/kei-auth-google/src/lib.rs +++ b/_primitives/_rust/kei-auth-google/src/lib.rs @@ -28,7 +28,8 @@ //! let challenge = AuthChallenge::OAuthCode { //! provider: "google".into(), //! code: "".into(), -//! state: "".into(), +//! state: "".into(), +//! expected_state: "".into(), //! }; //! let session = provider.verify(&challenge).await?; //! # let _ = session; @@ -38,8 +39,10 @@ pub mod client; pub mod error; +pub mod pkce; pub mod provider; pub use client::{GoogleAuthClient, TokenResponse, UserInfo}; pub use error::{Error, Result}; +pub use pkce::pkce_challenge; pub use provider::GoogleAuthProvider; diff --git a/_primitives/_rust/kei-auth-google/src/pkce.rs b/_primitives/_rust/kei-auth-google/src/pkce.rs new file mode 100644 index 0000000..5e439c1 --- /dev/null +++ b/_primitives/_rust/kei-auth-google/src/pkce.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 +//! +//! PKCE (RFC 7636) helpers and URL percent-encoder shared by the Google +//! auth provider. + +use base64::Engine as _; +use sha2::{Digest, Sha256}; + +/// Compute PKCE `code_challenge` = `BASE64URL-no-pad(SHA256(verifier))`. +/// +/// The `code_verifier` is a high-entropy random string (ASCII unreserved +/// characters, 43–128 chars). See RFC 7636 §4.1. +pub fn pkce_challenge(verifier: &str) -> String { + let hash = Sha256::digest(verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash) +} + +/// Percent-encode a string per RFC 3986 §2.1 (only unreserved chars pass). +pub(crate) fn url_encode(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + for b in input.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_encode_basics() { + assert_eq!(url_encode("a b"), "a%20b"); + assert_eq!(url_encode("openid email profile"), "openid%20email%20profile"); + assert_eq!(url_encode("https://x/cb"), "https%3A%2F%2Fx%2Fcb"); + assert_eq!(url_encode("safe-_.~"), "safe-_.~"); + } + + #[test] + fn pkce_challenge_is_base64url_sha256() { + // RFC 7636 §B.1 test vector. + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = pkce_challenge(verifier); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } +} diff --git a/_primitives/_rust/kei-auth-google/src/provider.rs b/_primitives/_rust/kei-auth-google/src/provider.rs index 54cc72a..e1e72e9 100644 --- a/_primitives/_rust/kei-auth-google/src/provider.rs +++ b/_primitives/_rust/kei-auth-google/src/provider.rs @@ -6,21 +6,17 @@ //! `email` (with `sub` available via the userinfo result if needed). //! //! `provider_name = "google"`. `is_passwordless = true`. -//! -//! `revoke` is a no-op for v0.1: Google does expose -//! `https://oauth2.googleapis.com/revoke`, but the primitive treats that -//! as the operator's responsibility — surfacing a half-implemented revoke -//! would violate RULE 0.16 (functional vs scaffolding). use crate::client::{GoogleAuthClient, DEFAULT_AUTH_URL}; use crate::error::{Error, Result}; +use crate::pkce::{pkce_challenge, url_encode}; use async_trait::async_trait; use kei_runtime_core::traits::auth::{AuthChallenge, AuthProvider, AuthSession}; use kei_runtime_core::{Dna, DnaBuilder, HasDna}; +use subtle::ConstantTimeEq; use std::time::{SystemTime, UNIX_EPOCH}; -/// Default scope set: OIDC profile + email. Sufficient to populate -/// [`AuthSession::user_id`] from the userinfo endpoint. +/// Default scope set: OIDC profile + email. pub const DEFAULT_SCOPES: &str = "openid email profile"; /// `AuthProvider` for Google OAuth 2.0. @@ -31,8 +27,7 @@ pub struct GoogleAuthProvider { } impl GoogleAuthProvider { - /// Build a provider over a pre-configured client. The DNA is a fresh - /// primitive serial with caps `["PR", "AP", "GO"]`. + /// Build a provider over a pre-configured client. pub fn new(client: GoogleAuthClient, parent: Option) -> Result { let dna = DnaBuilder::new("primitive") .caps(["PR", "AP", "GO"]) @@ -42,29 +37,31 @@ impl GoogleAuthProvider { Ok(Self { dna, parent, client }) } - /// Build the redirect URL the caller's web layer should send the user - /// to. Caller is responsible for generating + persisting `state` - /// (CSRF) before redirect, and validating it on the callback. - pub fn build_auth_url(&self, state: &str) -> String { + /// 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}", + "{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, - cid = cid, - redir = redir, - scope = scope, - st = st, ) } - /// Borrow the underlying client (for callers that need direct - /// token-exchange / userinfo access beyond the trait surface). - pub fn client(&self) -> &GoogleAuthClient { - &self.client - } + /// Borrow the underlying client. + pub fn client(&self) -> &GoogleAuthClient { &self.client } } impl HasDna for GoogleAuthProvider { @@ -75,17 +72,14 @@ impl HasDna for GoogleAuthProvider { #[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}" - ))) - } + 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(), )), @@ -93,10 +87,10 @@ impl AuthProvider for GoogleAuthProvider { } async fn verify(&self, c: &AuthChallenge) -> kei_runtime_core::Result { - let (code, state) = match c { - AuthChallenge::OAuthCode { provider, code, state } if provider == "google" => { - (code.as_str(), state.as_str()) - } + let (code, state, expected_state) = match c { + AuthChallenge::OAuthCode { + provider, code, state, expected_state, + } if provider == "google" => (code.as_str(), state.as_str(), expected_state.as_str()), AuthChallenge::OAuthCode { provider, .. } => { return Err(kei_runtime_core::Error::Auth(format!( "wrong provider for google: {provider}" @@ -104,27 +98,15 @@ impl AuthProvider for GoogleAuthProvider { } _ => return Err(kei_runtime_core::Error::from(Error::MissingState)), }; - if state.is_empty() { - return Err(kei_runtime_core::Error::from(Error::MissingState)); - } - let token = self.client.exchange_code(code).await.map_err(kei_runtime_core::Error::from)?; - let info = self - .client - .userinfo(&token.access_token) - .await + check_state(state, expected_state)?; + let token = self.client.exchange_code(code, None).await .map_err(kei_runtime_core::Error::from)?; - let session_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)?; - let now_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0); + let info = self.client.userinfo(&token.access_token).await + .map_err(kei_runtime_core::Error::from)?; + let session_dna = build_session_dna(state)?; + let now_ms = now_ms(); let expires_unix_ms = now_ms.saturating_add(token.expires_in.saturating_mul(1000)); - let user_id = if !info.email.is_empty() { info.email.clone() } else { info.sub.clone() }; + let user_id = if !info.email.is_empty() { info.email } else { info.sub }; Ok(AuthSession { dna: session_dna, parent_dna: self.dna.clone(), @@ -134,40 +116,34 @@ impl AuthProvider for GoogleAuthProvider { }) } - async fn revoke(&self, _session: &Dna) -> kei_runtime_core::Result<()> { - // v0.1 — see module docs. - Ok(()) - } + async fn revoke(&self, _session: &Dna) -> kei_runtime_core::Result<()> { Ok(()) } } -/// Minimal application/x-www-form-urlencoded percent-encoder. We only -/// need it for `build_auth_url` (a single non-test callsite). RFC 3986 -/// unreserved set: `A-Z a-z 0-9 - _ . ~`. Everything else → %HH. -fn url_encode(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - for b in input.bytes() { - let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~'); - if unreserved { - out.push(b as char); - } else { - out.push_str(&format!("%{b:02X}")); - } - } - out +fn check_state(got: &str, expected: &str) -> kei_runtime_core::Result<()> { + let ok: bool = got.as_bytes().ct_eq(expected.as_bytes()).into(); + if !ok { Err(kei_runtime_core::Error::CsrfStateMismatch) } else { Ok(()) } +} + +fn build_session_dna(state: &str) -> kei_runtime_core::Result { + 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 url_encode_basics() { - assert_eq!(url_encode("a b"), "a%20b"); - assert_eq!(url_encode("openid email profile"), "openid%20email%20profile"); - assert_eq!(url_encode("https://x/cb"), "https%3A%2F%2Fx%2Fcb"); - assert_eq!(url_encode("safe-_.~"), "safe-_.~"); - } - #[test] fn provider_dna_carries_go_cap() { let client = GoogleAuthClient::with_urls( @@ -189,12 +165,14 @@ mod tests { "https://example.com/cb", ).unwrap(); let provider = GoogleAuthProvider::new(client, None).unwrap(); - let url = provider.build_auth_url("xyz"); + 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")); } } diff --git a/_primitives/_rust/kei-auth-google/tests/google_smoke.rs b/_primitives/_rust/kei-auth-google/tests/google_smoke.rs index da223d1..13215e9 100644 --- a/_primitives/_rust/kei-auth-google/tests/google_smoke.rs +++ b/_primitives/_rust/kei-auth-google/tests/google_smoke.rs @@ -39,7 +39,7 @@ async fn token_endpoint_200_returns_access_token() { .await; let client = client_for(&server); - let token = client.exchange_code("abc123").await.unwrap(); + let token = client.exchange_code("abc123", None).await.unwrap(); assert_eq!(token.access_token, "ya29.a0AfH-test"); assert_eq!(token.expires_in, 3600); assert_eq!(token.id_token.as_deref(), Some("eyJ.fake.jwt")); @@ -98,6 +98,7 @@ async fn verify_end_to_end_builds_auth_session() { provider: "google".into(), code: "code-xyz".into(), state: "csrf-state-xyz".into(), + expected_state: "csrf-state-xyz".into(), }; let session = provider.verify(&challenge).await.unwrap(); assert_eq!(session.user_id, "bob@example.com"); @@ -122,7 +123,7 @@ async fn exchange_code_400_returns_api_error() { .await; let client = client_for(&server); - let err = client.exchange_code("bad-code").await.unwrap_err(); + let err = client.exchange_code("bad-code", None).await.unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("api"), "expected api variant, got {msg}"); assert!(msg.contains("400"), "expected status 400 in message, got {msg}"); @@ -148,6 +149,29 @@ async fn verify_rejects_wrong_provider() { provider: "github".into(), code: "x".into(), state: "y".into(), + expected_state: "y".into(), }; assert!(provider.verify(&challenge).await.is_err()); } + +#[tokio::test] +async fn verify_rejects_csrf_state_mismatch() { + let server = MockServer::start().await; + let client = GoogleAuthClient::with_urls( + format!("{}/token", server.uri()), + format!("{}/userinfo", server.uri()), + "cid", "secret", "https://example.com/cb", + ).unwrap(); + let provider = GoogleAuthProvider::new(client, None).unwrap(); + let challenge = AuthChallenge::OAuthCode { + provider: "google".into(), + code: "code".into(), + state: "got-this-state".into(), + expected_state: "expected-state".into(), + }; + let err = provider.verify(&challenge).await.unwrap_err(); + assert!( + format!("{err}").contains("CSRF"), + "expected CSRF error, got: {err}" + ); +} diff --git a/_primitives/_rust/kei-auth-magiclink/Cargo.toml b/_primitives/_rust/kei-auth-magiclink/Cargo.toml index 6dcc596..2259cc9 100644 --- a/_primitives/_rust/kei-auth-magiclink/Cargo.toml +++ b/_primitives/_rust/kei-auth-magiclink/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "kei-auth-magiclink" version = "0.1.0" -edition = "2021" -rust-version = "1.75" +edition.workspace = true +rust-version.workspace = true description = "AuthProvider impl for passwordless email magic-link tokens (HMAC-SHA256, stateless). Wave 7 atomar; sibling of kei-auth (multi-tenant tokens) and kei-auth-{google,github,microsoft,apple}." authors = ["Denis Parfionovich "] license = "Apache-2.0" diff --git a/_primitives/_rust/kei-auth-webauthn/Cargo.toml b/_primitives/_rust/kei-auth-webauthn/Cargo.toml index 2a7abc0..c3696a5 100644 --- a/_primitives/_rust/kei-auth-webauthn/Cargo.toml +++ b/_primitives/_rust/kei-auth-webauthn/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "kei-auth-webauthn" version = "0.1.0" -edition = "2021" -rust-version = "1.75" +edition.workspace = true +rust-version.workspace = true description = "WebAuthn passkey AuthProvider impl for kei-runtime-core (Wave 7 atomar). Wraps webauthn-rs 0.5; stateless ceremony APIs (registration + authentication). Sibling of kei-auth-{google,apple,github,microsoft}." license = "Apache-2.0" authors = ["Denis Parfionovich "] diff --git a/_primitives/_rust/kei-auth/Cargo.toml b/_primitives/_rust/kei-auth/Cargo.toml index d5ce650..402ec79 100644 --- a/_primitives/_rust/kei-auth/Cargo.toml +++ b/_primitives/_rust/kei-auth/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "kei-auth" version = "0.1.0" -edition = "2021" -rust-version = "1.75" +edition.workspace = true +rust-version.workspace = true description = "Multi-tenant session tokens with scopes + HMAC-signed expiry (SQLite backend)." authors = ["Denis Parfionovich "] @@ -15,16 +15,16 @@ name = "kei_auth" path = "src/lib.rs" [dependencies] -rusqlite = { version = "0.31", features = ["bundled"] } -clap = { version = "4", features = ["derive"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -anyhow = "1" -chrono = { version = "0.4", default-features = false, features = ["clock"] } +rusqlite = { workspace = true } +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } hmac = "0.12" -sha2 = "0.10" +sha2 = { workspace = true } base64 = "0.22" rand = "0.8" [dev-dependencies] -tempfile = "3" +tempfile = { workspace = true } diff --git a/_primitives/_rust/kei-auth/src/main.rs b/_primitives/_rust/kei-auth/src/main.rs index 796691b..4ae282f 100644 --- a/_primitives/_rust/kei-auth/src/main.rs +++ b/_primitives/_rust/kei-auth/src/main.rs @@ -4,11 +4,15 @@ //! leaked the HMAC signing secret through `/proc//cmdline` and //! shell history. The only supported key source is the `KEI_AUTH_KEY` //! env var (sourced from `~/.claude/secrets/.env` per RULE 0.8). +//! +//! Token argument: pass `-` or set `KEI_AUTH_TOKEN` env var to avoid +//! leaking tokens via shell history or `/proc//cmdline`. use clap::{Parser, Subcommand}; use kei_auth::schema::open; use kei_auth::scopes::Scope; use kei_auth::tokens::{issue, revoke, verify}; +use std::io::Read; use std::path::PathBuf; use std::process::ExitCode; use std::str::FromStr; @@ -37,6 +41,20 @@ fn db_path(o: Option) -> PathBuf { PathBuf::from(home).join(".claude/auth/auth.sqlite") } +/// Read token from env `KEI_AUTH_TOKEN`, or from stdin when arg is `-`, +/// or return the arg as-is. Avoids token leakage via shell history. +fn resolve_token(arg: &str) -> anyhow::Result { + if let Ok(t) = std::env::var("KEI_AUTH_TOKEN") { + return Ok(t.trim().to_owned()); + } + if arg == "-" { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + return Ok(buf.trim().to_owned()); + } + Ok(arg.to_owned()) +} + fn key() -> anyhow::Result> { let k = std::env::var("KEI_AUTH_KEY").map_err(|_| { anyhow::anyhow!( @@ -48,6 +66,13 @@ fn key() -> anyhow::Result> { it leaked the secret via /proc//cmdline." ) })?; + if k.len() < 32 { + anyhow::bail!( + "KEI_AUTH_KEY must be ≥32 bytes (got {}). \ + Generate a strong key: export KEI_AUTH_KEY=\"$(openssl rand -hex 32)\"", + k.len() + ); + } Ok(k.into_bytes()) } @@ -61,11 +86,13 @@ fn run() -> anyhow::Result<()> { println!("{}", issue(&conn, &user, &project, sc, ttl, &k)?); } Cmd::Verify { token } => { - let out = verify(&conn, &token, &k)?; + let t = resolve_token(&token)?; + let out = verify(&conn, &t, &k)?; println!("user={} project={} scope={}", out.user_id, out.project, out.scope); } Cmd::Revoke { token } => { - let n = revoke(&conn, &token)?; + let t = resolve_token(&token)?; + let n = revoke(&conn, &t)?; println!("revoked {} row(s)", n); } } diff --git a/_primitives/_rust/kei-auth/src/tokens.rs b/_primitives/_rust/kei-auth/src/tokens.rs index 42011d0..da03b77 100644 --- a/_primitives/_rust/kei-auth/src/tokens.rs +++ b/_primitives/_rust/kei-auth/src/tokens.rs @@ -11,7 +11,7 @@ use anyhow::{anyhow, Result}; use base64::Engine; use chrono::Utc; use rand::RngCore; -use rusqlite::{params, Connection}; +use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::str::FromStr; @@ -94,9 +94,11 @@ pub fn verify(conn: &Connection, token: &str, key: &[u8]) -> Result = conn.query_row( "SELECT revoked_at FROM auth_tokens WHERE token_hash=?1", - params![hash], |r| r.get(0)).ok(); + params![hash], |r| r.get(0)).optional()?; match row { None => Err(anyhow!("token unknown to server")), Some(rev) if rev > 0 => Err(anyhow!("token revoked")), diff --git a/_primitives/_rust/kei-runtime-core/Cargo.toml b/_primitives/_rust/kei-runtime-core/Cargo.toml index 2d39993..2068f07 100644 --- a/_primitives/_rust/kei-runtime-core/Cargo.toml +++ b/_primitives/_rust/kei-runtime-core/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "kei-runtime-core" version = "0.1.0" -edition = "2021" -rust-version = "1.75" +edition.workspace = true +rust-version.workspace = true description = "Hosted Sleep runtime substrate — 12 traits + DNA + plugin registry. No impls; impls live in sibling kei-{compute,llm,git,...}-* crates." authors = ["Denis Parfionovich "] license = "Apache-2.0" @@ -12,17 +12,18 @@ name = "kei_runtime_core" path = "src/lib.rs" [dependencies] -async-trait = "0.1" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "1" -tokio = { version = "1", features = ["macros", "rt", "sync"] } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } sha2 = { workspace = true } rand = "0.8" +subtle = "2" kei-shared = { path = "../kei-shared" } [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true } [package.metadata.keisei] backend = "none" diff --git a/_primitives/_rust/kei-runtime-core/src/error.rs b/_primitives/_rust/kei-runtime-core/src/error.rs index 0b45e7e..07d4e8c 100644 --- a/_primitives/_rust/kei-runtime-core/src/error.rs +++ b/_primitives/_rust/kei-runtime-core/src/error.rs @@ -22,6 +22,9 @@ pub enum Error { #[error("auth: {0}")] Auth(String), + #[error("CSRF state mismatch")] + CsrfStateMismatch, + #[error("provider: {0}")] Provider(String), diff --git a/_primitives/_rust/kei-runtime-core/src/lib.rs b/_primitives/_rust/kei-runtime-core/src/lib.rs index 9f8965f..74cd885 100644 --- a/_primitives/_rust/kei-runtime-core/src/lib.rs +++ b/_primitives/_rust/kei-runtime-core/src/lib.rs @@ -19,12 +19,14 @@ pub mod dna; pub mod error; pub mod genealogy; pub mod registry; +pub mod secrets; pub mod traits; pub use dna::{Dna, DnaBuilder, HasDna}; pub use error::{Error, Result}; pub use genealogy::HasGenealogy; pub use registry::{Registry, RegistryEntry}; +pub use secrets::SecretString; pub use traits::*; // Re-export the wire-format SSoT from kei-shared so consumers don't need diff --git a/_primitives/_rust/kei-runtime-core/src/secrets.rs b/_primitives/_rust/kei-runtime-core/src/secrets.rs new file mode 100644 index 0000000..a649167 --- /dev/null +++ b/_primitives/_rust/kei-runtime-core/src/secrets.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 + +//! `SecretString` — a zeroising, redacting string newtype. +//! +//! Stores a sensitive value (password, API key, JWT secret) and ensures: +//! - `Debug` prints `""` — never the value. +//! - `Drop` zeroes the heap bytes before deallocation. +//! +//! The value is exposed only via [`SecretString::expose`], forcing callers +//! to be explicit about accessing the secret. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// A string whose `Debug` impl is redacted and whose `Drop` zeroes memory. +#[derive(Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SecretString(String); + +impl SecretString { + /// Wrap a value. The caller is responsible for not logging the input. + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + /// Expose the raw value. Name is intentionally verbose. + pub fn expose(&self) -> &str { + &self.0 + } +} + +/// Always prints `` — never the secret value. +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} + +/// Not `Display` — secrets should not be formatted accidentally. + +impl Drop for SecretString { + fn drop(&mut self) { + // Safety: we are zeroing the heap bytes of the String before it is + // freed. This is safe because: + // 1. We have exclusive ownership (we are in Drop). + // 2. The bytes are valid UTF-8 (all zeros is not, but the memory is + // about to be freed so UTF-8 invariant need not hold after). + // SAFETY: we're in Drop; the Vec backing is about to be freed. + unsafe { + let buf = self.0.as_bytes_mut(); + for b in buf.iter_mut() { + std::ptr::write_volatile(b, 0u8); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_is_redacted() { + let s = SecretString::new("super-secret"); + assert_eq!(format!("{:?}", s), ""); + } + + #[test] + fn expose_returns_value() { + let s = SecretString::new("my-password"); + assert_eq!(s.expose(), "my-password"); + } + + #[test] + fn clone_is_independent() { + let a = SecretString::new("abc"); + let b = a.clone(); + assert_eq!(a.expose(), b.expose()); + } +} diff --git a/_primitives/_rust/kei-runtime-core/src/traits/auth.rs b/_primitives/_rust/kei-runtime-core/src/traits/auth.rs index e38a998..e79d372 100644 --- a/_primitives/_rust/kei-runtime-core/src/traits/auth.rs +++ b/_primitives/_rust/kei-runtime-core/src/traits/auth.rs @@ -3,6 +3,7 @@ use crate::dna::{Dna, HasDna}; use crate::error::Result; +use crate::secrets::SecretString; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -17,8 +18,19 @@ pub struct AuthSession { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AuthChallenge { MagicLink { email: String }, - Password { email: String, password: String }, - OAuthCode { provider: String, code: String, state: String }, + /// `password` is wrapped in [`SecretString`] so it prints as + /// `` in logs and is zeroed on drop. + Password { email: String, password: SecretString }, + /// `state` — the value returned by the OAuth provider in the callback. + /// `expected_state` — the nonce generated when the auth URL was built; + /// must equal `state` (verified via constant-time comparison in each + /// provider's `verify()` impl). + OAuthCode { + provider: String, + code: String, + state: String, + expected_state: String, + }, SshKeySig { key_id: String, signature: String }, }