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
28 changes: 28 additions & 0 deletions .changeset/lazy-pears-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'seamless-auth-api': minor
---

Ask for exactly the user verification that will be enforced.

Registration advertised `userVerification: 'preferred'`, telling the
authenticator verification was optional, and then rejected a response that
skipped it, because SimpleWebAuthn's `requireUserVerification` defaults to true
and this server never set it. A user on an authenticator that skips verification
completed the whole ceremony and failed at the last step, having never been asked
to verify. Authentication separately asked for `required`, so the two halves
disagreed.

`authenticator_policy.userVerification` now drives both, for registration and
authentication, so what the browser is asked for and what the server accepts come
from one value and cannot drift. It accepts `required`, `preferred` or
`discouraged` and defaults to `required`.

The default does not change what is accepted, since that was already enforced. It
changes what is asked for, so an authenticator is told to verify rather than
being allowed to skip and be rejected afterwards.

Step-up deliberately still requires verification regardless of the policy. It
exists to re-verify the human, and without verification it is a second signature
from a key the session already proved it holds.

Requires `@seamless-auth/types` 0.13.0.
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ LOCKOUT_POLICY={"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeco
# JSON. Which authenticators this deployment will enrol. attachment is any, platform or
# cross-platform. "any" offers both built-in authenticators and roaming security keys;
# naming one narrows the browser picker and rejects a request asking for the other.
AUTHENTICATOR_POLICY={"attachment":"any"}
# userVerification is required, preferred or discouraged. It drives both what the
# browser is asked for and what the server enforces, so the two cannot disagree.
AUTHENTICATOR_POLICY={"attachment":"any","userVerification":"required"}

# SERVICE TOKENS
# Required for trusted server adapters and internal bearer validation.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ Validation is enforced by [`systemConfig.schema.ts`](../src/schemas/systemConfig
| `passkey_login_fallback_enabled` | boolean | `PASSKEY_LOGIN_FALLBACK_ENABLED` | `true` |
| `oauth_providers` | provider[] | `OAUTH_PROVIDERS` | `[]` |
| `lockout_policy` | object | `LOCKOUT_POLICY` | `{enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}` |
| `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any"}` |
| `authenticator_policy` | object | `AUTHENTICATOR_POLICY` | `{attachment:"any",userVerification:"required"}` |
| `session_idle_ttl` | string (`\d+[smhd]`) | `SESSION_IDLE_TTL` | `8h` |
| `access_token_ttl` | string (`\d+[smhd]`) | `ACCESS_TOKEN_TTL` | - |
| `refresh_token_ttl` | string (`\d+[smhd]`) | `REFRESH_TOKEN_TTL` | - |
Expand Down
12 changes: 11 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6333,9 +6333,14 @@
"type": "string",
"enum": ["any", "platform", "cross-platform"],
"default": "any"
},
"userVerification": {
"type": "string",
"enum": ["required", "preferred", "discouraged"],
"default": "required"
}
},
"default": { "attachment": "any" }
"default": { "attachment": "any", "userVerification": "required" }
},
"access_token_ttl": { "type": "string", "pattern": "^\\d+[smhd]$" },
"session_idle_ttl": {
Expand Down Expand Up @@ -6536,6 +6541,11 @@
"type": "string",
"enum": ["any", "platform", "cross-platform"],
"default": "any"
},
"userVerification": {
"type": "string",
"enum": ["required", "preferred", "discouraged"],
"default": "required"
}
}
},
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"@seamless-auth/messaging": "^0.1.0",
"@seamless-auth/messaging-aws": "^0.1.0",
"@seamless-auth/messaging-twilio": "^0.1.0",
"@seamless-auth/types": "^0.12.0",
"@seamless-auth/types": "^0.13.0",
"@simplewebauthn/server": "^13.1.1",
"base64url": "^3.0.1",
"bcrypt-ts": "^7.1.0",
Expand Down
1 change: 1 addition & 0 deletions src/config/systemConfig.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const SYSTEM_CONFIG_DEFAULTS: Partial<SystemConfig> = {
},
authenticator_policy: {
attachment: 'any',
userVerification: 'required',
},
session_idle_ttl: '8h',
passkey_login_fallback_enabled: true,
Expand Down
8 changes: 8 additions & 0 deletions src/controllers/stepUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ export const startWebAuthnStepUp = async (req: Request, res: Response) => {
id: credential.id,
transports: credential.transports,
})),
// Deliberately not the deployment's authenticator_policy. Step-up exists to
// re-verify the human; without user verification it is a second signature
// from a key the session already proved it holds, which proves nothing
// extra. A deployment that relaxes verification generally should still get
// a real check when elevating.
userVerification: 'required',
timeout: 60000,
rpID: rpid,
Expand Down Expand Up @@ -199,6 +204,9 @@ export const finishWebAuthnStepUp = async (req: Request, res: Response) => {
const { origins, rpid } = await getSystemConfig();
const verification = await verifyAuthenticationResponse({
response: assertionResponse,
// Matches the 'required' asked for above rather than relying on a library
// default, so the ask and the enforcement cannot drift apart.
requireUserVerification: true,
expectedChallenge,
expectedOrigin: origins,
expectedRPID: rpid,
Expand Down
16 changes: 11 additions & 5 deletions src/controllers/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ const registerWebAuthn = async (req: Request, res: Response) => {
});

const { app_name, rpid, authenticator_policy } = await getSystemConfig();
const userVerification = authenticator_policy.userVerification;
const pinnedAttachment =
authenticator_policy.attachment === 'any' ? null : authenticator_policy.attachment;

Expand Down Expand Up @@ -164,7 +165,9 @@ const registerWebAuthn = async (req: Request, res: Response) => {
// this to 'platform' hides roaming authenticators from the browser picker
// entirely, which makes issued security keys impossible to enrol.
authenticatorSelection: {
userVerification: 'preferred',
// The same value is enforced at verification below, so the browser is
// never asked for less than the server will accept.
userVerification,
residentKey: 'preferred',
...(effectiveAttachment ? { authenticatorAttachment: effectiveAttachment } : {}),
},
Expand Down Expand Up @@ -264,13 +267,14 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => {

let verification;
try {
const { origins, rpid } = await getSystemConfig();
const { origins, rpid, authenticator_policy } = await getSystemConfig();

verification = await verifyRegistrationResponse({
response: attestationResponse,
expectedChallenge,
expectedOrigin: origins,
expectedRPID: rpid,
requireUserVerification: authenticator_policy.userVerification === 'required',
// Pinned to the advertised set. The library default here is every
// algorithm it knows, which would accept a credential using something
// this server never offered.
Expand Down Expand Up @@ -413,7 +417,8 @@ const generateWebAuthn = async (req: Request, res: Response) => {
return res.status(401).send('Credentials not found');
}

const { rpid } = await getSystemConfig();
const { rpid, authenticator_policy } = await getSystemConfig();
const userVerification = authenticator_policy.userVerification;

const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({
allowCredentials: assertionCredentials.map((cred) => {
Expand All @@ -422,7 +427,7 @@ const generateWebAuthn = async (req: Request, res: Response) => {
transports: cred.transports,
};
}),
userVerification: 'required',
userVerification,
timeout: 60000,
rpID: rpid,
extensions: buildPrfAuthenticationExtensions(prf),
Expand Down Expand Up @@ -532,12 +537,13 @@ const verifyWebAuthn = async (req: Request, res: Response) => {
let verification;

try {
const { origins, rpid } = await getSystemConfig();
const { origins, rpid, authenticator_policy } = await getSystemConfig();
verification = await verifyAuthenticationResponse({
response: assertionResponse,
expectedChallenge,
expectedOrigin: origins,
expectedRPID: rpid,
requireUserVerification: authenticator_policy.userVerification === 'required',
credential: {
id: cred.id,
// @ts-expect-error Needed to work.
Expand Down
13 changes: 12 additions & 1 deletion src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6850,7 +6850,8 @@ export interface paths {
};
/**
* @default {
* "attachment": "any"
* "attachment": "any",
* "userVerification": "required"
* }
*/
authenticator_policy: {
Expand All @@ -6859,6 +6860,11 @@ export interface paths {
* @enum {string}
*/
attachment: 'any' | 'platform' | 'cross-platform';
/**
* @default required
* @enum {string}
*/
userVerification: 'required' | 'preferred' | 'discouraged';
};
access_token_ttl: string;
/** @default 8h */
Expand Down Expand Up @@ -6985,6 +6991,11 @@ export interface paths {
* @enum {string}
*/
attachment?: 'any' | 'platform' | 'cross-platform';
/**
* @default required
* @enum {string}
*/
userVerification?: 'required' | 'preferred' | 'discouraged';
};
access_token_ttl?: string;
session_idle_ttl?: string;
Expand Down
82 changes: 76 additions & 6 deletions tests/integration/webauthn/webauthn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ beforeEach(() => {
access_token_ttl: '15m',
refresh_token_ttl: '1h',
session_idle_ttl: '8h',
authenticator_policy: { attachment: 'any' },
authenticator_policy: { attachment: 'any', userVerification: 'required' },
});
(Credential.findAll as any).mockResolvedValue([]);
(Credential.findOne as any).mockResolvedValue(null);
Expand All @@ -88,7 +88,7 @@ describe('GET /webauthn/register/start', () => {
(getSystemConfig as any).mockResolvedValue({
app_name: 'SeamlessAuth',
rpid: 'localhost',
authenticator_policy: { attachment: 'any' },
authenticator_policy: { attachment: 'any', userVerification: 'required' },
});

const { generateRegistrationOptions } = await import('@simplewebauthn/server');
Expand All @@ -108,7 +108,7 @@ describe('GET /webauthn/register/start', () => {
(getSystemConfig as any).mockResolvedValue({
app_name: 'SeamlessAuth',
rpid: 'localhost',
authenticator_policy: { attachment: 'any' },
authenticator_policy: { attachment: 'any', userVerification: 'required' },
});

const { generateRegistrationOptions } = await import('@simplewebauthn/server');
Expand Down Expand Up @@ -203,7 +203,7 @@ describe('GET /webauthn/register/start', () => {
(getSystemConfig as any).mockResolvedValue({
app_name: 'SeamlessAuth',
rpid: 'localhost',
authenticator_policy: { attachment: 'cross-platform' },
authenticator_policy: { attachment: 'cross-platform', userVerification: 'required' },
});
const { generateRegistrationOptions } = await import('@simplewebauthn/server');
(generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' });
Expand All @@ -224,7 +224,7 @@ describe('GET /webauthn/register/start', () => {
(getSystemConfig as any).mockResolvedValue({
app_name: 'SeamlessAuth',
rpid: 'localhost',
authenticator_policy: { attachment: 'cross-platform' },
authenticator_policy: { attachment: 'cross-platform', userVerification: 'required' },
});
const { generateRegistrationOptions } = await import('@simplewebauthn/server');
(generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' });
Expand All @@ -240,7 +240,7 @@ describe('GET /webauthn/register/start', () => {
(getSystemConfig as any).mockResolvedValue({
app_name: 'SeamlessAuth',
rpid: 'localhost',
authenticator_policy: { attachment: 'cross-platform' },
authenticator_policy: { attachment: 'cross-platform', userVerification: 'required' },
});
const { generateRegistrationOptions } = await import('@simplewebauthn/server');
(generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' });
Expand Down Expand Up @@ -303,6 +303,38 @@ describe('GET /webauthn/register/start', () => {
expect(options.supportedAlgorithmIDs).toEqual([-8, -7, -257, -65535]);
});

// Registration used to ask for 'preferred' while the library default enforced
// 'required', so a user on an authenticator that skips verification completed
// the whole ceremony and was rejected at the last step.
it('asks for exactly the verification it will enforce', async () => {
const { generateRegistrationOptions } = await import('@simplewebauthn/server');
(generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' });

const res = await request(app).get('/webauthn/register/start');

expect(res.status).toBe(200);

const [options] = (generateRegistrationOptions as any).mock.calls.at(-1);

expect(options.authenticatorSelection.userVerification).toBe('required');
});

it('follows a deployment that relaxes verification', async () => {
(getSystemConfig as any).mockResolvedValue({
app_name: 'SeamlessAuth',
rpid: 'localhost',
authenticator_policy: { attachment: 'any', userVerification: 'discouraged' },
});
const { generateRegistrationOptions } = await import('@simplewebauthn/server');
(generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' });

await request(app).get('/webauthn/register/start');

const [options] = (generateRegistrationOptions as any).mock.calls.at(-1);

expect(options.authenticatorSelection.userVerification).toBe('discouraged');
});

it('returns 500 when credential lookup fails', async () => {
(Credential.findAll as any).mockRejectedValue(new Error('db down'));

Expand Down Expand Up @@ -384,6 +416,42 @@ describe('challenge is spent on every login outcome', () => {
});
});

describe('verification policy reaches both verifiers', () => {
it('enforces user verification at registration when the policy requires it', async () => {
(User.findOne as any).mockResolvedValue(buildUser());
const { verifyRegistrationResponse } = await import('@simplewebauthn/server');
(verifyRegistrationResponse as any).mockResolvedValue({ verified: false });

await request(app)
.post('/webauthn/register/finish')
.send({ attestationResponse: {}, metadata: {} });

const [options] = (verifyRegistrationResponse as any).mock.calls.at(-1);

expect(options.requireUserVerification).toBe(true);
});

it('stops enforcing it when the deployment relaxes the policy', async () => {
(getSystemConfig as any).mockResolvedValue({
app_name: 'SeamlessAuth',
rpid: 'localhost',
origins: ['http://localhost:5137'],
authenticator_policy: { attachment: 'any', userVerification: 'discouraged' },
});
(User.findOne as any).mockResolvedValue(buildUser());
const { verifyRegistrationResponse } = await import('@simplewebauthn/server');
(verifyRegistrationResponse as any).mockResolvedValue({ verified: false });

await request(app)
.post('/webauthn/register/finish')
.send({ attestationResponse: {}, metadata: {} });

const [options] = (verifyRegistrationResponse as any).mock.calls.at(-1);

expect(options.requireUserVerification).toBe(false);
});
});

describe('POST /webauthn/register/finish', () => {
it('creates credential and session', async () => {
const user = buildUser();
Expand Down Expand Up @@ -895,6 +963,7 @@ describe('POST /webauthn/login/finish', () => {
(getSystemConfig as any).mockResolvedValue({
origins: ['http://localhost:5137'],
rpid: 'localhost',
authenticator_policy: { attachment: 'any', userVerification: 'required' },
});
const { verifyAuthenticationResponse } = await import('@simplewebauthn/server');
(verifyAuthenticationResponse as any).mockRejectedValue(new Error('bad assertion'));
Expand Down Expand Up @@ -953,6 +1022,7 @@ describe('POST /webauthn/login/finish', () => {
(getSystemConfig as any).mockResolvedValue({
origins: ['http://localhost:5137'],
rpid: 'localhost',
authenticator_policy: { attachment: 'any', userVerification: 'required' },
});
const { verifyAuthenticationResponse } = await import('@simplewebauthn/server');
(verifyAuthenticationResponse as any).mockResolvedValue({
Expand Down
Loading