Last open item from the roadmap + post-1.0 list. Run against the live Clerk instance closing-seasnail-39.clerk.accounts.dev. - Backend (crates/cm-auth/tests/live_clerk.rs, CM_LIVE_CLERK=1): pulls REAL discovery + JWKS from the live instance, mints a REAL session JWT via Clerk's Backend API (create user -> open session -> session token), and runs it through AuthService::authenticate — verify + JIT provision (keyed on the real sub), duplicate-subject suppression, tamper rejection against the live JWKS. Decodes the instance domain from the publishable key; cleans up the test user after. PASSING - Frontend: built with AUTH_MODE=clerk + real keys, next start serves Clerk's <SignIn /> at /login wired to the instance (instance domain + data-clerk attributes present in the HTML). Both halves confirmed end to end against production Clerk - docs/clerk.md: documented the smoke procedure for both halves 166 Rust tests (+6 live, key-gated). Keys used via env only, never stored — rotate them (they passed through chat). Co-Authored-By: Claude Fable 5 <[email protected]>
134 lines
4.5 KiB
Rust
134 lines
4.5 KiB
Rust
//! Deployment smoke against a REAL Clerk instance (CM_LIVE_CLERK=1 +
|
|
//! CLERK_SECRET_KEY + CLERK_PUBLISHABLE_KEY): discovery and JWKS come
|
|
//! from Clerk's live infrastructure, the session JWT is minted by
|
|
//! Clerk's Backend API for a real user, and `AuthService::authenticate`
|
|
//! verifies it and JIT-provisions — the exact production path.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use base64::engine::general_purpose::STANDARD;
|
|
use base64::Engine;
|
|
use cm_auth::{AuthService, JwtVerifier};
|
|
use cm_domain::{Role, Workspace, WorkspaceId};
|
|
use serde_json::{json, Value};
|
|
|
|
const BAPI: &str = "https://api.clerk.com/v1";
|
|
|
|
fn live() -> Option<(String, String)> {
|
|
if std::env::var("CM_LIVE_CLERK").as_deref() != Ok("1") {
|
|
eprintln!("skipped: set CM_LIVE_CLERK=1 to run");
|
|
return None;
|
|
}
|
|
let (Ok(secret), Ok(publishable)) = (
|
|
std::env::var("CLERK_SECRET_KEY"),
|
|
std::env::var("CLERK_PUBLISHABLE_KEY"),
|
|
) else {
|
|
eprintln!("skipped: CLERK_SECRET_KEY / CLERK_PUBLISHABLE_KEY not set");
|
|
return None;
|
|
};
|
|
// The publishable key embeds the instance domain:
|
|
// pk_test_<base64("<slug>.clerk.accounts.dev$")>.
|
|
let encoded = publishable
|
|
.trim_start_matches("pk_test_")
|
|
.trim_start_matches("pk_live_");
|
|
let domain = STANDARD
|
|
.decode(encoded)
|
|
.ok()
|
|
.and_then(|bytes| String::from_utf8(bytes).ok())
|
|
.map(|s| s.trim_end_matches('$').to_owned())
|
|
.expect("publishable key decodes to the instance domain");
|
|
Some((secret, format!("https://{domain}")))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_real_clerk_session_token_authenticates_and_provisions() {
|
|
let Some((secret, issuer)) = live() else {
|
|
return;
|
|
};
|
|
let pool = cm_testkit::test_pool().await;
|
|
let ws = Workspace {
|
|
id: WorkspaceId::new(),
|
|
name: "Acme".into(),
|
|
plan: "team".into(),
|
|
};
|
|
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
|
|
|
// Real discovery + JWKS from the live instance.
|
|
let verifier = JwtVerifier::discover(&issuer)
|
|
.await
|
|
.expect("clerk discovery");
|
|
let auth = AuthService::new(pool.clone()).with_verifier(Arc::new(verifier));
|
|
|
|
// Mint a REAL session token via Clerk's Backend API.
|
|
let client = reqwest::Client::new();
|
|
let email = format!("smoke+{}@clawmates.work", uuid::Uuid::now_v7().simple());
|
|
let user: Value = client
|
|
.post(format!("{BAPI}/users"))
|
|
.bearer_auth(&secret)
|
|
.json(&json!({
|
|
"email_address": [email],
|
|
"skip_password_requirement": true,
|
|
"first_name": "Smoke",
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let user_id = user["id"].as_str().expect("user created").to_owned();
|
|
|
|
let session: Value = client
|
|
.post(format!("{BAPI}/sessions"))
|
|
.bearer_auth(&secret)
|
|
.json(&json!({"user_id": user_id}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let session_id = session["id"].as_str().expect("session created");
|
|
let token: Value = client
|
|
.post(format!("{BAPI}/sessions/{session_id}/tokens"))
|
|
.bearer_auth(&secret)
|
|
.json(&json!({}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let jwt = token["jwt"].as_str().expect("session jwt minted");
|
|
|
|
// THE smoke: the production verify path accepts Clerk's real JWT and
|
|
// JIT-provisions the user (no custom claims configured on a fresh
|
|
// instance, so email synthesizes and role defaults to Member).
|
|
let authed = auth.authenticate(jwt).await.expect("real token verifies");
|
|
assert_eq!(authed.workspace_id, ws.id);
|
|
assert_eq!(authed.role, Role::Member);
|
|
let subject: String = sqlx::query_scalar("SELECT auth_subject FROM users WHERE id = $1")
|
|
.bind(authed.user_id.as_uuid())
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(subject, user_id);
|
|
|
|
// Same token again: same user, no duplicate.
|
|
let again = auth.authenticate(jwt).await.unwrap();
|
|
assert_eq!(again.user_id, authed.user_id);
|
|
|
|
// A tampered token is refused even with the real JWKS.
|
|
let mut forged = jwt.to_owned();
|
|
forged.replace_range(forged.len() - 4.., "AAAA");
|
|
assert!(auth.authenticate(&forged).await.is_err());
|
|
|
|
// Clean the instance up.
|
|
client
|
|
.delete(format!("{BAPI}/users/{user_id}"))
|
|
.bearer_auth(&secret)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
}
|