Clerk deployment smoke: validated against a real instance, both halves
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]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e65bcd4130
commit
b9fdec9173
Generated
+1
@@ -537,6 +537,7 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ thiserror = { workspace = true }
|
|||||||
time = { workspace = true }
|
time = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
uuid = { workspace = true }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
rsa = { version = "0.9", features = ["pem"] }
|
rsa = { version = "0.9", features = ["pem"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! 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();
|
||||||
|
}
|
||||||
+24
-3
@@ -70,6 +70,27 @@ exercise the full verification path with real RSA keys against a live
|
|||||||
local issuer publishing real discovery + JWKS documents: JIT provisioning
|
local issuer publishing real discovery + JWKS documents: JIT provisioning
|
||||||
and role mapping, duplicate-subject suppression, expired tokens, tokens
|
and role mapping, duplicate-subject suppression, expired tokens, tokens
|
||||||
signed by the wrong key, foreign issuers, and the end-to-end
|
signed by the wrong key, foreign issuers, and the end-to-end
|
||||||
`Authorization: Bearer` round trip into a protected endpoint. Validating
|
`Authorization: Bearer` round trip into a protected endpoint.
|
||||||
against a live Clerk instance additionally requires real instance keys and
|
|
||||||
is a deployment smoke step, not a CI gate.
|
## Deployment smoke (real instance)
|
||||||
|
|
||||||
|
`crates/cm-auth/tests/live_clerk.rs` validates the production path against
|
||||||
|
a **live Clerk instance**: it pulls real discovery + JWKS, uses Clerk's
|
||||||
|
Backend API to create a user, open a session, and mint a real session JWT,
|
||||||
|
then runs that token through `AuthService::authenticate` — proving
|
||||||
|
verification, JIT provisioning, duplicate-subject suppression, and
|
||||||
|
tamper rejection against Clerk's actual keys. It cleans up the user
|
||||||
|
afterward. Run it with your instance keys (key-gated, never in the default
|
||||||
|
suite):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CM_LIVE_CLERK=1 \
|
||||||
|
CLERK_SECRET_KEY=sk_test_… \
|
||||||
|
CLERK_PUBLISHABLE_KEY=pk_test_… \
|
||||||
|
cargo test -p cm-auth --test live_clerk
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend half is smoked by building with `AUTH_MODE=clerk` and the
|
||||||
|
same keys and confirming `/login` serves Clerk's `<SignIn />` wired to the
|
||||||
|
instance — `npx next start` then `curl /login` shows the instance domain
|
||||||
|
and `data-clerk` attributes.
|
||||||
|
|||||||
Reference in New Issue
Block a user