Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions firebase-rules-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions functions/auth/cleanupExpiredWebauthnChallenges.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
95 changes: 95 additions & 0 deletions functions/auth/cleanupExpiredWebauthnChallenges.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
84 changes: 84 additions & 0 deletions functions/auth/generateAuthenticationOptions.js
Original file line number Diff line number Diff line change
@@ -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' });
}
});
});
144 changes: 144 additions & 0 deletions functions/auth/generateAuthenticationOptions.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading