Skip to content

feat(hooks): post_login and pre_token login hooks as signed callouts - #583

Merged
Bccorb merged 11 commits into
mainfrom
feat/login-hooks
Nov 8, 2026
Merged

Bccorb merged 11 commits into
mainfrom
feat/login-hooks

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #580 (feat/signed-webhooks). Review and merge that first; this PR's base moves to main when it lands. Builds against the published @seamless-auth/types 0.40.0 that #580 now pins. The refusal bodies are typed from its LoginDeniedErrorSchema and LoginHookUnavailableErrorSchema.

What it adds

ADR 0005 E5: post_login and pre_token login hooks, signed like webhooks (E1) and reached through the shared callout transport (E8), with secrets encrypted at rest (E9).

  • Tables. login_hooks (one row per trigger: url, timeoutMs, failureMode, enabled, encrypted secret plus previous secret and expiry), login_hook_invocations (pruned hourly past 30 days), and sessions."hookClaims" jsonb.
  • post_login runs inside issueSessionAndRespond, after the factor checks and before the concurrent session limit and Session.create. Allow or deny.
  • pre_token runs right after it for a new session, and in POST /organizations/:id/switch with the new organization and its roles. Its claims are stored on the session and put in the token by mintAccessToken.
  • Never called on refresh (claims are copied to the rotated session), on session exchange (the child copies the parent's claims), for a break-glass administrator (isBreakGlassAdmin), or for a recovery session (account recovery, recovery-code sign-in, a recovery session switching organization).
  • One attempt, the hook's own timeout (default 2000 ms, 100 to 5000). Timeout, non-2xx, a redirect (never followed), a body that fails PostLoginHookResultSchema/PreTokenHookResultSchema, or claims over budget follow failureMode: closed refuses the sign-in, open continues without the hook's claims.
  • Claims. Reserved names are stripped and audited (login_hook_claims_stripped). The rest must match the claim name pattern and depth 3, and fit 1 KiB, or the hook has failed. mintAccessToken filters reserved names again, and custom claims are spread first in the JWT so they cannot replace a protected claim. Recovery tokens carry none.
  • Call body is built from LoginHookCallSchema and parsed through it, so nothing beyond the published shape reaches a hook. A test asserts no access token, refresh token, cookie, verification code, TOTP seed or signing secret appears in it.
  • Admin routes per LoginHookRoutes: list, PUT/GET/DELETE by trigger, rotate secret (24 h overlap, two signatures), test call, invocation log. Writes take requireAdmin('write') and step-up, are in GUARDED, and are audited with the URL stripped of its query. Creating or rotating in production without CALLOUT_SECRET_ENCRYPTION_KEY is a 409, and boot now counts login hooks for calloutSecretsExist.
  • Audit types: login_hook_success, login_hook_denied (carries reason), login_hook_failed, login_hook_claims_stripped, admin_login_hook_set, admin_login_hook_deleted, admin_login_hook_secret_rotated.

Contract changes

  • Sign-in completion routes (/webauthn/login/finish, /otp/verify-email-otp, /otp/verify-login-email-otp, /otp/verify-login-phone-otp, /totp/verify-login, /magic-link/check, /oauth/:providerId/callback) and POST /organizations/:id/switch can now answer:

    • 403 { "error": "login_denied", "message"?: "<hook userMessage>" } on a deny. reason is audited and never returned.
    • 503 { "error": "login_hook_unavailable" } when a closed hook fails.

    The OpenAPI response maps for those routes now list 403 and 503.

  • Access tokens can carry pre_token claims.

  • issueSessionAndRespond now resolves a boolean (false when a hook refused and it already answered).

Adapter impact, read from the sibling repos: seamless-auth-server passes both bodies through unchanged (readPassthroughFailure, and the manifest catch-all for TOTP). seamless-auth-js keeps the body, but SeamlessAuthError.message is the error code (extractMessage prefers body.error), so the hook's text is only at err.body.message, and the React views show fixed strings. Showing userMessage needs a small SDK change; nothing breaks without it. 403 is not mistaken for step-up and 503 triggers no refresh.

Deviations from ADR 0005

Recorded under "Found while building" in the ADR:

  • A closed failure answers 503 rather than a 403.
  • issueSessionAndRespond answers the refusal itself, because most sign-in controllers turn a thrown error into a 500.
  • A claim with a bad name or depth fails the hook rather than being dropped. Only reserved names are stripped. Over budget fails as too_large.
  • Hook claims are cleared when a mint finds the user has left the session's organization.
  • client in the call is always null until the provider can supply it (seamless-auth-oidc-provider#47).
  • The test call uses a placeholder user and no request details, and is not logged as an invocation.

Checks

Run at 13c14ed on @seamless-auth/types 0.40.0 from npm, by the pre-commit hook:

  • npm run lint, npm run format, npm run typecheck and npm run build: clean.
  • npm run coverage: 188 files, 3268 passed, 1 todo. Statements 99.07%, branches 95.74%, functions 99.41%, lines 99.27%.
  • npm run generate:api: regenerated (picks up misconfigured in the shared callout failure enum), no further diff.
  • Postgres project (TEST_DB=postgres, local container, at 794284e on 0.39.0): 15 files, 263 passed, including the new tests/postgres/loginHooks.spec.ts (claims written at sign-in survive /refresh with no second hook call, null round-trips, invocation pruning, the timeout check constraint). Not rerun on 0.40.0; the change touches no database code.
  • Each security rule was confirmed by mutation: with the rule removed, its test fails (reserved stripping at the hook and at mint, call body shape, redirect as failure, closed mode, deny without reason, no session on deny, break-glass and recovery exemptions at sign-in and switch, claims carried on refresh).
  • Not run: the e2e suite and a live hook against a deployed instance.

Closes #419.

DELETE /users/credentials removed a passkey without writing an audit
event, so a change to what an account can sign in with left no trace.
It now writes credentials_deleted naming the passkey.
Adds webhook endpoints and deliveries behind an audited admin API with
step-up on every write. Payloads use the Standard Webhooks signature
scheme, are built from committed audit rows past a cursor, and are
retried eight times over just under 42 hours before they are dead
lettered. An endpoint that answers 410 or fails for three days is
disabled.

Every callout to an adopter URL goes through one transport that checks
the address it connects to, never reaches link-local, multicast or the
API itself, never follows redirects, and in production refuses private
addresses unless CALLOUT_PRIVATE_HOSTS lists the host. Signing secrets
are encrypted under CALLOUT_SECRET_ENCRYPTION_KEY.

Writes session_created once per completed sign-in, which user.signed_in
is built from, since login_success precedes any factor.

Records the design in ADR 0005. Closes #418.
Adds the two login hooks from ADR 0005 (E5), signed with the Standard
Webhooks scheme like webhooks and reached through the shared callout
transport.

- post_login runs once the factor is proven and before the session
  exists, and may deny. pre_token runs for a new session and on
  organization switch, and may add claims or deny.
- pre_token claims are kept on the session (sessions.hookClaims), copied
  on refresh and to an exchanged session, so neither calls a hook. They
  are cleared when the user leaves the organization they were decided
  for.
- One attempt with the hook's timeout. A failure follows the hook's
  failure mode. A deny answers 403 login_denied with the hook's
  userMessage; a closed failure answers 503 login_hook_unavailable.
- Reserved claim names are stripped and audited, and filtered again at
  mint. Break-glass administrators and recovery sessions never call a
  hook.
- Admin routes under /admin/login-hooks/:trigger, with step-up and an
  audit event on every write, and an invocation log pruned after 30 days.

Closes #419.
A disabled endpoint was given no deliveries, so events that happened
while it was off were never sent, including after the API disabled it
for a 410 or three days of failures. Its deliveries are now queued on
hold, and turning it back on releases them, back as far as the 30 days
deliveries are kept. The release uses the database clock, which the due
check compares against.
@Bccorb
Bccorb changed the base branch from feat/signed-webhooks to main November 8, 2026 03:29
@Bccorb
Bccorb marked this pull request as ready for review November 8, 2026 03:40
@Bccorb
Bccorb merged commit fe012cb into main Nov 8, 2026
9 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.

feat: pre-token and post-login hooks as HTTP callouts

1 participant