CRUMB a card from devarno-cloud

Airlock JWT Handoff & the smo1_session Cookie

smo1 advanced 7 min read

ELI5

app.smo1.io is not under devarno.cloud, so it cannot read Airlock’s login cookie. Instead the dashboard sends you to Airlock, Airlock signs you in and sends you back with a short-lived signed note saying who you are. The dashboard checks the signature against Airlock’s public key, then writes its own wristband cookie, good for eight hours. purr-api never sees that cookie: the dashboard turns it into a fresh five-minute pass for each API call.

Technical Deep Dive

Source: meow-web README.md:61-69, src/lib/airlock.ts:30-65, 82-114, 132-196, 205-210, src/app/api/auth/login/route.ts:13-28, src/app/api/auth/callback/route.ts:15-61, src/middleware.ts (origin/main e5d808c), read 2026-10-25. Airlock-side registration (handoff_consumers migration 0029, OIDC client retired in airlock#112) is as stated in meow-web README.md:67; not re-read in airlock for this card.

Login sequence

sequenceDiagram
autonumber
actor U as User
participant M as meow-web
participant A as Airlock
U->>M: GET /dashboard without smo1_session
M-->>U: 302 to /api/auth/login?callbackUrl=/dashboard
U->>M: GET /api/auth/login
M-->>U: 302 to AIRLOCK_HANDOFF_URL?return=app origin /api/auth/callback?next=/dashboard
U->>A: handoff request, sign in if needed
A-->>U: 302 to /api/auth/callback?token=JWT and next
U->>M: GET /api/auth/callback
M->>A: GET JWKS, cached 5 minutes
M->>M: jwtVerify EdDSA, iss = AIRLOCK_ISSUER_URL, aud = NEXT_PUBLIC_APP_URL
M-->>U: Set-Cookie smo1_session, 302 to next

Points that the earlier version of this card got wrong:

  • No PKCE, no OIDC client, no code exchange. buildHandoffRedirect sends only a return parameter (airlock.ts:205-210). Airlock decides whether to hand off by checking the return origin against its registered handoff_consumers; https://app.smo1.io is registered there.
  • No user sync call. meow-web does not call purr-api at login. The user row is created on the first proxied API call, when purr-api validates the service JWT and runs EnsureExists (smo1-004).
  • The key is picked by type, not kid. verifyHandoffToken takes the first JWKS entry with kty=OKP, crv=Ed25519 (airlock.ts:91). A rotation that publishes two Ed25519 keys at once would verify against whichever comes first.

Format base64url(payload).base64url(HMAC-SHA256(SESSION_SECRET, payloadB64)) with payload {userId, email, role, iat, exp} (airlock.ts:132-155).

AttributeValueSource
Namesmo1_sessioncallback/route.ts:13
LifetimemaxAge: 28800 (8 h) and exp = iat + 28800callback/route.ts:50, airlock.ts:134
FlagshttpOnly, sameSite: lax, path: /, secure only when NODE_ENV=productioncallback/route.ts:45-51
next handlingonly same-origin paths (/ but not //) are honouredcallback/route.ts:41

verifyLocalSession checks the HMAC with a plain !== string compare, then exp (airlock.ts:160-196). The edge middleware.ts only checks that the cookie exists; the real verification happens in route handlers such as the BFF proxy (smo1-027).

Logout

/api/auth/logout clears the cookie and, for browser navigations, redirects to NEXT_PUBLIC_LANDING_URL, defaulting to https://smo1.io since meow-web #36 (2026-10-22; it previously defaulted to localhost because the variable is unset on Vercel prod).

Key Terms

  • JWT handoff — Airlock’s cross-apex pattern: a signed, short-lived token in a redirect, then a local session
  • handoff_consumers — Airlock’s registry of origins allowed to receive a handoff token
  • EdDSA / Ed25519 — the signature algorithm on Airlock handoff tokens
  • SESSION_SECRET — meow-web’s HMAC key for smo1_session; never shared with purr-api
  • SERVICE_JWT_SECRET — the separate key meow-web shares with purr-api (smo1-027)

Q&A

Q: What cryptographic algorithm does Airlock use for its JWT? A: Ed25519 (JOSE EdDSA). meow-web imports the OKP/Ed25519 key from Airlock’s JWKS and verifies issuer and audience with jose.jwtVerify.

Q: How long does a smo1_session cookie last? A: Eight hours: the cookie’s maxAge is 28,800 s and the signed payload carries the same exp.

Q: What happens if the Airlock JWKS endpoint is unreachable? A: Within five minutes of the last fetch nothing changes, because the keys are cached in module memory. After that fetchJWKS throws, the callback answers 401 Invalid or expired token, and new sign-ins fail. Existing smo1_session cookies keep working because they never touch Airlock.

Q: Why is there no OIDC client or PKCE in this flow? A: The handoff pattern returns a signed token directly to a registered origin, so there is no authorization code to protect. smo1’s OIDC client was retired in airlock#112; AIRLOCK_CLIENT_ID and AIRLOCK_CLIENT_SECRET are unused.

Examples

A support agent opens app.smo1.io/settings/billing in a clean window. The middleware sees no cookie and redirects to /api/auth/login?callbackUrl=/settings/billing, Airlock signs them in, the callback verifies the token and lands them back on billing with an eight-hour cookie. Their first page load calls /api/purr/billing, which is where purr-api first learns who they are.

neighbors on the map