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:
Omar Sobh
2026-06-10 10:29:27 -05:00
co-authored by Claude Fable 5
parent ace66d7ffb
commit cbc8d35a2e
21 changed files with 872 additions and 4 deletions
+227
View File
@@ -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)
));
}