Bloque B2 — auth handlers route DEVICES + DEVICE_REVOKE

Audit P1: `ActiveAuth.listDevices()` and `revokeDevice()` POSTed
to `/api/auth/devices` and `/api/auth/devices/revoke`, but the
generic handler (the same one used by the SvelteKit integration)
only routed current/csrf/password/recovery/sign-out — devices and
OAuth fell through to 404.

Closes the device gap end-to-end:
- `AuthHandlerEngine` (handler-runtime contract) now declares
  `listDevices` and `revokeDevice`. The engine already implemented
  them; the gap was purely in the handler surface.
- `createAuthRouteHandlers` adds two new handlers and registers
  them in `handle()`:
  - `GET /api/auth/devices` → `engine.listDevices({ actorRef })`
  - `POST /api/auth/devices/revoke` → CSRF-verified, body
    `{ deviceId, meta? }` → `engine.revokeDevice({ actorRef, ... })`
- Both derive `actorRef` via a new internal `requireAuthCurrent`
  helper that calls `engine.current()` and returns 401 when the
  session is anonymous, mirroring how the rest of the auth API
  treats unauthenticated requests.

OAuth / MFA / WebAuthn endpoints (which the audit also flagged in
the same finding) stay deferred — those are bigger surface
additions that need server-side flow work, not just routing. The
client cooperates: those methods are not yet declared on
`ActiveAuth`. Devices / device revoke are the only pair the client
already exposed and the handler ignored.

Test: `src/svrs/auth/test/handlers-devices.test.ts` exercises
routing + auth gating with stub engines (4 cases). Engine-level
device semantics (revocation invalidates bound sessions etc.)
remain covered by `engine-password.test.ts`.

Suite: 1496 / 1496.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
dev 5 months ago
parent 2a6706370e
commit 45011ea4a7

@ -62,6 +62,8 @@ export interface AuthHandlerEngine {
completeEmailVerification(input: AuthEmailVerificationCompleteInput): Promise<unknown>;
requestPasswordReset(input: AuthPasswordResetRequestInput): Promise<unknown>;
completePasswordReset(input: AuthPasswordResetCompleteInput): Promise<unknown>;
listDevices(input: import('$libs/auth/types').AuthListDevicesInput): Promise<unknown>;
revokeDevice(input: import('$libs/auth/types').AuthRevokeDeviceInput): Promise<unknown>;
}
export async function verifyAuthRequestCsrf(

@ -1,7 +1,11 @@
import {
AUTH_ERR_SESSION_REQUIRED,
AUTH_HTTP_METHODS,
AUTH_ROUTE_PATHS
AUTH_HTTP_STATUS,
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES
} from '$libs/auth/consts';
import { extractAuthRequestMeta } from '$libs/auth/helpers';
import {
authJson,
authRouteNotFound,
@ -12,10 +16,12 @@ import {
type AuthRouteRequest
} from './handler-runtime.ts';
import type {
AuthCurrentView,
AuthEmailVerificationCompleteInput,
AuthEmailVerificationRequestInput,
AuthPasswordResetCompleteInput,
AuthPasswordResetRequestInput,
AuthRevokeDeviceInput,
AuthSignInPasswordInput,
AuthSignUpPasswordInput
} from '$libs/auth/types';
@ -31,6 +37,8 @@ export interface AuthRouteHandlers {
readonly completeEmailVerification: (input: AuthRouteRequest) => Promise<Response>;
readonly requestPasswordReset: (input: AuthRouteRequest) => Promise<Response>;
readonly completePasswordReset: (input: AuthRouteRequest) => Promise<Response>;
readonly listDevices: (input: AuthRouteRequest) => Promise<Response>;
readonly revokeDevice: (input: AuthRouteRequest) => Promise<Response>;
readonly handle: (input: AuthRouteRequest) => Promise<Response>;
}
@ -132,6 +140,59 @@ export function createAuthRouteHandlers(engine: AuthHandlerEngine): AuthRouteHan
});
}
/**
* Resolve the authenticated session of `input` via `engine.current()`,
* mirroring how other endpoints derive it from cookies. Returns the
* `AuthCurrentView` when authenticated, or a 401 `Response` ready to
* send when not. Encapsulates the duplication between `listDevices`
* and `revokeDevice`, which both require an actor.
*/
async function requireAuthCurrent(
input: AuthRouteRequest
): Promise<{ ok: true; current: AuthCurrentView } | { ok: false; response: Response }> {
const current = (await engine.current({
tenantId: input.tenantId,
request: toAuthRequestLike(input.request)
})) as AuthCurrentView;
if (
current.session.status !== AUTH_SESSION_STATUSES.AUTHENTICATED ||
!current.session.actorRef
) {
return {
ok: false,
response: authJson(
{ error: { code: AUTH_ERR_SESSION_REQUIRED } },
{ status: AUTH_HTTP_STATUS.UNAUTHORIZED }
)
};
}
return { ok: true, current };
}
async function listDevices(input: AuthRouteRequest): Promise<Response> {
return protectAuthHandler(async () => {
const auth = await requireAuthCurrent(input);
if (!auth.ok) return auth.response;
return authJson(await engine.listDevices({ actorRef: auth.current.session.actorRef! }));
});
}
async function revokeDevice(input: AuthRouteRequest): Promise<Response> {
return protectAuthHandler(async () => {
await verifyAuthRequestCsrf(engine, input);
const auth = await requireAuthCurrent(input);
if (!auth.ok) return auth.response;
const body = (await input.request.json()) as Omit<AuthRevokeDeviceInput, 'actorRef'>;
return authJson(
await engine.revokeDevice({
...body,
actorRef: auth.current.session.actorRef!,
meta: body.meta ?? extractAuthRequestMeta(toAuthRequestLike(input.request))
})
);
});
}
async function handle(input: AuthRouteRequest): Promise<Response> {
const pathname = new URL(input.request.url).pathname;
const method = input.request.method;
@ -154,6 +215,10 @@ export function createAuthRouteHandlers(engine: AuthHandlerEngine): AuthRouteHan
return requestPasswordReset(input);
if (pathname === AUTH_ROUTE_PATHS.PASSWORD_RESET_COMPLETE && method === AUTH_HTTP_METHODS.POST)
return completePasswordReset(input);
if (pathname === AUTH_ROUTE_PATHS.DEVICES && method === AUTH_HTTP_METHODS.GET)
return listDevices(input);
if (pathname === AUTH_ROUTE_PATHS.DEVICE_REVOKE && method === AUTH_HTTP_METHODS.POST)
return revokeDevice(input);
return authRouteNotFound();
}
@ -168,6 +233,8 @@ export function createAuthRouteHandlers(engine: AuthHandlerEngine): AuthRouteHan
completeEmailVerification,
requestPasswordReset,
completePasswordReset,
listDevices,
revokeDevice,
handle
};
}

@ -0,0 +1,138 @@
/**
* Regression test for the audit P1 finding: the auth client calls
* `/api/auth/devices` and `/api/auth/devices/revoke`, but the
* generic handler didn't route them. Closes the gap by routing
* them through `engine.listDevices` / `engine.revokeDevice`,
* deriving the actor from the authenticated session.
*
* The tests use a stub `AuthHandlerEngine` so they exercise routing
* + auth gating without depending on the memory ports' specific
* cookie/session-extraction behaviour. Engine-level device
* semantics (revocation closes bound sessions, etc.) are already
* covered by `engine-password.test.ts`.
*/
import { describe, expect, it, vi } from 'vitest';
import {
AUTH_HTTP_STATUS,
AUTH_ROUTE_PATHS,
AUTH_SESSION_STATUSES
} from '$libs/auth';
import { createAuthRouteHandlers, type AuthHandlerEngine } from '$svrs/auth';
const TENANT = 'tenant-1';
const ACTOR = { tenantId: TENANT, actorId: 'actor-1' };
const DEVICE = { id: 'device-1', name: 'phone' };
function buildRequest(method: string, path: string, body?: unknown, csrfToken?: string): Request {
const init: RequestInit = { method };
const headers: Record<string, string> = {};
if (body !== undefined) {
init.body = JSON.stringify(body);
headers['content-type'] = 'application/json';
}
if (csrfToken !== undefined) {
// CSRF check requires header AND a matching cookie.
headers['x-active-auth-csrf'] = csrfToken;
headers['cookie'] = `__Host-active.auth.csrf=${csrfToken}`;
}
init.headers = headers;
return new Request(`http://localhost${path}`, init);
}
function authenticatedEngine(): AuthHandlerEngine {
return {
current: vi.fn(async () => ({
session: {
status: AUTH_SESSION_STATUSES.AUTHENTICATED,
sessionId: 'sess-1',
actorRef: ACTOR
}
})),
issueCsrf: vi.fn(),
verifyCsrf: vi.fn(async () => undefined),
signUpPassword: vi.fn(),
signInPassword: vi.fn(),
signOut: vi.fn(),
signOutGlobal: vi.fn(),
requestEmailVerification: vi.fn(),
completeEmailVerification: vi.fn(),
requestPasswordReset: vi.fn(),
completePasswordReset: vi.fn(),
listDevices: vi.fn(async () => [DEVICE]),
revokeDevice: vi.fn(async () => ({ ok: true, revokedSessionIds: ['sess-1'] }))
};
}
function anonymousEngine(): AuthHandlerEngine {
return {
...authenticatedEngine(),
current: vi.fn(async () => ({
session: { status: AUTH_SESSION_STATUSES.ANONYMOUS }
})),
listDevices: vi.fn(),
revokeDevice: vi.fn()
};
}
describe('auth route handlers — devices', () => {
it('GET /devices returns 401 when not authenticated', async () => {
const engine = anonymousEngine();
const handlers = createAuthRouteHandlers(engine);
const res = await handlers.handle({
tenantId: TENANT,
request: buildRequest('GET', AUTH_ROUTE_PATHS.DEVICES)
});
expect(res.status).toBe(AUTH_HTTP_STATUS.UNAUTHORIZED);
expect(engine.listDevices).not.toHaveBeenCalled();
});
it('GET /devices forwards to engine.listDevices with the resolved actor', async () => {
const engine = authenticatedEngine();
const handlers = createAuthRouteHandlers(engine);
const res = await handlers.handle({
tenantId: TENANT,
request: buildRequest('GET', AUTH_ROUTE_PATHS.DEVICES)
});
expect(res.status).toBe(AUTH_HTTP_STATUS.OK);
expect(engine.listDevices).toHaveBeenCalledTimes(1);
expect(engine.listDevices).toHaveBeenCalledWith({ actorRef: ACTOR });
const body = (await res.json()) as ReadonlyArray<unknown>;
expect(body).toEqual([DEVICE]);
});
it('POST /devices/revoke returns 401 when not authenticated', async () => {
const engine = anonymousEngine();
const handlers = createAuthRouteHandlers(engine);
const res = await handlers.handle({
tenantId: TENANT,
request: buildRequest('POST', AUTH_ROUTE_PATHS.DEVICE_REVOKE, { deviceId: 'd' }, 'csrf-1')
});
expect(res.status).toBe(AUTH_HTTP_STATUS.UNAUTHORIZED);
expect(engine.revokeDevice).not.toHaveBeenCalled();
});
it('POST /devices/revoke forwards to engine.revokeDevice with actor + body', async () => {
const engine = authenticatedEngine();
const handlers = createAuthRouteHandlers(engine);
const res = await handlers.handle({
tenantId: TENANT,
request: buildRequest(
'POST',
AUTH_ROUTE_PATHS.DEVICE_REVOKE,
{ deviceId: 'd-2' },
'csrf-2'
)
});
expect(res.status).toBe(AUTH_HTTP_STATUS.OK);
expect(engine.revokeDevice).toHaveBeenCalledTimes(1);
expect(engine.revokeDevice).toHaveBeenCalledWith(
expect.objectContaining({ actorRef: ACTOR, deviceId: 'd-2' })
);
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.