mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
212 lines
7.5 KiB
JavaScript
212 lines
7.5 KiB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {registerHooks} from 'node:module';
|
|
import {describe, test} from 'node:test';
|
|
import {installElectronStub, installTestModuleStub} from './LocalAppTestSupport.test.mjs';
|
|
|
|
const PROTOCOL_STATE_KEY = '__fluxerPasskeyProtocolTestState__';
|
|
const WEBAUTHN_STUB_URL = 'file:///fluxer-webauthn-passkey-test-stub.cjs';
|
|
const CEREMONY_STATE_KEY = '__fluxerPasskeyCeremonyTestState__';
|
|
|
|
const protocolState = {activePlan: null, cachedPlans: new Map()};
|
|
const ceremonyState = {calls: []};
|
|
globalThis[PROTOCOL_STATE_KEY] = protocolState;
|
|
globalThis[CEREMONY_STATE_KEY] = ceremonyState;
|
|
|
|
installTestModuleStub(
|
|
'@electron/main/LocalAppProtocol',
|
|
`const state = globalThis[${JSON.stringify(PROTOCOL_STATE_KEY)}];
|
|
export const getDesktopLocalAppProtocol = () => ({
|
|
getActivePlan: () => state.activePlan,
|
|
findPlanForRoute: (key) => (state.activePlan?.instanceKey === key ? state.activePlan : (state.cachedPlans.get(key) ?? null)),
|
|
});`,
|
|
);
|
|
|
|
registerHooks({
|
|
resolve(specifier, context, nextResolve) {
|
|
if (specifier === '@fluxer/webauthn') {
|
|
return {shortCircuit: true, url: WEBAUTHN_STUB_URL};
|
|
}
|
|
return nextResolve(specifier, context);
|
|
},
|
|
load(url, context, nextLoad) {
|
|
if (url === WEBAUTHN_STUB_URL) {
|
|
return {
|
|
shortCircuit: true,
|
|
format: 'commonjs',
|
|
source: `const state = globalThis[${JSON.stringify(CEREMONY_STATE_KEY)}];
|
|
const credential = {
|
|
rawId: Buffer.from('credential-id'),
|
|
response: Buffer.from(JSON.stringify({clientDataJSON: 'client-data'})),
|
|
authenticatorAttachment: null,
|
|
};
|
|
module.exports = {
|
|
isSupported: async () => true,
|
|
get: async (options) => {
|
|
state.calls.push(options);
|
|
return credential;
|
|
},
|
|
create: async (options) => {
|
|
state.calls.push(options);
|
|
return credential;
|
|
},
|
|
};`,
|
|
};
|
|
}
|
|
return nextLoad(url, context);
|
|
},
|
|
});
|
|
|
|
const handlers = new Map();
|
|
installElectronStub({
|
|
ipcMain: {
|
|
handle: (channel, listener) => handlers.set(channel, listener),
|
|
removeHandler: (channel) => handlers.delete(channel),
|
|
},
|
|
BrowserWindow: {fromWebContents: () => null, getFocusedWindow: () => null},
|
|
});
|
|
|
|
const {registerPasskeyHandlers} = await import('./Passkeys.ts');
|
|
|
|
registerPasskeyHandlers();
|
|
|
|
const REQUEST_OPTIONS = {
|
|
challenge: 'Y2hhbGxlbmdl',
|
|
rpId: 'fluxer.app',
|
|
allowCredentials: [],
|
|
};
|
|
|
|
const REGISTRATION_OPTIONS = {
|
|
challenge: 'Y2hhbGxlbmdl',
|
|
rp: {id: 'fluxer.app', name: 'Fluxer'},
|
|
user: {id: 'dXNlcg', name: 'user', displayName: 'user'},
|
|
pubKeyCredParams: [{type: 'public-key', alg: -7}],
|
|
};
|
|
|
|
function plan(apiEndpoint, webAppEndpoint) {
|
|
return {
|
|
instanceKey: apiEndpoint,
|
|
document: {},
|
|
selfHosted: false,
|
|
endpoints: {
|
|
apiEndpoint,
|
|
apiPublicEndpoint: apiEndpoint,
|
|
webAppEndpoint,
|
|
mediaEndpoint: null,
|
|
staticCdnEndpoint: null,
|
|
uploadRelayEndpoint: null,
|
|
gatewayEndpoint: null,
|
|
inviteEndpoint: null,
|
|
giftEndpoint: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function localAppEvent() {
|
|
const frame = {url: 'fluxer-app://app/'};
|
|
return {senderFrame: frame, sender: {getURL: () => frame.url}};
|
|
}
|
|
|
|
async function authenticate(requestContext) {
|
|
ceremonyState.calls = [];
|
|
const handler = handlers.get('passkey-authenticate');
|
|
assert.ok(handler != null);
|
|
return await handler(localAppEvent(), REQUEST_OPTIONS, requestContext);
|
|
}
|
|
|
|
async function register(requestContext) {
|
|
ceremonyState.calls = [];
|
|
const handler = handlers.get('passkey-register');
|
|
assert.ok(handler != null);
|
|
return await handler(localAppEvent(), REGISTRATION_OPTIONS, requestContext);
|
|
}
|
|
|
|
describe('passkey ceremony origin', () => {
|
|
test('an instance may only sign a ceremony for its own registrable site', async () => {
|
|
protocolState.activePlan = plan('https://api.fluxer.app', 'https://web.fluxer.app');
|
|
await authenticate();
|
|
assert.equal(ceremonyState.calls[0]?.origin, 'https://web.fluxer.app');
|
|
|
|
protocolState.activePlan = plan('http://localhost:8088/api', 'http://localhost:8088');
|
|
await authenticate();
|
|
assert.equal(ceremonyState.calls[0]?.origin, 'http://localhost:8088');
|
|
});
|
|
|
|
test('an instance may not name another site as the ceremony origin', async () => {
|
|
protocolState.activePlan = plan('https://evil.example', 'https://web.fluxer.app');
|
|
await assert.rejects(authenticate(), /UntrustedPasskeyCeremonyOriginError/u);
|
|
assert.deepEqual(ceremonyState.calls, []);
|
|
});
|
|
|
|
test('a bare address or public suffix may not borrow another host', async () => {
|
|
protocolState.activePlan = plan('https://10.0.0.1', 'https://20.0.0.1');
|
|
await assert.rejects(authenticate(), /UntrustedPasskeyCeremonyOriginError/u);
|
|
|
|
protocolState.activePlan = plan('https://api.fluxer.app', 'https://web.fluxer.app.');
|
|
await assert.rejects(authenticate(), /UntrustedPasskeyCeremonyOriginError/u);
|
|
|
|
assert.deepEqual(ceremonyState.calls, []);
|
|
});
|
|
|
|
test('sharing a public suffix is not sharing a site', async () => {
|
|
for (const [api, webApp] of [
|
|
['https://api.evil.co.uk', 'https://bank.co.uk'],
|
|
['https://evil.github.io', 'https://victim.github.io'],
|
|
['https://evil.vercel.app', 'https://victim.vercel.app'],
|
|
['https://evil.pages.dev', 'https://victim.pages.dev'],
|
|
['https://evil.fluxer.app', 'https://web.fluxer.app'],
|
|
]) {
|
|
protocolState.activePlan = plan(api, webApp);
|
|
await assert.rejects(authenticate(), /UntrustedPasskeyCeremonyOriginError/u);
|
|
}
|
|
assert.deepEqual(ceremonyState.calls, []);
|
|
});
|
|
|
|
test('a ceremony without an active plan is refused', async () => {
|
|
protocolState.activePlan = null;
|
|
await assert.rejects(authenticate(), /requires a browser frame origin/u);
|
|
assert.deepEqual(ceremonyState.calls, []);
|
|
});
|
|
|
|
test('a signed-out ceremony uses the plan of the instance that issued the options', async () => {
|
|
protocolState.activePlan = null;
|
|
const official = plan('https://api.fluxer.app', 'https://web.fluxer.app');
|
|
protocolState.cachedPlans = new Map([[official.instanceKey, official]]);
|
|
await authenticate({instanceKey: official.instanceKey});
|
|
assert.equal(ceremonyState.calls[0]?.origin, 'https://web.fluxer.app');
|
|
await register({instanceKey: official.instanceKey, pin: '1234'});
|
|
assert.equal(ceremonyState.calls[0]?.origin, 'https://web.fluxer.app');
|
|
assert.equal(ceremonyState.calls[0]?.pin, '1234');
|
|
protocolState.cachedPlans = new Map();
|
|
});
|
|
|
|
test('the named instance wins over the active plan of another instance', async () => {
|
|
protocolState.activePlan = plan('http://localhost:8088/api', 'http://localhost:8088');
|
|
const official = plan('https://api.fluxer.app', 'https://web.fluxer.app');
|
|
protocolState.cachedPlans = new Map([[official.instanceKey, official]]);
|
|
await authenticate({instanceKey: official.instanceKey});
|
|
assert.equal(ceremonyState.calls[0]?.origin, 'https://web.fluxer.app');
|
|
protocolState.cachedPlans = new Map();
|
|
});
|
|
|
|
test('an unknown instance never falls back to the active plan', async () => {
|
|
protocolState.activePlan = plan('https://api.fluxer.app', 'https://web.fluxer.app');
|
|
protocolState.cachedPlans = new Map();
|
|
await assert.rejects(
|
|
authenticate({instanceKey: 'https://unknown.example/api'}),
|
|
/requires a browser frame origin/u,
|
|
);
|
|
assert.deepEqual(ceremonyState.calls, []);
|
|
});
|
|
|
|
test('a keyed plan is still held to its own site', async () => {
|
|
protocolState.activePlan = null;
|
|
const hostile = plan('https://evil.example', 'https://web.fluxer.app');
|
|
protocolState.cachedPlans = new Map([[hostile.instanceKey, hostile]]);
|
|
await assert.rejects(authenticate({instanceKey: hostile.instanceKey}), /UntrustedPasskeyCeremonyOriginError/u);
|
|
assert.deepEqual(ceremonyState.calls, []);
|
|
protocolState.cachedPlans = new Map();
|
|
});
|
|
});
|