Repository navigation
feat(hooks): post_login and pre_token login hooks as signed callouts - #583
Merged
Merged
Conversation
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.
This was referenced Nov 8, 2026
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
marked this pull request as ready for review
November 8, 2026 03:40
Bccorb
added a commit
that referenced
this pull request
Nov 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #580 (
feat/signed-webhooks). Review and merge that first; this PR's base moves tomainwhen it lands. Builds against the published@seamless-auth/types0.40.0 that #580 now pins. The refusal bodies are typed from itsLoginDeniedErrorSchemaandLoginHookUnavailableErrorSchema.What it adds
ADR 0005 E5:
post_loginandpre_tokenlogin hooks, signed like webhooks (E1) and reached through the shared callout transport (E8), with secrets encrypted at rest (E9).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), andsessions."hookClaims" jsonb.issueSessionAndRespond, after the factor checks and before the concurrent session limit andSession.create. Allow or deny.POST /organizations/:id/switchwith the new organization and its roles. Its claims are stored on the session and put in the token bymintAccessToken.isBreakGlassAdmin), or for a recovery session (account recovery, recovery-code sign-in, a recovery session switching organization).PostLoginHookResultSchema/PreTokenHookResultSchema, or claims over budget followfailureMode:closedrefuses the sign-in,opencontinues without the hook's claims.login_hook_claims_stripped). The rest must match the claim name pattern and depth 3, and fit 1 KiB, or the hook has failed.mintAccessTokenfilters reserved names again, and custom claims are spread first in the JWT so they cannot replace a protected claim. Recovery tokens carry none.LoginHookCallSchemaand 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.LoginHookRoutes: list, PUT/GET/DELETE by trigger, rotate secret (24 h overlap, two signatures), test call, invocation log. Writes takerequireAdmin('write')and step-up, are inGUARDED, and are audited with the URL stripped of its query. Creating or rotating in production withoutCALLOUT_SECRET_ENCRYPTION_KEYis a 409, and boot now counts login hooks forcalloutSecretsExist.login_hook_success,login_hook_denied(carriesreason),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) andPOST /organizations/:id/switchcan now answer:{ "error": "login_denied", "message"?: "<hook userMessage>" }on a deny.reasonis audited and never returned.{ "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_tokenclaims.issueSessionAndRespondnow 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, butSeamlessAuthError.messageis theerrorcode (extractMessageprefersbody.error), so the hook's text is only aterr.body.message, and the React views show fixed strings. ShowinguserMessageneeds 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:
issueSessionAndRespondanswers the refusal itself, because most sign-in controllers turn a thrown error into a 500.too_large.clientin the call is always null until the provider can supply it (seamless-auth-oidc-provider#47).Checks
Run at 13c14ed on
@seamless-auth/types0.40.0 from npm, by the pre-commit hook:npm run lint,npm run format,npm run typecheckandnpm 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 upmisconfiguredin the shared callout failure enum), no further diff.TEST_DB=postgres, local container, at 794284e on 0.39.0): 15 files, 263 passed, including the newtests/postgres/loginHooks.spec.ts(claims written at sign-in survive/refreshwith 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.Closes #419.