Airlock Bearer Auth
kahn intermediate 6 min read
ELI5
A guard at the door checks your wristband. Some wristbands have the signature printed on them (just check it against the master stamp); others are plain plastic and need a phone-call to the wristband desk to confirm you’re still allowed in. Either way, the guard then matches your tenant tag against the room you’re trying to enter.
Technical Deep Dive
backend/kahn/cloud_auth.py validates Authorization: Bearer <token> for every cloud-mode request that needs a tenant. The dispatch is by token shape, not by route.
Dispatch
flowchart TD A["Authorization: Bearer X"] --> B{"X.count('.') == 2?"} B -->|yes| C["JWT path: JWKS verify (sig, iss, aud, exp)"] B -->|no| D["Opaque path: RFC 7662 introspection"] C --> E["principal_from_oidc_claims(claims)"] D --> E E --> F["Principal(user_id=sub#124;client_id, tenant_id=org_id)"] F --> G{"org_id == tenant.airlock_org_id?"} G -->|no| H[403] G -->|yes| I[allow]JWT Path
- Validates against airlock’s JWKS, fetched via
httpx. - JWKS cached for
JWKS_CACHE_SECONDS = 300(5 minutes). - A new
kidafter rotation triggers exactly one fetch and ~30ms latency hit. - Verifies
iss,aud,exp. Algorithms: EdDSA, RSA, ES (PyJWT[crypto] ≥ 2.8).
Opaque Path
- POSTs to
AIRLOCK_INTROSPECTION_URLwith KAHN’s own client credentials (AIRLOCK_INTROSPECTION_CLIENT_ID/_SECRET). - Trusts
active: true+isscheck. - Cache TTL =
min(token.exp - now, INTROSPECTION_CACHE_SECONDS=60), keyed on the token; boundedmax_entries=4096LRU-by-insertion. - Cache evicts naturally at
exp; revoked tokens cannot outlive their originalexp.
End-to-End
sequenceDiagram autonumber participant P as Producer participant K as KAHN /v1/ingest/* participant A as airlock P->>K: POST + Bearer token K->>K: classify (two dots?) alt JWT K->>A: GET /.well-known/jwks.json (cache miss) A-->>K: keys K->>K: verify sig, iss, aud, exp else Opaque K->>A: POST /introspect (basic auth: KAHN client) A-->>K: {active, sub|client_id, org_id, exp} end K->>K: principal.tenant_id == airlock_org_id? alt match K-->>P: 200 / 202 ingest else mismatch K-->>P: 403 endTenant Provisioning
The kahn-harness client is provisioned twice in lockstep: pnpm --filter airlock run seed:kahn-harness against airlock’s DATABASE_URL, and python scripts/provision-tenant.py --org-id "service:kahn-harness" against KAHN’s DSN. The two org_id values must match exactly.
Key Terms
- Principal → Resolved actor:
user_id(orclient_id) +tenant_id. Created byprincipal_from_oidc_claims. - JWKS → JSON Web Key Set; airlock’s published signing keys.
- org_id → Airlock’s tenant claim; mapped 1:1 to KAHN’s
tenants.airlock_org_idcolumn.
Q&A
Q: Why two paths instead of one?
A: Airlock issues both signed JWTs (handoff / user-session path) and opaque OAuth access tokens (@better-auth/oauth-provider client_credentials). The two formats need different validators; KAHN dispatches by shape so a producer can use whichever airlock issues.
Q: What’s the worst-case latency after a JWKS rotation?
A: One cache miss + one HTTPS round-trip — about 30ms. After the first request, the new key is hot for JWKS_CACHE_SECONDS.
Q: Why isn’t the introspection cache TTL just 60s flat?
A: Because tokens may expire sooner than 60s. The min keeps the cache from holding a token past its own exp.
Examples
If scripts/soak-refute.py reports Outcome: oidc-misconfigured, the airlock-side schema or the harness client likely drifted; the diagnostic memo is at ~/.claude/projects/-home-devarno-code-workspace-kahn-hq/memory/project_airlock_oauth_drift.md.
neighbors on the map
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry
- Multi-Strategy Authentication debugging 401 errors across different clients
- LORE RBAC & Airlock Auth Flow implementing authentication in a new LORE page