CRUMB a card from devarno-cloud

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 kid after 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_URL with KAHN’s own client credentials (AIRLOCK_INTROSPECTION_CLIENT_ID / _SECRET).
  • Trusts active: true + iss check.
  • Cache TTL = min(token.exp - now, INTROSPECTION_CACHE_SECONDS=60), keyed on the token; bounded max_entries=4096 LRU-by-insertion.
  • Cache evicts naturally at exp; revoked tokens cannot outlive their original exp.

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
end

Tenant 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 (or client_id) + tenant_id. Created by principal_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_id column.

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