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
parent
2a6706370e
commit
45011ea4a7
@ -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…
Reference in new issue