feat(audit): record a bearer refused for the wrong token type - #289
Merged
Conversation
verifyBearerAuth refuses a request before any handler runs and wrote nothing durable, so a caller presenting the wrong kind of token at a protected route left only an application log line. Moving passkey enrollment behind an access session made that specific: an ephemeral token offered at /webauthn/register/start is the account takeover probe the gate exists to stop. The refusal now writes bearer_token_failed when the presented token verifies against this issuer's keys but its typ is not the one the route requires, carrying the expected and presented types, the matched route pattern and the token's subject. Narrower than any 401 on purpose. A missing, malformed, expired or unsigned credential costs a caller nothing to produce, and recording those would let one scanner, or one signing key rotation, bury the rows that name a real attempt. Widening waits on audit retention (#173). Closes #286
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.
Closes #286.
What
verifyBearerAuthrefuses a request before any handler runs and wrote nothing durable, so a caller presenting the wrong kind of token at a protected route left only an application log line. Moving passkey enrollment behind an access session (#287) made that specific: an ephemeral token offered at/webauthn/register/startis exactly the account takeover probe the gate exists to stop, and refusing it was invisible.A refused bearer now writes a
bearer_token_failedauth event when the presented token verifies against this issuer's keys but itstypis not the one the route requires. The row carries the expected and presented types, the matched route pattern, and the token's subject.Why it is narrower than "any 401"
The issue calls this out: fired on every expired access token, the event buries the signal it exists to surface. The trigger is therefore "a credential this server issued, offered at a gate it does not open", not "the request was refused".
A missing header, a malformed string, an unknown
kid, a bad signature and an expired token all cost a caller nothing to produce, and one scanner, or one signing key rotation retiring every outstanding token at once, would fill the window with rows that name nobody. A token of the wrong type has to have been minted here first, which bounds the volume to real flows. Widening this waits on retention and bulk export (#173).findMisusedBearerreads the unverifiedtypclaim first, purely to decide whether a second signature verification is worth spending. That keeps the ordinary refusals (expired token, rotated session) off the extra work, and it never decides anything: the event is built from the verified payload.Judgement calls, made deliberately
userIdis null. A refused token has established no principal. The subject goes in metadata, where it says whose flow token is being offered without asserting the caller is that user, and where it cannot be a foreign key: an ephemeral subject may be the decoy/loginmints for an address with no usable account, which resolves to no row.docs/security-posture.mdnow also says what the subject does not prove, since/loginmints an ephemeral token from an address alone.401 { "error": "unauthorized" }in the same place whether or not a row is written.LOCKOUT_FAILURE_TYPESis hand-named and unchanged, andrecordAuthFailureno-ops for a null user anyway. The new type does joinFAILURE_EVENT_TYPES, so it reaches the admin anomaly view, which is the point: that detector already searched forbearer_token_failedand nothing emitted it.Contract note
Minor and additive.
bearer_token_failedis a new value of the auth event type, which appears in theGET /admin/auth-eventstypequery enum inopenapi.jsonandsrc/generated/api.ts(regenerated), and as a new value in event list responses. Nothing existing changes shape, and no route, status code or token claim moves. Worth a look fromseamless-auth-admin-dashboardif it renders event types from a closed list, and from@seamless-auth/typesif it keeps its own copy of the enum.Checks
npm run typecheck,npm run lint,npm run format:check,npm run build: cleannpm run test:run: 114 files, 1353 passed, 1 skipped, 1 todonpm run coverage: 98.75% statements, 96.06% branches, 98.96% functions, 98.95% lines, all above threshold. The new module is at 100%./security-reviewon the branch: no findings. The one sub-threshold observation it raised (a third party can produce a row naming a victim's subject) is now documented indocs/security-posture.md.