Rebrand: TeamClaw -> Clawmates (clawmates.work)
Full-depth rename per the approved plan; the 'claw' product vocabulary (claws, /claws routes, clawId, Claw Chat) stays — it is now the brand. - Display brand: Clawmates (manifest, titles, hero, login/rail logo 'clawmates'); default host app.clawmates.work; registry ghcr.io/clawmates - Crates tc-* -> cm-* (16 crates + all imports); binaries clawmates-server/broker/bundler; images clawmates/*; env prefix CLAWMATES_* (+ CM_TEST_DATABASE_URL / CM_LIVE_LLM); config clawmates.toml; helm chart deploy/helm/clawmates with clawmates-* resources; db names clawmates*; sockets /run/clawmates; cookie cm_session; kind cluster clawmates-test; seccomp node profile clawmates-agent-profile.json - All 9 Playwright brand assertions updated in lockstep; historical spec document left untouched as the only remaining 'TeamClaw' - Local env migrated: dev pg clawmates-dev-pg/clawmates_dev, shared test server clawmates-test-pg, kind cluster recreated with image + profile, compose images rebuilt under clawmates/* Verified end to end: 161 Rust + 68 frontend tests, 29 Playwright journeys, 4 live kind tests, helm/install/LOC/placeholder gates, and the clean-room install rehearsal serving the clawmates login page from a signed bundle of the rebuilt images. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8046853feb
commit
add4f79fed
@@ -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()))
|
||||
}
|
||||
Reference in New Issue
Block a user