diff --git a/core.ref b/core.ref
index 6b4d597..4139cc4 100644
--- a/core.ref
+++ b/core.ref
@@ -1 +1 @@
-a8f017b4781c4ffe28870a4e79db69434379c176
+7dfbc3d3b766fb5688903d1943d4050132ad064d
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 4f3c137..c202d0d 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -170,6 +170,18 @@
"quietNoPortal": "Das Portal ist erreichbar, sobald das VPN verbunden ist.",
"killSwitchNote": "Hinweis zum Kill-Switch",
"saveFailed": "Einstellung konnte nicht gespeichert werden: {{ error }}"
+ },
+ "signin": {
+ "title": "Mit Firmenkonto anmelden",
+ "desc": "Statt mit einem Einrichtungscode: mit deinem Konto bei",
+ "microsoft": "Mit Microsoft anmelden",
+ "user": "E-Mail oder Benutzername",
+ "password": "Passwort",
+ "totp": "Bestätigungscode",
+ "totpHint": "6 Ziffern aus deiner Authenticator-App",
+ "submit": "Anmelden",
+ "running": "Anmeldung läuft …",
+ "browser": "Bitte melde dich im Browser an. Danach geht es hier von selbst weiter."
}
},
"vault": {
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 7cb18b8..22f630f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -170,6 +170,18 @@
"quietNoPortal": "The portal is available once the VPN is connected.",
"killSwitchNote": "Kill switch note",
"saveFailed": "Setting could not be saved: {{ error }}"
+ },
+ "signin": {
+ "title": "Sign in with company account",
+ "desc": "Instead of a setup code: with your account on",
+ "microsoft": "Sign in with Microsoft",
+ "user": "Email or username",
+ "password": "Password",
+ "totp": "Verification code",
+ "totpHint": "6 digits from your authenticator app",
+ "submit": "Sign in",
+ "running": "Signing in …",
+ "browser": "Please sign in in your browser. The app continues on its own afterwards."
}
},
"vault": {
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 12d66ee..e0e2cfe 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -410,6 +410,32 @@
Server
+
+
+
Konfiguration importieren
diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js
index 7e4217c..2b373b5 100644
--- a/src/renderer/renderer.js
+++ b/src/renderer/renderer.js
@@ -3,7 +3,7 @@
* UI-Logik und State Management
*/
-const { tunnel, server, config, killSwitch, rdpAllow, autostart, logs, update, services, traffic, dns, shell, peer, permissions, onPortalUrl, portal, getVersion, getDeviceId, window: win, locale, policy: clientPolicy, notify: notifyApi, chat: chatApi, school: schoolApi, gatedrop: gatedropApi, vault: vaultApi } = window.gatecontrol;
+const { tunnel, server, config, killSwitch, rdpAllow, autostart, logs, update, services, traffic, dns, shell, peer, permissions, onPortalUrl, portal, getVersion, getDeviceId, window: win, locale, policy: clientPolicy, notify: notifyApi, chat: chatApi, school: schoolApi, gatedrop: gatedropApi, vault: vaultApi, signin: signinApi } = window.gatecontrol;
const { t } = window.gatecontrol.i18n;
// Aktive Berechtigungen (werden beim Connect geladen)
@@ -217,6 +217,9 @@ function navigateTo(page) {
// Vault: footer in the status bar, secrets leave the screen with the page
if (page === 'vault' && prev !== 'vault') vaultView?.show();
else if (prev === 'vault' && page !== 'vault') vaultView?.hide();
+ // Company sign-in: offered for the server in "Server-URL"; a waiting browser sign-in ends with the page
+ if (page === 'settings' && prev !== 'settings') lookupSignin({ keep: true });
+ else if (prev === 'settings' && page !== 'settings') cancelSignin();
}
// Navigation aus dem Main Process
@@ -609,6 +612,196 @@ function showServerStatus(message, type) {
}
}
+// ── Settings: Company sign-in ("Mit Firmenkonto anmelden") ──
+// GateControl ≥ 1.201.0: for the address in "Server-URL" the app asks the
+// server which sign-in methods it offers. Older servers (404) or no answer →
+// the card stays hidden. Password and Microsoft sign-in end with a setup code
+// that the main process redeems through the normal setup-code path; the page
+// never sees the code.
+const SIGNIN_LOOKUP_DELAY_MS = 500;
+let signinOffer = null; // { url, methods }
+let signinLookupUrl = '';
+let signinLookupId = 0;
+let signinLookupTimer = null;
+let signinBusy = null; // null | 'password' | 'browser'
+
+function signinServerUrl() {
+ const url = el.serverUrl.value.trim();
+ if (!url) return '';
+ return /^https?:\/\//i.test(url) ? url : `https://${url}`;
+}
+
+function signinHost(url) {
+ try { return new URL(url).host; } catch { return url; }
+}
+
+/** keep: do not ask again when the address did not change (opening Settings). */
+function lookupSignin({ keep = false } = {}) {
+ const url = signinServerUrl();
+ if (keep && url === signinLookupUrl) return;
+ if (signinBusy) return;
+ clearTimeout(signinLookupTimer);
+ const id = ++signinLookupId;
+ signinLookupUrl = url;
+ signinOffer = null;
+ $('#signin-card').hidden = true;
+ if (!url || !signinApi) return;
+ signinLookupTimer = setTimeout(async () => {
+ let methods = null;
+ try { methods = await signinApi.methods(url); } catch { methods = null; }
+ if (id !== signinLookupId) return; // the address changed meanwhile
+ if (methods && methods.available) {
+ signinOffer = { url, methods };
+ renderSignin();
+ }
+ }, keep ? 0 : SIGNIN_LOOKUP_DELAY_MS);
+}
+
+function cancelSignin() {
+ if (signinBusy === 'browser' && signinApi) signinApi.cancel().catch(() => {});
+}
+
+function renderSignin() {
+ const card = $('#signin-card');
+ if (!signinOffer || policyState.locks?.server) {
+ card.hidden = true;
+ return;
+ }
+ const { url, methods } = signinOffer;
+ $('#signin-host').textContent = signinHost(url);
+
+ const ms = $('#signin-ms');
+ ms.textContent = '';
+ methods.microsoft.forEach((m) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'btn btn-sec';
+ btn.dataset.id = String(m.id);
+ const label = document.createElement('span');
+ label.dataset.i18n = 'ui.signin.microsoft';
+ label.textContent = t('ui.signin.microsoft');
+ btn.appendChild(label);
+ if (m.label) {
+ const sub = document.createElement('span');
+ sub.className = 'muted';
+ sub.textContent = `· ${m.label}`;
+ btn.appendChild(sub);
+ }
+ btn.addEventListener('click', () => signinMicrosoft(m.id));
+ ms.appendChild(btn);
+ });
+ ms.hidden = methods.microsoft.length === 0;
+
+ const pw = methods.password.enabled;
+ $('#signin-user-field').hidden = !pw;
+ $('#signin-pass-field').hidden = !pw;
+ $('#btn-signin').hidden = !pw;
+ const hint = $('#signin-hint');
+ hint.textContent = methods.password.hint || '';
+ hint.hidden = !methods.password.hint;
+ $('#signin-totp-field').hidden = true;
+ $('#signin-totp').value = '';
+ $('#signin-status').hidden = true;
+ card.hidden = false;
+ setSigninBusy(null);
+}
+
+function updateSigninSubmit() {
+ const totpOn = !$('#signin-totp-field').hidden;
+ const ok = $('#signin-user').value.trim() && $('#signin-pass').value
+ && (!totpOn || /^\d{6}$/.test($('#signin-totp').value.replace(/\s/g, '')));
+ $('#btn-signin').disabled = !!signinBusy || !ok;
+}
+
+function setSigninBusy(mode) {
+ signinBusy = mode;
+ $$('#signin-ms .btn').forEach((b) => { b.disabled = !!mode; });
+ ['#signin-user', '#signin-pass', '#signin-totp'].forEach((sel) => { $(sel).disabled = !!mode; });
+ $('#btn-signin-cancel').hidden = mode !== 'browser';
+ updateSigninSubmit();
+}
+
+function showSigninStatus(message, type) {
+ const status = $('#signin-status');
+ status.hidden = false;
+ status.textContent = message;
+ status.className = `field-status ${type}`;
+}
+
+function signinDone(result) {
+ if (result.success) {
+ const url = signinOffer.url;
+ el.serverUrl.value = url;
+ el.apiKey.value = '';
+ view.serverUrl = url;
+ renderOverviewHeader();
+ updateSideSub();
+ $('#signin-pass').value = '';
+ $('#signin-totp').value = '';
+ $('#signin-totp-field').hidden = true;
+ updateSigninSubmit();
+ showSigninStatus(t('server.enrolled', { peerId: result.peerId }), 'success');
+ return;
+ }
+ if (result.cancelled) {
+ $('#signin-status').hidden = true;
+ return;
+ }
+ if (result.totpRequired) {
+ const field = $('#signin-totp-field');
+ const first = field.hidden;
+ field.hidden = false;
+ $('#signin-totp').value = '';
+ updateSigninSubmit();
+ $('#signin-totp').focus();
+ showSigninStatus(result.error, first && result.code === 'totp_required' ? 'info' : 'error');
+ return;
+ }
+ showSigninStatus(result.error || t('server.signin.failedCode', { error: 'unknown' }), 'error');
+}
+
+async function signinPassword() {
+ if (signinBusy || !signinOffer || $('#btn-signin').disabled) return;
+ const totpOn = !$('#signin-totp-field').hidden;
+ showSigninStatus(t('ui.signin.running'), 'info');
+ setSigninBusy('password');
+ let result;
+ try {
+ result = await signinApi.password({
+ url: signinOffer.url,
+ username: $('#signin-user').value.trim(),
+ password: $('#signin-pass').value,
+ totp: totpOn ? $('#signin-totp').value.replace(/\s/g, '') : undefined,
+ });
+ } catch (err) {
+ result = { success: false, error: err.message };
+ }
+ setSigninBusy(null);
+ signinDone(result);
+}
+
+async function signinMicrosoft(id) {
+ if (signinBusy || !signinOffer) return;
+ showSigninStatus(t('ui.signin.browser'), 'info');
+ setSigninBusy('browser');
+ let result;
+ try {
+ result = await signinApi.microsoft({ url: signinOffer.url, id });
+ } catch (err) {
+ result = { success: false, error: err.message };
+ }
+ setSigninBusy(null);
+ signinDone(result);
+}
+
+el.serverUrl.addEventListener('input', () => lookupSignin());
+$('#btn-signin').addEventListener('click', signinPassword);
+$('#btn-signin-cancel').addEventListener('click', cancelSignin);
+['#signin-user', '#signin-pass', '#signin-totp'].forEach((sel) => {
+ $(sel).addEventListener('input', updateSigninSubmit);
+ $(sel).addEventListener('keydown', (e) => { if (e.key === 'Enter') signinPassword(); });
+});
+
// ── Settings: Config Import ──────────────────────────────
$('#btn-import-file').addEventListener('click', async () => {
const result = await config.importFile();
@@ -1584,6 +1777,8 @@ function applyPolicyUi() {
const importCard = $('#btn-import-file')?.closest('section');
if (serverCard) serverCard.hidden = !!l.server;
if (importCard) importCard.hidden = !!l.server;
+ if (l.server) cancelSignin();
+ $('#signin-card').hidden = !!l.server || !signinOffer;
const serverHint = $('#policy-server-hint');
if (serverHint) {
serverHint.hidden = !l.server;
diff --git a/test/e2e/signin.spec.js b/test/e2e/signin.spec.js
new file mode 100644
index 0000000..877b572
--- /dev/null
+++ b/test/e2e/signin.spec.js
@@ -0,0 +1,166 @@
+'use strict';
+
+// "Mit Firmenkonto anmelden" in Settings → Connection (core signin:*,
+// GateControl ≥ 1.201.0): the card appears only when the server in
+// "Server-URL" offers it; password (+ 2FA) and Microsoft (browser + loopback +
+// handoff with PKCE) both end with a setup code that the app redeems at
+// /api/v1/client/enroll like a typed code.
+
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const { test, expect } = require('./support/fixtures');
+const { openSettingsTab } = require('./support/ui');
+
+const SECRET = 'Sommer-2026!';
+
+function get(url) {
+ return new Promise((resolve, reject) => {
+ http.get(url, { agent: false }, (res) => {
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => resolve({ status: res.statusCode, body }));
+ }).on('error', reject);
+ });
+}
+
+function enrollCodes(mock) {
+ return mock.requests.filter((r) => r.path === '/api/v1/client/enroll').map((r) => JSON.parse(r.raw.toString('utf8')).code);
+}
+
+/**
+ * None of the files the app writes itself (its logs, the e2e events, the
+ * stores in userData, its console output) contains the secrets. Chromium's
+ * own profile files stay out: Windows keeps them locked while the app runs.
+ */
+function assertNotWritten(dir, secrets) {
+ const files = [];
+ const walk = (d) => {
+ if (!fs.existsSync(d)) return;
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
+ const p = path.join(d, e.name);
+ if (e.isDirectory()) walk(p);
+ else if (e.isFile()) files.push(p);
+ }
+ };
+ walk(path.join(dir, 'logs'));
+ walk(path.join(dir, 'userData', 'logs')); // electron-log (main.log)
+ for (const name of ['events.jsonl', 'main-process.log']) {
+ if (fs.existsSync(path.join(dir, name))) files.push(path.join(dir, name));
+ }
+ const userData = path.join(dir, 'userData');
+ if (fs.existsSync(userData)) {
+ for (const e of fs.readdirSync(userData, { withFileTypes: true })) {
+ if (e.isFile() && e.name.endsWith('.json')) files.push(path.join(userData, e.name));
+ }
+ }
+ expect(files.some((f) => f.endsWith('.log') && fs.readFileSync(f, 'utf8').includes('Company sign-in')), 'the app log was checked').toBe(true);
+ for (const file of files) {
+ const text = fs.readFileSync(file).toString('latin1');
+ for (const s of secrets) expect(text.includes(s), `${s} in ${file}`).toBe(false);
+ }
+}
+
+async function typeServer(page, mock) {
+ await openSettingsTab(page, 'conn');
+ await page.locator('#server-url').fill(mock.url);
+}
+
+test.describe('company sign-in', () => {
+ test('older server (404): no sign-in card', async ({ launchApp, mock }) => {
+ const { page } = await launchApp();
+ await typeServer(page, mock);
+ await expect.poll(() => mock.count('/api/v1/client/signin')).toBeGreaterThanOrEqual(1);
+ await page.waitForTimeout(500);
+ await expect(page.locator('#signin-card')).toBeHidden();
+ });
+
+ test('password with 2FA sets the device up through the setup-code path', async ({ launchApp, mock }) => {
+ const signin = mock.setSignin({ totp: '246810' });
+ const { page, dir } = await launchApp();
+ await typeServer(page, mock);
+
+ await expect(page.locator('#signin-card')).toBeVisible();
+ await expect(page.locator('#signin-hint')).toHaveText('Firmenkonto (corp.local): mit dem Windows-Passwort anmelden');
+ await expect(page.locator('#signin-ms .btn')).toHaveCount(1);
+ await expect(page.locator('#signin-ms .btn')).toContainText('Contoso');
+
+ await page.locator('#signin-user').fill('anna@corp.local');
+ await page.locator('#signin-pass').fill('falsch');
+ await page.locator('#btn-signin').click();
+ await expect(page.locator('#signin-status')).toHaveClass(/error/);
+
+ await page.locator('#signin-pass').fill(SECRET);
+ await page.locator('#btn-signin').click();
+ await expect(page.locator('#signin-totp-field')).toBeVisible();
+ await expect(page.locator('#btn-signin')).toBeDisabled();
+ await page.locator('#signin-totp').fill('246810');
+ await page.locator('#signin-totp').press('Enter');
+
+ await expect(page.locator('#signin-status')).toHaveClass(/success/);
+ expect(signin.issued).toHaveLength(1);
+ expect(enrollCodes(mock)).toEqual(signin.issued);
+ expect(signin.attempts.map((a) => a.totp)).toEqual([null, null, '246810']);
+ expect(await page.locator('#signin-pass').inputValue()).toBe('');
+ // The same as after "Save & register": token stored for the server.
+ expect(mock.count('/api/v1/client/register')).toBe(0);
+ assertNotWritten(dir, [SECRET, '246810', signin.issued[0]]);
+ });
+
+ test('Mit Microsoft anmelden: default browser, loopback answer, handoff', async ({ launchApp, mock }) => {
+ const signin = mock.setSignin({ password: { enabled: false, hint: null }, microsoft: [{ id: 7, label: 'Contoso' }, { id: 9, label: 'Fabrikam' }] });
+ const { page, events, dir } = await launchApp();
+ await typeServer(page, mock);
+ await expect(page.locator('#signin-card')).toBeVisible();
+ await expect(page.locator('#signin-user-field')).toBeHidden();
+ await expect(page.locator('#btn-signin')).toBeHidden();
+ await expect(page.locator('#signin-ms .btn')).toHaveCount(2);
+
+ await page.locator('#signin-ms .btn').nth(1).click();
+ await expect(page.locator('#btn-signin-cancel')).toBeVisible();
+ const opened = () => events().filter((e) => e.type === 'shell.openExternal').map((e) => e.url);
+ await expect.poll(() => opened().length).toBe(1);
+ const start = new URL(opened()[0]);
+ expect(`${start.origin}${start.pathname}`).toBe(`${mock.url}/auth/oidc/9/start`);
+ const q = Object.fromEntries(start.searchParams);
+ expect(q.client).toBe('app');
+ expect(q.redirect_uri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/signin$/);
+
+ const handoff = signin.issueHandoff(q.challenge);
+ const back = new URL(q.redirect_uri);
+ back.searchParams.set('handoff', handoff);
+ back.searchParams.set('state', q.state);
+ const res = await get(back.toString());
+ expect(res.status).toBe(200);
+ expect(res.body).toContain('Anmeldung abgeschlossen – du kannst dieses Fenster schließen.');
+
+ await expect(page.locator('#signin-status')).toHaveClass(/success/);
+ expect(enrollCodes(mock)).toEqual(signin.issued);
+ await expect(get(back.toString())).rejects.toThrow();
+ assertNotWritten(dir, [handoff, signin.issued[0]]);
+ });
+
+ test('Microsoft: a foreign state is refused, cancel ends the wait', async ({ launchApp, mock }) => {
+ mock.setSignin();
+ const { page, events } = await launchApp();
+ await typeServer(page, mock);
+ await expect(page.locator('#signin-card')).toBeVisible();
+
+ const opened = () => events().filter((e) => e.type === 'shell.openExternal').map((e) => e.url);
+ await page.locator('#signin-ms .btn').first().click();
+ await expect.poll(() => opened().length).toBe(1);
+ const q = Object.fromEntries(new URL(opened()[0]).searchParams);
+ expect((await get(`${q.redirect_uri}?handoff=abc&state=not-the-state`)).status).toBe(400);
+ await expect(page.locator('#signin-status')).toHaveClass(/error/);
+ expect(mock.count('/api/v1/client/signin/handoff')).toBe(0);
+
+ await page.locator('#signin-ms .btn').first().click();
+ await expect.poll(() => opened().length).toBe(2);
+ await page.locator('#btn-signin-cancel').click();
+ await expect(page.locator('#btn-signin-cancel')).toBeHidden();
+ await expect(page.locator('#signin-status')).toBeHidden();
+ const q2 = Object.fromEntries(new URL(opened()[1]).searchParams);
+ await expect(get(`${q2.redirect_uri}?handoff=abc&state=${q2.state}`)).rejects.toThrow();
+ });
+});
diff --git a/test/e2e/support/mock-server.js b/test/e2e/support/mock-server.js
index 5636cc5..b69371e 100644
--- a/test/e2e/support/mock-server.js
+++ b/test/e2e/support/mock-server.js
@@ -31,6 +31,11 @@
* /nominatim/search, /nominatim/reverse canned addresses (`setGeocode()`)
* — the app's MapService points there in e2e mode (GC_E2E_MAP_URL).
*
+ * Company sign-in: without `server.setSignin()` /api/v1/client/signin*
+ * answers 404 like a server before GateControl 1.201.0. With it,
+ * support/signin-mock.js answers (methods, password with optional 2FA,
+ * handoff with PKCE check).
+ *
* The update offer is configurable per test (`server.setUpdate(...)`), all
* requests are recorded (`server.requests`).
*/
@@ -41,6 +46,7 @@ const selfsigned = require('selfsigned');
const { createMessenger } = require('./messenger-mock');
const { createSchool } = require('./school-mock');
const { createVault } = require('./vault-mock');
+const { createSignin } = require('./signin-mock');
const API_TOKEN = 'gc_e2e_test_token';
// 1 × 1 PNG, served for every map tile
@@ -104,6 +110,7 @@ async function startMockServer() {
let messenger = null; // support/messenger-mock.js, null = no Messenger plugin
let school = null; // support/school-mock.js, null = no Schule plugin
let vault = null; // support/vault-mock.js, null = no Vault plugin
+ let signin = null; // support/signin-mock.js, null = server before company sign-in (404)
const acks = [];
const prefs = [];
let geocode = {
@@ -217,6 +224,12 @@ async function startMockServer() {
return json(404, { ok: false, error: 'not_found' });
}
+ // Company sign-in: public like enroll, 404 on servers without it.
+ if (route === 'signin' || route.startsWith('signin/')) {
+ if (signin && signin.handle({ method: req.method, route, raw }, json)) return undefined;
+ return json(404, { ok: false, error: 'not_found' });
+ }
+
if (route === 'enroll') {
return json(200, { ok: true, token: API_TOKEN, peerId: PEER_ID, config: null });
}
@@ -336,6 +349,11 @@ async function startMockServer() {
/** Canned Nominatim answers: { search: [results], reverse: result }. */
setGeocode(g) { geocode = { ...geocode, ...g }; },
pushPrefs: () => prefs.slice(),
+ /** Enables company sign-in (support/signin-mock.js); returns its state (issued codes, issueHandoff). */
+ setSignin(opts = {}) {
+ signin = createSignin(opts);
+ return signin;
+ },
count(pathname) { return requests.filter((r) => r.path === pathname).length; },
close: () => new Promise((resolve) => {
for (const res of streams) res.end();
diff --git a/test/e2e/support/signin-mock.js b/test/e2e/support/signin-mock.js
new file mode 100644
index 0000000..ad9468a
--- /dev/null
+++ b/test/e2e/support/signin-mock.js
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Company sign-in of the mock server ("Mit Firmenkonto anmelden",
+ * GateControl ≥ 1.201.0):
+ *
+ * GET /api/v1/client/signin → offered methods
+ * POST /api/v1/client/signin/password → { username, password, totp? } → { ok, code }
+ * POST /api/v1/client/signin/handoff → { handoff, verifier } → { ok, code }
+ *
+ * The browser part of "Mit Microsoft anmelden" is played by the test:
+ * issueHandoff(challenge) stands for the server after a Microsoft sign-in,
+ * the test then calls the app's loopback redirect URI itself. Every issued
+ * setup code is listed in `issued` (the app redeems it at /enroll).
+ */
+
+const crypto = require('crypto');
+
+function createSignin({
+ password = { enabled: true, hint: 'Firmenkonto (corp.local): mit dem Windows-Passwort anmelden' },
+ microsoft = [{ id: 7, label: 'Contoso' }],
+ user = 'anna@corp.local',
+ secret = 'Sommer-2026!',
+ totp = null,
+} = {}) {
+ const issued = [];
+ const handoffs = new Map(); // handoff → challenge
+ const attempts = [];
+
+ const newCode = () => {
+ const hex = crypto.randomBytes(8).toString('hex').toUpperCase();
+ const code = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12)}`;
+ issued.push(code);
+ return { ok: true, code, expiresAt: new Date(Date.now() + 600000).toISOString() };
+ };
+
+ function handle({ method, route, raw }, json) {
+ if (route === 'signin' && method === 'GET') {
+ json(200, { ok: true, version: 1, password, microsoft });
+ return true;
+ }
+ const fail = (status, error) => { json(status, { ok: false, error }); return true; };
+ let body = {};
+ try { body = JSON.parse(raw.toString('utf8') || '{}'); } catch { /* empty */ }
+ if (route === 'signin/password' && method === 'POST') {
+ attempts.push({ username: body.username, totp: body.totp || null, hostname: body.hostname, platform: body.platform });
+ if (body.username !== user || body.password !== secret) return fail(401, 'invalid_credentials');
+ if (totp && !body.totp) return fail(401, 'totp_required');
+ if (totp && body.totp !== totp) return fail(401, 'totp_invalid');
+ json(200, newCode());
+ return true;
+ }
+ if (route === 'signin/handoff' && method === 'POST') {
+ const challenge = handoffs.get(body.handoff);
+ handoffs.delete(body.handoff);
+ const sent = typeof body.verifier === 'string' ? crypto.createHash('sha256').update(body.verifier).digest('base64url') : null;
+ if (!challenge || sent !== challenge) return fail(400, 'invalid_or_expired');
+ json(200, newCode());
+ return true;
+ }
+ return false;
+ }
+
+ return {
+ handle,
+ issued,
+ attempts,
+ /** One-time handoff bound to the challenge (what the server redirects the browser with). */
+ issueHandoff(challenge) {
+ const handoff = crypto.randomBytes(24).toString('base64url');
+ handoffs.set(handoff, challenge);
+ return handoff;
+ },
+ };
+}
+
+module.exports = { createSignin };