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:
parent
4afc85ca30
commit
f47c087646
25 changed files with 772 additions and 305 deletions
|
|
@ -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]
|
||||
|
|
|
|||
47
_primitives/_rust/kei-auth-apple/src/claims.rs
Normal file
47
_primitives/_rust/kei-auth-apple/src/claims.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(_)));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
77
_primitives/_rust/kei-auth-apple/tests/helpers/mod.rs
Normal file
77
_primitives/_rust/kei-auth-apple/tests/helpers/mod.rs
Normal 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",
|
||||
})
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
51
_primitives/_rust/kei-auth-google/src/pkce.rs
Normal file
51
_primitives/_rust/kei-auth-google/src/pkce.rs
Normal 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, 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>"]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ pub enum Error {
|
|||
#[error("auth: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("CSRF state mismatch")]
|
||||
CsrfStateMismatch,
|
||||
|
||||
#[error("provider: {0}")]
|
||||
Provider(String),
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
81
_primitives/_rust/kei-runtime-core/src/secrets.rs
Normal file
81
_primitives/_rust/kei-runtime-core/src/secrets.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue