Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/quick-moths-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'seamless-auth-api': patch
---

A bearer refused at the auth gate now leaves an audit record when the token was one this
server issued.

`verifyBearerAuth` refuses a request before any handler runs, and that refusal reached
the application log and nothing else, so a caller presenting the wrong kind of token at a
protected route left no durable trace. 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, and refusing it was invisible.

The new `bearer_token_failed` event is written when the presented token verifies against
this issuer's keys but its `typ` is not the one the route requires. It carries the
expected and presented types, the matched route pattern and the token's subject, with
`userId` left null because a refused token has established no principal.

Deliberately narrower than any 401. 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 it waits on audit
retention (#173).

Nothing changes on the wire. The refusal answers the same 401 in the same place, and the
event is only visible to operators through the admin and internal event views, where
`bearer_token_failed` is a new value of the auth event type.
52 changes: 52 additions & 0 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,58 @@ option today.
`auth_failures` rows are not pruned, which matches `auth_events`. Retention is
[issue #173](https://github.com/fells-code/seamless-auth-api/issues/173).

## Refusals at the auth gate

**Posture: only a credential this server issued is worth a row.**

`verifyBearerAuth` refuses a request before any handler runs, and that refusal used to
leave an application log line and nothing durable. Failed OTP codes, failed assertions
and locked accounts all reach `auth_events`; a caller presenting the wrong kind of token
at a protected route did not.

The gap became a specific one when passkey enrollment moved to `auth: 'access'`. An
ephemeral token proves possession of an address and nothing more, so offering one at
`/webauthn/register/start` is the account takeover probe that gate exists to stop.
Refusing it is the right answer. Refusing it invisibly is not.

A refused bearer now writes `bearer_token_failed` when, and only when, the token
verifies against this issuer's keys and its `typ` is not the one the gate requires. The
row carries the expected and presented types, the matched route pattern, and the token's
subject.

### Why not every 401

The refusals left out are the ones anybody can manufacture. 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 and keeps the rows
worth reading.

Widening this waits on retention and bulk export
([issue #173](https://github.com/fells-code/seamless-auth-api/issues/173)). Until a window
can be pruned, a high-volume event type costs the trail more than it adds.

### The subject is metadata, not `user_id`

A refused token has established no principal, so the event records `userId: null`. The
subject is kept in metadata, where it says whose flow token is being offered without
asserting that the caller is that user. It also could not be a foreign key: an ephemeral
subject may be the decoy `/login` mints for an address with no usable account, which
resolves to no row at all.

Read the row as "this account's flow token was offered here", not as "this account did
it". `/login` mints an ephemeral token from an address alone, so anyone who knows an
address can produce a row naming its owner. That is the same reason `userId` is null:
the subject is what the token claims, and the token proves possession of an address,
not of the account.

### It changes no response

The event is written server side and never reflected to the caller. The refusal answers
the same `401 { "error": "unauthorized" }` whether or not a row is written, so the decoy
rules above are untouched.

## Static analysis triage

**Posture: gated on a zero baseline, deliberately.** CodeQL runs on every pull request,
Expand Down
2 changes: 2 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,7 @@
"auth_action_incremented",
"admin_device_replacement_recovery",
"admin_session_revoked",
"bearer_token_failed",
"credentials_deleted",
"informational",
"internal_user_updated_by_owner",
Expand Down Expand Up @@ -1757,6 +1758,7 @@
"auth_action_incremented",
"admin_device_replacement_recovery",
"admin_session_revoked",
"bearer_token_failed",
"credentials_deleted",
"informational",
"internal_user_updated_by_owner",
Expand Down
14 changes: 7 additions & 7 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions src/controllers/internalSecurity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ export const getSecurityAnomalies = async (_req: Request, res: Response) => {

try {
// Derived from AUTH_EVENT_TYPES. The hand-maintained list searched for five names
// nothing emitted (bearer_token_failed, jwks_failed, otp_failed,
// recovery_otp_failed, user_data_failed) while missing verify_otp_failed,
// totp_failed, magic_link_failed, and logout_failed, which are emitted.
// nothing emitted (jwks_failed, otp_failed, recovery_otp_failed, user_data_failed,
// and bearer_token_failed, which the auth gate now does emit) while missing
// verify_otp_failed, totp_failed, magic_link_failed, and logout_failed.
const FAILURE_TYPES = FAILURE_EVENT_TYPES;

const events = await AuthEvent.findAll({
Expand Down
2 changes: 2 additions & 0 deletions src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,6 +1430,7 @@ export interface paths {
| 'auth_action_incremented'
| 'admin_device_replacement_recovery'
| 'admin_session_revoked'
| 'bearer_token_failed'
| 'credentials_deleted'
| 'informational'
| 'internal_user_updated_by_owner'
Expand Down Expand Up @@ -1494,6 +1495,7 @@ export interface paths {
| 'auth_action_incremented'
| 'admin_device_replacement_recovery'
| 'admin_session_revoked'
| 'bearer_token_failed'
| 'credentials_deleted'
| 'informational'
| 'internal_user_updated_by_owner'
Expand Down
38 changes: 38 additions & 0 deletions src/middleware/verifyBearerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,49 @@

import { NextFunction, Request, Response } from 'express';

import { AuthEventService } from '../services/authEventService.js';
import { findMisusedBearer } from '../services/bearerRefusal.js';
import { AuthTokenType, validateBearerToken } from '../services/sessionService.js';
import { AuthenticatedRequest } from '../types/types.js';
import getLogger from '../utils/logger.js';

const logger = getLogger('verifyBearerAuth');

/** The matched route pattern, so path parameters do not turn one route into many. */
function routeLabel(req: Request): string {
const pattern = typeof req.route?.path === 'string' ? req.route.path : req.path;

return `${req.baseUrl ?? ''}${pattern ?? ''}`;
}

/**
* Records a refusal that a token this server issued caused.
*
* The subject is metadata rather than `userId`: a refused token has established no
* principal, and an ephemeral subject may be the decoy `/login` mints for an address
* with no account, which resolves to no row at all.
*
* Written server side and never reflected to the caller, so the refusal it describes
* answers exactly as it did before.
*/
async function recordBearerMisuse(req: Request, token: string, expectedType: AuthTokenType) {
const misuse = await findMisusedBearer(token, expectedType);

if (!misuse) return;

await AuthEventService.log({
type: 'bearer_token_failed',
req,
metadata: {
reason: 'wrong_token_type',
expected: expectedType,
presented: misuse.presentedType,
route: routeLabel(req),
subject: misuse.subject,
},
});
}

export async function verifyBearerAuth(
req: Request,
res: Response,
Expand All @@ -29,6 +66,7 @@ export async function verifyBearerAuth(
const result = await validateBearerToken(token, authType);
if (!result) {
logger.error(`Invalid ${authType} bearer token`);
await recordBearerMisuse(req, token, authType);
return res.status(401).json({ error: 'unauthorized' });
}
(req as AuthenticatedRequest).user = result.user;
Expand Down
9 changes: 5 additions & 4 deletions src/schemas/authEvent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const AUTH_EVENT_TYPES = [
'auth_action_incremented',
'admin_device_replacement_recovery',
'admin_session_revoked',
'bearer_token_failed',
'credentials_deleted',
'informational',
'internal_user_updated_by_owner',
Expand Down Expand Up @@ -78,10 +79,10 @@ export type AuthEventType = z.infer<typeof AuthEventTypeEnum>;
* Types grouped by outcome, derived rather than hand-listed.
*
* Consumers used to keep their own copies of these groupings, which drifted: the
* anomaly detector searched for `otp_failed`, `bearer_token_failed`, and three other
* names nothing emitted, so those failures were invisible, while `verify_otp_failed`
* and `magic_link_failed` were emitted and never searched for. Deriving the groups
* means adding an event type puts it in the right bucket automatically.
* anomaly detector searched for five names nothing emitted, such as `otp_failed` and
* `jwks_failed`, so those failures were invisible, while `verify_otp_failed` and
* `magic_link_failed` were emitted and never searched for. Deriving the groups means
* adding an event type puts it in the right bucket automatically.
*/
export const FAILURE_EVENT_TYPES = AUTH_EVENT_TYPES.filter((type) =>
type.endsWith('_failed'),
Expand Down
72 changes: 72 additions & 0 deletions src/services/bearerRefusal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { AuthTokenType, verifyJwtWithKid } from './sessionService.js';

export interface MisusedBearer {
presentedType: string;
subject: string | null;
}

/**
* The `typ` a bearer claims, read without verifying anything.
*
* This only decides whether a refusal is worth a second verification. A token whose
* claimed type is the one the gate asked for cannot be a type mismatch however it is
* signed, and that covers the ordinary refusals (an expired access token, a rotated
* session), which would otherwise pay for a verification that can only conclude there
* is nothing to record. The event itself is built from the verified payload.
*/
function peekTokenType(token: string): string | null {
const segment = token.split('.')[1];

if (!segment) return null;

try {
const claims: unknown = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));

if (!claims || typeof claims !== 'object') return null;

const typ = (claims as Record<string, unknown>).typ;

return typeof typ === 'string' ? typ : null;
} catch {
return null;
}
}

/**
* Identifies a bearer this issuer minted that was presented at a gate it does not open.
*
* Deliberately narrower than "the request was refused". A missing, malformed, expired
* or unsigned credential is something any caller can produce for free, so recording
* those would let one scanner, or one signing key rotation, fill `auth_events` with
* rows that name no attacker and bury the ones that do. A token of the wrong type had
* to be issued by this server first, which bounds the volume to real flows and makes
* the row worth reading: it is an ephemeral token, which proves possession of an
* address and nothing else, being offered where an access session is required.
*/
export async function findMisusedBearer(
token: string,
expectedType: AuthTokenType,
): Promise<MisusedBearer | null> {
const claimedType = peekTokenType(token);

if (claimedType === null || claimedType === expectedType) {
return null;
}

const payload = await verifyJwtWithKid(token);

if (!payload || typeof payload.typ !== 'string' || payload.typ === expectedType) {
return null;
}

return {
presentedType: payload.typ,
subject: typeof payload.sub === 'string' ? payload.sub : null,
};
}
31 changes: 31 additions & 0 deletions tests/integration/webauthn/enrollmentAuth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,37 @@ describe('passkey enrollment requires an access session', () => {
expect(generateRegistrationOptions).not.toHaveBeenCalled();
});

it.each([
['get', '/webauthn/register/start'],
['post', '/webauthn/register/finish'],
])('records the refusal on %s %s so enrollment probing is visible', async (method, path) => {
const { verifyJwtWithKid } = await import('../../../src/services/sessionService.js');
const { AuthEventService } = await import('../../../src/services/authEventService.js');

(verifyJwtWithKid as any).mockResolvedValue({ typ: 'ephemeral', sub: 'user-1' });

const claims = Buffer.from(JSON.stringify({ typ: 'ephemeral', sub: 'user-1' })).toString(
'base64url',
);

await (request(app) as any)
[method](path)
.set('Authorization', `Bearer header.${claims}.signature`);

expect(AuthEventService.log).toHaveBeenCalledWith(
expect.objectContaining({
type: 'bearer_token_failed',
metadata: expect.objectContaining({
reason: 'wrong_token_type',
expected: 'access',
presented: 'ephemeral',
route: path,
subject: 'user-1',
}),
}),
);
});

it.each([
['get', '/webauthn/register/start'],
['post', '/webauthn/register/finish'],
Expand Down
Loading
Loading