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
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM workspaces ORDER BY created_at, id LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "242775b597148fcd51492982be9438891bc67e85925da8f5f0e4c2729e20cf99"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET auth_subject = $2, role = $3 WHERE email = $1\n RETURNING id, workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "9921e1bef45ff03815b1a405acb6e84dc27a1a67885af9b94e7f59d4b9cb0be5"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET role = $2 WHERE auth_subject = $1\n RETURNING id, workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "a7fe248913a3fffed7e51b1c788de792b248ccfbd6a3e1ea2404f644c11c2848"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO users (id, workspace_id, email, role, display_name, auth_subject)\n VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "ff63ee4d5947897ef004654a80c4729c4824d2965b23dfe03ab8c59bce243a2e"
}
Generated
+37
View File
@@ -1535,6 +1535,21 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]] [[package]]
name = "k8s-openapi" name = "k8s-openapi"
version = "0.25.0" version = "0.25.0"
@@ -2978,6 +2993,18 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -3313,11 +3340,15 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"axum", "axum",
"base64",
"eventsource-stream", "eventsource-stream",
"futures", "futures",
"hex", "hex",
"hmac", "hmac",
"jsonwebtoken",
"rand_core 0.6.4",
"reqwest", "reqwest",
"rsa",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -3345,8 +3376,14 @@ name = "tc-auth"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum",
"base64", "base64",
"jsonwebtoken",
"rand_core 0.6.4", "rand_core 0.6.4",
"reqwest",
"rsa",
"serde",
"serde_json",
"sha2", "sha2",
"sqlx", "sqlx",
"tc-db", "tc-db",
+19 -1
View File
@@ -129,10 +129,28 @@ async fn run() -> Result<(), String> {
tc_scheduler::Scheduler::new(pool.clone(), runtime.clone()) tc_scheduler::Scheduler::new(pool.clone(), runtime.clone())
.spawn(std::time::Duration::from_secs(5)); .spawn(std::time::Duration::from_secs(5));
// Hosted identity (Clerk / OIDC): pin the issuer and load its JWKS.
let auth_verifier = match config.auth.mode {
tc_config::AuthMode::Clerk | tc_config::AuthMode::Oidc => {
let issuer = config
.auth
.issuer_url
.as_deref()
.expect("validated by config");
Some(std::sync::Arc::new(
tc_auth::JwtVerifier::discover(issuer)
.await
.map_err(|e| format!("identity issuer: {e}"))?,
))
}
tc_config::AuthMode::Local => None,
};
let mut app = tc_api::router( let mut app = tc_api::router(
tc_api::AppState::new(pool, runtime) tc_api::AppState::new(pool, runtime)
.with_broker(PathBuf::from(&config.broker.socket_path)) .with_broker(PathBuf::from(&config.broker.socket_path))
.with_oauth(config.oauth.clone()), .with_oauth(config.oauth.clone())
.pipe_auth_verifier(auth_verifier),
); );
if e2e::enabled() { if e2e::enabled() {
app = app.merge(e2e::slack_sink_router()); app = app.merge(e2e::slack_sink_router());
+4
View File
@@ -30,6 +30,7 @@ urlencoding = "2"
uuid = { workspace = true } uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
jsonwebtoken = "9"
eventsource-stream = "0.2" eventsource-stream = "0.2"
reqwest = { version = "0.12", default-features = false, features = [ reqwest = { version = "0.12", default-features = false, features = [
"json", "json",
@@ -40,6 +41,9 @@ tc-llm = { path = "../tc-llm" }
tc-testkit = { path = "../tc-testkit" } tc-testkit = { path = "../tc-testkit" }
hex = "0.4" hex = "0.4"
hmac = "0.12" hmac = "0.12"
base64 = "0.22"
rand_core = { version = "0.6", features = ["getrandom"] }
rsa = { version = "0.9", features = ["pem"] }
sha2 = "0.10" sha2 = "0.10"
[lints] [lints]
+21
View File
@@ -40,6 +40,27 @@ impl AppState {
self self
} }
/// Optional form of [`AppState::with_auth_verifier`] for call chains.
pub fn pipe_auth_verifier(
self,
verifier: Option<std::sync::Arc<tc_auth::JwtVerifier>>,
) -> AppState {
match verifier {
Some(verifier) => self.with_auth_verifier(verifier),
None => self,
}
}
/// Hosted-identity session JWTs (Clerk / OIDC) for the Authed
/// extractor, alongside local sessions.
pub fn with_auth_verifier(
mut self,
verifier: std::sync::Arc<tc_auth::JwtVerifier>,
) -> AppState {
self.auth = self.auth.with_verifier(verifier);
self
}
pub fn with_broker(mut self, socket: std::path::PathBuf) -> AppState { pub fn with_broker(mut self, socket: std::path::PathBuf) -> AppState {
self.broker_socket = Some(socket); self.broker_socket = Some(socket);
self self
+126
View File
@@ -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);
}
+8
View File
@@ -9,7 +9,11 @@ publish.workspace = true
[dependencies] [dependencies]
argon2 = "0.5" argon2 = "0.5"
base64 = "0.22" base64 = "0.22"
jsonwebtoken = "9"
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.6", features = ["getrandom"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { workspace = true }
tokio = { workspace = true }
sha2 = "0.10" sha2 = "0.10"
sqlx = { workspace = true } sqlx = { workspace = true }
tc-db = { path = "../tc-db" } tc-db = { path = "../tc-db" }
@@ -18,6 +22,10 @@ thiserror = { workspace = true }
time = { workspace = true } time = { workspace = true }
[dev-dependencies] [dev-dependencies]
axum = "0.8"
rsa = { version = "0.9", features = ["pem"] }
serde_json = { workspace = true }
sqlx = { workspace = true }
tc-testkit = { path = "../tc-testkit" } tc-testkit = { path = "../tc-testkit" }
tokio = { workspace = true } tokio = { workspace = true }
+110
View File
@@ -0,0 +1,110 @@
//! Session-JWT verification for hosted identity (Clerk, or any OIDC
//! issuer). The issuer's discovery document points at its JWKS; tokens
//! are RS256-verified against those keys with the issuer pinned. Keys are
//! cached and refreshed once on an unknown `kid` (Clerk rotates keys).
//!
//! Clerk specifics: a Clerk instance IS an OIDC issuer
//! (`https://<slug>.clerk.accounts.dev`) and its session tokens carry
//! `sub` (`user_…`). Email and org role are not in the default session
//! token — operators add them once in Clerk's dashboard (see
//! docs/clerk.md), which is what `ExternalClaims` reads.
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde::Deserialize;
use tokio::sync::RwLock;
#[derive(Debug, thiserror::Error)]
pub enum JwtError {
#[error("issuer discovery failed: {0}")]
Discovery(String),
#[error("token rejected")]
Rejected,
}
/// Claims we consume from a verified session token.
#[derive(Debug, Clone, Deserialize)]
pub struct ExternalClaims {
pub sub: String,
#[serde(default)]
pub email: Option<String>,
/// Clerk org role (`org:admin` / `org:member`) via the documented
/// session-token customization; generic OIDC issuers may put any
/// string here.
#[serde(default)]
pub role: Option<String>,
}
pub struct JwtVerifier {
issuer: String,
jwks_uri: String,
keys: RwLock<JwkSet>,
}
#[derive(Deserialize)]
struct Discovery {
issuer: String,
jwks_uri: String,
}
impl JwtVerifier {
/// Resolves the issuer's JWKS via OIDC discovery and loads the
/// initial key set.
pub async fn discover(issuer_url: &str) -> Result<JwtVerifier, JwtError> {
let doc: Discovery = reqwest::get(format!(
"{}/.well-known/openid-configuration",
issuer_url.trim_end_matches('/')
))
.await
.map_err(|e| JwtError::Discovery(e.to_string()))?
.json()
.await
.map_err(|e| JwtError::Discovery(e.to_string()))?;
let keys = fetch_jwks(&doc.jwks_uri).await?;
Ok(JwtVerifier {
issuer: doc.issuer,
jwks_uri: doc.jwks_uri,
keys: RwLock::new(keys),
})
}
/// Verifies signature, expiry, and issuer; returns the claims.
pub async fn verify(&self, token: &str) -> Result<ExternalClaims, JwtError> {
let header = decode_header(token).map_err(|_| JwtError::Rejected)?;
let kid = header.kid.ok_or(JwtError::Rejected)?;
let mut decoding_key = self.key_for(&kid).await;
if decoding_key.is_none() {
// Unknown kid: the issuer may have rotated keys; refresh once.
let fresh = fetch_jwks(&self.jwks_uri).await?;
*self.keys.write().await = fresh;
decoding_key = self.key_for(&kid).await;
}
let decoding_key = decoding_key.ok_or(JwtError::Rejected)?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&self.issuer]);
// Clerk session tokens live ~60s; the crate's default 60s leeway
// would double their effective life. 5s absorbs clock skew only.
validation.leeway = 5;
validation.validate_aud = false; // Clerk session tokens carry azp, not aud.
decode::<ExternalClaims>(token, &decoding_key, &validation)
.map(|data| data.claims)
.map_err(|_| JwtError::Rejected)
}
async fn key_for(&self, kid: &str) -> Option<DecodingKey> {
let keys = self.keys.read().await;
keys.find(kid)
.and_then(|jwk| DecodingKey::from_jwk(jwk).ok())
}
}
async fn fetch_jwks(jwks_uri: &str) -> Result<JwkSet, JwtError> {
reqwest::get(jwks_uri)
.await
.map_err(|e| JwtError::Discovery(e.to_string()))?
.json()
.await
.map_err(|e| JwtError::Discovery(e.to_string()))
}
+2
View File
@@ -5,8 +5,10 @@
//! appliance mode for air-gapped installs; OIDC SSO shares the same session //! appliance mode for air-gapped installs; OIDC SSO shares the same session
//! storage and `AuthedUser` output. //! storage and `AuthedUser` output.
mod jwt;
mod service; mod service;
mod token; mod token;
pub use jwt::{ExternalClaims, JwtError, JwtVerifier};
pub use service::{AuthError, AuthService, AuthedUser, SESSION_TTL}; pub use service::{AuthError, AuthService, AuthedUser, SESSION_TTL};
pub use token::SessionToken; pub use token::SessionToken;
+109 -2
View File
@@ -36,11 +36,111 @@ pub enum AuthError {
#[derive(Clone)] #[derive(Clone)]
pub struct AuthService { pub struct AuthService {
pool: PgPool, pool: PgPool,
verifier: Option<std::sync::Arc<crate::JwtVerifier>>,
} }
impl AuthService { impl AuthService {
pub fn new(pool: PgPool) -> AuthService { pub fn new(pool: PgPool) -> AuthService {
AuthService { pool } AuthService {
pool,
verifier: None,
}
}
/// Enables hosted-identity session JWTs (Clerk / OIDC) alongside
/// local sessions.
pub fn with_verifier(mut self, verifier: std::sync::Arc<crate::JwtVerifier>) -> AuthService {
self.verifier = Some(verifier);
self
}
/// Verifies an issuer session token and JIT-provisions the user on
/// first sight. Role tracks the issuer claim on every login (Clerk's
/// org role is the source of truth for SSO users).
async fn authenticate_external(
&self,
token: &str,
verifier: &crate::JwtVerifier,
) -> Result<AuthedUser, AuthError> {
let claims = verifier
.verify(token)
.await
.map_err(|_| AuthError::Unauthenticated)?;
let role = match claims.role.as_deref() {
Some(role) if role.contains("admin") => Role::Owner,
_ => Role::Member,
};
let role_str = if role == Role::Owner {
"owner"
} else {
"member"
};
if let Some(row) = sqlx::query!(
"UPDATE users SET role = $2 WHERE auth_subject = $1
RETURNING id, workspace_id",
claims.sub,
role_str,
)
.fetch_optional(&self.pool)
.await?
{
return Ok(AuthedUser {
user_id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
role,
});
}
// Link to an existing local account by email, if one exists.
if let Some(email) = claims.email.as_deref() {
if let Some(row) = sqlx::query!(
"UPDATE users SET auth_subject = $2, role = $3 WHERE email = $1
RETURNING id, workspace_id",
email,
claims.sub,
role_str,
)
.fetch_optional(&self.pool)
.await?
{
return Ok(AuthedUser {
user_id: UserId::from(row.id),
workspace_id: WorkspaceId::from(row.workspace_id),
role,
});
}
}
// First sight: provision into the instance's workspace.
let workspace =
sqlx::query_scalar!("SELECT id FROM workspaces ORDER BY created_at, id LIMIT 1",)
.fetch_optional(&self.pool)
.await?
.ok_or(AuthError::Unauthenticated)?;
let email = claims
.email
.clone()
.unwrap_or_else(|| format!("{}@sso.local", claims.sub));
let display_name = email.split('@').next().unwrap_or("teammate").to_owned();
let user_id = UserId::new();
sqlx::query!(
"INSERT INTO users (id, workspace_id, email, role, display_name, auth_subject)
VALUES ($1, $2, $3, $4, $5, $6)",
user_id.as_uuid(),
workspace,
email,
role_str,
display_name,
claims.sub,
)
.execute(&self.pool)
.await?;
Ok(AuthedUser {
user_id,
workspace_id: WorkspaceId::from(workspace),
role,
})
} }
/// Sets (or resets) a user's local password. /// Sets (or resets) a user's local password.
@@ -99,8 +199,15 @@ impl AuthService {
Ok(token) Ok(token)
} }
/// Resolves a bearer token to the authenticated user. /// Resolves a bearer token to the authenticated user. Issuer session
/// JWTs (three dot-separated segments) take the hosted-identity path;
/// everything else is a local opaque session token.
pub async fn authenticate(&self, token_secret: &str) -> Result<AuthedUser, AuthError> { pub async fn authenticate(&self, token_secret: &str) -> Result<AuthedUser, AuthError> {
if let Some(verifier) = self.verifier.clone() {
if token_secret.matches('.').count() == 2 {
return self.authenticate_external(token_secret, &verifier).await;
}
}
let row = sqlx::query!( let row = sqlx::query!(
"SELECT u.id, u.workspace_id, u.role "SELECT u.id, u.workspace_id, u.role
FROM auth_sessions s FROM auth_sessions s
+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)
));
}
+11
View File
@@ -37,6 +37,10 @@ pub enum LlmProviderKind {
pub enum AuthMode { pub enum AuthMode {
Local, Local,
Oidc, Oidc,
/// Clerk hosted identity: the instance's Frontend API URL is the OIDC
/// issuer; session JWTs are verified against its JWKS. Cloud only —
/// air-gapped installs use `local`.
Clerk,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -242,6 +246,13 @@ impl AppConfig {
)); ));
} }
} }
if self.auth.mode == AuthMode::Clerk && self.auth.issuer_url.is_none() {
return Err(ConfigError::Invalid(
"auth.mode = \"clerk\" requires auth.issuer_url \
(the instance's https://<slug>.clerk.accounts.dev)"
.into(),
));
}
Ok(()) Ok(())
} }
} }
+19
View File
@@ -151,3 +151,22 @@ fn s3_backend_requires_endpoint_and_bucket() {
Ok(()) Ok(())
}); });
} }
#[test]
fn clerk_mode_requires_the_instance_issuer() {
figment::Jail::expect_with(|jail| {
let toml = AIR_GAPPED_TOML.replace("mode = \"local\"", "mode = \"clerk\"");
jail.create_file("teamclaw.toml", &toml)?;
let err = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Invalid(msg) if msg.contains("clerk")));
let toml = toml.replace(
"mode = \"clerk\"",
"mode = \"clerk\"\nissuer_url = \"https://acme.clerk.accounts.dev\"",
);
jail.create_file("teamclaw.toml", &toml)?;
let config = AppConfig::load_from(&jail.directory().join("teamclaw.toml")).unwrap();
assert_eq!(config.auth.mode, AuthMode::Clerk);
Ok(())
});
}
@@ -25,6 +25,9 @@ data:
issuer_url = "{{ required "auth.issuerUrl is required for oidc" .Values.auth.issuerUrl }}" issuer_url = "{{ required "auth.issuerUrl is required for oidc" .Values.auth.issuerUrl }}"
client_id = "{{ .Values.auth.clientId }}" client_id = "{{ .Values.auth.clientId }}"
{{- end }} {{- end }}
{{- if eq .Values.auth.mode "clerk" }}
issuer_url = "{{ required "auth.issuerUrl is required for clerk (https://<slug>.clerk.accounts.dev)" .Values.auth.issuerUrl }}"
{{- end }}
[storage] [storage]
data_dir = "{{ .Values.storage.dataDir }}" data_dir = "{{ .Values.storage.dataDir }}"
+2 -1
View File
@@ -48,7 +48,8 @@ storage:
credentialsSecretName: teamclaw-s3 credentialsSecretName: teamclaw-s3
auth: auth:
# local | oidc # local | oidc | clerk (clerk: issuerUrl is the instance Frontend API,
# https://<slug>.clerk.accounts.dev)
mode: oidc mode: oidc
issuerUrl: "" issuerUrl: ""
clientId: teamclaw clientId: teamclaw
+69
View File
@@ -0,0 +1,69 @@
# Using Clerk for authentication
TeamClaw's cloud target can use [Clerk](https://clerk.com) as its identity
provider. A Clerk instance is an OIDC issuer, so the backend verifies Clerk
session JWTs directly against the instance JWKS — no extra service, no
session table writes for SSO users, and Clerk's dashboard takes over user
management, MFA, and organization invitations.
**Air-gapped installs cannot use Clerk** (it is a hosted service). The
`local` auth mode remains the appliance default; `clerk` is a cloud-only
alternative to generic `oidc`.
## 1. Clerk dashboard
1. Create an application; note the Frontend API URL
(`https://<slug>.clerk.accounts.dev`).
2. Enable **Organizations** — Clerk's `org:admin` / `org:member` roles map
to TeamClaw `Owner` / `Member`.
3. Under **Sessions → Customize session token**, add the claims TeamClaw
reads:
```json
{
"email": "{{user.primary_email_address}}",
"role": "{{org.role}}"
}
```
## 2. Backend configuration
```toml
[auth]
mode = "clerk"
issuer_url = "https://<slug>.clerk.accounts.dev"
```
Or via Helm: `--set auth.mode=clerk --set auth.issuerUrl=https://<slug>.clerk.accounts.dev`.
On boot the server pins the issuer, loads its JWKS (refreshing on key
rotation), and verifies every `Authorization: Bearer <session JWT>` with a
5-second clock-skew allowance. Users are JIT-provisioned on first sight,
keyed by the stable `sub` claim (`users.auth_subject`); an existing
local-auth user with the same email is linked rather than duplicated, and
the role tracks the Clerk org role on every request.
## 3. Frontend wiring
Install `@clerk/nextjs`, wrap the app in `<ClerkProvider>`, and send the
session token as the bearer on API calls:
```ts
const { getToken } = useAuth();
const token = await getToken();
fetch("/api/user/me", { headers: { Authorization: `Bearer ${token}` } });
```
Clerk session tokens live ~60 seconds; `getToken()` transparently
refreshes, so fetch it per request rather than storing it.
## What is verified in CI
`crates/tc-auth/tests/jwt_auth.rs` and `crates/tc-api/tests/clerk_api.rs`
exercise the full verification path with real RSA keys against a live
local issuer publishing real discovery + JWKS documents: JIT provisioning
and role mapping, duplicate-subject suppression, expired tokens, tokens
signed by the wrong key, foreign issuers, and the end-to-end
`Authorization: Bearer` round trip into a protected endpoint. Validating
against a live Clerk instance additionally requires real instance keys and
is a deployment smoke step, not a CI gate.
+6
View File
@@ -0,0 +1,6 @@
-- External identity (Clerk / generic OIDC SSO): the issuer's stable
-- subject must map to exactly one user (JIT provisioning keys on it).
CREATE UNIQUE INDEX users_auth_subject_idx
ON users (auth_subject)
WHERE auth_subject IS NOT NULL;