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>
This commit is contained in:
Parfii-bot 2026-05-02 21:39:18 +08:00
parent 85a61d7253
commit 8b0401b9db
25 changed files with 772 additions and 305 deletions

View file

@ -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 <info@greendragon.info>"]
@ -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]

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! 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<String>,
#[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<String>),
#[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,
}
}
}

View file

@ -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<TokenResponse> {
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<TokenResponse> {
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)

View file

@ -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<Error> 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}"))
}

View file

@ -1,43 +1,98 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//
//! 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<String>,
#[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<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.
///
/// 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<IdTokenClaims> {
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<IdTokenClaims> {
#[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(_)));

View file

@ -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;

View file

@ -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<Dna>,
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<Dna>) -> AppleResult<Self> {
/// `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<String>,
parent: Option<Dna>,
) -> AppleResult<Self> {
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<Dna> {
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<AuthSession> {
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)
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
}

View file

@ -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"));

View file

@ -0,0 +1,77 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//
//! 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::<Vec<_>>()
.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",
})
}

View file

@ -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 <info@greendragon.info>"]
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]

View file

@ -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 <access_token>`
//!
//! 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 `<redacted>` 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<TokenResponse> {
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<TokenResponse> {
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 }
}

View file

@ -28,7 +28,8 @@
//! let challenge = AuthChallenge::OAuthCode {
//! provider: "google".into(),
//! code: "<code from redirect>".into(),
//! state: "<csrf state>".into(),
//! state: "<csrf state from callback>".into(),
//! expected_state: "<csrf state you generated>".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;

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! 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, 43128 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");
}
}

View file

@ -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<Dna>) -> Result<Self> {
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<AuthSession> {
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<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 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"));
}
}

View file

@ -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}"
);
}

View file

@ -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 <info@greendragon.info>"]
license = "Apache-2.0"

View file

@ -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 <info@greendragon.info>"]

View file

@ -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 <info@greendragon.info>"]
@ -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 }

View file

@ -4,11 +4,15 @@
//! leaked the HMAC signing secret through `/proc/<pid>/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/<pid>/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 {
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<String> {
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<Vec<u8>> {
let k = std::env::var("KEI_AUTH_KEY").map_err(|_| {
anyhow::anyhow!(
@ -48,6 +66,13 @@ fn key() -> anyhow::Result<Vec<u8>> {
it leaked the secret via /proc/<pid>/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);
}
}

View file

@ -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<VerifyOutcom
return Err(anyhow!("token expired"));
}
let hash = sha256_hex(token.as_bytes());
// Use `.optional()?` to distinguish DB errors (propagated) from
// genuine absence (token not in DB → "unknown to server").
let row: Option<i64> = 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")),

View file

@ -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 <info@greendragon.info>"]
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"

View file

@ -22,6 +22,9 @@ pub enum Error {
#[error("auth: {0}")]
Auth(String),
#[error("CSRF state mismatch")]
CsrfStateMismatch,
#[error("provider: {0}")]
Provider(String),

View file

@ -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

View file

@ -0,0 +1,81 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//! `SecretString` — a zeroising, redacting string newtype.
//!
//! Stores a sensitive value (password, API key, JWT secret) and ensures:
//! - `Debug` prints `"<redacted>"` — 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<String>) -> Self {
Self(s.into())
}
/// Expose the raw value. Name is intentionally verbose.
pub fn expose(&self) -> &str {
&self.0
}
}
/// Always prints `<redacted>` — never the secret value.
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
/// 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<u8> 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), "<redacted>");
}
#[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());
}
}

View file

@ -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
/// `<redacted>` 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 },
}