diff --git a/firebase-rules-template.json b/firebase-rules-template.json index b3b6dc20..f13978cf 100644 --- a/firebase-rules-template.json +++ b/firebase-rules-template.json @@ -451,6 +451,20 @@ ".write": false, ".indexOn": ["email"] }, + "webauthnCredentials": { + ".write": false, + "$uid": { + ".read": "auth !== null && ($uid === auth.uid || root.child('logins/' + auth.uid + '/admin').val() === true)" + } + }, + "webauthnChallenges": { + ".read": false, + ".write": false + }, + "webauthnCredentialOwners": { + ".read": false, + ".write": false + }, "profiles": { "$profile_id": { ".read": "auth !== null && $profile_id === auth.uid", diff --git a/functions/auth/cleanupExpiredWebauthnChallenges.js b/functions/auth/cleanupExpiredWebauthnChallenges.js new file mode 100644 index 00000000..6ab1d282 --- /dev/null +++ b/functions/auth/cleanupExpiredWebauthnChallenges.js @@ -0,0 +1,32 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); + +exports.cleanupExpiredWebauthnChallenges = functions + .region('europe-west1') + .pubsub + .schedule('every 60 minutes') + .onRun(async () => { + const db = admin.database(); + const ref = db.ref('/webauthnChallenges'); + const snapshot = await ref.once('value'); + + if (!snapshot.exists()) { + return; + } + + const updates = {}; + const now = Date.now(); + + snapshot.forEach(child => { + const val = child.val() || {}; + if (typeof val.expiry !== 'number' || val.expiry <= now) { + updates[child.key] = null; + } + }); + + if (Object.keys(updates).length > 0) { + await ref.update(updates); + } + }); diff --git a/functions/auth/cleanupExpiredWebauthnChallenges.spec.js b/functions/auth/cleanupExpiredWebauthnChallenges.spec.js new file mode 100644 index 00000000..4a2c57e0 --- /dev/null +++ b/functions/auth/cleanupExpiredWebauthnChallenges.spec.js @@ -0,0 +1,95 @@ +describe('functions', () => { + describe('auth/cleanupExpiredWebauthnChallenges', () => { + let mockAdmin; + let mockFunctions; + let capturedHandler; + let mockChallengesRef; + + const now = Date.now(); + + beforeEach(() => { + jest.resetModules(); + capturedHandler = null; + + mockChallengesRef = { + once: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + + mockAdmin = { + database: jest.fn().mockReturnValue({ + ref: jest.fn().mockReturnValue(mockChallengesRef) + }) + }; + + const mockSchedule = { + onRun: jest.fn().mockImplementation(handler => { capturedHandler = handler; }) + }; + + mockFunctions = { + pubsub: { + schedule: jest.fn().mockReturnValue(mockSchedule) + } + }; + mockFunctions.region = jest.fn(() => mockFunctions); + + jest.mock('firebase-admin', () => mockAdmin); + jest.mock('firebase-functions', () => mockFunctions); + + require('./cleanupExpiredWebauthnChallenges'); + }); + + const makeSnapshot = (entries) => ({ + exists: () => entries.length > 0, + forEach: (cb) => entries.forEach(({ key, val }) => cb({ key, val: () => val })), + }); + + it('does nothing when no challenges exist', async () => { + mockChallengesRef.once.mockResolvedValue(makeSnapshot([])); + await capturedHandler(); + expect(mockChallengesRef.update).not.toHaveBeenCalled(); + }); + + it('deletes expired challenges', async () => { + mockChallengesRef.once.mockResolvedValue(makeSnapshot([ + { key: 'k1', val: { expiry: now - 1000 } }, + { key: 'k2', val: { expiry: now - 5000 } }, + ])); + await capturedHandler(); + expect(mockChallengesRef.update).toHaveBeenCalledWith({ k1: null, k2: null }); + }); + + it('keeps non-expired challenges', async () => { + mockChallengesRef.once.mockResolvedValue(makeSnapshot([ + { key: 'k1', val: { expiry: now + 60000 } }, + { key: 'k2', val: { expiry: now + 600000 } }, + ])); + await capturedHandler(); + expect(mockChallengesRef.update).not.toHaveBeenCalled(); + }); + + it('only deletes expired when mixed with fresh', async () => { + mockChallengesRef.once.mockResolvedValue(makeSnapshot([ + { key: 'k1', val: { expiry: now - 1000 } }, + { key: 'k2', val: { expiry: now + 60000 } }, + { key: 'k3', val: { expiry: now - 5000 } }, + ])); + await capturedHandler(); + expect(mockChallengesRef.update).toHaveBeenCalledWith({ k1: null, k3: null }); + }); + + it('deletes records with missing or non-numeric expiry', async () => { + mockChallengesRef.once.mockResolvedValue(makeSnapshot([ + { key: 'k1', val: {} }, + { key: 'k2', val: { expiry: 'not-a-number' } }, + { key: 'k3', val: { expiry: now + 60000 } }, + ])); + await capturedHandler(); + expect(mockChallengesRef.update).toHaveBeenCalledWith({ k1: null, k2: null }); + }); + + it('is scheduled to run every 60 minutes', () => { + expect(mockFunctions.pubsub.schedule).toHaveBeenCalledWith('every 60 minutes'); + }); + }); +}); diff --git a/functions/auth/generateAuthenticationOptions.js b/functions/auth/generateAuthenticationOptions.js new file mode 100644 index 00000000..51f12750 --- /dev/null +++ b/functions/auth/generateAuthenticationOptions.js @@ -0,0 +1,84 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const cors = require('cors')({ origin: true }); +const { generateAuthenticationOptions } = require('@simplewebauthn/server'); +const { + getRpConfig, + persistChallenge, +} = require('./webauthnHelpers'); + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +async function resolveUidByEmail(email) { + try { + const userRecord = await admin.auth().getUserByEmail(email); + return userRecord.uid; + } catch (e) { + if (e && e.code === 'auth/user-not-found') { + return null; + } + throw e; + } +} + +async function loadAllowCredentials(uid) { + const snapshot = await admin.database().ref('/webauthnCredentials').child(uid).once('value'); + if (!snapshot.exists()) { + return []; + } + const value = snapshot.val() || {}; + return Object.keys(value).map(credentialID => ({ + id: credentialID, + transports: Array.isArray(value[credentialID].transports) ? value[credentialID].transports : undefined, + })); +} + +exports.generateWebauthnAuthenticationOptions = functions.region('europe-west1').https.onRequest((req, res) => { + return cors(req, res, async () => { + try { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const rawEmail = req.body && typeof req.body.email === 'string' ? req.body.email.trim() : ''; + const email = rawEmail ? rawEmail.toLowerCase() : null; + + if (email && !EMAIL_REGEX.test(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + const { rpID } = getRpConfig(); + + let uid = null; + let allowCredentials = []; + + if (email) { + uid = await resolveUidByEmail(email); + if (uid) { + allowCredentials = await loadAllowCredentials(uid); + } + // Intentionally do not disclose whether the email exists; return plausible options. + } + + const options = await generateAuthenticationOptions({ + rpID, + userVerification: 'preferred', + allowCredentials, + }); + + const challengeKey = await persistChallenge({ + type: 'authentication', + challenge: options.challenge, + uid, + email, + }); + + res.status(200).json({ options, challengeKey }); + } catch (error) { + console.error('Error generating passkey authentication options:', error); + res.status(500).json({ error: 'Failed to generate authentication options' }); + } + }); +}); diff --git a/functions/auth/generateAuthenticationOptions.spec.js b/functions/auth/generateAuthenticationOptions.spec.js new file mode 100644 index 00000000..399d6d64 --- /dev/null +++ b/functions/auth/generateAuthenticationOptions.spec.js @@ -0,0 +1,144 @@ +describe('functions', () => { + describe('auth/generateAuthenticationOptions', () => { + let mockAdmin; + let mockFunctions; + let mockCors; + let capturedHandler; + let mockCredentialsRef; + let mockAuthAdmin; + let mockGenerateAuthenticationOptions; + let mockPersistChallenge; + let mockGetRpConfig; + + beforeEach(() => { + jest.resetModules(); + capturedHandler = null; + + mockCors = jest.fn().mockImplementation((req, res, cb) => cb()); + + mockFunctions = { + https: { onRequest: jest.fn().mockImplementation(h => { capturedHandler = h; }) }, + }; + mockFunctions.region = jest.fn(() => mockFunctions); + + mockCredentialsRef = { + child: jest.fn().mockReturnThis(), + once: jest.fn().mockResolvedValue({ exists: () => false, val: () => null }), + }; + + mockAuthAdmin = { + getUserByEmail: jest.fn(), + }; + + mockAdmin = { + database: jest.fn().mockReturnValue({ ref: jest.fn().mockReturnValue(mockCredentialsRef) }), + auth: jest.fn().mockReturnValue(mockAuthAdmin), + }; + + mockGenerateAuthenticationOptions = jest.fn().mockResolvedValue({ + challenge: 'auth-challenge', + rpId: 'flightbox.ch', + }); + mockPersistChallenge = jest.fn().mockResolvedValue('ck-456'); + mockGetRpConfig = jest.fn().mockReturnValue({ + rpID: 'flightbox.ch', + expectedOrigins: ['https://flightbox.ch'], + }); + + jest.mock('firebase-admin', () => mockAdmin); + jest.mock('firebase-functions', () => mockFunctions); + jest.mock('cors', () => () => mockCors); + jest.mock('@simplewebauthn/server', () => ({ + generateAuthenticationOptions: mockGenerateAuthenticationOptions, + })); + jest.mock('./webauthnHelpers', () => ({ + getRpConfig: mockGetRpConfig, + persistChallenge: mockPersistChallenge, + })); + + require('./generateAuthenticationOptions'); + }); + + const makeReq = (method, body = {}) => ({ method, body }); + const makeRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); + + it('returns 405 on GET', async () => { + const res = makeRes(); + await capturedHandler(makeReq('GET'), res); + expect(res.status).toHaveBeenCalledWith(405); + }); + + it('returns 400 on invalid email format', async () => { + const res = makeRes(); + await capturedHandler(makeReq('POST', { email: 'not-an-email' }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns usernameless options when no email given', async () => { + const res = makeRes(); + await capturedHandler(makeReq('POST', {}), res); + + expect(mockGenerateAuthenticationOptions).toHaveBeenCalledWith(expect.objectContaining({ + rpID: 'flightbox.ch', + allowCredentials: [], + userVerification: 'preferred', + })); + expect(mockPersistChallenge).toHaveBeenCalledWith(expect.objectContaining({ + type: 'authentication', + uid: null, + email: null, + })); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + options: expect.objectContaining({ challenge: 'auth-challenge' }), + challengeKey: 'ck-456', + }); + }); + + it('returns empty allowCredentials for unknown email without disclosing', async () => { + const notFound = Object.assign(new Error('no user'), { code: 'auth/user-not-found' }); + mockAuthAdmin.getUserByEmail.mockRejectedValue(notFound); + + const res = makeRes(); + await capturedHandler(makeReq('POST', { email: 'UNKNOWN@Example.com' }), res); + + expect(mockAuthAdmin.getUserByEmail).toHaveBeenCalledWith('unknown@example.com'); + expect(mockGenerateAuthenticationOptions.mock.calls[0][0].allowCredentials).toEqual([]); + expect(mockPersistChallenge).toHaveBeenCalledWith(expect.objectContaining({ + uid: null, + email: 'unknown@example.com', + })); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns populated allowCredentials for known email', async () => { + mockAuthAdmin.getUserByEmail.mockResolvedValue({ uid: 'u1' }); + mockCredentialsRef.once.mockResolvedValue({ + exists: () => true, + val: () => ({ + 'Y3JlZEE': { transports: ['internal'] }, + }), + }); + + const res = makeRes(); + await capturedHandler(makeReq('POST', { email: 'a@b.c' }), res); + + const call = mockGenerateAuthenticationOptions.mock.calls[0][0]; + expect(call.allowCredentials).toHaveLength(1); + expect(call.allowCredentials[0].transports).toEqual(['internal']); + + expect(mockPersistChallenge).toHaveBeenCalledWith(expect.objectContaining({ + uid: 'u1', + email: 'a@b.c', + })); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns 500 on unexpected error', async () => { + mockGetRpConfig.mockImplementation(() => { throw new Error('boom'); }); + const res = makeRes(); + await capturedHandler(makeReq('POST', {}), res); + expect(res.status).toHaveBeenCalledWith(500); + }); + }); +}); diff --git a/functions/auth/generateRegistrationOptions.js b/functions/auth/generateRegistrationOptions.js new file mode 100644 index 00000000..ab66f909 --- /dev/null +++ b/functions/auth/generateRegistrationOptions.js @@ -0,0 +1,75 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const cors = require('cors')({ origin: true }); +const { generateRegistrationOptions } = require('@simplewebauthn/server'); +const { + AuthError, + getRpConfig, + persistChallenge, + verifyAuthenticatedUser, +} = require('./webauthnHelpers'); + +async function loadExistingCredentials(uid) { + const snapshot = await admin.database().ref('/webauthnCredentials').child(uid).once('value'); + if (!snapshot.exists()) { + return []; + } + const value = snapshot.val() || {}; + return Object.keys(value).map(credentialID => ({ + id: credentialID, + transports: Array.isArray(value[credentialID].transports) ? value[credentialID].transports : undefined, + })); +} + +exports.generateWebauthnRegistrationOptions = functions.region('europe-west1').https.onRequest((req, res) => { + return cors(req, res, async () => { + try { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + let authContext; + try { + authContext = await verifyAuthenticatedUser(req); + } catch (e) { + if (e instanceof AuthError) { + return res.status(401).json({ error: e.message }); + } + throw e; + } + + const { uid, email } = authContext; + const { rpID, rpName } = getRpConfig(); + + const existing = await loadExistingCredentials(uid); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: Buffer.from(uid, 'utf8'), + userName: email || uid, + userDisplayName: email || uid, + attestationType: 'none', + excludeCredentials: existing, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }); + + const challengeKey = await persistChallenge({ + type: 'registration', + challenge: options.challenge, + uid, + email, + }); + + res.status(200).json({ options, challengeKey }); + } catch (error) { + console.error('Error generating passkey registration options:', error); + res.status(500).json({ error: 'Failed to generate registration options' }); + } + }); +}); diff --git a/functions/auth/generateRegistrationOptions.spec.js b/functions/auth/generateRegistrationOptions.spec.js new file mode 100644 index 00000000..d9227339 --- /dev/null +++ b/functions/auth/generateRegistrationOptions.spec.js @@ -0,0 +1,152 @@ +describe('functions', () => { + describe('auth/generateRegistrationOptions', () => { + let mockAdmin; + let mockFunctions; + let mockCors; + let capturedHandler; + let mockCredentialsRef; + let mockGenerateRegistrationOptions; + let mockPersistChallenge; + let mockVerifyRecentAuth; + let mockGetRpConfig; + + beforeEach(() => { + jest.resetModules(); + capturedHandler = null; + + mockCors = jest.fn().mockImplementation((req, res, cb) => cb()); + + mockFunctions = { + https: { + onRequest: jest.fn().mockImplementation(handler => { capturedHandler = handler; }), + }, + }; + mockFunctions.region = jest.fn(() => mockFunctions); + + mockCredentialsRef = { + child: jest.fn().mockReturnThis(), + once: jest.fn().mockResolvedValue({ exists: () => false, val: () => null }), + }; + + mockAdmin = { + database: jest.fn().mockReturnValue({ + ref: jest.fn().mockReturnValue(mockCredentialsRef), + }), + }; + + mockGenerateRegistrationOptions = jest.fn().mockResolvedValue({ + challenge: 'server-challenge', + rp: { id: 'flightbox.ch' }, + user: { id: 'u1', name: 'user@example.com' }, + }); + + mockPersistChallenge = jest.fn().mockResolvedValue('challenge-key-123'); + mockVerifyRecentAuth = jest.fn(); + mockGetRpConfig = jest.fn().mockReturnValue({ + rpID: 'flightbox.ch', + rpName: 'Flightbox', + expectedOrigins: ['https://flightbox.ch'], + }); + + jest.mock('firebase-admin', () => mockAdmin); + jest.mock('firebase-functions', () => mockFunctions); + jest.mock('cors', () => () => mockCors); + jest.mock('@simplewebauthn/server', () => ({ + generateRegistrationOptions: mockGenerateRegistrationOptions, + })); + jest.mock('./webauthnHelpers', () => { + class AuthError extends Error { + constructor(msg) { super(msg); this.name = 'AuthError'; } + } + return { + AuthError, + getRpConfig: mockGetRpConfig, + persistChallenge: mockPersistChallenge, + verifyAuthenticatedUser: mockVerifyRecentAuth, + }; + }); + + require('./generateRegistrationOptions'); + }); + + const makeReq = (method, headers = {}, body = {}) => ({ method, headers, body }); + const makeRes = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }); + + it('returns 405 on GET', async () => { + const res = makeRes(); + await capturedHandler(makeReq('GET'), res); + expect(res.status).toHaveBeenCalledWith(405); + }); + + it('returns 401 when verifyAuthenticatedUser rejects with AuthError', async () => { + const { AuthError } = require('./webauthnHelpers'); + mockVerifyRecentAuth.mockRejectedValue(new AuthError('Missing header')); + const res = makeRes(); + await capturedHandler(makeReq('POST'), res); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Missing header' }); + }); + + it('returns 500 on unexpected error during auth verify', async () => { + mockVerifyRecentAuth.mockRejectedValue(new Error('internal')); + const res = makeRes(); + await capturedHandler(makeReq('POST'), res); + expect(res.status).toHaveBeenCalledWith(500); + }); + + it('returns options + challengeKey on success', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'user@example.com' }); + + const res = makeRes(); + await capturedHandler(makeReq('POST'), res); + + expect(mockGenerateRegistrationOptions).toHaveBeenCalledWith(expect.objectContaining({ + rpID: 'flightbox.ch', + rpName: 'Flightbox', + userName: 'user@example.com', + attestationType: 'none', + excludeCredentials: [], + authenticatorSelection: expect.objectContaining({ + residentKey: 'preferred', + userVerification: 'preferred', + }), + })); + + expect(mockPersistChallenge).toHaveBeenCalledWith({ + type: 'registration', + challenge: 'server-challenge', + uid: 'u1', + email: 'user@example.com', + }); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + options: expect.objectContaining({ challenge: 'server-challenge' }), + challengeKey: 'challenge-key-123', + }); + }); + + it('passes existing credentials in excludeCredentials', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'u@e.com' }); + + mockCredentialsRef.once.mockResolvedValue({ + exists: () => true, + val: () => ({ + 'Y3JlZDE': { transports: ['internal'] }, + 'Y3JlZDI': { transports: ['usb'] }, + }), + }); + + const res = makeRes(); + await capturedHandler(makeReq('POST'), res); + + const call = mockGenerateRegistrationOptions.mock.calls[0][0]; + expect(call.excludeCredentials).toHaveLength(2); + expect(call.excludeCredentials[0].transports).toEqual(['internal']); + expect(call.excludeCredentials[1].transports).toEqual(['usb']); + }); + }); +}); diff --git a/functions/auth/removePasskey.js b/functions/auth/removePasskey.js new file mode 100644 index 00000000..85e9d1f6 --- /dev/null +++ b/functions/auth/removePasskey.js @@ -0,0 +1,60 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const cors = require('cors')({ origin: true }); +const { + AuthError, + verifyAuthenticatedUser, +} = require('./webauthnHelpers'); + +exports.removeWebauthnCredential = functions.region('europe-west1').https.onRequest((req, res) => { + return cors(req, res, async () => { + try { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + let authContext; + try { + authContext = await verifyAuthenticatedUser(req); + } catch (e) { + if (e instanceof AuthError) { + return res.status(401).json({ error: e.message }); + } + throw e; + } + + const { uid } = authContext; + const { credentialId } = req.body || {}; + + if (!credentialId || typeof credentialId !== 'string') { + return res.status(400).json({ error: 'credentialId is required' }); + } + + const credentialRef = admin.database() + .ref('/webauthnCredentials').child(uid).child(credentialId); + const snapshot = await credentialRef.once('value'); + if (!snapshot.exists()) { + return res.status(404).json({ error: 'Credential not found' }); + } + + const ownerRef = admin.database().ref('/webauthnCredentialOwners').child(credentialId); + const ownerSnap = await ownerRef.once('value'); + if (ownerSnap.exists()) { + const ownerVal = ownerSnap.val() || {}; + if (ownerVal.uid && ownerVal.uid !== uid) { + return res.status(403).json({ error: 'Not owner of credential' }); + } + } + + await credentialRef.remove(); + await ownerRef.remove(); + + res.status(200).json({ success: true }); + } catch (error) { + console.error('Error removing passkey:', error); + res.status(500).json({ error: 'Failed to remove passkey' }); + } + }); +}); diff --git a/functions/auth/removePasskey.spec.js b/functions/auth/removePasskey.spec.js new file mode 100644 index 00000000..81e2de66 --- /dev/null +++ b/functions/auth/removePasskey.spec.js @@ -0,0 +1,104 @@ +describe('functions', () => { + describe('auth/removePasskey', () => { + let mockAdmin; + let mockFunctions; + let mockCors; + let capturedHandler; + let mockDbRef; + let mockVerifyRecentAuth; + + beforeEach(() => { + jest.resetModules(); + capturedHandler = null; + + mockCors = jest.fn().mockImplementation((req, res, cb) => cb()); + + mockFunctions = { + https: { onRequest: jest.fn().mockImplementation(h => { capturedHandler = h; }) }, + }; + mockFunctions.region = jest.fn(() => mockFunctions); + + mockDbRef = { + child: jest.fn().mockReturnThis(), + once: jest.fn(), + remove: jest.fn().mockResolvedValue(undefined), + }; + + mockAdmin = { + database: jest.fn().mockReturnValue({ ref: jest.fn().mockReturnValue(mockDbRef) }), + }; + + mockVerifyRecentAuth = jest.fn(); + + jest.mock('firebase-admin', () => mockAdmin); + jest.mock('firebase-functions', () => mockFunctions); + jest.mock('cors', () => () => mockCors); + jest.mock('./webauthnHelpers', () => { + class AuthError extends Error { + constructor(m) { super(m); this.name = 'AuthError'; } + } + return { + AuthError, + verifyAuthenticatedUser: mockVerifyRecentAuth, + }; + }); + + require('./removePasskey'); + }); + + const makeReq = (method, body = {}) => ({ method, headers: { authorization: 'Bearer x' }, body }); + const makeRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); + const snap = (val) => ({ exists: () => val !== null && val !== undefined, val: () => val }); + + it('returns 405 on GET', async () => { + const res = makeRes(); + await capturedHandler(makeReq('GET'), res); + expect(res.status).toHaveBeenCalledWith(405); + }); + + it('returns 401 on auth error', async () => { + const { AuthError } = require('./webauthnHelpers'); + mockVerifyRecentAuth.mockRejectedValue(new AuthError('missing')); + const res = makeRes(); + await capturedHandler(makeReq('POST', { credentialId: 'c' }), res); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('returns 400 when credentialId missing', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1' }); + const res = makeRes(); + await capturedHandler(makeReq('POST', {}), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 404 when credential not found', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1' }); + mockDbRef.once.mockResolvedValue(snap(null)); + const res = makeRes(); + await capturedHandler(makeReq('POST', { credentialId: 'c' }), res); + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 403 if owner index points to different uid', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1' }); + mockDbRef.once + .mockResolvedValueOnce(snap({ publicKey: 'x' })) // credential exists + .mockResolvedValueOnce(snap({ uid: 'u2' })); // owner mismatch + const res = makeRes(); + await capturedHandler(makeReq('POST', { credentialId: 'c' }), res); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('removes credential and owner on success', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1' }); + mockDbRef.once + .mockResolvedValueOnce(snap({ publicKey: 'x' })) + .mockResolvedValueOnce(snap({ uid: 'u1' })); + const res = makeRes(); + await capturedHandler(makeReq('POST', { credentialId: 'c' }), res); + expect(mockDbRef.remove).toHaveBeenCalledTimes(2); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ success: true }); + }); + }); +}); diff --git a/functions/auth/verifyAuthentication.js b/functions/auth/verifyAuthentication.js new file mode 100644 index 00000000..549521ec --- /dev/null +++ b/functions/auth/verifyAuthentication.js @@ -0,0 +1,121 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const cors = require('cors')({ origin: true }); +const { verifyAuthenticationResponse } = require('@simplewebauthn/server'); +const { + getRpConfig, + consumeChallenge, +} = require('./webauthnHelpers'); + +async function resolveUidForCredential(credentialId, challengeUid) { + if (challengeUid) { + return challengeUid; + } + const snapshot = await admin.database().ref('/webauthnCredentialOwners').child(credentialId).once('value'); + if (!snapshot.exists()) { + return null; + } + const value = snapshot.val() || {}; + return typeof value.uid === 'string' ? value.uid : null; +} + +async function loadCredential(uid, credentialId) { + const snapshot = await admin.database().ref('/webauthnCredentials').child(uid).child(credentialId).once('value'); + if (!snapshot.exists()) { + return null; + } + return snapshot.val(); +} + +async function resolveUserEmail(uid) { + try { + const record = await admin.auth().getUser(uid); + return record.email || null; + } catch (e) { + return null; + } +} + +exports.verifyWebauthnAuthentication = functions.region('europe-west1').https.onRequest((req, res) => { + return cors(req, res, async () => { + try { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const { challengeKey, assertionResponse } = req.body || {}; + if (!challengeKey || !assertionResponse || !assertionResponse.id) { + return res.status(400).json({ error: 'challengeKey and assertionResponse are required' }); + } + + let challengeRecord; + try { + challengeRecord = await consumeChallenge(challengeKey, 'authentication'); + } catch (e) { + return res.status(400).json({ error: 'Invalid or expired challenge' }); + } + + const credentialId = assertionResponse.id; + + const uid = await resolveUidForCredential(credentialId, challengeRecord.uid); + if (!uid) { + return res.status(400).json({ error: 'Unknown credential' }); + } + + const stored = await loadCredential(uid, credentialId); + if (!stored) { + return res.status(400).json({ error: 'Unknown credential' }); + } + + const { rpID, expectedOrigins } = getRpConfig(); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: challengeRecord.challenge, + expectedOrigin: expectedOrigins, + expectedRPID: rpID, + requireUserVerification: false, + authenticator: { + credentialID: credentialId, + credentialPublicKey: Buffer.from(stored.publicKey, 'base64url'), + counter: stored.counter || 0, + transports: Array.isArray(stored.transports) ? stored.transports : undefined, + }, + }); + } catch (e) { + console.warn('Authentication verification failed:', e.message); + return res.status(400).json({ error: 'Authentication verification failed' }); + } + + if (!verification.verified || !verification.authenticationInfo) { + return res.status(400).json({ error: 'Authentication not verified' }); + } + + const newCounter = verification.authenticationInfo.newCounter; + const storedCounter = stored.counter || 0; + if (newCounter !== 0 || storedCounter !== 0) { + if (typeof newCounter !== 'number' || newCounter <= storedCounter) { + console.warn('Passkey counter did not increase — possible clone', { uid, credentialId, storedCounter, newCounter }); + return res.status(400).json({ error: 'Authentication verification failed' }); + } + } + + await admin.database().ref('/webauthnCredentials').child(uid).child(credentialId).update({ + counter: newCounter, + lastUsedAt: Date.now(), + }); + + const email = challengeRecord.email || await resolveUserEmail(uid); + + const customToken = await admin.auth().createCustomToken(uid, email ? { email } : {}); + res.status(200).json({ token: customToken }); + } catch (error) { + console.error('Error verifying passkey authentication:', error); + res.status(500).json({ error: 'Failed to verify authentication' }); + } + }); +}); diff --git a/functions/auth/verifyAuthentication.spec.js b/functions/auth/verifyAuthentication.spec.js new file mode 100644 index 00000000..87969c36 --- /dev/null +++ b/functions/auth/verifyAuthentication.spec.js @@ -0,0 +1,200 @@ +describe('functions', () => { + describe('auth/verifyAuthentication', () => { + let mockAdmin; + let mockFunctions; + let mockCors; + let capturedHandler; + let mockDbRef; + let mockAuthAdmin; + let mockVerifyAuthenticationResponse; + let mockConsumeChallenge; + let mockGetRpConfig; + + const credentialId = 'Y3JlZC1pZA'; + const publicKeyB64url = Buffer.from('pk').toString('base64url'); + + beforeEach(() => { + jest.resetModules(); + capturedHandler = null; + + mockCors = jest.fn().mockImplementation((req, res, cb) => cb()); + + mockFunctions = { + https: { onRequest: jest.fn().mockImplementation(h => { capturedHandler = h; }) }, + }; + mockFunctions.region = jest.fn(() => mockFunctions); + + // Per-ref mocks so we can distinguish calls; all ref paths share one mock for simplicity. + mockDbRef = { + child: jest.fn().mockReturnThis(), + once: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + + mockAuthAdmin = { + createCustomToken: jest.fn().mockResolvedValue('ct-xyz'), + getUser: jest.fn(), + }; + + mockAdmin = { + database: jest.fn().mockReturnValue({ ref: jest.fn().mockReturnValue(mockDbRef) }), + auth: jest.fn().mockReturnValue(mockAuthAdmin), + }; + + mockVerifyAuthenticationResponse = jest.fn(); + mockConsumeChallenge = jest.fn(); + mockGetRpConfig = jest.fn().mockReturnValue({ + rpID: 'flightbox.ch', + expectedOrigins: ['https://flightbox.ch'], + }); + + jest.mock('firebase-admin', () => mockAdmin); + jest.mock('firebase-functions', () => mockFunctions); + jest.mock('cors', () => () => mockCors); + jest.mock('@simplewebauthn/server', () => ({ + verifyAuthenticationResponse: mockVerifyAuthenticationResponse, + })); + jest.mock('./webauthnHelpers', () => ({ + getRpConfig: mockGetRpConfig, + consumeChallenge: mockConsumeChallenge, + })); + + require('./verifyAuthentication'); + }); + + const makeReq = (method, body = {}) => ({ method, body }); + const makeRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); + const snap = (val) => ({ exists: () => val !== null && val !== undefined, val: () => val }); + + it('returns 405 on GET', async () => { + const res = makeRes(); + await capturedHandler(makeReq('GET'), res); + expect(res.status).toHaveBeenCalledWith(405); + }); + + it('returns 400 when fields missing', async () => { + const res = makeRes(); + await capturedHandler(makeReq('POST', {}), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 on invalid challenge', async () => { + mockConsumeChallenge.mockRejectedValue(new Error('expired')); + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + assertionResponse: { id: credentialId }, + }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired challenge' }); + }); + + it('returns 400 when credential unknown (no owner index)', async () => { + mockConsumeChallenge.mockResolvedValue({ challenge: 'c', uid: null, email: null }); + mockDbRef.once.mockResolvedValue(snap(null)); + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + assertionResponse: { id: credentialId }, + }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('mints custom token with email from challenge on success', async () => { + mockConsumeChallenge.mockResolvedValue({ challenge: 'c', uid: 'u1', email: 'a@b.c' }); + // Sequence of once calls: loadCredential (uid path) + mockDbRef.once.mockResolvedValueOnce(snap({ + publicKey: publicKeyB64url, + counter: 5, + transports: ['internal'], + })); + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 6 }, + }); + + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + assertionResponse: { id: credentialId }, + }), res); + + expect(mockAuthAdmin.createCustomToken).toHaveBeenCalledWith('u1', { email: 'a@b.c' }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ token: 'ct-xyz' }); + + // Counter update + expect(mockDbRef.update).toHaveBeenCalledWith(expect.objectContaining({ + counter: 6, + lastUsedAt: expect.any(Number), + })); + }); + + it('resolves uid via owner index when challenge has no uid', async () => { + mockConsumeChallenge.mockResolvedValue({ challenge: 'c', uid: null, email: null }); + // First once: owner lookup, second: credential lookup, third: getUser + mockDbRef.once + .mockResolvedValueOnce(snap({ uid: 'u1' })) + .mockResolvedValueOnce(snap({ publicKey: publicKeyB64url, counter: 0 })); + mockAuthAdmin.getUser.mockResolvedValue({ email: 'x@y.z' }); + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 0 }, + }); + + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + assertionResponse: { id: credentialId }, + }), res); + + expect(mockAuthAdmin.createCustomToken).toHaveBeenCalledWith('u1', { email: 'x@y.z' }); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('rejects when counter does not strictly increase', async () => { + mockConsumeChallenge.mockResolvedValue({ challenge: 'c', uid: 'u1', email: 'a@b.c' }); + mockDbRef.once.mockResolvedValueOnce(snap({ + publicKey: publicKeyB64url, + counter: 5, + })); + mockVerifyAuthenticationResponse.mockResolvedValue({ + verified: true, + authenticationInfo: { newCounter: 5 }, + }); + + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + assertionResponse: { id: credentialId }, + }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(mockAuthAdmin.createCustomToken).not.toHaveBeenCalled(); + }); + + it('returns 400 when verify rejects', async () => { + mockConsumeChallenge.mockResolvedValue({ challenge: 'c', uid: 'u1', email: 'a@b.c' }); + mockDbRef.once.mockResolvedValueOnce(snap({ publicKey: publicKeyB64url, counter: 0 })); + mockVerifyAuthenticationResponse.mockRejectedValue(new Error('bad sig')); + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + assertionResponse: { id: credentialId }, + }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when verify returns not verified', async () => { + mockConsumeChallenge.mockResolvedValue({ challenge: 'c', uid: 'u1', email: 'a@b.c' }); + mockDbRef.once.mockResolvedValueOnce(snap({ publicKey: publicKeyB64url, counter: 0 })); + mockVerifyAuthenticationResponse.mockResolvedValue({ verified: false }); + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + assertionResponse: { id: credentialId }, + }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + }); +}); diff --git a/functions/auth/verifyRegistration.js b/functions/auth/verifyRegistration.js new file mode 100644 index 00000000..6c6c4fb0 --- /dev/null +++ b/functions/auth/verifyRegistration.js @@ -0,0 +1,138 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const cors = require('cors')({ origin: true }); +const { verifyRegistrationResponse } = require('@simplewebauthn/server'); +const { + AuthError, + getRpConfig, + consumeChallenge, + verifyAuthenticatedUser, +} = require('./webauthnHelpers'); + +function deriveDeviceName(userAgent) { + if (typeof userAgent !== 'string' || !userAgent) return 'Passkey'; + if (/iPhone/.test(userAgent)) return 'iPhone'; + if (/iPad/.test(userAgent)) return 'iPad'; + if (/Android/.test(userAgent)) return 'Android'; + if (/Macintosh|Mac OS X/.test(userAgent)) return 'Mac'; + if (/Windows/.test(userAgent)) return 'Windows'; + if (/Linux/.test(userAgent)) return 'Linux'; + return 'Passkey'; +} + +function toBase64Url(value) { + if (!value) return null; + if (typeof value === 'string') return value; + if (value instanceof Uint8Array || Buffer.isBuffer(value)) { + return Buffer.from(value).toString('base64url'); + } + return null; +} + +exports.verifyWebauthnRegistration = functions.region('europe-west1').https.onRequest((req, res) => { + return cors(req, res, async () => { + try { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + let authContext; + try { + authContext = await verifyAuthenticatedUser(req); + } catch (e) { + if (e instanceof AuthError) { + return res.status(401).json({ error: e.message }); + } + throw e; + } + + const { uid } = authContext; + const { challengeKey, attestationResponse, userAgent } = req.body || {}; + + if (!challengeKey || !attestationResponse) { + return res.status(400).json({ error: 'challengeKey and attestationResponse are required' }); + } + + let challengeRecord; + try { + challengeRecord = await consumeChallenge(challengeKey, 'registration'); + } catch (e) { + return res.status(400).json({ error: 'Invalid or expired challenge' }); + } + + if (challengeRecord.uid !== uid) { + return res.status(400).json({ error: 'Challenge does not belong to this user' }); + } + + const { rpID, expectedOrigins } = getRpConfig(); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: attestationResponse, + expectedChallenge: challengeRecord.challenge, + expectedOrigin: expectedOrigins, + expectedRPID: rpID, + requireUserVerification: false, + }); + } catch (e) { + console.warn('Registration verification failed:', e.message); + return res.status(400).json({ error: 'Registration verification failed' }); + } + + if (!verification.verified || !verification.registrationInfo) { + return res.status(400).json({ error: 'Registration not verified' }); + } + + const info = verification.registrationInfo; + // v10 server returns a flat registrationInfo; support a nested `credential` shape too for forward compat. + const credential = info.credential || {}; + const credentialIDRaw = info.credentialID || credential.id; + const credentialPublicKeyRaw = info.credentialPublicKey || credential.publicKey; + const counter = typeof info.counter === 'number' + ? info.counter + : (typeof credential.counter === 'number' ? credential.counter : 0); + + const credentialID = toBase64Url(credentialIDRaw); + const publicKey = toBase64Url(credentialPublicKeyRaw); + + if (!credentialID || !publicKey) { + return res.status(500).json({ error: 'Malformed registration info' }); + } + + const transports = Array.isArray(credential.transports) + ? credential.transports + : (attestationResponse.response && Array.isArray(attestationResponse.response.transports)) + ? attestationResponse.response.transports + : []; + + const now = Date.now(); + const record = { + publicKey, + counter, + transports, + deviceName: deriveDeviceName(userAgent), + createdAt: now, + lastUsedAt: null, + aaguid: info.aaguid || null, + backupEligible: info.credentialBackedUp === true || credential.backupEligible === true || false, + backupState: info.credentialDeviceType === 'multiDevice' || credential.backupState === true || false, + }; + + await admin.database().ref('/webauthnCredentials').child(uid).child(credentialID).set(record); + await admin.database().ref('/webauthnCredentialOwners').child(credentialID).set({ uid }); + + res.status(200).json({ + success: true, + credentialId: credentialID, + deviceName: record.deviceName, + createdAt: record.createdAt, + }); + } catch (error) { + console.error('Error verifying passkey registration:', error); + res.status(500).json({ error: 'Failed to verify registration' }); + } + }); +}); diff --git a/functions/auth/verifyRegistration.spec.js b/functions/auth/verifyRegistration.spec.js new file mode 100644 index 00000000..74706a39 --- /dev/null +++ b/functions/auth/verifyRegistration.spec.js @@ -0,0 +1,225 @@ +describe('functions', () => { + describe('auth/verifyRegistration', () => { + let mockAdmin; + let mockFunctions; + let mockCors; + let capturedHandler; + let mockDbRef; + let mockVerifyRegistrationResponse; + let mockConsumeChallenge; + let mockVerifyRecentAuth; + let mockGetRpConfig; + + const makeCredentialIdBuffer = () => Buffer.from('credid-bytes'); + const credentialIdBase64url = Buffer.from('credid-bytes').toString('base64url'); + const publicKeyBase64url = Buffer.from('pubkey-bytes').toString('base64url'); + + beforeEach(() => { + jest.resetModules(); + capturedHandler = null; + + mockCors = jest.fn().mockImplementation((req, res, cb) => cb()); + + mockFunctions = { + https: { onRequest: jest.fn().mockImplementation(h => { capturedHandler = h; }) }, + }; + mockFunctions.region = jest.fn(() => mockFunctions); + + mockDbRef = { + child: jest.fn().mockReturnThis(), + set: jest.fn().mockResolvedValue(undefined), + }; + + mockAdmin = { + database: jest.fn().mockReturnValue({ ref: jest.fn().mockReturnValue(mockDbRef) }), + }; + + mockVerifyRegistrationResponse = jest.fn(); + mockConsumeChallenge = jest.fn(); + mockVerifyRecentAuth = jest.fn(); + mockGetRpConfig = jest.fn().mockReturnValue({ + rpID: 'flightbox.ch', + expectedOrigins: ['https://flightbox.ch'], + }); + + jest.mock('firebase-admin', () => mockAdmin); + jest.mock('firebase-functions', () => mockFunctions); + jest.mock('cors', () => () => mockCors); + jest.mock('@simplewebauthn/server', () => ({ + verifyRegistrationResponse: mockVerifyRegistrationResponse, + })); + jest.mock('./webauthnHelpers', () => { + class AuthError extends Error { + constructor(m) { super(m); this.name = 'AuthError'; } + } + return { + AuthError, + getRpConfig: mockGetRpConfig, + consumeChallenge: mockConsumeChallenge, + verifyAuthenticatedUser: mockVerifyRecentAuth, + }; + }); + + require('./verifyRegistration'); + }); + + const makeReq = (method, body = {}) => ({ method, headers: { authorization: 'Bearer x' }, body }); + const makeRes = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); + + it('returns 405 on GET', async () => { + const res = makeRes(); + await capturedHandler(makeReq('GET'), res); + expect(res.status).toHaveBeenCalledWith(405); + }); + + it('returns 401 on auth error', async () => { + const { AuthError } = require('./webauthnHelpers'); + mockVerifyRecentAuth.mockRejectedValue(new AuthError('nope')); + const res = makeRes(); + await capturedHandler(makeReq('POST', {}), res); + expect(res.status).toHaveBeenCalledWith(401); + }); + + it('returns 400 when challengeKey missing', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + const res = makeRes(); + await capturedHandler(makeReq('POST', { attestationResponse: {} }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 on invalid challenge', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + mockConsumeChallenge.mockRejectedValue(new Error('expired')); + const res = makeRes(); + await capturedHandler(makeReq('POST', { challengeKey: 'k', attestationResponse: {} }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired challenge' }); + }); + + it('returns 400 when challenge uid does not match', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + mockConsumeChallenge.mockResolvedValue({ uid: 'other-uid', challenge: 'c' }); + const res = makeRes(); + await capturedHandler(makeReq('POST', { challengeKey: 'k', attestationResponse: {} }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when verification rejects', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + mockConsumeChallenge.mockResolvedValue({ uid: 'u1', challenge: 'c' }); + mockVerifyRegistrationResponse.mockRejectedValue(new Error('bad attestation')); + const res = makeRes(); + await capturedHandler(makeReq('POST', { challengeKey: 'k', attestationResponse: {} }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when verification is not verified', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + mockConsumeChallenge.mockResolvedValue({ uid: 'u1', challenge: 'c' }); + mockVerifyRegistrationResponse.mockResolvedValue({ verified: false }); + const res = makeRes(); + await capturedHandler(makeReq('POST', { challengeKey: 'k', attestationResponse: {} }), res); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('stores credential and returns summary on success (v10 shape)', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + mockConsumeChallenge.mockResolvedValue({ uid: 'u1', challenge: 'c' }); + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: makeCredentialIdBuffer(), + publicKey: Buffer.from('pubkey-bytes'), + counter: 0, + transports: ['internal'], + }, + aaguid: 'aa-guid', + credentialBackedUp: false, + credentialDeviceType: 'singleDevice', + }, + }); + + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + attestationResponse: { response: { transports: ['internal'] } }, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }), res); + + expect(res.status).toHaveBeenCalledWith(200); + const responseBody = res.json.mock.calls[0][0]; + expect(responseBody.success).toBe(true); + expect(responseBody.credentialId).toBe(credentialIdBase64url); + expect(responseBody.deviceName).toBe('Mac'); + + // Credential was stored under uid + credentialID + const setCalls = mockDbRef.set.mock.calls; + expect(setCalls.length).toBe(2); + const credRecord = setCalls[0][0]; + expect(credRecord.publicKey).toBe(publicKeyBase64url); + expect(credRecord.counter).toBe(0); + expect(credRecord.transports).toEqual(['internal']); + expect(credRecord.deviceName).toBe('Mac'); + expect(setCalls[1][0]).toEqual({ uid: 'u1' }); + }); + + it('supports legacy flat registrationInfo shape', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + mockConsumeChallenge.mockResolvedValue({ uid: 'u1', challenge: 'c' }); + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: true, + registrationInfo: { + credentialID: makeCredentialIdBuffer(), + credentialPublicKey: Buffer.from('pubkey-bytes'), + counter: 0, + aaguid: null, + }, + }); + + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + attestationResponse: { response: {} }, + }), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json.mock.calls[0][0].deviceName).toBe('Passkey'); + }); + + it('derives device name from user-agent variants', async () => { + mockVerifyRecentAuth.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + mockConsumeChallenge.mockResolvedValue({ uid: 'u1', challenge: 'c' }); + mockVerifyRegistrationResponse.mockResolvedValue({ + verified: true, + registrationInfo: { + credential: { + id: makeCredentialIdBuffer(), + publicKey: Buffer.from('pubkey-bytes'), + counter: 0, + }, + }, + }); + + const cases = [ + ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', 'iPhone'], + ['Mozilla/5.0 (iPad; CPU OS 17_0)', 'iPad'], + ['Mozilla/5.0 (Linux; Android 14)', 'Android'], + ['Mozilla/5.0 (Windows NT 10.0)', 'Windows'], + ['Mozilla/5.0 (X11; Linux x86_64)', 'Linux'], + ['weird-ua', 'Passkey'], + ]; + + for (const [ua, expected] of cases) { + mockDbRef.set.mockClear(); + const res = makeRes(); + await capturedHandler(makeReq('POST', { + challengeKey: 'k', + attestationResponse: { response: {} }, + userAgent: ua, + }), res); + expect(res.json.mock.calls[0][0].deviceName).toBe(expected); + } + }); + }); +}); diff --git a/functions/auth/webauthnHelpers.js b/functions/auth/webauthnHelpers.js new file mode 100644 index 00000000..8cf8e3c8 --- /dev/null +++ b/functions/auth/webauthnHelpers.js @@ -0,0 +1,110 @@ +'use strict'; + +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); +const crypto = require('crypto'); + +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +function getRpConfig() { + const cfg = (functions.config() && functions.config().webauthn) || {}; + const rpID = cfg.rpid || cfg.rp_id; + const rpName = cfg.rpname || cfg.rp_name || 'Flightbox'; + const originsRaw = cfg.origins || cfg.expected_origins || cfg.expectedorigins; + const expectedOrigins = Array.isArray(originsRaw) + ? originsRaw + : typeof originsRaw === 'string' + ? originsRaw.split(',').map(s => s.trim()).filter(Boolean) + : []; + if (!rpID || expectedOrigins.length === 0) { + throw new Error('WebAuthn RP config missing: functions.config().webauthn.{rpid,origins} must be set'); + } + return { rpID, rpName, expectedOrigins }; +} + +function generateChallengeKey() { + return crypto.randomBytes(24).toString('base64url'); +} + +async function persistChallenge({ type, challenge, uid, email }) { + const key = generateChallengeKey(); + const expiry = Date.now() + CHALLENGE_TTL_MS; + const record = { + type, + challenge, + expiry, + uid: uid || null, + email: email || null, + attempts: 0, + }; + await admin.database().ref('/webauthnChallenges').child(key).set(record); + return key; +} + +async function consumeChallenge(key, expectedType) { + if (!key || typeof key !== 'string') { + throw new Error('Invalid challenge key'); + } + const ref = admin.database().ref('/webauthnChallenges').child(key); + let claimed = null; + // RTDB transactions call the update function with the locally cached value + // first (often null). Returning undefined aborts without refetching, so we + // must return null — Firebase will detect the server mismatch and retry + // with the real record. + const result = await ref.transaction(current => { + if (current === null || current === undefined) { + return null; + } + claimed = current; + return null; + }); + if (!result.committed || !claimed) { + throw new Error('Challenge not found'); + } + if (claimed.type !== expectedType) { + throw new Error('Challenge type mismatch'); + } + if (typeof claimed.expiry !== 'number' || claimed.expiry <= Date.now()) { + throw new Error('Challenge expired'); + } + return claimed; +} + +async function verifyAuthenticatedUser(req) { + const header = req.headers && (req.headers.authorization || req.headers.Authorization); + if (!header || typeof header !== 'string') { + throw new AuthError('Missing Authorization header'); + } + const match = header.match(/^Bearer\s+(.+)$/i); + if (!match) { + throw new AuthError('Malformed Authorization header'); + } + const token = match[1].trim(); + let decoded; + try { + decoded = await admin.auth().verifyIdToken(token, true); + } catch (e) { + throw new AuthError('Invalid ID token'); + } + if (!decoded || !decoded.uid) { + throw new AuthError('Invalid ID token'); + } + return { uid: decoded.uid, email: decoded.email || null }; +} + +class AuthError extends Error { + constructor(message) { + super(message); + this.name = 'AuthError'; + } +} + +module.exports = { + CHALLENGE_TTL_MS, + AuthError, + getRpConfig, + generateChallengeKey, + persistChallenge, + consumeChallenge, + verifyAuthenticatedUser, +}; diff --git a/functions/auth/webauthnHelpers.spec.js b/functions/auth/webauthnHelpers.spec.js new file mode 100644 index 00000000..d6b978dc --- /dev/null +++ b/functions/auth/webauthnHelpers.spec.js @@ -0,0 +1,173 @@ +describe('functions', () => { + describe('auth/webauthnHelpers', () => { + let mockAdmin; + let mockFunctions; + let mockChallengesRef; + let mockAuthAdmin; + let helpers; + + beforeEach(() => { + jest.resetModules(); + + mockChallengesRef = { + child: jest.fn().mockReturnThis(), + set: jest.fn().mockResolvedValue(undefined), + transaction: jest.fn(), + }; + + mockAuthAdmin = { + verifyIdToken: jest.fn(), + }; + + mockAdmin = { + database: jest.fn().mockReturnValue({ + ref: jest.fn().mockReturnValue(mockChallengesRef), + }), + auth: jest.fn().mockReturnValue(mockAuthAdmin), + }; + + mockFunctions = { + config: jest.fn().mockReturnValue({ + webauthn: { + rpid: 'flightbox.ch', + rpname: 'Flightbox', + origins: 'https://flightbox.ch,https://www.flightbox.ch', + }, + }), + }; + + jest.mock('firebase-admin', () => mockAdmin); + jest.mock('firebase-functions', () => mockFunctions); + + helpers = require('./webauthnHelpers'); + }); + + describe('getRpConfig', () => { + it('returns parsed RP config', () => { + const cfg = helpers.getRpConfig(); + expect(cfg.rpID).toBe('flightbox.ch'); + expect(cfg.rpName).toBe('Flightbox'); + expect(cfg.expectedOrigins).toEqual(['https://flightbox.ch', 'https://www.flightbox.ch']); + }); + + it('accepts origins as array', () => { + mockFunctions.config.mockReturnValue({ + webauthn: { rpid: 'flightbox.ch', origins: ['https://a', 'https://b'] }, + }); + const cfg = helpers.getRpConfig(); + expect(cfg.expectedOrigins).toEqual(['https://a', 'https://b']); + }); + + it('throws if rpid missing', () => { + mockFunctions.config.mockReturnValue({ webauthn: { origins: 'https://x' } }); + expect(() => helpers.getRpConfig()).toThrow(/rpid/); + }); + + it('throws if origins empty', () => { + mockFunctions.config.mockReturnValue({ webauthn: { rpid: 'x' } }); + expect(() => helpers.getRpConfig()).toThrow(/origins/); + }); + }); + + describe('persistChallenge', () => { + it('writes record with expiry and returns key', async () => { + const key = await helpers.persistChallenge({ + type: 'registration', + challenge: 'chal', + uid: 'u1', + email: 'a@b.c', + }); + expect(typeof key).toBe('string'); + expect(key.length).toBeGreaterThan(10); + expect(mockChallengesRef.child).toHaveBeenCalledWith(key); + expect(mockChallengesRef.set).toHaveBeenCalledWith(expect.objectContaining({ + type: 'registration', + challenge: 'chal', + uid: 'u1', + email: 'a@b.c', + attempts: 0, + })); + const record = mockChallengesRef.set.mock.calls[0][0]; + expect(record.expiry).toBeGreaterThan(Date.now()); + }); + + it('accepts null uid/email', async () => { + await helpers.persistChallenge({ type: 'authentication', challenge: 'c' }); + const record = mockChallengesRef.set.mock.calls[0][0]; + expect(record.uid).toBeNull(); + expect(record.email).toBeNull(); + }); + }); + + describe('consumeChallenge', () => { + const mockTransactionWith = (currentValue) => { + mockChallengesRef.transaction.mockImplementation(async (updateFn) => { + const ret = updateFn(currentValue); + return { committed: ret !== undefined, snapshot: {} }; + }); + }; + + it('atomically claims the record and returns it on happy path', async () => { + const record = { type: 'registration', challenge: 'c', expiry: Date.now() + 60000, uid: 'u1' }; + mockTransactionWith(record); + const out = await helpers.consumeChallenge('k', 'registration'); + expect(out).toEqual(record); + expect(mockChallengesRef.transaction).toHaveBeenCalled(); + const updateFn = mockChallengesRef.transaction.mock.calls[0][0]; + expect(updateFn(record)).toBeNull(); + // On the initial cached-null call, we must return null (not undefined) + // so the transaction retries instead of aborting. + expect(updateFn(null)).toBeNull(); + }); + + it('throws when challenge missing', async () => { + mockTransactionWith(null); + await expect(helpers.consumeChallenge('k', 'registration')).rejects.toThrow(/not found/); + }); + + it('throws and claims on wrong type', async () => { + mockTransactionWith({ + type: 'authentication', challenge: 'c', expiry: Date.now() + 60000, + }); + await expect(helpers.consumeChallenge('k', 'registration')).rejects.toThrow(/type/); + expect(mockChallengesRef.transaction).toHaveBeenCalled(); + }); + + it('throws and claims on expired challenge', async () => { + mockTransactionWith({ + type: 'registration', challenge: 'c', expiry: Date.now() - 1, + }); + await expect(helpers.consumeChallenge('k', 'registration')).rejects.toThrow(/expired/); + expect(mockChallengesRef.transaction).toHaveBeenCalled(); + }); + + it('throws on invalid key', async () => { + await expect(helpers.consumeChallenge('', 'registration')).rejects.toThrow(); + }); + }); + + describe('verifyAuthenticatedUser', () => { + const makeReq = (header) => ({ headers: header ? { authorization: header } : {} }); + + it('throws AuthError when header missing', async () => { + await expect(helpers.verifyAuthenticatedUser(makeReq(null))).rejects.toBeInstanceOf(helpers.AuthError); + }); + + it('throws AuthError on malformed header', async () => { + await expect(helpers.verifyAuthenticatedUser(makeReq('Basic xyz'))).rejects.toBeInstanceOf(helpers.AuthError); + }); + + it('throws when verifyIdToken rejects', async () => { + mockAuthAdmin.verifyIdToken.mockRejectedValue(new Error('bad')); + await expect(helpers.verifyAuthenticatedUser(makeReq('Bearer xyz'))).rejects.toBeInstanceOf(helpers.AuthError); + }); + + it('returns uid and email on valid token', async () => { + mockAuthAdmin.verifyIdToken.mockResolvedValue({ uid: 'u1', email: 'a@b.c' }); + const out = await helpers.verifyAuthenticatedUser(makeReq('Bearer xyz')); + expect(out).toEqual({ uid: 'u1', email: 'a@b.c' }); + expect(mockAuthAdmin.verifyIdToken).toHaveBeenCalledWith('xyz', true); + }); + }); + }); +}); diff --git a/functions/index.js b/functions/index.js index 4330f692..744eb82e 100644 --- a/functions/index.js +++ b/functions/index.js @@ -22,6 +22,12 @@ const { generateSignInCode } = require('./auth/generateSignInCode'); const { verifySignInCode } = require('./auth/verifySignInCode'); const { cleanupExpiredSignInCodes } = require('./auth/cleanupExpiredSignInCodes'); const { createTestEmailToken } = require('./auth/createTestEmailToken'); +const { generateWebauthnRegistrationOptions } = require('./auth/generateRegistrationOptions'); +const { verifyWebauthnRegistration } = require('./auth/verifyRegistration'); +const { generateWebauthnAuthenticationOptions } = require('./auth/generateAuthenticationOptions'); +const { verifyWebauthnAuthentication } = require('./auth/verifyAuthentication'); +const { removeWebauthnCredential } = require('./auth/removePasskey'); +const { cleanupExpiredWebauthnChallenges } = require('./auth/cleanupExpiredWebauthnChallenges'); const api = require('./api'); const webhook = require('./webhook'); const associatedMovementsTriggers = require('./associatedMovements/setAssociatedMovementsTriggers'); @@ -36,6 +42,12 @@ exports.generateSignInCode = generateSignInCode; exports.verifySignInCode = verifySignInCode; exports.cleanupExpiredSignInCodes = cleanupExpiredSignInCodes; exports.createTestEmailToken = createTestEmailToken; +exports.generateWebauthnRegistrationOptions = generateWebauthnRegistrationOptions; +exports.verifyWebauthnRegistration = verifyWebauthnRegistration; +exports.generateWebauthnAuthenticationOptions = generateWebauthnAuthenticationOptions; +exports.verifyWebauthnAuthentication = verifyWebauthnAuthentication; +exports.removeWebauthnCredential = removeWebauthnCredential; +exports.cleanupExpiredWebauthnChallenges = cleanupExpiredWebauthnChallenges; exports.api = api; exports.webhook = webhook; exports.setAssociatedMovementOnCreatedDeparture = associatedMovementsTriggers.setAssociatedMovementOnCreatedDeparture; diff --git a/functions/package-lock.json b/functions/package-lock.json index 71f9dd3a..aa18f539 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,6 +6,8 @@ "": { "name": "functions", "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^10.0.1", "cors": "^2.8.4", "firebase-admin": "^12.0.0", "firebase-functions": "^4.7.0", @@ -608,6 +610,12 @@ "node": ">=6" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@jest/console": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-24.9.0.tgz", @@ -900,6 +908,12 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -931,6 +945,64 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz", + "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -995,6 +1067,39 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-10.0.1.tgz", + "integrity": "sha512-djNWcRn+H+6zvihBFJSpG3fzb0NQS9c/Mw5dYOtZ9H+oDw8qn9Htqxt4cpqRvSOAfwqP7rOvE9rwqVaoGGc3hg==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^10.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz", + "integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -1581,6 +1686,20 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -2448,6 +2567,15 @@ "node": ">= 0.10" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "6.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", @@ -7705,6 +7833,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", diff --git a/functions/package.json b/functions/package.json index 6a2ab8d2..19fbfbe6 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,6 +5,8 @@ "node": "22" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^10.0.1", "cors": "^2.8.4", "firebase-admin": "^12.0.0", "firebase-functions": "^4.7.0", diff --git a/package-lock.json b/package-lock.json index 5c1cc6ef..18e388e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "license": "UNLICENSED", "dependencies": { + "@simplewebauthn/browser": "^13.3.0", "date-fns": "^4.1.0", "final-form": "^5.0.0", "i18next": "^26.0.5", @@ -5230,6 +5231,12 @@ "react": "^16.14.0 || 17.x || 18.x || 19.x" } }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.34.49", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", diff --git a/package.json b/package.json index 5b3b0644..8bf8a78c 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "url": "https://github.com/odch/flightbox.git" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", "date-fns": "^4.1.0", "final-form": "^5.0.0", "i18next": "^26.0.5", diff --git a/projects/default.json b/projects/default.json index ea574560..81e68876 100644 --- a/projects/default.json +++ b/projects/default.json @@ -32,5 +32,6 @@ "maskContactInformation": true, "memberManagement": false, "privacySettings": false, - "profileEnabled": true + "profileEnabled": true, + "passkeysEnabled": false } diff --git a/projects/lszm.json b/projects/lszm.json index ff5aee28..aedec5e1 100644 --- a/projects/lszm.json +++ b/projects/lszm.json @@ -55,7 +55,8 @@ "test": { "firebaseProjectId": "lszm-test", "firebaseDatabaseUrl": "https://lszm-test-eu.europe-west1.firebasedatabase.app", - "firebaseApiKey": "AIzaSyBP3IcalmLfA6gi9bq4Ln6aDyelL4gOLGg" + "firebaseApiKey": "AIzaSyBP3IcalmLfA6gi9bq4Ln6aDyelL4gOLGg", + "passkeysEnabled": true }, "production": { "firebaseProjectId": "lszm-prod", diff --git a/src/components/LoginPage/EmailLoginForm.tsx b/src/components/LoginPage/EmailLoginForm.tsx index f338ad3f..54c46e04 100644 --- a/src/components/LoginPage/EmailLoginForm.tsx +++ b/src/components/LoginPage/EmailLoginForm.tsx @@ -7,6 +7,8 @@ import Failure from './Failure'; import Button from '../Button'; import GuestTokenLogin from '../../containers/GuestTokenLoginContainer' import OtpCodeForm from './OtpCodeForm'; +import PasskeyLoginButton from './PasskeyLoginButton'; +import { isPasskeySupported } from '../../util/webauthn'; const handleSubmit = (authenticate, email, local, e) => { e.preventDefault(); @@ -69,7 +71,11 @@ const EmailLoginForm = props => { emailSent, otpVerificationFailure, updateEmail, - resetOtp + resetOtp, + passkeysEnabled, + loginWithPasskey, + passkeyLoginSubmitting, + passkeyLoginFailure, } = props; if (emailSent) { @@ -122,6 +128,17 @@ const EmailLoginForm = props => { )} )} + {!guestOnly && passkeysEnabled && isPasskeySupported() && ( + <> + {t('login.or')} + + > + )} {queryToken && ( guestOnly ? @@ -152,6 +169,10 @@ const EmailLoginForm = props => { failure: PropTypes.bool.isRequired, emailSent: PropTypes.bool.isRequired, otpVerificationFailure: PropTypes.bool.isRequired, + passkeysEnabled: PropTypes.bool, + loginWithPasskey: PropTypes.func, + passkeyLoginSubmitting: PropTypes.bool, + passkeyLoginFailure: PropTypes.bool, }; export default EmailLoginForm; diff --git a/src/components/LoginPage/PasskeyLoginButton.spec.tsx b/src/components/LoginPage/PasskeyLoginButton.spec.tsx new file mode 100644 index 00000000..d0b63302 --- /dev/null +++ b/src/components/LoginPage/PasskeyLoginButton.spec.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { renderWithTheme, screen, fireEvent } from '../../../test/renderWithTheme'; +import PasskeyLoginButton from './PasskeyLoginButton'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../MaterialIcon', () => { + const React = require('react'); + return function MockMaterialIcon({ icon }: { icon: string }) { + return ; + }; +}); + +describe('PasskeyLoginButton', () => { + const baseProps = { + submitting: false, + failure: false, + loginWithPasskey: jest.fn(), + }; + + beforeEach(() => { + (window as any).PublicKeyCredential = function () {}; + jest.clearAllMocks(); + }); + + afterEach(() => { + delete (window as any).PublicKeyCredential; + }); + + it('returns null when PublicKeyCredential is not available', () => { + delete (window as any).PublicKeyCredential; + const { container } = renderWithTheme(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the passkey label regardless of whether email is present', () => { + const { unmount } = renderWithTheme(); + expect(screen.getByText('login.signInWithPasskey')).toBeInTheDocument(); + unmount(); + renderWithTheme(); + expect(screen.getByText('login.signInWithPasskey')).toBeInTheDocument(); + }); + + it('calls loginWithPasskey with email when present', () => { + const loginWithPasskey = jest.fn(); + renderWithTheme( + + ); + fireEvent.click(screen.getByText('login.signInWithPasskey')); + expect(loginWithPasskey).toHaveBeenCalledWith('user@example.com'); + }); + + it('calls loginWithPasskey with undefined when no email (usernameless)', () => { + const loginWithPasskey = jest.fn(); + renderWithTheme(); + fireEvent.click(screen.getByText('login.signInWithPasskey')); + expect(loginWithPasskey).toHaveBeenCalledWith(undefined); + }); + + it('shows failure message on failure', () => { + renderWithTheme(); + expect(screen.getByText('login.passkeyFailure')).toBeInTheDocument(); + }); +}); diff --git a/src/components/LoginPage/PasskeyLoginButton.tsx b/src/components/LoginPage/PasskeyLoginButton.tsx new file mode 100644 index 00000000..b4e43a7e --- /dev/null +++ b/src/components/LoginPage/PasskeyLoginButton.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import Button from '../Button'; + +const StyledButton = styled(Button)` + margin-top: 1em; + margin-bottom: 0.5em; + width: 100%; +`; + +const FailureWrapper = styled.div` + color: #ed351c; + margin-top: 0.5em; + font-size: 0.9em; +`; + +interface PasskeyLoginButtonProps { + email?: string; + submitting: boolean; + failure: boolean; + loginWithPasskey: (email?: string) => void; +} + +const PasskeyLoginButton: React.FC = ({ + email, + submitting, + failure, + loginWithPasskey, +}) => { + const { t } = useTranslation(); + + if (typeof window === 'undefined' || !(window as any).PublicKeyCredential) { + return null; + } + + const hasEmail = typeof email === 'string' && email.length > 0; + + const handleClick = () => { + loginWithPasskey(hasEmail ? email : undefined); + }; + + return ( + + + {failure && ( + + {t('login.passkeyFailure')} + + )} + + ); +}; + +export default PasskeyLoginButton; diff --git a/src/components/PostLoginPasskeyPrompt/PostLoginPasskeyPrompt.spec.tsx b/src/components/PostLoginPasskeyPrompt/PostLoginPasskeyPrompt.spec.tsx new file mode 100644 index 00000000..28b7f948 --- /dev/null +++ b/src/components/PostLoginPasskeyPrompt/PostLoginPasskeyPrompt.spec.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { BrowserRouter } from 'react-router-dom'; +import { renderWithTheme, screen, fireEvent } from '../../../test/renderWithTheme'; +import PostLoginPasskeyPrompt from './PostLoginPasskeyPrompt'; + +const theme = { colors: { main: '#003863', background: '#fafafa', danger: '#e00f00' }, images: { logo: '' } }; +const withWrappers = (el: React.ReactElement) => ( + + {el} + +); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('PostLoginPasskeyPrompt', () => { + const baseProps = { + show: true, + submitting: false, + failure: false, + loadPasskeys: jest.fn(), + registerPasskey: jest.fn(), + }; + + beforeEach(() => { + (window as any).PublicKeyCredential = function () {}; + window.localStorage.clear(); + jest.clearAllMocks(); + }); + + afterEach(() => { + delete (window as any).PublicKeyCredential; + window.localStorage.clear(); + }); + + it('renders nothing when show is false', () => { + const { container } = renderWithTheme( + + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when PublicKeyCredential is unavailable', () => { + delete (window as any).PublicKeyCredential; + const { container } = renderWithTheme(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when permanently dismissed (count >= 2)', () => { + window.localStorage.setItem('passkeyPromptDismissCount', '2'); + const { container } = renderWithTheme(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when dismissed once within the TTL window', () => { + window.localStorage.setItem('passkeyPromptDismissCount', '1'); + window.localStorage.setItem('passkeyPromptDismissedAt', String(Date.now())); + const { container } = renderWithTheme(); + expect(container.firstChild).toBeNull(); + }); + + it('renders again after the TTL window elapses (count === 1, old timestamp)', () => { + window.localStorage.setItem('passkeyPromptDismissCount', '1'); + const longAgo = Date.now() - 31 * 24 * 60 * 60 * 1000; + window.localStorage.setItem('passkeyPromptDismissedAt', String(longAgo)); + renderWithTheme(); + expect(screen.getByText('profile.passkeyPromptTitle')).toBeInTheDocument(); + }); + + it('renders title, description, register button and dismiss link', () => { + renderWithTheme(); + expect(screen.getByText('profile.passkeyPromptTitle')).toBeInTheDocument(); + expect(screen.getByText('profile.passkeyPromptDescription')).toBeInTheDocument(); + expect(screen.getByText('profile.passkeyPromptRegister')).toBeInTheDocument(); + expect(screen.getByText('profile.passkeyPromptDismiss')).toBeInTheDocument(); + }); + + it('shows the "dismiss permanent" label after one prior dismissal', () => { + window.localStorage.setItem('passkeyPromptDismissCount', '1'); + const longAgo = Date.now() - 31 * 24 * 60 * 60 * 1000; + window.localStorage.setItem('passkeyPromptDismissedAt', String(longAgo)); + renderWithTheme(); + expect(screen.getByText('profile.passkeyPromptDismissPermanent')).toBeInTheDocument(); + expect(screen.queryByText('profile.passkeyPromptDismiss')).not.toBeInTheDocument(); + }); + + it('calls loadPasskeys on mount when show is true', () => { + const loadPasskeys = jest.fn(); + renderWithTheme(); + expect(loadPasskeys).toHaveBeenCalled(); + }); + + it('does not call loadPasskeys when show is false', () => { + const loadPasskeys = jest.fn(); + renderWithTheme( + + ); + expect(loadPasskeys).not.toHaveBeenCalled(); + }); + + it('calls registerPasskey when register button is clicked', () => { + const registerPasskey = jest.fn(); + renderWithTheme( + + ); + fireEvent.click(screen.getByText('profile.passkeyPromptRegister')); + expect(registerPasskey).toHaveBeenCalled(); + }); + + it('increments dismiss count, persists timestamp, and hides on dismiss click', () => { + const { container } = renderWithTheme(); + fireEvent.click(screen.getByText('profile.passkeyPromptDismiss')); + expect(container.firstChild).toBeNull(); + expect(window.localStorage.getItem('passkeyPromptDismissCount')).toBe('1'); + expect(window.localStorage.getItem('passkeyPromptDismissedAt')).toBeTruthy(); + }); + + it('marks as permanently dismissed on second dismiss click', () => { + window.localStorage.setItem('passkeyPromptDismissCount', '1'); + const longAgo = Date.now() - 31 * 24 * 60 * 60 * 1000; + window.localStorage.setItem('passkeyPromptDismissedAt', String(longAgo)); + renderWithTheme(); + fireEvent.click(screen.getByText('profile.passkeyPromptDismissPermanent')); + expect(window.localStorage.getItem('passkeyPromptDismissCount')).toBe('2'); + }); + + it('auto-dismisses after a successful registration (submitting true → false, no failure)', () => { + const { container, rerender } = renderWithTheme( + + ); + expect(container.firstChild).not.toBeNull(); + rerender(withWrappers()); + expect(container.firstChild).toBeNull(); + expect(window.localStorage.getItem('passkeyPromptDismissCount')).toBe('1'); + }); + + it('stays visible when registration fails', () => { + const { container, rerender } = renderWithTheme( + + ); + rerender(withWrappers()); + expect(container.firstChild).not.toBeNull(); + }); +}); diff --git a/src/components/PostLoginPasskeyPrompt/PostLoginPasskeyPrompt.tsx b/src/components/PostLoginPasskeyPrompt/PostLoginPasskeyPrompt.tsx new file mode 100644 index 00000000..285ce574 --- /dev/null +++ b/src/components/PostLoginPasskeyPrompt/PostLoginPasskeyPrompt.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; + +const DISMISS_TS_KEY = 'passkeyPromptDismissedAt'; +const DISMISS_COUNT_KEY = 'passkeyPromptDismissCount'; +const DISMISS_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +const Wrapper = styled.div` + border-radius: 10px; + background-color: ${props => props.theme.colors.background}; + padding: 1em; + flex: 1 1 300px; + min-width: 280px; + max-width: 500px; + display: flex; + flex-direction: column; + align-items: flex-start; +`; + +const Title = styled.h3` + color: ${props => props.theme.colors.main}; + margin: 0 0 0.5em; + font-size: 1.1em; +`; + +const Description = styled.p` + margin: 0 0 1em; + font-size: 0.95em; + line-height: 1.4; +`; + +const RegisterButton = styled.button` + background-color: ${props => props.theme.colors.main}; + color: #fff; + border: none; + border-radius: 5px; + padding: 0.6em 1.5em; + font-size: 1em; + cursor: pointer; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; + +const DismissLink = styled.button` + background: none; + border: none; + color: #999; + font-size: 0.85em; + cursor: pointer; + margin-top: auto; + padding: 1.5em 0 0; + display: block; +`; + +function getDismissCount(): number { + try { + return parseInt(window.localStorage.getItem(DISMISS_COUNT_KEY) || '0', 10) || 0; + } catch { + return 0; + } +} + +function isDismissed(): boolean { + const count = getDismissCount(); + if (count >= 2) return true; + if (count === 1) { + try { + const ts = parseInt(window.localStorage.getItem(DISMISS_TS_KEY) || '0', 10); + if (Date.now() - ts < DISMISS_TTL_MS) return true; + } catch { + // ignore + } + } + return false; +} + +function markDismissed(): void { + try { + const next = getDismissCount() + 1; + window.localStorage.setItem(DISMISS_COUNT_KEY, String(next)); + window.localStorage.setItem(DISMISS_TS_KEY, String(Date.now())); + } catch { + // ignore + } +} + +interface Props { + show: boolean; + submitting: boolean; + failure: boolean; + loadPasskeys: () => void; + registerPasskey: () => void; +} + +const PostLoginPasskeyPrompt: React.FC = ({ + show, + submitting, + failure, + loadPasskeys, + registerPasskey, +}) => { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState(isDismissed()); + const nextDismissIsPermanent = getDismissCount() >= 1; + const [wasSubmitting, setWasSubmitting] = useState(false); + + useEffect(() => { + if (show) { + loadPasskeys(); + } + }, [show]); + + useEffect(() => { + if (wasSubmitting && !submitting && !failure) { + markDismissed(); + setDismissed(true); + } + setWasSubmitting(submitting); + }, [submitting, failure]); + + if (!show || dismissed) return null; + + const supported = typeof window !== 'undefined' && !!(window as any).PublicKeyCredential; + if (!supported) return null; + + const handleDismiss = () => { + markDismissed(); + setDismissed(true); + }; + + return ( + + {t('profile.passkeyPromptTitle')} + {t('profile.passkeyPromptDescription')} + registerPasskey()} + disabled={submitting} + data-cy="passkey-prompt-register" + > + {t('profile.passkeyPromptRegister')} + + + {nextDismissIsPermanent ? t('profile.passkeyPromptDismissPermanent') : t('profile.passkeyPromptDismiss')} + + + ); +}; + +export default PostLoginPasskeyPrompt; diff --git a/src/components/PostLoginPasskeyPrompt/index.tsx b/src/components/PostLoginPasskeyPrompt/index.tsx new file mode 100644 index 00000000..5891175c --- /dev/null +++ b/src/components/PostLoginPasskeyPrompt/index.tsx @@ -0,0 +1 @@ +export { default } from './PostLoginPasskeyPrompt'; diff --git a/src/components/ProfilePage/PasskeyManager/PasskeyListItem.spec.tsx b/src/components/ProfilePage/PasskeyManager/PasskeyListItem.spec.tsx new file mode 100644 index 00000000..f0ffa69e --- /dev/null +++ b/src/components/ProfilePage/PasskeyManager/PasskeyListItem.spec.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { renderWithTheme, screen, fireEvent } from '../../../../test/renderWithTheme'; +import PasskeyListItem from './PasskeyListItem'; +import type { Passkey } from '../../../modules/auth'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: any) => (opts && opts.date ? `${key}:${opts.date}` : key), + }), +})); + +jest.mock('../../MaterialIcon', () => { + const ReactLocal = require('react'); + return function MockMaterialIcon({ icon }: { icon: string }) { + return ReactLocal.createElement('span', { 'data-testid': `icon-${icon}` }); + }; +}); + +describe('PasskeyListItem', () => { + const basePasskey: Passkey = { + credentialId: 'cred-abc', + deviceName: 'Mac', + createdAt: Date.UTC(2026, 3, 15), + lastUsedAt: null, + }; + + it('renders device name and created date', () => { + renderWithTheme(); + expect(screen.getByText('Mac')).toBeInTheDocument(); + expect(screen.getByText(/profile.passkeysCreatedAt:/)).toBeInTheDocument(); + }); + + it('shows "never used" when lastUsedAt is null', () => { + renderWithTheme(); + expect(screen.getByText('profile.passkeysLastUsedNever')).toBeInTheDocument(); + }); + + it('shows last-used date when set', () => { + const p = { ...basePasskey, lastUsedAt: Date.UTC(2026, 3, 18) }; + renderWithTheme(); + expect(screen.getByText(/profile.passkeysLastUsed:/)).toBeInTheDocument(); + }); + + it('does not render the confirmation modal by default', () => { + renderWithTheme(); + expect(screen.queryByText('profile.passkeysRemoveConfirm')).not.toBeInTheDocument(); + }); + + it('opens the confirmation modal when trash icon is clicked', () => { + renderWithTheme(); + fireEvent.click(screen.getByTestId('icon-delete')); + expect(screen.getByText('profile.passkeysRemoveConfirm')).toBeInTheDocument(); + }); + + it('does not call onRemove when opening the modal', () => { + const onRemove = jest.fn(); + renderWithTheme(); + fireEvent.click(screen.getByTestId('icon-delete')); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it('calls onRemove with credentialId and closes modal on confirm', () => { + const onRemove = jest.fn(); + renderWithTheme(); + fireEvent.click(screen.getByTestId('icon-delete')); + fireEvent.click(screen.getByText('profile.passkeysRemove')); + expect(onRemove).toHaveBeenCalledWith('cred-abc'); + expect(screen.queryByText('profile.passkeysRemoveConfirm')).not.toBeInTheDocument(); + }); + + it('closes the modal on cancel without calling onRemove', () => { + const onRemove = jest.fn(); + renderWithTheme(); + fireEvent.click(screen.getByTestId('icon-delete')); + fireEvent.click(screen.getByText('common.cancel')); + expect(screen.queryByText('profile.passkeysRemoveConfirm')).not.toBeInTheDocument(); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it('closes the modal when the backdrop is clicked', () => { + const onRemove = jest.fn(); + renderWithTheme(); + fireEvent.click(screen.getByTestId('icon-delete')); + fireEvent.click(screen.getByTestId('modal-mask')); + expect(screen.queryByText('profile.passkeysRemoveConfirm')).not.toBeInTheDocument(); + expect(onRemove).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ProfilePage/PasskeyManager/PasskeyListItem.tsx b/src/components/ProfilePage/PasskeyManager/PasskeyListItem.tsx new file mode 100644 index 00000000..114a40b3 --- /dev/null +++ b/src/components/ProfilePage/PasskeyManager/PasskeyListItem.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import MaterialIcon from '../../MaterialIcon'; +import ModalDialog from '../../ModalDialog'; +import Button from '../../Button'; +import dates from '../../../util/dates'; +import type { Passkey } from '../../../modules/auth'; + +const Item = styled.div` + border: 1px solid #ddd; + border-radius: 4px; + background-color: #fff; + padding: 0.75em 1em; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1em; +`; + +const Info = styled.div` + flex: 1; + min-width: 0; +`; + +const Name = styled.div` + font-weight: bold; + margin-bottom: 0.35em; +`; + +const Meta = styled.div` + font-size: 0.85em; + color: #666; + line-height: 1.5; +`; + +const IconButton = styled.button` + background: none; + border: none; + cursor: pointer; + color: #999; + padding: 4px; + font-family: inherit; + + &:hover { + color: ${props => props.theme.colors.danger}; + } +`; + +const Question = styled.div` + font-size: 1.5em; + margin-bottom: 1em; +`; + +const Data = styled.div` + margin-bottom: 1em; +`; + +const DataItem = styled.div` + margin-bottom: 0.3em; +`; + +const DialogButton = styled(Button)` + @media(max-width: 600px) { + width: 100%; + } +`; + +const DeleteButton = styled(DialogButton)` + float: right; + + @media(max-width: 600px) { + margin-top: 1em; + margin-bottom: 1em; + } +`; + +const formatDate = (timestamp: number) => { + return dates.formatDate(new Date(timestamp).toISOString()); +}; + +interface Props { + passkey: Passkey; + onRemove: (credentialId: string) => void; +} + +const PasskeyListItem: React.FC = ({ passkey, onRemove }) => { + const { t } = useTranslation(); + const [confirming, setConfirming] = useState(false); + + const handleConfirm = () => { + setConfirming(false); + onRemove(passkey.credentialId); + }; + + const confirmContent = ( + + {t('profile.passkeysRemoveConfirm')} + + {t('profile.passkeysRemoveDevice')} {passkey.deviceName} + {t('profile.passkeysCreatedAt', { date: formatDate(passkey.createdAt) })} + + + + setConfirming(false)} + neutral + /> + + + ); + + return ( + <> + + + {passkey.deviceName} + {t('profile.passkeysCreatedAt', { date: formatDate(passkey.createdAt) })} + + {passkey.lastUsedAt + ? t('profile.passkeysLastUsed', { date: formatDate(passkey.lastUsedAt) }) + : t('profile.passkeysLastUsedNever')} + + + setConfirming(true)} + title={t('profile.passkeysRemove')} + data-cy="remove-passkey" + > + + + + {confirming && ( + setConfirming(false)}/> + )} + > + ); +}; + +export default PasskeyListItem; diff --git a/src/components/ProfilePage/PasskeyManager/PasskeyManager.spec.tsx b/src/components/ProfilePage/PasskeyManager/PasskeyManager.spec.tsx new file mode 100644 index 00000000..478cf76e --- /dev/null +++ b/src/components/ProfilePage/PasskeyManager/PasskeyManager.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { renderWithTheme, screen, fireEvent } from '../../../../test/renderWithTheme'; +import PasskeyManager from './PasskeyManager'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string, opts?: any) => (opts && opts.date ? `${key}:${opts.date}` : key) }), +})); + +jest.mock('../../MaterialIcon', () => { + const React = require('react'); + return function MockMaterialIcon({ icon }: { icon: string }) { + return ; + }; +}); + +describe('PasskeyManager', () => { + const baseProps = { + passkeys: [], + submitting: false, + failure: false, + removalFailure: false, + loadPasskeys: jest.fn(), + registerPasskey: jest.fn(), + removePasskey: jest.fn(), + }; + + beforeEach(() => { + (window as any).PublicKeyCredential = function () {}; + jest.clearAllMocks(); + }); + + afterEach(() => { + delete (window as any).PublicKeyCredential; + }); + + it('calls loadPasskeys on mount', () => { + const loadPasskeys = jest.fn(); + renderWithTheme(); + expect(loadPasskeys).toHaveBeenCalled(); + }); + + it('shows empty notice when no passkeys', () => { + renderWithTheme(); + expect(screen.getByText('profile.passkeysEmpty')).toBeInTheDocument(); + }); + + it('lists registered passkeys', () => { + const passkeys = [ + { credentialId: 'c1', deviceName: 'My Laptop', createdAt: 0, lastUsedAt: null }, + { credentialId: 'c2', deviceName: 'My Phone', createdAt: 0, lastUsedAt: 1000 }, + ]; + renderWithTheme(); + expect(screen.getByText('My Laptop')).toBeInTheDocument(); + expect(screen.getByText('My Phone')).toBeInTheDocument(); + }); + + it('hides register button when WebAuthn unsupported', () => { + delete (window as any).PublicKeyCredential; + renderWithTheme(); + expect(screen.queryByText('profile.passkeysRegister')).not.toBeInTheDocument(); + }); + + it('calls registerPasskey when register button is clicked', () => { + const registerPasskey = jest.fn(); + renderWithTheme(); + fireEvent.click(screen.getByText('profile.passkeysRegister')); + expect(registerPasskey).toHaveBeenCalled(); + }); + + it('shows error when registration fails', () => { + renderWithTheme( + + ); + expect(screen.getByText('custom error')).toBeInTheDocument(); + }); + + it('shows default failure message when no errorMessage', () => { + renderWithTheme(); + expect(screen.getByText('profile.passkeysRegistrationFailure')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ProfilePage/PasskeyManager/PasskeyManager.tsx b/src/components/ProfilePage/PasskeyManager/PasskeyManager.tsx new file mode 100644 index 00000000..18e85f16 --- /dev/null +++ b/src/components/ProfilePage/PasskeyManager/PasskeyManager.tsx @@ -0,0 +1,129 @@ +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { useTranslation } from 'react-i18next'; +import Button from '../../Button'; +import PasskeyListItem from './PasskeyListItem'; +import type { Passkey } from '../../../modules/auth'; + +const Section = styled.div` + padding: 1em; + border: 1px solid #ddd; + background-color: #fefefe; + box-shadow: 0 -1px 0 rgba(0,0,0,.03), 0 0 2px rgba(0,0,0,.03), 0 2px 4px rgba(0,0,0,.06); +`; + +const SectionTitle = styled.h1` + font-weight: bold; + font-size: 1.5em; + margin-bottom: 0.5em; +`; + +const Description = styled.p` + color: #666; + margin-bottom: 1em; +`; + +const List = styled.div` + display: flex; + flex-direction: column; + gap: 0.5em; + margin-bottom: 1em; +`; + +const EmptyNotice = styled.div` + color: #888; + font-style: italic; + margin-bottom: 1em; +`; + +const Actions = styled.div` + display: flex; + gap: 0.5em; +`; + +const ErrorNotice = styled.div` + color: #ed351c; + margin: 0.5em 0; +`; + +interface PasskeyManagerProps { + passkeys: Passkey[]; + submitting: boolean; + failure: boolean; + errorMessage?: string; + removalFailure: boolean; + removalErrorMessage?: string; + loadPasskeys: () => void; + registerPasskey: () => void; + removePasskey: (credentialId: string) => void; +} + +const PasskeyManager: React.FC = ({ + passkeys, + submitting, + failure, + errorMessage, + removalFailure, + removalErrorMessage, + loadPasskeys, + registerPasskey, + removePasskey, +}) => { + const { t } = useTranslation(); + + useEffect(() => { + loadPasskeys(); + }, []); + + const supported = typeof window !== 'undefined' && !!(window as any).PublicKeyCredential; + + return ( + + {t('profile.passkeysTitle')} + {t('profile.passkeysDescription')} + + {passkeys.length === 0 ? ( + {t('profile.passkeysEmpty')} + ) : ( + + {passkeys.map(p => ( + + ))} + + )} + + {failure && ( + + {errorMessage || t('profile.passkeysRegistrationFailure')} + + )} + + {removalFailure && ( + + {removalErrorMessage || t('profile.passkeysRemovalFailure')} + + )} + + {supported && ( + + registerPasskey()} + loading={submitting} + disabled={submitting} + primary + dataCy="register-passkey" + /> + + )} + + ); +}; + +export default PasskeyManager; diff --git a/src/components/ProfilePage/PasskeyManager/index.tsx b/src/components/ProfilePage/PasskeyManager/index.tsx new file mode 100644 index 00000000..50a90999 --- /dev/null +++ b/src/components/ProfilePage/PasskeyManager/index.tsx @@ -0,0 +1 @@ +export { default } from './PasskeyManager'; diff --git a/src/components/ProfilePage/ProfilePage.tsx b/src/components/ProfilePage/ProfilePage.tsx index 71d1a2b3..cd911b69 100644 --- a/src/components/ProfilePage/ProfilePage.tsx +++ b/src/components/ProfilePage/ProfilePage.tsx @@ -7,7 +7,9 @@ import JumpNavigation from '../JumpNavigation' import VerticalHeaderLayout from '../VerticalHeaderLayout' import ProfileForm from './ProfileForm' import AircraftList from './AircraftList' +import PasskeyManager from './PasskeyManager' import type { Aircraft } from '../../modules/profile/migration' +import type { Passkey } from '../../modules/auth' const Content = styled.div` padding: 2em; @@ -27,6 +29,16 @@ interface ProfilePageProps { addAircraft: (aircraft: Aircraft) => void; updateAircraft: (index: number, aircraft: Aircraft) => void; removeAircraft: (index: number) => void; + passkeysEnabled: boolean; + passkeys: Passkey[]; + passkeyRegistrationSubmitting: boolean; + passkeyRegistrationFailure: boolean; + passkeyRegistrationErrorMessage?: string; + passkeyRemovalFailure: boolean; + passkeyRemovalErrorMessage?: string; + loadPasskeys: () => void; + registerPasskey: () => void; + removePasskey: (credentialId: string) => void; } const ProfilePage: React.FC = ({ @@ -37,6 +49,16 @@ const ProfilePage: React.FC = ({ addAircraft, updateAircraft, removeAircraft, + passkeysEnabled, + passkeys, + passkeyRegistrationSubmitting, + passkeyRegistrationFailure, + passkeyRegistrationErrorMessage, + passkeyRemovalFailure, + passkeyRemovalErrorMessage, + loadPasskeys, + registerPasskey, + removePasskey, }) => { const { t } = useTranslation(); @@ -60,6 +82,19 @@ const ProfilePage: React.FC = ({ updateAircraft={updateAircraft} removeAircraft={removeAircraft} /> + {passkeysEnabled && ( + + )} diff --git a/src/components/StartPage/InstallCard/InstallCard.tsx b/src/components/StartPage/InstallCard/InstallCard.tsx index 5e3403ec..aaeb3d4d 100644 --- a/src/components/StartPage/InstallCard/InstallCard.tsx +++ b/src/components/StartPage/InstallCard/InstallCard.tsx @@ -6,10 +6,13 @@ import { usePwaInstall, AuthData } from './usePwaInstall'; const Wrapper = styled.div` border-radius: 10px; background-color: ${props => props.theme.colors.background}; - margin: 1em auto; padding: 1em; - width: 80%; + flex: 1 1 300px; + min-width: 280px; max-width: 500px; + display: flex; + flex-direction: column; + align-items: flex-start; `; const Title = styled.h3` @@ -40,6 +43,7 @@ const DismissLink = styled.button` color: #999; font-size: 0.85em; cursor: pointer; + margin-top: auto; padding: 1.5em 0 0; display: block; `; diff --git a/src/components/StartPage/Main.tsx b/src/components/StartPage/Main.tsx index 44dc0247..ca43a716 100644 --- a/src/components/StartPage/Main.tsx +++ b/src/components/StartPage/Main.tsx @@ -4,16 +4,28 @@ import styled from 'styled-components'; import Hints from './Hints'; import EntryPoints from './EntryPoints'; import InstallCard from './InstallCard'; +import PostLoginPasskeyPrompt from '../../containers/PostLoginPasskeyPromptContainer'; const Wrapper = styled.div` padding-top: 100px; `; +const Promotions = styled.div` + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1em; + margin: 1em 1em 0; +`; + const Main = ({ auth }: any) => ( - + + + + ); diff --git a/src/containers/EmailLoginFormContainer.tsx b/src/containers/EmailLoginFormContainer.tsx index 59cf96fe..54a79e11 100644 --- a/src/containers/EmailLoginFormContainer.tsx +++ b/src/containers/EmailLoginFormContainer.tsx @@ -1,6 +1,6 @@ import {connect} from 'react-redux'; import {updateEmail, resetOtp} from '../modules/ui/loginPage'; -import {sendAuthenticationEmail, verifyOtpCode} from '../modules/auth'; +import {sendAuthenticationEmail, verifyOtpCode, loginWithPasskey} from '../modules/auth'; import {hideLogin} from '../modules/ui/showLogin'; import EmailLoginForm from '../components/LoginPage/EmailLoginForm'; import {RootState} from '../modules'; @@ -12,11 +12,15 @@ const mapStateToProps = (state: RootState) => { let submitting = false; let failure = false; let otpVerificationFailure = false; + let passkeyLoginSubmitting = false; + let passkeyLoginFailure = false; if (state.auth) { submitting = state.auth.submitting || false; failure = state.auth.failure || false; otpVerificationFailure = state.auth.otpVerificationFailure || false; + passkeyLoginSubmitting = (state.auth.passkeyLogin && state.auth.passkeyLogin.submitting) || false; + passkeyLoginFailure = (state.auth.passkeyLogin && state.auth.passkeyLogin.failure) || false; } return { @@ -26,6 +30,9 @@ const mapStateToProps = (state: RootState) => { emailSent, showCancel, otpVerificationFailure, + passkeysEnabled: __CONF__.passkeysEnabled === true, + passkeyLoginSubmitting, + passkeyLoginFailure, }; }; @@ -34,6 +41,7 @@ const mapActionCreators = { resetOtp, sendAuthenticationEmail, verifyOtpCode, + loginWithPasskey, onCancel: hideLogin, }; diff --git a/src/containers/PostLoginPasskeyPromptContainer.tsx b/src/containers/PostLoginPasskeyPromptContainer.tsx new file mode 100644 index 00000000..75388e4b --- /dev/null +++ b/src/containers/PostLoginPasskeyPromptContainer.tsx @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { loadPasskeys, registerPasskey } from '../modules/auth'; +import PostLoginPasskeyPrompt from '../components/PostLoginPasskeyPrompt'; +import { RootState } from '../modules'; + +const mapStateToProps = (state: RootState) => { + const auth = state.auth; + const data: any = auth && auth.data; + const authenticated = !!(auth && auth.authenticated); + const isRealUser = authenticated && data && data.uid && !data.guest && !data.kiosk && data.uid !== 'ipauth'; + const passkeys = (auth && auth.passkeys) || []; + const show = __CONF__.passkeysEnabled === true && isRealUser && passkeys.length === 0; + + return { + show, + submitting: (auth && auth.passkeyRegistration && auth.passkeyRegistration.submitting) || false, + failure: (auth && auth.passkeyRegistration && auth.passkeyRegistration.failure) || false, + }; +}; + +const mapActionCreators = { + loadPasskeys, + registerPasskey, +}; + +export default connect(mapStateToProps, mapActionCreators)(PostLoginPasskeyPrompt); diff --git a/src/containers/ProfilePageContainer.tsx b/src/containers/ProfilePageContainer.tsx index 80624c8d..3683215d 100644 --- a/src/containers/ProfilePageContainer.tsx +++ b/src/containers/ProfilePageContainer.tsx @@ -1,11 +1,19 @@ import {connect} from 'react-redux'; import {loadProfile, saveProfile, addAircraft, updateAircraft, removeAircraft} from '../modules/profile'; +import {loadPasskeys, registerPasskey, removePasskey} from '../modules/auth'; import ProfilePage from '../components/ProfilePage'; import {RootState} from '../modules'; const mapStateToProps = (state: RootState) => ({ profile: state.profile.profile, saving: state.profile.saving, + passkeysEnabled: __CONF__.passkeysEnabled === true, + passkeys: (state.auth && state.auth.passkeys) || [], + passkeyRegistrationSubmitting: (state.auth && state.auth.passkeyRegistration && state.auth.passkeyRegistration.submitting) || false, + passkeyRegistrationFailure: (state.auth && state.auth.passkeyRegistration && state.auth.passkeyRegistration.failure) || false, + passkeyRegistrationErrorMessage: state.auth && state.auth.passkeyRegistration && state.auth.passkeyRegistration.errorMessage, + passkeyRemovalFailure: (state.auth && state.auth.passkeyRemoval && state.auth.passkeyRemoval.failure) || false, + passkeyRemovalErrorMessage: state.auth && state.auth.passkeyRemoval && state.auth.passkeyRemoval.errorMessage, }); const mapActionCreators = { @@ -14,6 +22,9 @@ const mapActionCreators = { addAircraft, updateAircraft, removeAircraft, + loadPasskeys, + registerPasskey, + removePasskey, }; export default connect(mapStateToProps, mapActionCreators)(ProfilePage); diff --git a/src/locales/de.json b/src/locales/de.json index 0b01d7a9..8f2f0ecb 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -52,7 +52,10 @@ "otpVerificationFailure": "Ungültiger oder abgelaufener Code. Bitte versuchen Sie es erneut.", "otpResend": "Code erneut senden", "otpResendIn": "Code erneut senden ({{seconds}}s)", - "otpChangeEmail": "Falsche E-Mail-Adresse? Hier ändern" + "otpChangeEmail": "Falsche E-Mail-Adresse? Hier ändern", + "signInWithPasskey": "Mit Passkey anmelden", + "passkeyFailure": "Anmeldung mit Passkey fehlgeschlagen", + "useEmailCodeInstead": "Stattdessen E-Mail-Code verwenden" }, "hints": { "departureBeforeStart_text": "Abflug immer vor dem Start", @@ -300,7 +303,24 @@ "noAircraft": "Noch keine Flugzeuge gespeichert.", "saveDetailsPrompt": "Pilotendaten und Flugzeug speichern für das nächste Mal?", "saveAircraftPrompt": "{{immatriculation}} im Profil speichern?", - "updateAircraftPrompt": "{{immatriculation}} im Profil aktualisieren?" + "updateAircraftPrompt": "{{immatriculation}} im Profil aktualisieren?", + "passkeysTitle": "Passkeys", + "passkeysDescription": "Melden Sie sich schneller an mit einem auf Ihrem Gerät gespeicherten Passkey.", + "passkeysEmpty": "Keine Passkeys registriert.", + "passkeysRegister": "Neuen Passkey registrieren", + "passkeysRemove": "Entfernen", + "passkeysRemoveConfirm": "Möchten Sie diesen Passkey wirklich entfernen?", + "passkeysRemoveDevice": "Gerät:", + "passkeysCreatedAt": "Hinzugefügt am {{date}}", + "passkeysLastUsed": "Zuletzt verwendet am {{date}}", + "passkeysLastUsedNever": "Noch nie verwendet", + "passkeysRegistrationFailure": "Passkey konnte nicht registriert werden. Bitte versuchen Sie es erneut.", + "passkeysRemovalFailure": "Passkey konnte nicht entfernt werden. Bitte versuchen Sie es erneut.", + "passkeyPromptTitle": "Schneller anmelden", + "passkeyPromptDescription": "Künftig mit Fingerabdruck oder Gesichtserkennung anmelden, ohne auf den E-Mail-Code zu warten.", + "passkeyPromptRegister": "Passkey einrichten", + "passkeyPromptDismiss": "Später", + "passkeyPromptDismissPermanent": "Nicht mehr anzeigen" }, "lockMovements": { "heading": "Erfasste Bewegungen sperren", diff --git a/src/locales/en.json b/src/locales/en.json index 3d043a16..9f081c21 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -52,7 +52,10 @@ "otpVerificationFailure": "Invalid or expired code. Please try again.", "otpResend": "Resend code", "otpResendIn": "Resend code ({{seconds}}s)", - "otpChangeEmail": "Wrong email address? Change it here" + "otpChangeEmail": "Wrong email address? Change it here", + "signInWithPasskey": "Sign in with passkey", + "passkeyFailure": "Passkey sign-in failed", + "useEmailCodeInstead": "Use email code instead" }, "hints": { "departureBeforeStart_text": "Always record departure before takeoff", @@ -300,7 +303,24 @@ "noAircraft": "No aircraft saved.", "saveDetailsPrompt": "Save pilot info and aircraft so they're prefilled next time?", "saveAircraftPrompt": "Save {{immatriculation}} to your profile?", - "updateAircraftPrompt": "Update {{immatriculation}} in your profile?" + "updateAircraftPrompt": "Update {{immatriculation}} in your profile?", + "passkeysTitle": "Passkeys", + "passkeysDescription": "Sign in faster by using a passkey stored on your device.", + "passkeysEmpty": "No passkeys registered.", + "passkeysRegister": "Register a new passkey", + "passkeysRemove": "Remove", + "passkeysRemoveConfirm": "Do you really want to remove this passkey?", + "passkeysRemoveDevice": "Device:", + "passkeysCreatedAt": "Added on {{date}}", + "passkeysLastUsed": "Last used on {{date}}", + "passkeysLastUsedNever": "Never used yet", + "passkeysRegistrationFailure": "Could not register passkey. Please try again.", + "passkeysRemovalFailure": "Could not remove passkey. Please try again.", + "passkeyPromptTitle": "Sign in faster", + "passkeyPromptDescription": "Sign in with your fingerprint or face, without waiting for an email code.", + "passkeyPromptRegister": "Set up passkey", + "passkeyPromptDismiss": "Later", + "passkeyPromptDismissPermanent": "Don't show again" }, "lockMovements": { "heading": "Lock recorded movements", diff --git a/src/modules/auth/actions.ts b/src/modules/auth/actions.ts index d5285f6f..5eaabdf1 100644 --- a/src/modules/auth/actions.ts +++ b/src/modules/auth/actions.ts @@ -17,6 +17,24 @@ export const LOGOUT = 'LOGOUT' as const; export const FIREBASE_AUTHENTICATION_EVENT = 'FIREBASE_AUTHENTICATION_EVENT' as const; export const SET_SUBMITTING = 'SET_SUBMITTING' as const; +export const REGISTER_PASSKEY = 'REGISTER_PASSKEY' as const; +export const REGISTER_PASSKEY_SUCCESS = 'REGISTER_PASSKEY_SUCCESS' as const; +export const REGISTER_PASSKEY_FAILURE = 'REGISTER_PASSKEY_FAILURE' as const; +export const LOGIN_WITH_PASSKEY = 'LOGIN_WITH_PASSKEY' as const; +export const LOGIN_WITH_PASSKEY_FAILURE = 'LOGIN_WITH_PASSKEY_FAILURE' as const; +export const LOAD_PASSKEYS = 'LOAD_PASSKEYS' as const; +export const LOAD_PASSKEYS_SUCCESS = 'LOAD_PASSKEYS_SUCCESS' as const; +export const REMOVE_PASSKEY = 'REMOVE_PASSKEY' as const; +export const REMOVE_PASSKEY_SUCCESS = 'REMOVE_PASSKEY_SUCCESS' as const; +export const REMOVE_PASSKEY_FAILURE = 'REMOVE_PASSKEY_FAILURE' as const; + +export interface Passkey { + credentialId: string; + deviceName: string; + createdAt: number; + lastUsedAt?: number | null; +} + export type AuthAction = | { type: typeof REQUEST_IP_AUTHENTICATION } | { type: typeof IP_AUTHENTICATION_FAILURE } @@ -35,7 +53,17 @@ export type AuthAction = | { type: typeof FIREBASE_AUTHENTICATION; payload: { authData: unknown } } | { type: typeof LOGOUT } | { type: typeof FIREBASE_AUTHENTICATION_EVENT; payload: { authData: unknown } } - | { type: typeof SET_SUBMITTING }; + | { type: typeof SET_SUBMITTING } + | { type: typeof REGISTER_PASSKEY } + | { type: typeof REGISTER_PASSKEY_SUCCESS } + | { type: typeof REGISTER_PASSKEY_FAILURE; payload: { message?: string } } + | { type: typeof LOGIN_WITH_PASSKEY; payload: { email?: string } } + | { type: typeof LOGIN_WITH_PASSKEY_FAILURE } + | { type: typeof LOAD_PASSKEYS } + | { type: typeof LOAD_PASSKEYS_SUCCESS; payload: { passkeys: Passkey[] } } + | { type: typeof REMOVE_PASSKEY; payload: { credentialId: string } } + | { type: typeof REMOVE_PASSKEY_SUCCESS; payload: { credentialId: string } } + | { type: typeof REMOVE_PASSKEY_FAILURE; payload: { credentialId: string; message?: string } }; export function requestIpAuthentication() { return { @@ -173,3 +201,63 @@ export function firebaseAuthentication(authData: unknown) { }, }; } + +export function registerPasskey() { + return { + type: REGISTER_PASSKEY, + }; +} + +export function registerPasskeySuccess() { + return { type: REGISTER_PASSKEY_SUCCESS }; +} + +export function registerPasskeyFailure(message?: string) { + return { + type: REGISTER_PASSKEY_FAILURE, + payload: { message }, + }; +} + +export function loginWithPasskey(email?: string) { + return { + type: LOGIN_WITH_PASSKEY, + payload: { email }, + }; +} + +export function loginWithPasskeyFailure() { + return { type: LOGIN_WITH_PASSKEY_FAILURE }; +} + +export function loadPasskeys() { + return { type: LOAD_PASSKEYS }; +} + +export function loadPasskeysSuccess(passkeys: Passkey[]) { + return { + type: LOAD_PASSKEYS_SUCCESS, + payload: { passkeys }, + }; +} + +export function removePasskey(credentialId: string) { + return { + type: REMOVE_PASSKEY, + payload: { credentialId }, + }; +} + +export function removePasskeySuccess(credentialId: string) { + return { + type: REMOVE_PASSKEY_SUCCESS, + payload: { credentialId }, + }; +} + +export function removePasskeyFailure(credentialId: string, message?: string) { + return { + type: REMOVE_PASSKEY_FAILURE, + payload: { credentialId, message }, + }; +} diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 3a7bb5e2..28afa043 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -5,7 +5,12 @@ import { logout, sendAuthenticationEmail, verifyOtpCode, - USERNAME_PASSWORD_AUTHENTICATION_FAILURE + USERNAME_PASSWORD_AUTHENTICATION_FAILURE, + registerPasskey, + loginWithPasskey, + loadPasskeys, + removePasskey, + Passkey, } from './actions'; import reducer from './reducer'; import sagas from './sagas'; @@ -17,9 +22,15 @@ export { authenticateAsGuest, sendAuthenticationEmail, verifyOtpCode, - logout + logout, + registerPasskey, + loginWithPasskey, + loadPasskeys, + removePasskey, }; +export type { Passkey }; + export {sagas}; export default reducer; diff --git a/src/modules/auth/reducer.spec.ts b/src/modules/auth/reducer.spec.ts index 0a787771..e5520999 100644 --- a/src/modules/auth/reducer.spec.ts +++ b/src/modules/auth/reducer.spec.ts @@ -11,6 +11,10 @@ const INITIAL_STATE = { submitting: false, failure: false, }, + passkeyRegistration: { submitting: false, failure: false }, + passkeyLogin: { submitting: false, failure: false }, + passkeyRemoval: { failure: false }, + passkeys: [], }; describe('modules', () => { @@ -26,20 +30,15 @@ describe('modules', () => { it('should set submitting to true, clear failure, and clear otpVerificationFailure', () => { expect( reducer({ - initialized: false, - authenticated: false, - submitting: false, + ...INITIAL_STATE, failure: true, otpVerificationFailure: true, - guestAuthentication: { submitting: false, failure: false }, }, actions.setSubmitting()) ).toEqual({ - initialized: false, - authenticated: false, + ...INITIAL_STATE, submitting: true, failure: false, otpVerificationFailure: false, - guestAuthentication: { submitting: false, failure: false }, }); }); }); @@ -148,6 +147,10 @@ describe('modules', () => { submitting: false, data: authData, guestAuthentication: INITIAL_STATE.guestAuthentication, + passkeyRegistration: { submitting: false, failure: false }, + passkeyLogin: { submitting: false, failure: false }, + passkeyRemoval: { failure: false }, + passkeys: [], }); }); @@ -161,6 +164,10 @@ describe('modules', () => { otpVerificationFailure: false, data: { uid: 'user-123' }, guestAuthentication: { submitting: false, failure: false }, + passkeyRegistration: { submitting: false, failure: false }, + passkeyLogin: { submitting: false, failure: false }, + passkeyRemoval: { failure: false }, + passkeys: [], }, actions.firebaseAuthenticationEvent(null)) ).toEqual(INITIAL_STATE); }); @@ -181,6 +188,126 @@ describe('modules', () => { }); }); }); + + describe('REGISTER_PASSKEY', () => { + it('sets passkeyRegistration.submitting', () => { + expect( + reducer(INITIAL_STATE, actions.registerPasskey()) + ).toEqual({ + ...INITIAL_STATE, + passkeyRegistration: { submitting: true, failure: false }, + }); + }); + }); + + describe('REGISTER_PASSKEY_FAILURE', () => { + it('sets passkeyRegistration.failure and error message', () => { + expect( + reducer(INITIAL_STATE, actions.registerPasskeyFailure('bad')) + ).toEqual({ + ...INITIAL_STATE, + passkeyRegistration: { submitting: false, failure: true, errorMessage: 'bad' }, + }); + }); + }); + + describe('REGISTER_PASSKEY_SUCCESS', () => { + it('clears submitting and failure', () => { + expect( + reducer({ + ...INITIAL_STATE, + passkeyRegistration: { submitting: true, failure: false }, + }, actions.registerPasskeySuccess()) + ).toEqual({ + ...INITIAL_STATE, + passkeyRegistration: { submitting: false, failure: false }, + }); + }); + }); + + describe('LOGIN_WITH_PASSKEY', () => { + it('sets passkeyLogin.submitting and clears failure', () => { + expect( + reducer({ + ...INITIAL_STATE, + passkeyLogin: { submitting: false, failure: true }, + }, actions.loginWithPasskey('a@b.c')) + ).toEqual({ + ...INITIAL_STATE, + passkeyLogin: { submitting: true, failure: false }, + }); + }); + }); + + describe('LOGIN_WITH_PASSKEY_FAILURE', () => { + it('sets failure and clears submitting', () => { + expect( + reducer({ + ...INITIAL_STATE, + passkeyLogin: { submitting: true, failure: false }, + }, actions.loginWithPasskeyFailure()) + ).toEqual({ + ...INITIAL_STATE, + passkeyLogin: { submitting: false, failure: true }, + }); + }); + }); + + describe('LOAD_PASSKEYS_SUCCESS', () => { + it('replaces passkeys list', () => { + const passkeys = [ + { credentialId: 'c1', deviceName: 'Laptop', createdAt: 1, lastUsedAt: null }, + ]; + expect( + reducer(INITIAL_STATE, actions.loadPasskeysSuccess(passkeys)) + ).toEqual({ + ...INITIAL_STATE, + passkeys, + }); + }); + }); + + describe('REMOVE_PASSKEY_SUCCESS', () => { + it('removes the matching credentialId', () => { + const state = { + ...INITIAL_STATE, + passkeys: [ + { credentialId: 'c1', deviceName: 'Laptop', createdAt: 1, lastUsedAt: null }, + { credentialId: 'c2', deviceName: 'Phone', createdAt: 2, lastUsedAt: null }, + ], + }; + expect( + reducer(state, actions.removePasskeySuccess('c1')) + ).toEqual({ + ...INITIAL_STATE, + passkeys: [ + { credentialId: 'c2', deviceName: 'Phone', createdAt: 2, lastUsedAt: null }, + ], + }); + }); + }); + + describe('REMOVE_PASSKEY / REMOVE_PASSKEY_FAILURE', () => { + it('clears removal failure state when remove starts', () => { + const state = { + ...INITIAL_STATE, + passkeyRemoval: { failure: true, errorMessage: 'boom' }, + }; + expect(reducer(state, actions.removePasskey('c1'))).toEqual({ + ...INITIAL_STATE, + passkeyRemoval: { failure: false }, + }); + }); + + it('stores failure with message', () => { + expect( + reducer(INITIAL_STATE, actions.removePasskeyFailure('c1', 'boom')) + ).toEqual({ + ...INITIAL_STATE, + passkeyRemoval: { failure: true, errorMessage: 'boom' }, + }); + }); + }); }); }); }); diff --git a/src/modules/auth/reducer.ts b/src/modules/auth/reducer.ts index 6d6052ef..d334199b 100644 --- a/src/modules/auth/reducer.ts +++ b/src/modules/auth/reducer.ts @@ -1,5 +1,5 @@ import * as actions from './actions'; -import { AuthAction } from './actions'; +import { AuthAction, Passkey } from './actions'; import reducer from '../../util/reducer'; interface GuestAuthState { @@ -7,6 +7,22 @@ interface GuestAuthState { failure: boolean; } +interface PasskeyRegistrationState { + submitting: boolean; + failure: boolean; + errorMessage?: string; +} + +interface PasskeyLoginState { + submitting: boolean; + failure: boolean; +} + +interface PasskeyRemovalState { + failure: boolean; + errorMessage?: string; +} + interface AuthState { initialized: boolean; authenticated: boolean; @@ -14,9 +30,17 @@ interface AuthState { failure: boolean; otpVerificationFailure?: boolean; guestAuthentication: GuestAuthState; + passkeyRegistration: PasskeyRegistrationState; + passkeyLogin: PasskeyLoginState; + passkeyRemoval: PasskeyRemovalState; + passkeys: Passkey[]; data?: unknown; } +const INITIAL_PASSKEY_REGISTRATION: PasskeyRegistrationState = { submitting: false, failure: false }; +const INITIAL_PASSKEY_LOGIN: PasskeyLoginState = { submitting: false, failure: false }; +const INITIAL_PASSKEY_REMOVAL: PasskeyRemovalState = { failure: false }; + const INITIAL_STATE: AuthState = { initialized: false, authenticated: false, @@ -26,7 +50,11 @@ const INITIAL_STATE: AuthState = { guestAuthentication: { submitting: false, failure: false - } + }, + passkeyRegistration: INITIAL_PASSKEY_REGISTRATION, + passkeyLogin: INITIAL_PASSKEY_LOGIN, + passkeyRemoval: INITIAL_PASSKEY_REMOVAL, + passkeys: [] }; const ACTION_HANDLERS = { @@ -85,7 +113,11 @@ const ACTION_HANDLERS = { failure: false, submitting: false, data: action.payload.authData, - guestAuthentication: INITIAL_STATE.guestAuthentication + guestAuthentication: INITIAL_STATE.guestAuthentication, + passkeyRegistration: INITIAL_PASSKEY_REGISTRATION, + passkeyLogin: INITIAL_PASSKEY_LOGIN, + passkeyRemoval: INITIAL_PASSKEY_REMOVAL, + passkeys: [] } as AuthState; } return INITIAL_STATE; @@ -97,6 +129,43 @@ const ACTION_HANDLERS = { otpVerificationFailure: true, }; }, + [actions.REGISTER_PASSKEY]: (state: AuthState) => ({ + ...state, + passkeyRegistration: { submitting: true, failure: false } + }), + [actions.REGISTER_PASSKEY_SUCCESS]: (state: AuthState) => ({ + ...state, + passkeyRegistration: { submitting: false, failure: false } + }), + [actions.REGISTER_PASSKEY_FAILURE]: (state: AuthState, action: AuthAction & { type: typeof actions.REGISTER_PASSKEY_FAILURE }) => ({ + ...state, + passkeyRegistration: { submitting: false, failure: true, errorMessage: action.payload.message } + }), + [actions.LOGIN_WITH_PASSKEY]: (state: AuthState) => ({ + ...state, + passkeyLogin: { submitting: true, failure: false } + }), + [actions.LOGIN_WITH_PASSKEY_FAILURE]: (state: AuthState) => ({ + ...state, + passkeyLogin: { submitting: false, failure: true } + }), + [actions.LOAD_PASSKEYS_SUCCESS]: (state: AuthState, action: AuthAction & { type: typeof actions.LOAD_PASSKEYS_SUCCESS }) => ({ + ...state, + passkeys: action.payload.passkeys + }), + [actions.REMOVE_PASSKEY]: (state: AuthState) => ({ + ...state, + passkeyRemoval: { failure: false } + }), + [actions.REMOVE_PASSKEY_SUCCESS]: (state: AuthState, action: AuthAction & { type: typeof actions.REMOVE_PASSKEY_SUCCESS }) => ({ + ...state, + passkeys: state.passkeys.filter(p => p.credentialId !== action.payload.credentialId), + passkeyRemoval: { failure: false } + }), + [actions.REMOVE_PASSKEY_FAILURE]: (state: AuthState, action: AuthAction & { type: typeof actions.REMOVE_PASSKEY_FAILURE }) => ({ + ...state, + passkeyRemoval: { failure: true, errorMessage: action.payload.message } + }), }; export type { AuthState }; diff --git a/src/modules/auth/sagas.spec.ts b/src/modules/auth/sagas.spec.ts index 85ea7dec..ab388a80 100644 --- a/src/modules/auth/sagas.spec.ts +++ b/src/modules/auth/sagas.spec.ts @@ -1,12 +1,18 @@ -import {call, put} from 'redux-saga/effects'; +import {call, put, select} from 'redux-saga/effects'; import * as actions from './actions'; import * as sagas from './sagas'; import {loadCredentialsToken, loadGuestToken, loadIpToken, loadKioskToken} from '../../util/auth'; import {expectDoneWithoutReturn, expectDoneWithReturn} from '../../../test/sagaUtils'; import firebase, {authenticate as fbAuth, requestSignInCode as fbRequestSignInCode, verifyOtpCode as fbVerifyOtpCode, unauth as fbUnauth, watchAuthState} from '../../util/firebase'; +import { + registerPasskey as fbRegisterPasskey, + authenticateWithPasskey as fbAuthenticateWithPasskey, + removePasskey as fbRemovePasskey, +} from '../../util/webauthn'; import {get} from 'firebase/database'; jest.mock('../../util/firebase'); +jest.mock('../../util/webauthn'); jest.mock('../../i18n', () => ({ language: 'de', })); @@ -572,6 +578,164 @@ describe('modules', () => { expectDoneWithoutReturn(generator); }); }); + + describe('doLoginWithPasskey', () => { + let setItemSpy: jest.SpyInstance; + + beforeEach(() => { + setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {}); + }); + + afterEach(() => { + setItemSpy.mockRestore(); + }); + + it('converges on requestFirebaseAuthentication like OTP flow', () => { + const action = actions.loginWithPasskey('user@example.com'); + const generator = sagas.doLoginWithPasskey(action); + + expect(generator.next().value).toEqual( + call(fbAuthenticateWithPasskey, 'user@example.com') + ); + + const token = 'ct-abc'; + expect(generator.next(token).value).toEqual( + put(actions.requestFirebaseAuthentication(token, actions.loginWithPasskeyFailure())) + ); + + expectDoneWithoutReturn(generator); + expect(setItemSpy).toHaveBeenCalledWith('isLocalSignIn', 'true'); + }); + + it('supports usernameless (no email) path', () => { + const action = actions.loginWithPasskey(); + const generator = sagas.doLoginWithPasskey(action); + + expect(generator.next().value).toEqual( + call(fbAuthenticateWithPasskey, undefined) + ); + }); + + it('puts failure action on exception', () => { + const action = actions.loginWithPasskey('user@example.com'); + const generator = sagas.doLoginWithPasskey(action); + + expect(generator.next().value).toEqual( + call(fbAuthenticateWithPasskey, 'user@example.com') + ); + + const error = new Error('verification failed'); + expect(generator.throw(error).value).toEqual(put(actions.loginWithPasskeyFailure())); + + expectDoneWithoutReturn(generator); + }); + }); + + describe('doRegisterPasskey', () => { + it('registers and reloads passkeys on success', () => { + const generator = sagas.doRegisterPasskey(); + + expect(generator.next().value).toEqual(call(fbRegisterPasskey)); + expect(generator.next({ credentialId: 'c', deviceName: 'Mac', createdAt: 1 }).value) + .toEqual(put(actions.registerPasskeySuccess())); + expect(generator.next().value).toEqual(put(actions.loadPasskeys())); + expectDoneWithoutReturn(generator); + }); + + it('puts failure action with message on exception', () => { + const generator = sagas.doRegisterPasskey(); + + expect(generator.next().value).toEqual(call(fbRegisterPasskey)); + + const error = new Error('boom'); + expect(generator.throw(error).value).toEqual( + put(actions.registerPasskeyFailure('boom')) + ); + + expectDoneWithoutReturn(generator); + }); + }); + + describe('doLoadPasskeys', () => { + it('loads credentials for current user', () => { + const generator = sagas.doLoadPasskeys(); + + expect(generator.next().value).toEqual(select(sagas.authDataSelector)); + + const authData = { uid: 'u1' }; + const mockRef = {}; + (firebase as jest.Mock).mockReturnValue(mockRef); + + expect(generator.next(authData).value).toEqual(call(get as any, mockRef)); + expect(firebase).toHaveBeenCalledWith('/webauthnCredentials/u1'); + + const snapshot = { + exists: () => true, + val: () => ({ + 'cid-1': { deviceName: 'Laptop', createdAt: 100, lastUsedAt: 200 }, + 'cid-2': { deviceName: 'Phone', createdAt: 300, lastUsedAt: null }, + }), + }; + expect(generator.next(snapshot).value).toEqual(put(actions.loadPasskeysSuccess([ + { credentialId: 'cid-1', deviceName: 'Laptop', createdAt: 100, lastUsedAt: 200 }, + { credentialId: 'cid-2', deviceName: 'Phone', createdAt: 300, lastUsedAt: null }, + ]))); + + expectDoneWithoutReturn(generator); + }); + + it('emits empty list when not authenticated', () => { + const generator = sagas.doLoadPasskeys(); + + expect(generator.next().value).toEqual(select(sagas.authDataSelector)); + expect(generator.next(null).value).toEqual(put(actions.loadPasskeysSuccess([]))); + + expectDoneWithoutReturn(generator); + }); + + it('emits empty list when snapshot has no children', () => { + const generator = sagas.doLoadPasskeys(); + + expect(generator.next().value).toEqual(select(sagas.authDataSelector)); + + const authData = { uid: 'u1' }; + const mockRef = {}; + (firebase as jest.Mock).mockReturnValue(mockRef); + + expect(generator.next(authData).value).toEqual(call(get as any, mockRef)); + + const snapshot = { exists: () => false, val: () => null }; + expect(generator.next(snapshot).value).toEqual(put(actions.loadPasskeysSuccess([]))); + + expectDoneWithoutReturn(generator); + }); + }); + + describe('doRemovePasskey', () => { + it('calls remove and dispatches success', () => { + const action = actions.removePasskey('cid-1'); + const generator = sagas.doRemovePasskey(action); + + expect(generator.next().value).toEqual(call(fbRemovePasskey, 'cid-1')); + expect(generator.next().value).toEqual(put(actions.removePasskeySuccess('cid-1'))); + + expectDoneWithoutReturn(generator); + }); + + it('dispatches failure with message on exception', () => { + const action = actions.removePasskey('cid-1'); + const generator = sagas.doRemovePasskey(action); + + expect(generator.next().value).toEqual(call(fbRemovePasskey, 'cid-1')); + + const error = new Error('boom'); + expect(generator.throw(error).value).toEqual( + put(actions.removePasskeyFailure('cid-1', 'boom')) + ); + + expectDoneWithoutReturn(generator); + }); + }); }); }); }); diff --git a/src/modules/auth/sagas.ts b/src/modules/auth/sagas.ts index c7f8500d..eae9218a 100644 --- a/src/modules/auth/sagas.ts +++ b/src/modules/auth/sagas.ts @@ -1,6 +1,7 @@ -import {all, call, fork, put, takeEvery} from 'redux-saga/effects' +import {all, call, fork, put, select, takeEvery} from 'redux-saga/effects' import {get, query, orderByChild, equalTo, limitToFirst} from 'firebase/database'; import * as actions from './actions'; +import {Passkey} from './actions'; import {loadCredentialsToken, loadGuestToken, loadIpToken, loadKioskToken} from '../../util/auth'; import createChannel from '../../util/createChannel'; import firebase, { @@ -10,6 +11,11 @@ import firebase, { unauth as fbUnauth, watchAuthState } from '../../util/firebase'; +import { + registerPasskey as fbRegisterPasskey, + authenticateWithPasskey as fbAuthenticateWithPasskey, + removePasskey as fbRemovePasskey, +} from '../../util/webauthn'; import {error as logError} from '../../util/log'; import {getKioskAuthQueryToken} from '../../util/getAuthQueryToken' import i18n from '../../i18n' @@ -161,6 +167,68 @@ export function* doFirebaseAuthentication(action: any) { } } +export const authDataSelector = (state: any) => state.auth && state.auth.data; + +export function* doLoginWithPasskey(action: any) { + try { + const email = action.payload && action.payload.email; + window.localStorage.setItem('isLocalSignIn', 'true'); + const token = yield call(fbAuthenticateWithPasskey, email); + yield put(actions.requestFirebaseAuthentication(token, actions.loginWithPasskeyFailure())); + } catch (e) { + logError('Passkey login failed', e); + yield put(actions.loginWithPasskeyFailure()); + } +} + +export function* doRegisterPasskey() { + try { + yield call(fbRegisterPasskey); + yield put(actions.registerPasskeySuccess()); + yield put(actions.loadPasskeys()); + } catch (e) { + logError('Passkey registration failed', e); + const message = e && (e as Error).message ? (e as Error).message : undefined; + yield put(actions.registerPasskeyFailure(message)); + } +} + +export function* doLoadPasskeys() { + try { + const authData = yield select(authDataSelector); + if (!authData || !authData.uid) { + yield put(actions.loadPasskeysSuccess([])); + return; + } + const snapshot = yield call(get, firebase(`/webauthnCredentials/${authData.uid}`)); + const value = snapshot.exists() ? snapshot.val() : null; + const passkeys: Passkey[] = value + ? Object.keys(value).map(credentialId => ({ + credentialId, + deviceName: value[credentialId].deviceName, + createdAt: value[credentialId].createdAt, + lastUsedAt: value[credentialId].lastUsedAt || null, + })) + : []; + yield put(actions.loadPasskeysSuccess(passkeys)); + } catch (e) { + logError('Failed to load passkeys', e); + yield put(actions.loadPasskeysSuccess([])); + } +} + +export function* doRemovePasskey(action: any) { + const { credentialId } = action.payload; + try { + yield call(fbRemovePasskey, credentialId); + yield put(actions.removePasskeySuccess(credentialId)); + } catch (e) { + logError('Failed to remove passkey', e); + const message = e && (e as Error).message ? (e as Error).message : undefined; + yield put(actions.removePasskeyFailure(credentialId, message)); + } +} + export function* doLogout() { yield call(fbUnauth); window.location.href = '/' @@ -250,6 +318,10 @@ export default function* sagas() { takeEvery(actions.REQUEST_FIREBASE_AUTHENTICATION, doFirebaseAuthentication), takeEvery(actions.LOGOUT, doLogout), takeEvery(actions.FIREBASE_AUTHENTICATION, doListenFirebaseAuthentication), + takeEvery(actions.LOGIN_WITH_PASSKEY, doLoginWithPasskey), + takeEvery(actions.REGISTER_PASSKEY, doRegisterPasskey), + takeEvery(actions.LOAD_PASSKEYS, doLoadPasskeys), + takeEvery(actions.REMOVE_PASSKEY, doRemovePasskey), fork(monitorFirebaseAuthentication, createFbAuthenticationChannel()), ]) } diff --git a/src/util/webauthn.spec.ts b/src/util/webauthn.spec.ts new file mode 100644 index 00000000..af7a9d66 --- /dev/null +++ b/src/util/webauthn.spec.ts @@ -0,0 +1,211 @@ +global.__FIREBASE_PROJECT_ID__ = 'test-project'; + +jest.mock('@simplewebauthn/browser', () => ({ + startRegistration: jest.fn(), + startAuthentication: jest.fn(), +})); + +jest.mock('./firebase', () => ({ + getIdToken: jest.fn(), +})); + +import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; +import { getIdToken } from './firebase'; +import { + registerPasskey, + authenticateWithPasskey, + removePasskey, + isPasskeySupported, +} from './webauthn'; + +describe('util/webauthn', () => { + let originalFetch; + + beforeEach(() => { + jest.clearAllMocks(); + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const okResponse = (body) => ({ + ok: true, + json: jest.fn().mockResolvedValue(body), + }); + + const errorResponse = (status, body) => ({ + ok: false, + status, + json: jest.fn().mockResolvedValue(body), + }); + + describe('registerPasskey', () => { + it('orchestrates options → ceremony → verify and returns summary', async () => { + (getIdToken as jest.Mock).mockResolvedValue('id-token-xyz'); + Object.defineProperty(global.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + configurable: true, + }); + + global.fetch = jest.fn() + .mockResolvedValueOnce(okResponse({ + options: { challenge: 'server-challenge' }, + challengeKey: 'ck1', + })) + .mockResolvedValueOnce(okResponse({ + success: true, + credentialId: 'cred-1', + deviceName: 'Mac', + createdAt: 1234567890, + })); + + (startRegistration as jest.Mock).mockResolvedValue({ + id: 'cred-1', + response: { transports: ['internal'] }, + }); + + const result = await registerPasskey(); + + expect(result).toEqual({ + credentialId: 'cred-1', + deviceName: 'Mac', + createdAt: 1234567890, + }); + + const firstCall = (global.fetch as jest.Mock).mock.calls[0]; + expect(firstCall[0]).toContain('generateWebauthnRegistrationOptions'); + expect(firstCall[1].headers.Authorization).toBe('Bearer id-token-xyz'); + + expect(startRegistration).toHaveBeenCalledWith({ + optionsJSON: { challenge: 'server-challenge' }, + }); + + const secondCall = (global.fetch as jest.Mock).mock.calls[1]; + expect(secondCall[0]).toContain('verifyWebauthnRegistration'); + const body = JSON.parse(secondCall[1].body); + expect(body).toEqual({ + challengeKey: 'ck1', + attestationResponse: { id: 'cred-1', response: { transports: ['internal'] } }, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }); + expect(secondCall[1].headers.Authorization).toBe('Bearer id-token-xyz'); + }); + + it('throws with server error message when verify fails', async () => { + (getIdToken as jest.Mock).mockResolvedValue('id-token-xyz'); + global.fetch = jest.fn() + .mockResolvedValueOnce(okResponse({ + options: { challenge: 'c' }, + challengeKey: 'k', + })) + .mockResolvedValueOnce(errorResponse(400, { error: 'Registration verification failed' })); + (startRegistration as jest.Mock).mockResolvedValue({ id: 'c1', response: {} }); + + await expect(registerPasskey()).rejects.toThrow('Registration verification failed'); + }); + + it('propagates browser API errors', async () => { + (getIdToken as jest.Mock).mockResolvedValue('id-token'); + global.fetch = jest.fn() + .mockResolvedValueOnce(okResponse({ options: {}, challengeKey: 'k' })); + (startRegistration as jest.Mock).mockRejectedValue(new Error('User cancelled')); + + await expect(registerPasskey()).rejects.toThrow('User cancelled'); + }); + }); + + describe('authenticateWithPasskey', () => { + it('returns token on success with email', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce(okResponse({ + options: { challenge: 'c' }, + challengeKey: 'k2', + })) + .mockResolvedValueOnce(okResponse({ token: 'custom-token-abc' })); + (startAuthentication as jest.Mock).mockResolvedValue({ id: 'cred-1' }); + + const result = await authenticateWithPasskey('user@example.com'); + + expect(result).toBe('custom-token-abc'); + const firstCall = (global.fetch as jest.Mock).mock.calls[0]; + expect(firstCall[0]).toContain('generateWebauthnAuthenticationOptions'); + expect(JSON.parse(firstCall[1].body)).toEqual({ email: 'user@example.com' }); + // No Authorization header on the public auth path + expect(firstCall[1].headers.Authorization).toBeUndefined(); + expect(startAuthentication).toHaveBeenCalledWith({ optionsJSON: { challenge: 'c' } }); + + const secondCall = (global.fetch as jest.Mock).mock.calls[1]; + const body = JSON.parse(secondCall[1].body); + expect(body).toEqual({ challengeKey: 'k2', assertionResponse: { id: 'cred-1' } }); + }); + + it('omits email from request body when not provided (usernameless)', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce(okResponse({ options: {}, challengeKey: 'k' })) + .mockResolvedValueOnce(okResponse({ token: 't' })); + (startAuthentication as jest.Mock).mockResolvedValue({ id: 'c' }); + + await authenticateWithPasskey(); + + const firstBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); + expect(firstBody).toEqual({}); + }); + + it('throws when server does not return a token', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce(okResponse({ options: {}, challengeKey: 'k' })) + .mockResolvedValueOnce(okResponse({})); + (startAuthentication as jest.Mock).mockResolvedValue({ id: 'c' }); + + await expect(authenticateWithPasskey()).rejects.toThrow('token'); + }); + + it('surfaces server error message on failure', async () => { + global.fetch = jest.fn() + .mockResolvedValueOnce(okResponse({ options: {}, challengeKey: 'k' })) + .mockResolvedValueOnce(errorResponse(400, { error: 'Authentication verification failed' })); + (startAuthentication as jest.Mock).mockResolvedValue({ id: 'c' }); + + await expect(authenticateWithPasskey()).rejects.toThrow('Authentication verification failed'); + }); + }); + + describe('removePasskey', () => { + it('posts credentialId with bearer token', async () => { + (getIdToken as jest.Mock).mockResolvedValue('id-token'); + global.fetch = jest.fn().mockResolvedValue(okResponse({ success: true })); + + await removePasskey('cred-xyz'); + + const call = (global.fetch as jest.Mock).mock.calls[0]; + expect(call[0]).toContain('removeWebauthnCredential'); + expect(call[1].headers.Authorization).toBe('Bearer id-token'); + expect(JSON.parse(call[1].body)).toEqual({ credentialId: 'cred-xyz' }); + }); + }); + + describe('isPasskeySupported', () => { + it('returns true when PublicKeyCredential is defined', () => { + (window as any).PublicKeyCredential = function() {}; + expect(isPasskeySupported()).toBe(true); + delete (window as any).PublicKeyCredential; + }); + + it('returns false when PublicKeyCredential is undefined', () => { + delete (window as any).PublicKeyCredential; + expect(isPasskeySupported()).toBe(false); + }); + }); + + it('does not import firebase/auth (decoupling guarantee)', () => { + // Read the source file and assert no firebase/auth import exists. + const fs = require('fs'); + const path = require('path'); + const src = fs.readFileSync(path.join(__dirname, 'webauthn.ts'), 'utf8'); + expect(src).not.toMatch(/from ['"]firebase\/auth['"]/); + expect(src).not.toMatch(/from ['"]firebase\/app['"]/); + expect(src).not.toMatch(/getAuth\(/); + }); +}); diff --git a/src/util/webauthn.ts b/src/util/webauthn.ts new file mode 100644 index 00000000..924b00fa --- /dev/null +++ b/src/util/webauthn.ts @@ -0,0 +1,102 @@ +import { + startRegistration, + startAuthentication, +} from '@simplewebauthn/browser'; +import { getIdToken } from './firebase'; + +export interface PasskeySummary { + credentialId: string; + deviceName: string; + createdAt: number; +} + +function endpoint(name: string): string { + return `https://europe-west1-${__FIREBASE_PROJECT_ID__}.cloudfunctions.net/${name}`; +} + +async function post( + name: string, + body: unknown, + authHeader?: string, +): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (authHeader) { + headers.Authorization = authHeader; + } + const response = await fetch(endpoint(name), { + method: 'POST', + headers, + body: JSON.stringify(body || {}), + }); + if (!response.ok) { + let message = `Request to ${name} failed`; + try { + const errorData = await response.json(); + if (errorData && errorData.error) { + message = errorData.error; + } + } catch { + // ignore JSON parse errors + } + throw new Error(message); + } + return response.json(); +} + +export async function registerPasskey(): Promise { + const idToken = await getIdToken(); + const authHeader = `Bearer ${idToken}`; + + const { options, challengeKey } = await post( + 'generateWebauthnRegistrationOptions', + {}, + authHeader, + ); + + const attestationResponse = await startRegistration({ optionsJSON: options }); + + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + const result = await post( + 'verifyWebauthnRegistration', + { challengeKey, attestationResponse, userAgent }, + authHeader, + ); + + return { + credentialId: result.credentialId, + deviceName: result.deviceName, + createdAt: result.createdAt, + }; +} + +export async function authenticateWithPasskey(email?: string): Promise { + const requestBody = email ? { email } : {}; + + const { options, challengeKey } = await post( + 'generateWebauthnAuthenticationOptions', + requestBody, + ); + + const assertionResponse = await startAuthentication({ optionsJSON: options }); + + const result = await post( + 'verifyWebauthnAuthentication', + { challengeKey, assertionResponse }, + ); + + if (!result || typeof result.token !== 'string') { + throw new Error('Passkey verification did not return a token'); + } + return result.token; +} + +export async function removePasskey(credentialId: string): Promise { + const idToken = await getIdToken(); + const authHeader = `Bearer ${idToken}`; + await post('removeWebauthnCredential', { credentialId }, authHeader); +} + +export function isPasskeySupported(): boolean { + return typeof window !== 'undefined' + && typeof (window as any).PublicKeyCredential !== 'undefined'; +}