feat(auth): migrate passkeys to fluxer.com (#2964)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-25 22:33:50 +02:00
1 parent 08e65d41c0
commit 841fb7af41
197 files changed
+17147 -12336

No files matched your search

@@ -0,0 +1,391 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {
createAuthHarness,
createTestAccount,
type LoginMfaResponse,
loginUser,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {
type BridgeNonce,
createBridgeNonce,
LEGACY_ORIGIN,
LEGACY_RP_ID,
registerPasskey,
runNativeSudoBridge,
setDomainMigration,
TARGET_ORIGIN,
} from '@app/api/auth/tests/PasskeyTestUtils';
import {
createAuthenticationResponse,
createTotpSecret,
createWebAuthnDevice,
generateTotpCode,
setWebAuthnTwoFactor,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {getConfig} from '@app/api/Config';
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {
PasskeyBridgeFinishResponse,
PasskeyBridgeLoginRedeemResponse,
PasskeyBridgeStartResponse,
PasskeyBridgeSudoRedeemResponse,
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
interface StartedBridge {
ceremonyId: string;
bridgeUrl: string | null;
nonce: BridgeNonce;
}
describe('Passkey bridge', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
await setDomainMigration(true);
});
afterEach(() => {
getConfig().instance.selfHosted = false;
});
afterAll(async () => {
await harness?.shutdown();
});
async function createLegacyAccount(): Promise<{account: TestAccount; device: WebAuthnDevice}> {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
return {account, device};
}
async function startLogin(body: Record<string, unknown> = {}, origin = TARGET_ORIGIN): Promise<StartedBridge> {
const nonce = createBridgeNonce();
const start = await createBuilderWithoutAuth<PasskeyBridgeStartResponse>(harness)
.post('/auth/passkey-bridge')
.header('origin', origin)
.body({purpose: 'login', runner: 'page', nonce_hash: nonce.nonceHash, ...body})
.execute();
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
}
async function startSudo(token: string, runner: 'page' | 'native' = 'page'): Promise<StartedBridge> {
const nonce = createBridgeNonce();
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
.post('/users/@me/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body({runner, nonce_hash: nonce.nonceHash})
.execute();
return {ceremonyId: start.ceremony_id, bridgeUrl: start.bridge_url, nonce};
}
async function fetchOptions(ceremonyId: string, origin = LEGACY_ORIGIN): Promise<WebAuthnAuthenticationOptions> {
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
.post(`/auth/passkey-bridge/${ceremonyId}/options`)
.header('origin', origin)
.execute();
return options;
}
async function complete(
ceremonyId: string,
device: WebAuthnDevice,
origin = LEGACY_ORIGIN,
): Promise<PasskeyBridgeFinishResponse> {
const options = await fetchOptions(ceremonyId, origin);
return createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${ceremonyId}/complete`)
.header('origin', origin)
.body({response: createAuthenticationResponse(device, options)})
.execute();
}
function completionCodeFrom(finish: PasskeyBridgeFinishResponse, ceremonyId: string): string {
const url = new URL(finish.return_url!);
const [id, code] = url.hash.slice('#passkey-bridge='.length).split('.');
expect(id).toBe(ceremonyId);
return code;
}
function redeemLogin(ceremonyId: string, nonce: string, completionCode: string) {
return createBuilderWithoutAuth<PasskeyBridgeLoginRedeemResponse>(harness)
.post(`/auth/passkey-bridge/${ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body({nonce, completion_code: completionCode});
}
it('refuses to start outside the new origin and on a self-hosted instance, whatever the switch', async () => {
const nonce = createBridgeNonce();
const body = {purpose: 'login', runner: 'native', nonce_hash: nonce.nonceHash};
for (const origin of [LEGACY_ORIGIN, 'https://evil.example']) {
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.header('origin', origin)
.body(body)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
}
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.body(body)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
getConfig().instance.selfHosted = true;
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body(body)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
getConfig().instance.selfHosted = false;
await setDomainMigration(false);
await createBuilderWithoutAuth(harness)
.post('/auth/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body(body)
.expect(HTTP_STATUS.OK)
.execute();
});
it('runs the ceremony only on the paired origin and keeps going when the switch goes off', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
expect(started.bridgeUrl).toBe(`${LEGACY_ORIGIN}/passkey-bridge#${started.ceremonyId}`);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/options`)
.header('origin', TARGET_ORIGIN)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
const options = await fetchOptions(started.ceremonyId);
expect(options.rpId).toBe(LEGACY_RP_ID);
expect(options.allowCredentials).toBeUndefined();
expect(options.userVerification).toBe('required');
await setDomainMigration(false);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
.header('origin', LEGACY_ORIGIN)
.body({response: createAuthenticationResponse(device, options)})
.expect(HTTP_STATUS.OK)
.execute();
});
it('signs in through a page ceremony and always returns to the bridge page', async () => {
const {account, device} = await createLegacyAccount();
const started = await startLogin({
return_path: '/api/v1/oauth2/authorize?prompt=none&redirect_uri=https://evil.example/cb',
});
const finish = await complete(started.ceremonyId, device);
expect(finish.completion_code).toBeNull();
const returnUrl = new URL(finish.return_url!);
expect(`${returnUrl.origin}${returnUrl.pathname}${returnUrl.search}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
const code = completionCodeFrom(finish, started.ceremonyId);
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
expect(redeemed.status).toBe('completed');
if (redeemed.status !== 'completed') return;
expect(redeemed.user_id).toBe(account.userId);
const me = await createBuilder<{id: string}>(harness, redeemed.token).get('/users/@me').execute();
expect(me.id).toBe(account.userId);
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
});
it('needs both the nonce and the completion code', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
const attacker = createBridgeNonce();
await redeemLogin(started.ceremonyId, attacker.nonce, code)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
.execute();
await redeemLogin(started.ceremonyId, started.nonce.nonce, code)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
const second = await startLogin();
completionCodeFrom(await complete(second.ceremonyId, device), second.ceremonyId);
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_PASSKEY_BRIDGE_NONCE)
.execute();
await redeemLogin(second.ceremonyId, second.nonce.nonce, 'A'.repeat(43))
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
});
it('keeps a pending ceremony when redeemed early or verification fails', async () => {
const {account, device} = await createLegacyAccount();
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
const started = await startLogin();
await redeemLogin(started.ceremonyId, started.nonce.nonce, 'A'.repeat(43))
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
const options = await fetchOptions(started.ceremonyId);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
.header('origin', LEGACY_ORIGIN)
.body({response: createAuthenticationResponse(target, options)})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
expect(redeemed.status).toBe('completed');
});
it('never lets a bridge challenge through the normal endpoints', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const options = await fetchOptions(started.ceremonyId);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.header('origin', LEGACY_ORIGIN)
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
});
it('reports a cancelled ceremony and refuses to cancel a completed one', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const cancelled = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/cancel`)
.header('origin', LEGACY_ORIGIN)
.execute();
const code = completionCodeFrom(cancelled, started.ceremonyId);
expect(await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute()).toEqual({status: 'cancelled'});
const second = await startLogin();
await complete(second.ceremonyId, device);
await createBuilderWithoutAuth(harness)
.post(`/auth/passkey-bridge/${second.ceremonyId}/cancel`)
.header('origin', LEGACY_ORIGIN)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
});
it('completes two-factor sign in for the ticket holder', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
await registerPasskey(
harness,
account.token,
device,
{mfa_method: 'totp', mfa_code: generateTotpCode(secret)},
'Old',
);
await setWebAuthnTwoFactor(harness, account.token, true, {mfa_method: 'totp', mfa_code: generateTotpCode(secret)});
const login = (await loginUser(harness, {email: account.email, password: account.password})) as LoginMfaResponse;
const started = await startLogin({purpose: 'login_mfa', ticket: login.ticket});
const options = await fetchOptions(started.ceremonyId);
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([device.credentialId.toString('base64url')]);
expect(options.userVerification).toBe('discouraged');
const code = completionCodeFrom(await complete(started.ceremonyId, device), started.ceremonyId);
const redeemed = await redeemLogin(started.ceremonyId, started.nonce.nonce, code).execute();
expect(redeemed.status).toBe('completed');
await createBuilderWithoutAuth(harness)
.post('/auth/login/mfa/totp')
.body({code: generateTotpCode(secret), ticket: login.ticket})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('issues a sudo token that passes a sudo-protected route', async () => {
const {account, device} = await createLegacyAccount();
const credentialId = device.credentialId.toString('base64url');
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
.body({name: 'Renamed'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
const redeemed = await runNativeSudoBridge(harness, account.token, device);
expect(redeemed.status).toBe('completed');
if (redeemed.status !== 'completed') return;
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialId}`)
.header(SUDO_MODE_HEADER, redeemed.sudo_token)
.body({name: 'Renamed'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('returns sudo page ceremonies to the bridge page on the new origin', async () => {
const {account, device} = await createLegacyAccount();
const started = await startSudo(account.token);
const finish = await complete(started.ceremonyId, device);
const returnUrl = new URL(finish.return_url!);
expect(`${returnUrl.origin}${returnUrl.pathname}`).toBe(`${TARGET_ORIGIN}/passkey-bridge`);
const code = completionCodeFrom(finish, started.ceremonyId);
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body({nonce: started.nonce.nonce, completion_code: code})
.execute();
expect(redeemed.status).toBe('completed');
});
it('does not consume a ceremony redeemed on the wrong route or by another user', async () => {
const {account, device} = await createLegacyAccount();
const other = await createTestAccount(harness);
const started = await startSudo(account.token, 'native');
const options = await fetchOptions(started.ceremonyId, TARGET_ORIGIN);
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${started.ceremonyId}/complete`)
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(device, options)})
.execute();
const body = {nonce: started.nonce.nonce, completion_code: finish.completion_code};
await redeemLogin(started.ceremonyId, body.nonce, body.completion_code!)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
await createBuilder(harness, other.token)
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body(body)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_BRIDGE)
.execute();
const redeemed = await createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, account.token)
.post(`/users/@me/passkey-bridge/${started.ceremonyId}/redeem`)
.header('origin', TARGET_ORIGIN)
.body(body)
.execute();
expect(redeemed.status).toBe('completed');
});
it('always stores the ceremony with an expiry', async () => {
const {device} = await createLegacyAccount();
const started = await startLogin();
const key = `passkey_bridge:${createHash('sha256').update(started.ceremonyId).digest('hex')}`;
const cache = getCacheService();
const ttls = [await cache.ttl(key)];
await fetchOptions(started.ceremonyId);
ttls.push(await cache.ttl(key));
await complete(started.ceremonyId, device);
ttls.push(await cache.ttl(key));
for (const ttl of ttls) {
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(600);
}
});
});
@@ -0,0 +1,260 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
LEGACY_RP_ID,
listPasskeys,
registerPasskey,
runNativeSudoBridge,
setDomainMigration,
TARGET_ORIGIN,
TARGET_RP_ID,
} from '@app/api/auth/tests/PasskeyTestUtils';
import {
createAuthenticationResponse,
createRegistrationResponse,
createWebAuthnDevice,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {PasskeyMigrationResponse} from '@fluxer/schema/src/domains/auth/PasskeyMigrationSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const MIGRATION_PATH = '/users/@me/mfa/webauthn/migration';
const MIGRATION_OPTIONS_PATH = '/users/@me/mfa/webauthn/migration/registration-options';
function credentialIdOf(device: WebAuthnDevice): string {
return device.credentialId.toString('base64url');
}
interface RpcSessionResponse {
data: {webauthn_credentials: Array<{id: string; rp_id: string}>};
}
describe('Passkey migration', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function createAssignedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice}> {
const account = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Laptop');
await setDomainMigration(true, [account.userId]);
return {account, legacy};
}
async function getPending(token: string): Promise<PasskeyMigrationResponse['pending']> {
const response = await createBuilder<PasskeyMigrationResponse>(harness, token).get(MIGRATION_PATH).execute();
return response.pending;
}
async function migrationOptions(token: string): Promise<WebAuthnRegistrationOptions> {
return createBuilder<WebAuthnRegistrationOptions>(harness, token)
.post(MIGRATION_OPTIONS_PATH)
.header('origin', TARGET_ORIGIN)
.execute();
}
function completeMigration(token: string, device: WebAuthnDevice, options: WebAuthnRegistrationOptions) {
return createBuilder(harness, token)
.post(MIGRATION_PATH)
.header('origin', TARGET_ORIGIN)
.body({response: createRegistrationResponse(device, options, 'Laptop'), challenge: options.challenge});
}
async function migrate(account: TestAccount, legacy: WebAuthnDevice): Promise<WebAuthnDevice> {
await runNativeSudoBridge(harness, account.token, legacy);
const target = createWebAuthnDevice();
await completeMigration(account.token, target, await migrationOptions(account.token))
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
return target;
}
async function discoverableLogin(device: WebAuthnDevice, origin?: string, status: number = HTTP_STATUS.OK) {
const optionsBuilder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null);
if (origin) optionsBuilder.header('origin', origin);
const options = await optionsBuilder.execute();
const builder = createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({response: createAuthenticationResponse(device, options), challenge: options.challenge})
.expect(status);
if (origin) builder.header('origin', origin);
await builder.execute();
}
it('records a pending update for any account on the new origin while the switch is on', async () => {
const unassigned = await createTestAccount(harness);
const unassignedDevice = createWebAuthnDevice();
await registerPasskey(harness, unassigned.token, unassignedDevice, {password: unassigned.password}, 'Laptop');
await setDomainMigration(false);
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
expect(await getPending(unassigned.token)).toBeNull();
await setDomainMigration(true);
expect((await runNativeSudoBridge(harness, unassigned.token, unassignedDevice)).status).toBe('completed');
expect(await getPending(unassigned.token)).toEqual({
credential_id: credentialIdOf(unassignedDevice),
name: 'Laptop',
cross_device: false,
});
});
it('needs a pending update and the new origin for registration options', async () => {
const {account, legacy} = await createAssignedAccount();
await createBuilder(harness, account.token)
.post(MIGRATION_OPTIONS_PATH)
.header('origin', TARGET_ORIGIN)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
.execute();
await runNativeSudoBridge(harness, account.token, legacy);
await createBuilder(harness, account.token)
.post(MIGRATION_OPTIONS_PATH)
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_PASSKEY_MIGRATION)
.execute();
const options = await migrationOptions(account.token);
expect(options.rp.id).toBe(TARGET_RP_ID);
});
it('replaces the passkey under the same name and hides the old one', async () => {
const {account, legacy} = await createAssignedAccount();
const target = await migrate(account, legacy);
const credentials = await listPasskeys(harness, account.token);
expect(credentials).toEqual([
expect.objectContaining({id: credentialIdOf(target), name: 'Laptop', rp_id: TARGET_RP_ID}),
]);
const old = await getUserRepository().getWebAuthnCredential(
createUserID(BigInt(account.userId)),
credentialIdOf(legacy),
);
expect(old?.supersededBy).toBe(credentialIdOf(target));
expect(await getPending(account.token)).toBeNull();
const ready = await createBuilder<RpcSessionResponse>(harness, '')
.post('/test/rpc-session-init')
.body({type: 'session', token: account.token, version: 1, ip: '127.0.0.1'})
.execute();
expect(ready.data.webauthn_credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual([
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
]);
});
it('keeps the old passkey working off the new origin', async () => {
const {account, legacy} = await createAssignedAccount();
const target = await migrate(account, legacy);
await discoverableLogin(legacy);
await discoverableLogin(legacy, TARGET_ORIGIN, HTTP_STATUS.UNAUTHORIZED);
await discoverableLogin(target, TARGET_ORIGIN);
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(legacy)}`)
.body({name: 'Renamed', password: account.password})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_WEBAUTHN_CREDENTIAL)
.execute();
const sudoOptions = await createBuilder<WebAuthnAuthenticationOptions>(harness, account.token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null)
.execute();
expect(sudoOptions.rpId).toBe(LEGACY_RP_ID);
expect(sudoOptions.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
});
it('removes the old passkey together with its replacement', async () => {
const {account, legacy} = await createAssignedAccount();
const target = await migrate(account, legacy);
await createBuilder(harness, account.token)
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(target)}`)
.body({password: account.password})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
expect(await getUserRepository().listWebAuthnCredentials(createUserID(BigInt(account.userId)))).toEqual([]);
await discoverableLogin(legacy, undefined, HTTP_STATUS.UNAUTHORIZED);
});
it('removes every remaining superseded passkey with the last visible one', async () => {
const account = await createTestAccount(harness);
const orphan = createWebAuthnDevice();
const visible = createWebAuthnDevice();
await registerPasskey(harness, account.token, orphan, {password: account.password}, 'Orphan');
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
const userId = createUserID(BigInt(account.userId));
await getUserRepository().setWebAuthnCredentialSupersededBy(userId, credentialIdOf(orphan), 'gone');
await createBuilder(harness, account.token)
.delete(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
.body({password: account.password})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
expect(await getUserRepository().listWebAuthnCredentials(userId)).toEqual([]);
});
it('has no way to attach the old passkey to another one', async () => {
const {account, legacy} = await createAssignedAccount();
await runNativeSudoBridge(harness, account.token, legacy);
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, target, {password: account.password}, 'Phone', TARGET_ORIGIN);
await createBuilder(harness, account.token)
.delete(MIGRATION_PATH)
.header('origin', TARGET_ORIGIN)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id).sort()).toEqual(
[credentialIdOf(legacy), credentialIdOf(target)].sort(),
);
});
it('never lets a migration challenge through the normal registration route', async () => {
const {account, legacy} = await createAssignedAccount();
await runNativeSudoBridge(harness, account.token, legacy);
const options = await migrationOptions(account.token);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.header('origin', TARGET_ORIGIN)
.body({
response: createRegistrationResponse(createWebAuthnDevice(), options, 'Sneaky'),
challenge: options.challenge,
name: 'Sneaky',
})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_WEBAUTHN_CREDENTIAL)
.execute();
});
it('creates one credential when two updates race', async () => {
const {account, legacy} = await createAssignedAccount();
await runNativeSudoBridge(harness, account.token, legacy);
const first = await migrationOptions(account.token);
const second = await migrationOptions(account.token);
const results = await Promise.all(
[first, second].map((options) =>
completeMigration(account.token, createWebAuthnDevice(), options)
.expect(HTTP_STATUS.NO_CONTENT)
.executeWithResponse()
.then(
() => 'ok',
() => 'failed',
),
),
);
expect(results.sort()).toEqual(['failed', 'ok']);
const credentials = await listPasskeys(harness, account.token);
expect(credentials).toHaveLength(1);
expect(credentials[0].rp_id).toBe(TARGET_RP_ID);
});
});
@@ -0,0 +1,232 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {
LEGACY_ORIGIN,
LEGACY_RP_ID,
listPasskeys,
registerPasskey,
TARGET_ORIGIN,
TARGET_RP_ID,
} from '@app/api/auth/tests/PasskeyTestUtils';
import {
createAuthenticationResponse,
createWebAuthnDevice,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {getConfig} from '@app/api/Config';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
function credentialIdOf(device: WebAuthnDevice): string {
return device.credentialId.toString('base64url');
}
describe('Passkey relying party selection', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
getConfig().instance.selfHosted = false;
});
afterAll(async () => {
await harness?.shutdown();
});
async function registrationRpId(account: TestAccount, origin?: string): Promise<string> {
const builder = createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({password: account.password});
if (origin) builder.header('origin', origin);
return (await builder.execute()).rp.id;
}
async function discoverableOptions(origin?: string): Promise<WebAuthnAuthenticationOptions> {
const builder = createBuilderWithoutAuth<WebAuthnAuthenticationOptions>(harness)
.post('/auth/webauthn/authentication-options')
.body(null);
if (origin) builder.header('origin', origin);
return builder.execute();
}
async function sudoOptions(token: string, origin?: string): Promise<WebAuthnAuthenticationOptions> {
const builder = createBuilder<WebAuthnAuthenticationOptions>(harness, token)
.post('/users/@me/sudo/webauthn/authentication-options')
.body(null);
if (origin) builder.header('origin', origin);
return builder.execute();
}
async function createMixedAccount(): Promise<{account: TestAccount; legacy: WebAuthnDevice; target: WebAuthnDevice}> {
const account = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
return {account, legacy, target};
}
it('uses the new relying party only for requests from the new origin', async () => {
const account = await createTestAccount(harness);
expect(await registrationRpId(account)).toBe(LEGACY_RP_ID);
expect(await registrationRpId(account, LEGACY_ORIGIN)).toBe(LEGACY_RP_ID);
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(TARGET_RP_ID);
expect((await discoverableOptions()).rpId).toBe(LEGACY_RP_ID);
expect((await discoverableOptions(LEGACY_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(TARGET_RP_ID);
});
it('keeps the legacy relying party everywhere on a self-hosted instance', async () => {
getConfig().instance.selfHosted = true;
const account = await createTestAccount(harness);
expect(await registrationRpId(account, TARGET_ORIGIN)).toBe(LEGACY_RP_ID);
expect((await discoverableOptions(TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
});
it('stores and exposes the relying party of each passkey', async () => {
const {account, legacy, target} = await createMixedAccount();
const credentials = await listPasskeys(harness, account.token);
expect(credentials.map(({id, rp_id}) => ({id, rp_id}))).toEqual(
expect.arrayContaining([
{id: credentialIdOf(legacy), rp_id: LEGACY_RP_ID},
{id: credentialIdOf(target), rp_id: TARGET_RP_ID},
]),
);
const legacyRow = await getUserRepository().getWebAuthnCredential(
createUserID(BigInt(account.userId)),
credentialIdOf(legacy),
);
expect(legacyRow?.rpId).toBeNull();
});
it('keeps the legacy options unchanged for a legacy-only account off the new origin', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
await registerPasskey(harness, account.token, device, {password: account.password}, 'Old');
for (const origin of [undefined, LEGACY_ORIGIN]) {
const options = await sudoOptions(account.token, origin);
expect(options.rpId).toBe(LEGACY_RP_ID);
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(device)]);
expect(options.userVerification).toBe('discouraged');
}
});
it('offers one relying party group per request', async () => {
const {account, legacy, target} = await createMixedAccount();
const onTarget = await sudoOptions(account.token, TARGET_ORIGIN);
expect(onTarget.rpId).toBe(TARGET_RP_ID);
expect(onTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
const offTarget = await sudoOptions(account.token);
expect(offTarget.rpId).toBe(LEGACY_RP_ID);
expect(offTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
});
it('falls back to the other group when the preferred one is empty', async () => {
const legacyOnly = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
await registerPasskey(harness, legacyOnly.token, legacy, {password: legacyOnly.password}, 'Old');
expect((await sudoOptions(legacyOnly.token, TARGET_ORIGIN)).rpId).toBe(LEGACY_RP_ID);
const targetOnly = await createTestAccount(harness);
const target = createWebAuthnDevice();
await registerPasskey(harness, targetOnly.token, target, {password: targetOnly.password}, 'New', TARGET_ORIGIN);
expect((await sudoOptions(targetOnly.token)).rpId).toBe(TARGET_RP_ID);
});
it('rejects a passkey from another relying party before the test mode shortcut', async () => {
const {legacy} = await createMixedAccount();
const options = await discoverableOptions(TARGET_ORIGIN);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(legacy, options), challenge: options.challenge})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
});
it('rejects a passkey outside the offered list before the test mode shortcut', async () => {
const account = await createTestAccount(harness);
const visible = createWebAuthnDevice();
const superseded = createWebAuthnDevice();
await registerPasskey(harness, account.token, visible, {password: account.password}, 'Visible');
await registerPasskey(harness, account.token, superseded, {password: account.password}, 'Superseded');
await getUserRepository().setWebAuthnCredentialSupersededBy(
createUserID(BigInt(account.userId)),
credentialIdOf(superseded),
credentialIdOf(visible),
);
const options = await sudoOptions(account.token, TARGET_ORIGIN);
expect(options.rpId).toBe(LEGACY_RP_ID);
expect(options.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(visible)]);
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
.header('origin', TARGET_ORIGIN)
.body({
name: 'Renamed',
mfa_method: 'webauthn',
webauthn_response: createAuthenticationResponse(superseded, options),
webauthn_challenge: options.challenge,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
const retry = await sudoOptions(account.token, TARGET_ORIGIN);
await createBuilder(harness, account.token)
.patch(`/users/@me/mfa/webauthn/credentials/${credentialIdOf(visible)}`)
.header('origin', TARGET_ORIGIN)
.body({
name: 'Renamed',
mfa_method: 'webauthn',
webauthn_response: createAuthenticationResponse(visible, retry),
webauthn_challenge: retry.challenge,
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('accepts a superseded passkey only off the new origin', async () => {
const account = await createTestAccount(harness);
const legacy = createWebAuthnDevice();
const target = createWebAuthnDevice();
await registerPasskey(harness, account.token, legacy, {password: account.password}, 'Old');
await registerPasskey(harness, account.token, target, {password: account.password}, 'New', TARGET_ORIGIN);
await getUserRepository().setWebAuthnCredentialSupersededBy(
createUserID(BigInt(account.userId)),
credentialIdOf(legacy),
credentialIdOf(target),
);
expect((await listPasskeys(harness, account.token)).map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
const offTarget = await discoverableOptions();
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.body({response: createAuthenticationResponse(legacy, offTarget), challenge: offTarget.challenge})
.expect(HTTP_STATUS.OK)
.execute();
const sudoOffTarget = await sudoOptions(account.token);
expect(sudoOffTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(legacy)]);
const onTarget = await discoverableOptions(TARGET_ORIGIN);
await createBuilderWithoutAuth(harness)
.post('/auth/webauthn/authenticate')
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(legacy, onTarget), challenge: onTarget.challenge})
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.PASSKEY_AUTHENTICATION_FAILED)
.execute();
const sudoOnTarget = await sudoOptions(account.token, TARGET_ORIGIN);
expect(sudoOnTarget.allowCredentials?.map((cred) => cred.id)).toEqual([credentialIdOf(target)]);
});
});
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash, randomBytes} from 'node:crypto';
import {
createAuthenticationResponse,
createRegistrationResponse,
type WebAuthnAuthenticationOptions,
type WebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {DEFAULT_DOMAIN_MIGRATION_CONFIG} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import type {
PasskeyBridgeFinishResponse,
PasskeyBridgeStartResponse,
PasskeyBridgeSudoRedeemResponse,
} from '@fluxer/schema/src/domains/auth/PasskeyBridgeSchemas';
export const TARGET_ORIGIN = 'https://fluxer.com';
export const LEGACY_ORIGIN = 'https://web.fluxer.app';
export const LEGACY_RP_ID = 'localhost';
export const TARGET_RP_ID = 'fluxer.com';
export interface PasskeyCredentialListItem {
id: string;
name: string;
rp_id: string;
}
export interface BridgeNonce {
nonce: string;
nonceHash: string;
}
export function createBridgeNonce(): BridgeNonce {
const nonce = randomBytes(32).toString('base64url');
return {nonce, nonceHash: createHash('sha256').update(nonce).digest('hex')};
}
export async function setDomainMigration(enabled: boolean, includedUserIds: Array<string> = []): Promise<void> {
await getInstanceConfigRepository().setDomainMigrationConfig({
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
enabled,
included_user_ids: includedUserIds,
});
}
export async function registerPasskey(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
sudo: Record<string, unknown>,
name: string,
origin?: string,
): Promise<void> {
const optionsBuilder = createBuilder<WebAuthnRegistrationOptions>(harness, token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body(sudo);
if (origin) optionsBuilder.header('origin', origin);
const options = await optionsBuilder.execute();
const registerBuilder = createBuilder(harness, token)
.post('/users/@me/mfa/webauthn/credentials')
.body({response: createRegistrationResponse(device, options, name), challenge: options.challenge, name})
.expect(204);
if (origin) registerBuilder.header('origin', origin);
await registerBuilder.execute();
}
export async function listPasskeys(harness: ApiTestHarness, token: string): Promise<Array<PasskeyCredentialListItem>> {
return createBuilder<Array<PasskeyCredentialListItem>>(harness, token)
.get('/users/@me/mfa/webauthn/credentials')
.execute();
}
export async function runNativeSudoBridge(
harness: ApiTestHarness,
token: string,
device: WebAuthnDevice,
nonce: BridgeNonce = createBridgeNonce(),
): Promise<PasskeyBridgeSudoRedeemResponse> {
const start = await createBuilder<PasskeyBridgeStartResponse>(harness, token)
.post('/users/@me/passkey-bridge')
.header('origin', TARGET_ORIGIN)
.body({runner: 'native', nonce_hash: nonce.nonceHash})
.execute();
const {options} = await createBuilderWithoutAuth<{options: WebAuthnAuthenticationOptions}>(harness)
.post(`/auth/passkey-bridge/${start.ceremony_id}/options`)
.header('origin', TARGET_ORIGIN)
.execute();
const finish = await createBuilderWithoutAuth<PasskeyBridgeFinishResponse>(harness)
.post(`/auth/passkey-bridge/${start.ceremony_id}/complete`)
.header('origin', TARGET_ORIGIN)
.body({response: createAuthenticationResponse(device, options)})
.execute();
return createBuilder<PasskeyBridgeSudoRedeemResponse>(harness, token)
.post(`/users/@me/passkey-bridge/${start.ceremony_id}/redeem`)
.header('origin', TARGET_ORIGIN)
.body({nonce: nonce.nonce, completion_code: finish.completion_code})
.execute();
}