Skip to content

feat(auth): add Bearer service-token path to isAuthorized#13

Merged
tx-joshg merged 1 commit into
mainfrom
feat/service-token-auth
May 27, 2026
Merged

feat(auth): add Bearer service-token path to isAuthorized#13
tx-joshg merged 1 commit into
mainfrom
feat/service-token-auth

Conversation

@tx-joshg

Copy link
Copy Markdown
Owner

Why

After #9 (Switch admin auth to OAuth) every callsite of `isAuthorized()` now requires a NextAuth cookie + AdminUser allowlist entry. Server-to-server callers can't carry a cookie session, so the NyTex staff-portal has been getting 401 Unauthorized on every `/api/invites`, `/api/submissions`, `/api/documents` call since the ADMIN_SECRET path was removed back in March (#0e8e44d).

End-user impact: every new hire that started I-9 in staff-portal silently fell through to a local-only wizard that wrote to staff-portal's DB and never reached open-i9. No submissions made it to the federal-form service.

What

  • New `isAuthorizedService(request)` helper. Reads `SERVICE_TOKEN` env var, compares against `Authorization: Bearer ...` with `crypto.timingSafeEqual` (constant-time).
  • `isAuthorized()` now accepts either path — cookie session OR Bearer token.
  • Backward compatible: when `SERVICE_TOKEN` is unset, the Bearer path is a no-op. Setting it on Railway turns it on.

After merge

  1. Set `SERVICE_TOKEN` env var on open-i9 service (Railway) to a random 32+ char string.
  2. Mirror onto staff-portal as `I9_SERVICE_TOKEN=${{open-i9.SERVICE_TOKEN}}` (cross-service reference).
  3. Verify: Josh hits `/i9` on staff-portal, sees the "Open I-9 form" handoff instead of "service unavailable".

Test plan

  • curl `-H 'Authorization: Bearer ' /api/invites` → still 401
  • curl `-H 'Authorization: Bearer ' /api/invites` → 200
  • Cookie-authed admin browsing /admin/ still works (no regression on UI path)

Sister services (specifically NyTex staff-portal — mints I-9 invites at
hire signup, syncs submission status on an hourly cron) can't carry an
OAuth user session, so they got 401 Unauthorized on every call after
#9 dropped the database-stored ADMIN_SECRET in favor of NextAuth-only
auth. staff-portal silently fell back to a local I-9 wizard that wrote
to ITS DB only — no submission ever reached this service.

New: isAuthorizedService(request) reads SERVICE_TOKEN from env and
compares the Bearer header against it with crypto.timingSafeEqual so
callers can't infer the token from response timing. isAuthorized()
accepts either path — cookie session for human admins on this app's
UI, Bearer token for machine callers.

When SERVICE_TOKEN is unset, the Bearer path is a no-op — no behavior
change vs current state. Set the env var on Railway (open-i9 service)
+ mirror as I9_SERVICE_TOKEN on staff-portal via cross-service ref to
enable the path.
@tx-joshg tx-joshg merged commit d7f19e4 into main May 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant