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,126 @@
|
||||
//! The full stack accepts Clerk session JWTs: a real RSA issuer, a real
|
||||
//! router, and `Authorization: Bearer <session JWT>` straight into a
|
||||
//! protected endpoint — the exact shape `@clerk/nextjs`'s getToken()
|
||||
//! produces.
|
||||
|
||||
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, Value};
|
||||
use tc_api::AppState;
|
||||
use tc_auth::JwtVerifier;
|
||||
use tc_domain::{Workspace, WorkspaceId};
|
||||
use tc_llm::ScriptedProvider;
|
||||
use tc_runtime::{Runtime, RuntimeConfig};
|
||||
|
||||
fn b64url(bytes: &[u8]) -> String {
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_clerk_session_jwt_reaches_protected_endpoints() {
|
||||
let pool = tc_testkit::test_pool().await;
|
||||
|
||||
// Real issuer with a real key.
|
||||
let mut rng = rand_core::OsRng;
|
||||
let key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
|
||||
let public = key.to_public_key();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let issuer = format!("http://{}", listener.local_addr().unwrap());
|
||||
let jwks = json!({"keys": [{
|
||||
"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "k1",
|
||||
"n": b64url(&public.n().to_bytes_be()),
|
||||
"e": b64url(&public.e().to_bytes_be()),
|
||||
}]});
|
||||
let discovery = json!({
|
||||
"issuer": issuer,
|
||||
"jwks_uri": format!("{issuer}/.well-known/jwks.json"),
|
||||
});
|
||||
let idp = 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, idp).await.unwrap();
|
||||
});
|
||||
|
||||
let ws = Workspace {
|
||||
id: WorkspaceId::new(),
|
||||
name: "Acme".into(),
|
||||
plan: "team".into(),
|
||||
};
|
||||
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
||||
|
||||
// The app, Clerk-configured.
|
||||
let runtime = Runtime::new(
|
||||
pool.clone(),
|
||||
Arc::new(ScriptedProvider::from_toml("").unwrap()),
|
||||
RuntimeConfig::basic("scripted", 1024),
|
||||
);
|
||||
let verifier = JwtVerifier::discover(&issuer).await.unwrap();
|
||||
let app =
|
||||
tc_api::router(AppState::new(pool.clone(), runtime).with_auth_verifier(Arc::new(verifier)));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
// What getToken() returns in the browser.
|
||||
let now = time::OffsetDateTime::now_utc().unix_timestamp();
|
||||
let mut header = Header::new(Algorithm::RS256);
|
||||
header.kid = Some("k1".into());
|
||||
let pem = key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF).unwrap();
|
||||
let session_jwt = encode(
|
||||
&header,
|
||||
&json!({
|
||||
"iss": issuer, "sub": "user_2clerk", "iat": now, "exp": now + 60,
|
||||
"email": "[email protected]", "role": "org:admin",
|
||||
}),
|
||||
&EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let me: Value = client
|
||||
.get(format!("{base}/api/user/me"))
|
||||
.bearer_auth(&session_jwt)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(me["email"], "[email protected]");
|
||||
assert_eq!(me["role"], "owner");
|
||||
|
||||
// No token still means no entry.
|
||||
let anonymous = client
|
||||
.get(format!("{base}/api/user/me"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(anonymous.status(), 401);
|
||||
}
|
||||
Reference in New Issue
Block a user