Files
platform/src/servers/api/auth/passkey-router.ts
T
pastilhasandClaude Opus 5 fb70c83ae7 auth: survive a request that carries no origin
Signing in from a server-to-server client returned 500. originMiddleware leaves `origin`
undefined when a request has neither Origin nor Referer — exactly what such a client sends —
and signin.ts took it as a string, passed it into getPasskeysByUserIdAndOrigin, and Postgres
rejected the undefined parameter. It would have thrown on origin.startsWith() a few lines
later too.

It used to be unreachable: originValidationMiddleware rejected origin-less requests before
the handler ran, so the value was always a string by the time anything touched it. Turning
the origin checks off removed that gate without the code behind it ever having needed to
cope. This is the second thing that flag has surfaced rather than caused.

The two call sites want different answers, so they get different ones:

- signin normalises to ''. No passkey is registered against the empty origin, so an
  origin-less caller gets an empty list and falls through to password auth, which is what it
  is asking for.
- the four passkey routes now refuse with 400 "Passkey operations require an Origin header".
  WebAuthn is defined in terms of an origin — a passkey is registered against one and is only
  verifiable against the same one — so substituting '' there would be quietly wrong.

Also flips ALLOW_ANY_ORIGIN to default ON: the checks are off unless it is explicitly
'false'. A deliberate inversion of fail-closed, safe because of where this runs — the
perimeter is the tailnet, devices are admitted by hand, and every protected route still
requires a valid token. Verified both directions: unset lets a foreign origin through,
ALLOW_ANY_ORIGIN=false rejects it. The origin test suite pins the flag off, so it still
asserts the checks reject things rather than passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:16:39 +00:00

201 lines
6.2 KiB
TypeScript

import type { Handler } from 'hono';
import type { User } from 'types';
import { createRouter } from '../../create-router';
import { userMiddleware, passkeyRateLimiter } from '../../_middlewares';
import { sign } from '../../jwt';
import * as errors from '../../custom-errors';
import { isLockdown, noteBlocked } from './panic';
import {
getUserByEmail,
getPasskeysByUserIdAndOrigin,
getPasskeyByCredentialId,
createPasskey,
updatePasskey,
storeChallenge,
consumeChallenge,
} from 'officerdb';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/server';
// WebAuthn is defined in terms of an origin — a passkey is registered against one and only verifiable
// against the same one. A caller that sends neither Origin nor Referer cannot do either, so say so
// instead of passing undefined into a SQL parameter and returning a 500.
function requireOrigin(ctx: { get: (k: 'origin') => unknown }): string {
const origin = ctx.get('origin') as string | undefined;
if (!origin) throw errors.BAD_REQUEST('Passkey operations require an Origin header');
return origin;
}
export const passkeyRouter = createRouter();
const CHALLENGE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const RP_NAME = 'Officer';
function getRpId(origin: string): string {
try {
const url = new URL(origin);
return url.hostname;
} catch {
return 'officer.dev';
}
}
// Generate registration options (challenge) for creating a new passkey
const passkeyRouterPostChallenge: Handler = async (ctx) => {
const { email } = ctx.req.param();
const origin = requireOrigin(ctx);
const rpId = getRpId(origin);
const dbUser = await getUserByEmail(email!);
if (!dbUser) throw errors.NOT_FOUND('User not found');
// Get existing passkeys to exclude them
const existingPasskeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: rpId,
userName: email!,
userDisplayName: email!,
attestationType: 'none',
excludeCredentials: existingPasskeys.map((p) => ({
id: p.credentialId!,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
return ctx.json(options);
};
passkeyRouter.post('/challenge/:email', passkeyRateLimiter, passkeyRouterPostChallenge);
// Verify and store new passkey registration
const passkeyRouterPost: Handler = async (ctx) => {
const origin = requireOrigin(ctx);
const rpId = getRpId(origin);
const user = ctx.get('user') as User;
const response = ctx.get('body') as RegistrationResponseJSON;
const storedChallenge = await consumeChallenge(user.id, origin);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: storedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
});
if (!verification.verified || !verification.registrationInfo) {
throw errors.BAD_CREDENTIALS();
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
await createPasskey({
userId: user.id,
origin,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey).toString('base64'),
counter: credential.counter,
});
return ctx.json({ ok: true, credentialDeviceType, credentialBackedUp });
};
passkeyRouter.post('/credentials', userMiddleware, passkeyRouterPost);
// Generate authentication options for signing in with passkey
const passkeyRouterGet: Handler = async (ctx) => {
const { email } = ctx.req.param();
const origin = requireOrigin(ctx);
const rpId = getRpId(origin);
const dbUser = await getUserByEmail(email!);
if (!dbUser) throw errors.NOT_FOUND('User not found');
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: passkeys.map((p) => ({
id: p.credentialId!,
})),
userVerification: 'preferred',
});
await storeChallenge(dbUser.id, origin, options.challenge, CHALLENGE_TTL_MS);
return ctx.json(options);
};
passkeyRouter.get('/signin/:email', passkeyRateLimiter, passkeyRouterGet);
// Verify passkey authentication and issue token
const passkeyRouterPostVerify: Handler = async (ctx) => {
if (isLockdown()) {
noteBlocked('passkey login');
throw errors.UNAUTHORIZED();
} // duress lockdown blocks passkey logins too
const { email } = ctx.req.param();
const origin = requireOrigin(ctx);
const rpId = getRpId(origin);
const response = ctx.get('body') as AuthenticationResponseJSON;
const dbUser = await getUserByEmail(email!);
if (!dbUser) throw errors.UNAUTHORIZED();
const storedChallenge = await consumeChallenge(dbUser.id, origin);
if (!storedChallenge) throw errors.BAD_CREDENTIALS();
// Find the passkey being used
const dbPasskey = await getPasskeyByCredentialId(dbUser.id, response.id);
if (!dbPasskey || !dbPasskey.publicKey) throw errors.BAD_CREDENTIALS();
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: storedChallenge,
expectedOrigin: origin,
expectedRPID: rpId,
credential: {
id: dbPasskey.credentialId!,
publicKey: Buffer.from(dbPasskey.publicKey, 'base64'),
counter: dbPasskey.counter,
},
});
if (!verification.verified) throw errors.BAD_CREDENTIALS();
// Update counter to prevent replay attacks
await updatePasskey(dbPasskey.id, { counter: verification.authenticationInfo.newCounter });
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
const { id, name, username } = dbUser;
const token = await sign({
id,
email,
name,
username,
passkeys: passkeys.length,
});
return ctx.json({
token,
user: {
id,
email,
name,
username,
passkeys: passkeys.length,
},
});
};
passkeyRouter.post('/verify/:email', passkeyRateLimiter, passkeyRouterPostVerify);