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
+20
@@ -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"
|
||||||
|
}
|
||||||
+30
@@ -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"
|
||||||
|
}
|
||||||
+29
@@ -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"
|
||||||
|
}
|
||||||
+19
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
@@ -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 }}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user