Clerk authentication: hosted-identity session JWTs as a first-class mode
- tc-auth JwtVerifier: OIDC discovery -> JWKS, RS256 with the issuer pinned, 5s leeway (the crate's default 60s would double the life of Clerk's 60s session tokens), key cache with one refresh on unknown kid (Clerk rotates). Serves auth.mode = clerk AND generic oidc — a Clerk instance IS an OIDC issuer, so one verifier covers both - AuthService.authenticate dispatches: JWT-shaped bearers take the hosted-identity path, everything else stays a local opaque session. External users JIT-provision keyed by the stable sub claim (users.auth_subject, unique partial index in migration 0007); an existing local account with the same email is LINKED, not duplicated; role tracks the issuer claim every request (org:admin -> Owner) - Config auth.mode = "clerk" (requires issuer_url; validated), server pins the issuer at boot, Helm values/configmap accept mode=clerk - Tests with REAL crypto, no mocks: fresh RSA keypairs, a live local issuer publishing real discovery + JWKS docs, Clerk-shaped tokens — JIT + role mapping, repeat-subject no-dup, expired refused (leeway regression), wrong-key forgery refused, foreign issuer refused, and the full router round trip with Authorization: Bearer <session JWT> - docs/clerk.md: dashboard session-token customization (email + org role claims), config, @clerk/nextjs getToken() wiring, what CI proves 157 Rust + 63 frontend tests + 29 journeys. Air-gapped installs keep local auth — Clerk is a cloud-only alternative, not a replacement. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ace66d7ffb
commit
cbc8d35a2e
@@ -0,0 +1,227 @@
|
||||
//! Clerk / OIDC session-token auth, tested with REAL crypto against a
|
||||
//! REAL issuer: a fresh RSA keypair, a live HTTP server publishing the
|
||||
//! OIDC discovery document and JWKS, and RS256 tokens shaped exactly like
|
||||
//! Clerk session JWTs. No mocks — this is the verification path itself.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
use rsa::pkcs1::EncodeRsaPrivateKey;
|
||||
use rsa::traits::PublicKeyParts;
|
||||
use rsa::RsaPrivateKey;
|
||||
use serde_json::json;
|
||||
use tc_auth::{AuthError, AuthService, JwtVerifier};
|
||||
use tc_domain::Role;
|
||||
|
||||
struct Issuer {
|
||||
url: String,
|
||||
encoding_key: EncodingKey,
|
||||
other_key: EncodingKey,
|
||||
}
|
||||
|
||||
fn b64url(bytes: &[u8]) -> String {
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
/// Generates a real RSA key, publishes its JWKS + discovery doc on a live
|
||||
/// local server, and returns signing keys (the real one and an imposter).
|
||||
async fn spawn_issuer() -> Issuer {
|
||||
let mut rng = rand_core::OsRng;
|
||||
let key = RsaPrivateKey::new(&mut rng, 2048).expect("keygen");
|
||||
let imposter = RsaPrivateKey::new(&mut rng, 2048).expect("keygen");
|
||||
let public = key.to_public_key();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let url = format!("http://{addr}");
|
||||
|
||||
let jwks = json!({
|
||||
"keys": [{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": "tc-test-key",
|
||||
"n": b64url(&public.n().to_bytes_be()),
|
||||
"e": b64url(&public.e().to_bytes_be()),
|
||||
}]
|
||||
});
|
||||
let discovery = json!({
|
||||
"issuer": url,
|
||||
"jwks_uri": format!("{url}/.well-known/jwks.json"),
|
||||
});
|
||||
let app = axum::Router::new()
|
||||
.route(
|
||||
"/.well-known/openid-configuration",
|
||||
axum::routing::get({
|
||||
let doc = discovery.clone();
|
||||
move || {
|
||||
let doc = doc.clone();
|
||||
async move { axum::Json(doc) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/.well-known/jwks.json",
|
||||
axum::routing::get({
|
||||
let doc = jwks.clone();
|
||||
move || {
|
||||
let doc = doc.clone();
|
||||
async move { axum::Json(doc) }
|
||||
}
|
||||
}),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let pem = key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF).unwrap();
|
||||
let imposter_pem = imposter.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF).unwrap();
|
||||
Issuer {
|
||||
url,
|
||||
encoding_key: EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap(),
|
||||
other_key: EncodingKey::from_rsa_pem(imposter_pem.as_bytes()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Clerk-shaped session token: iss, sub `user_…`, short exp, plus the
|
||||
/// custom claims our docs tell operators to add (email, role).
|
||||
fn token(
|
||||
issuer: &Issuer,
|
||||
key: &EncodingKey,
|
||||
sub: &str,
|
||||
email: &str,
|
||||
role: &str,
|
||||
exp_offset: i64,
|
||||
) -> String {
|
||||
let now = time::OffsetDateTime::now_utc().unix_timestamp();
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some("tc-test-key".into());
|
||||
encode(
|
||||
&header,
|
||||
&json!({
|
||||
"iss": issuer.url,
|
||||
"sub": sub,
|
||||
"iat": now,
|
||||
"exp": now + exp_offset,
|
||||
"email": email,
|
||||
"role": role,
|
||||
}),
|
||||
key,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clerk_tokens_authenticate_with_jit_provisioning_and_role_mapping() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let issuer = spawn_issuer().await;
|
||||
let ws = tc_domain::Workspace {
|
||||
id: tc_domain::WorkspaceId::new(),
|
||||
name: "Acme".into(),
|
||||
plan: "team".into(),
|
||||
};
|
||||
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
||||
|
||||
let verifier = JwtVerifier::discover(&issuer.url).await.expect("discovery");
|
||||
let auth = AuthService::new(pool.clone()).with_verifier(Arc::new(verifier));
|
||||
|
||||
// An org admin signs in for the first time: JIT-provisioned as Owner.
|
||||
let admin = token(
|
||||
&issuer,
|
||||
&issuer.encoding_key,
|
||||
"user_2adm",
|
||||
"[email protected]",
|
||||
"org:admin",
|
||||
60,
|
||||
);
|
||||
let authed = auth.authenticate(&admin).await.expect("admin verifies");
|
||||
assert_eq!(authed.role, Role::Owner);
|
||||
assert_eq!(authed.workspace_id, ws.id);
|
||||
|
||||
// Same subject again: the SAME user, not a duplicate.
|
||||
let again = auth.authenticate(&admin).await.unwrap();
|
||||
assert_eq!(again.user_id, authed.user_id);
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM users WHERE auth_subject = 'user_2adm'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
// A plain member maps to Member.
|
||||
let member = token(
|
||||
&issuer,
|
||||
&issuer.encoding_key,
|
||||
"user_2mem",
|
||||
"[email protected]",
|
||||
"org:member",
|
||||
60,
|
||||
);
|
||||
let authed = auth.authenticate(&member).await.unwrap();
|
||||
assert_eq!(authed.role, Role::Member);
|
||||
|
||||
// Expired tokens are refused.
|
||||
let expired = token(
|
||||
&issuer,
|
||||
&issuer.encoding_key,
|
||||
"user_2adm",
|
||||
"[email protected]",
|
||||
"org:admin",
|
||||
-10,
|
||||
);
|
||||
assert!(matches!(
|
||||
auth.authenticate(&expired).await,
|
||||
Err(AuthError::Unauthenticated)
|
||||
));
|
||||
|
||||
// A token signed by a DIFFERENT key — even with perfect claims — fails.
|
||||
let forged = token(
|
||||
&issuer,
|
||||
&issuer.other_key,
|
||||
"user_2adm",
|
||||
"[email protected]",
|
||||
"org:admin",
|
||||
60,
|
||||
);
|
||||
assert!(matches!(
|
||||
auth.authenticate(&forged).await,
|
||||
Err(AuthError::Unauthenticated)
|
||||
));
|
||||
|
||||
// Garbage is refused without panicking.
|
||||
assert!(auth.authenticate("not.a.jwt").await.is_err());
|
||||
assert!(auth.authenticate("xyz").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_wrong_issuer_is_refused() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
let real = spawn_issuer().await;
|
||||
let evil = spawn_issuer().await;
|
||||
let ws = tc_domain::Workspace {
|
||||
id: tc_domain::WorkspaceId::new(),
|
||||
name: "Acme".into(),
|
||||
plan: "team".into(),
|
||||
};
|
||||
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
||||
|
||||
let verifier = JwtVerifier::discover(&real.url).await.unwrap();
|
||||
let auth = AuthService::new(pool.clone()).with_verifier(Arc::new(verifier));
|
||||
|
||||
// Signed correctly by the EVIL issuer's own key, claiming its own iss:
|
||||
// the verifier pinned to the real issuer must refuse it.
|
||||
let foreign = token(
|
||||
&evil,
|
||||
&evil.encoding_key,
|
||||
"user_2evil",
|
||||
"[email protected]",
|
||||
"org:admin",
|
||||
60,
|
||||
);
|
||||
assert!(matches!(
|
||||
auth.authenticate(&foreign).await,
|
||||
Err(AuthError::Unauthenticated)
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user