detached 0ab5a9d

feat(web): sign in with a passkey and manage passkeys on the web

commit
0ab5a9deb6f2e939b5b2eadfb786a111b469a63b
author
brice · · 2026-10-12 15:16 UTC
parent
0818b8d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
45 files · +1949 -144
M internal/adapters/web/ASSETS.md +1 -0
@@ -10,6 +10,7 @@ content-hashed names. Nothing is fetched at runtime.
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
1212 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
13+| `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
1314 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1415 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
1516 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
@@ -10,6 +10,7 @@ content-hashed names. Nothing is fetched at runtime.
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |12 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
13+| `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
13 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |14 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
14 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |15 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
15 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |16 | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` |
M internal/adapters/web/assets.go +2 -1
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
2323 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2424 // the content, so they can be cached for ever.
2525 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Icon string
26+ CSS, HTMX, Live, SSE, App, Files, Passkey, Icon string
2727 // Font is the UI font, preloaded by every page.
2828 Font string
2929 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
5656 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
5757 {"ermite.js", func(a *Assets, u string) { a.App = u }},
5858 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
5960 }
6061
6162 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
23 // Assets are the URLs of the assets a page loads. Their names carry a hash of23 // Assets are the URLs of the assets a page loads. Their names carry a hash of
24 // the content, so they can be cached for ever.24 // the content, so they can be cached for ever.
25 type Assets struct {25 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Icon string26+ CSS, HTMX, Live, SSE, App, Files, Passkey, Icon string
27 // Font is the UI font, preloaded by every page.27 // Font is the UI font, preloaded by every page.
28 Font string28 Font string
29 // Rules is the speculation rules file named by the Speculation-Rules header.29 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},56 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
57 {"ermite.js", func(a *Assets, u string) { a.App = u }},57 {"ermite.js", func(a *Assets, u string) { a.App = u }},
58 {"files.js", func(a *Assets, u string) { a.Files = u }},58 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
59 }60 }
60 61
61 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):62 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
A internal/adapters/web/jstest/passkey.test.mjs +215 -0
@@ -0,0 +1,215 @@
1+// Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and
2+// navigator.credentials stubbed. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test, beforeEach } from 'node:test';
6+import { dom, el, installDom, settle } from './dom.mjs';
7+
8+const doc = installDom({ parseHTML: () => [] });
9+doc.addEventListener = () => { dom.listened = true; };
10+// Node defines navigator as a getter: replace it, as a browser test would.
11+const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
12+setNavigator({ credentials: {} });
13+globalThis.PublicKeyCredential = undefined;
14+await import('../static/js/passkey.js');
15+const P = window.ermitePasskey;
16+
17+const OPTIONS = { publicKey: { challenge: 'abc' } };
18+const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
19+
20+let fetches; // {url, init}
21+let answers; // url -> {ok, status, body}
22+let parsed; // [kind, publicKey]
23+let nav; // where the page was sent: 'assign:/' or 'reload'
24+let status;
25+let name;
26+
27+function page(kind) {
28+ dom.body = el('body');
29+ status = el('p', { attrs: { 'data-passkey-status': '' } });
30+ name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
31+ const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });
32+ dom.body.append(status, name, btn);
33+ return btn;
34+}
35+
36+beforeEach(() => {
37+ fetches = [];
38+ parsed = [];
39+ nav = '';
40+ answers = {
41+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
42+ '/finish': { ok: true, status: 204, body: null },
43+ };
44+ globalThis.fetch = async (url, init) => {
45+ fetches.push({ url, init });
46+ const a = answers[url];
47+ return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } };
48+ };
49+ globalThis.PublicKeyCredential = {
50+ parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
51+ parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; },
52+ };
53+ setNavigator({
54+ credentials: {
55+ get: async (o) => { navigator.got = o; return CRED; },
56+ create: async (o) => { navigator.created = o; return CRED; },
57+ },
58+ });
59+ globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
60+});
61+
62+const clickOn = (target) => P.click({ target, preventDefault() {} });
63+
64+test('login: begin, get, finish with toJSON(), then home', async () => {
65+ const btn = page('login');
66+ await clickOn(btn);
67+
68+ assert.equal(fetches.length, 2);
69+ assert.equal(fetches[0].url, '/begin');
70+ assert.equal(fetches[0].init.method, 'POST');
71+ assert.equal(fetches[0].init.credentials, 'same-origin');
72+ assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
73+ assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
74+
75+ assert.equal(fetches[1].url, '/finish');
76+ assert.equal(fetches[1].init.method, 'POST');
77+ assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
78+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
79+ assert.equal(nav, 'assign:/');
80+ assert.equal(status.textContent, '');
81+ assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
82+});
83+
84+test('register: create with the trimmed name, then reload', async () => {
85+ const btn = page('register');
86+ await clickOn(btn);
87+
88+ assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
89+ assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
90+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
91+ assert.equal(nav, 'reload');
92+});
93+
94+test('a click inside the button reaches it, other clicks do nothing', async () => {
95+ const btn = page('login');
96+ const inner = el('span');
97+ btn.append(inner);
98+ await clickOn(inner);
99+ assert.equal(fetches.length, 2);
100+
101+ fetches = [];
102+ await clickOn(el('div'));
103+ assert.equal(fetches.length, 0);
104+});
105+
106+test('begin refused: the server reason is shown, nothing else is called', async () => {
107+ const btn = page('login');
108+ answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
109+ await clickOn(btn);
110+ assert.equal(status.textContent, 'too many attempts');
111+ assert.equal(fetches.length, 1);
112+ assert.equal(nav, '');
113+ assert.equal(btn.disabled, false, 'the button is usable again');
114+});
115+
116+test('finish refused: the reason is shown and the page stays', async () => {
117+ const btn = page('login');
118+ answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
119+ await clickOn(btn);
120+ assert.equal(status.textContent, 'passkey not accepted');
121+ assert.equal(nav, '');
122+ assert.equal(btn.disabled, false);
123+});
124+
125+test('a refusal without a JSON body falls back to the status', async () => {
126+ const btn = page('register');
127+ answers['/finish'] = { ok: false, status: 502, body: null };
128+ await clickOn(btn);
129+ assert.equal(status.textContent, 'the server answered 502');
130+ assert.equal(nav, '');
131+});
132+
133+test('the owner cancels the prompt', async () => {
134+ const btn = page('login');
135+ navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
136+ await clickOn(btn);
137+ assert.match(status.textContent, /cancelled, or it took too long/);
138+ assert.equal(fetches.length, 1, 'finish is not called');
139+ assert.equal(btn.disabled, false);
140+});
141+
142+test('an authenticator that already holds a passkey', async () => {
143+ const btn = page('register');
144+ navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
145+ await clickOn(btn);
146+ assert.match(status.textContent, /already holds a passkey/);
147+ assert.equal(fetches.length, 1);
148+});
149+
150+test('a network failure is reported', async () => {
151+ const btn = page('login');
152+ globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
153+ await clickOn(btn);
154+ assert.equal(status.textContent, 'Failed to fetch');
155+ assert.equal(btn.disabled, false);
156+});
157+
158+test('a null credential is not sent', async () => {
159+ const btn = page('login');
160+ navigator.credentials.get = async () => null;
161+ await clickOn(btn);
162+ assert.equal(status.textContent, 'no passkey was returned');
163+ assert.equal(fetches.length, 1);
164+});
165+
166+test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
167+ const login = page('login');
168+ const register = el('button', { attrs: { 'data-passkey': 'register' } });
169+ dom.body.append(register);
170+ globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON
171+ P.init();
172+ assert.equal(login.disabled, true);
173+ assert.equal(register.disabled, true);
174+ assert.equal(status.textContent, 'this browser does not support passkeys');
175+
176+ login.disabled = false;
177+ await clickOn(login);
178+ assert.equal(fetches.length, 0);
179+ assert.equal(status.textContent, 'this browser does not support passkeys');
180+});
181+
182+test('no PublicKeyCredential at all', async () => {
183+ const btn = page('login');
184+ globalThis.PublicKeyCredential = undefined;
185+ P.init();
186+ assert.equal(btn.disabled, true);
187+ assert.equal(status.textContent, 'this browser does not support passkeys');
188+});
189+
190+test('a supported browser keeps the buttons enabled', () => {
191+ const btn = page('login');
192+ P.init();
193+ assert.equal(btn.disabled, undefined);
194+ assert.equal(status.textContent, '');
195+});
196+
197+test('a disabled button ignores clicks', async () => {
198+ const btn = page('login');
199+ btn.disabled = true;
200+ await clickOn(btn);
201+ assert.equal(fetches.length, 0);
202+});
203+
204+test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => {
205+ const btn = page('register');
206+ delete PublicKeyCredential.parseCreationOptionsFromJSON;
207+ await clickOn(btn);
208+ assert.equal(fetches.length, 0);
209+ assert.equal(status.textContent, 'this browser does not support passkeys');
210+});
211+
212+test('the script registers one delegated click listener', () => {
213+ assert.equal(dom.listened, true);
214+ void settle;
215+});
@@ -0,0 +1,215 @@
1+// Tests of static/js/passkey.js on a tiny hand-rolled DOM, with fetch and
2+// navigator.credentials stubbed. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test, beforeEach } from 'node:test';
6+import { dom, el, installDom, settle } from './dom.mjs';
7+
8+const doc = installDom({ parseHTML: () => [] });
9+doc.addEventListener = () => { dom.listened = true; };
10+// Node defines navigator as a getter: replace it, as a browser test would.
11+const setNavigator = (v) => Object.defineProperty(globalThis, 'navigator', { value: v, configurable: true, writable: true });
12+setNavigator({ credentials: {} });
13+globalThis.PublicKeyCredential = undefined;
14+await import('../static/js/passkey.js');
15+const P = window.ermitePasskey;
16+
17+const OPTIONS = { publicKey: { challenge: 'abc' } };
18+const CRED = { toJSON: () => ({ id: 'cid', type: 'public-key' }) };
19+
20+let fetches; // {url, init}
21+let answers; // url -> {ok, status, body}
22+let parsed; // [kind, publicKey]
23+let nav; // where the page was sent: 'assign:/' or 'reload'
24+let status;
25+let name;
26+
27+function page(kind) {
28+ dom.body = el('body');
29+ status = el('p', { attrs: { 'data-passkey-status': '' } });
30+ name = el('input', { attrs: { 'data-passkey-name': '' }, value: ' Pixel 9 ' });
31+ const btn = el('button', { attrs: { 'data-passkey': kind, 'data-begin': '/begin', 'data-finish': '/finish' } });
32+ dom.body.append(status, name, btn);
33+ return btn;
34+}
35+
36+beforeEach(() => {
37+ fetches = [];
38+ parsed = [];
39+ nav = '';
40+ answers = {
41+ '/begin': { ok: true, status: 200, body: { ceremony: 'c1', options: OPTIONS } },
42+ '/finish': { ok: true, status: 204, body: null },
43+ };
44+ globalThis.fetch = async (url, init) => {
45+ fetches.push({ url, init });
46+ const a = answers[url];
47+ return { ok: a.ok, status: a.status, json: async () => { if (a.body === null) throw new Error('no body'); return a.body; } };
48+ };
49+ globalThis.PublicKeyCredential = {
50+ parseRequestOptionsFromJSON: (o) => { parsed.push(['get', o]); return { parsedGet: o }; },
51+ parseCreationOptionsFromJSON: (o) => { parsed.push(['create', o]); return { parsedCreate: o }; },
52+ };
53+ setNavigator({
54+ credentials: {
55+ get: async (o) => { navigator.got = o; return CRED; },
56+ create: async (o) => { navigator.created = o; return CRED; },
57+ },
58+ });
59+ globalThis.location = { assign: (u) => { nav = 'assign:' + u; }, reload: () => { nav = 'reload'; } };
60+});
61+
62+const clickOn = (target) => P.click({ target, preventDefault() {} });
63+
64+test('login: begin, get, finish with toJSON(), then home', async () => {
65+ const btn = page('login');
66+ await clickOn(btn);
67+
68+ assert.equal(fetches.length, 2);
69+ assert.equal(fetches[0].url, '/begin');
70+ assert.equal(fetches[0].init.method, 'POST');
71+ assert.equal(fetches[0].init.credentials, 'same-origin');
72+ assert.deepEqual(parsed, [['get', OPTIONS.publicKey]]);
73+ assert.deepEqual(navigator.got, { publicKey: { parsedGet: OPTIONS.publicKey } });
74+
75+ assert.equal(fetches[1].url, '/finish');
76+ assert.equal(fetches[1].init.method, 'POST');
77+ assert.equal(fetches[1].init.headers['Content-Type'], 'application/json');
78+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: '', credential: { id: 'cid', type: 'public-key' } });
79+ assert.equal(nav, 'assign:/');
80+ assert.equal(status.textContent, '');
81+ assert.equal(btn.disabled, true, 'stays disabled while the page navigates');
82+});
83+
84+test('register: create with the trimmed name, then reload', async () => {
85+ const btn = page('register');
86+ await clickOn(btn);
87+
88+ assert.deepEqual(parsed, [['create', OPTIONS.publicKey]]);
89+ assert.deepEqual(navigator.created, { publicKey: { parsedCreate: OPTIONS.publicKey } });
90+ assert.deepEqual(JSON.parse(fetches[1].init.body), { ceremony: 'c1', name: 'Pixel 9', credential: { id: 'cid', type: 'public-key' } });
91+ assert.equal(nav, 'reload');
92+});
93+
94+test('a click inside the button reaches it, other clicks do nothing', async () => {
95+ const btn = page('login');
96+ const inner = el('span');
97+ btn.append(inner);
98+ await clickOn(inner);
99+ assert.equal(fetches.length, 2);
100+
101+ fetches = [];
102+ await clickOn(el('div'));
103+ assert.equal(fetches.length, 0);
104+});
105+
106+test('begin refused: the server reason is shown, nothing else is called', async () => {
107+ const btn = page('login');
108+ answers['/begin'] = { ok: false, status: 429, body: { error: 'too many attempts' } };
109+ await clickOn(btn);
110+ assert.equal(status.textContent, 'too many attempts');
111+ assert.equal(fetches.length, 1);
112+ assert.equal(nav, '');
113+ assert.equal(btn.disabled, false, 'the button is usable again');
114+});
115+
116+test('finish refused: the reason is shown and the page stays', async () => {
117+ const btn = page('login');
118+ answers['/finish'] = { ok: false, status: 401, body: { error: 'passkey not accepted' } };
119+ await clickOn(btn);
120+ assert.equal(status.textContent, 'passkey not accepted');
121+ assert.equal(nav, '');
122+ assert.equal(btn.disabled, false);
123+});
124+
125+test('a refusal without a JSON body falls back to the status', async () => {
126+ const btn = page('register');
127+ answers['/finish'] = { ok: false, status: 502, body: null };
128+ await clickOn(btn);
129+ assert.equal(status.textContent, 'the server answered 502');
130+ assert.equal(nav, '');
131+});
132+
133+test('the owner cancels the prompt', async () => {
134+ const btn = page('login');
135+ navigator.credentials.get = async () => { throw Object.assign(new Error('x'), { name: 'NotAllowedError' }); };
136+ await clickOn(btn);
137+ assert.match(status.textContent, /cancelled, or it took too long/);
138+ assert.equal(fetches.length, 1, 'finish is not called');
139+ assert.equal(btn.disabled, false);
140+});
141+
142+test('an authenticator that already holds a passkey', async () => {
143+ const btn = page('register');
144+ navigator.credentials.create = async () => { throw Object.assign(new Error('x'), { name: 'InvalidStateError' }); };
145+ await clickOn(btn);
146+ assert.match(status.textContent, /already holds a passkey/);
147+ assert.equal(fetches.length, 1);
148+});
149+
150+test('a network failure is reported', async () => {
151+ const btn = page('login');
152+ globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); };
153+ await clickOn(btn);
154+ assert.equal(status.textContent, 'Failed to fetch');
155+ assert.equal(btn.disabled, false);
156+});
157+
158+test('a null credential is not sent', async () => {
159+ const btn = page('login');
160+ navigator.credentials.get = async () => null;
161+ await clickOn(btn);
162+ assert.equal(status.textContent, 'no passkey was returned');
163+ assert.equal(fetches.length, 1);
164+});
165+
166+test('an old browser: the buttons are disabled with a reason, nothing is fetched', async () => {
167+ const login = page('login');
168+ const register = el('button', { attrs: { 'data-passkey': 'register' } });
169+ dom.body.append(register);
170+ globalThis.PublicKeyCredential = {}; // no parse*OptionsFromJSON
171+ P.init();
172+ assert.equal(login.disabled, true);
173+ assert.equal(register.disabled, true);
174+ assert.equal(status.textContent, 'this browser does not support passkeys');
175+
176+ login.disabled = false;
177+ await clickOn(login);
178+ assert.equal(fetches.length, 0);
179+ assert.equal(status.textContent, 'this browser does not support passkeys');
180+});
181+
182+test('no PublicKeyCredential at all', async () => {
183+ const btn = page('login');
184+ globalThis.PublicKeyCredential = undefined;
185+ P.init();
186+ assert.equal(btn.disabled, true);
187+ assert.equal(status.textContent, 'this browser does not support passkeys');
188+});
189+
190+test('a supported browser keeps the buttons enabled', () => {
191+ const btn = page('login');
192+ P.init();
193+ assert.equal(btn.disabled, undefined);
194+ assert.equal(status.textContent, '');
195+});
196+
197+test('a disabled button ignores clicks', async () => {
198+ const btn = page('login');
199+ btn.disabled = true;
200+ await clickOn(btn);
201+ assert.equal(fetches.length, 0);
202+});
203+
204+test('only login supports needs parseRequestOptionsFromJSON, register parseCreationOptionsFromJSON', async () => {
205+ const btn = page('register');
206+ delete PublicKeyCredential.parseCreationOptionsFromJSON;
207+ await clickOn(btn);
208+ assert.equal(fetches.length, 0);
209+ assert.equal(status.textContent, 'this browser does not support passkeys');
210+});
211+
212+test('the script registers one delegated click listener', () => {
213+ assert.equal(dom.listened, true);
214+ void settle;
215+});
M internal/adapters/web/pages.templ +86 -0
@@ -182,3 +182,89 @@ templ Login(l Layout, p LoginPage) {
182182 </section>
183183 }
184184 }
185+
186+// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
187+// Without script the button is hidden and the page says what to do instead.
188+templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
189+ @Shell(l, nil) {
190+ <section class="state" aria-labelledby="state-title">
191+ <h1 id="state-title">{ p.Title }</h1>
192+ <p class="state-act">
193+ <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
194+ </p>
195+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
196+ for _, line := range p.Lines {
197+ <p class="dim">{ line }</p>
198+ }
199+ <p class="state-back"><a href="/">Back to home</a></p>
200+ </section>
201+ }
202+}
203+
204+// Passkeys lists the owner's passkeys, removes them and adds one.
205+templ Passkeys(l Layout, p PasskeysPage) {
206+ @Shell(l, nil) {
207+ <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2>
208+ <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>
209+ if len(p.Rows) > 0 {
210+ <table class="list passkeys" aria-label="Passkeys">
211+ <thead>
212+ <tr>
213+ <th class="cur"></th>
214+ <th class="c-name">Name</th>
215+ <th class="c-when">Created</th>
216+ <th class="c-when">Last used</th>
217+ <th class="c-sync">Synced</th>
218+ <th class="c-act"><span class="sr">Remove</span></th>
219+ </tr>
220+ </thead>
221+ <tbody>
222+ for _, r := range p.Rows {
223+ <tr>
224+ <td class="cur" aria-hidden="true"></td>
225+ <td class="c-name">{ r.Name }</td>
226+ <td class="c-when nowrap">
227+ <time
228+ if r.CreatedAt != "" {
229+ datetime={ r.CreatedAt }
230+ }
231+ >{ r.Created }</time>
232+ </td>
233+ <td class="c-when nowrap">
234+ <time
235+ if r.LastUsedAt != "" {
236+ datetime={ r.LastUsedAt }
237+ }
238+ >{ r.LastUsed }</time>
239+ </td>
240+ <td class="c-sync">
241+ if r.Synced {
242+ yes
243+ } else {
244+ no
245+ }
246+ </td>
247+ <td class="c-act">
248+ <form method="post" action={ templ.SafeURL(r.Remove) }>
249+ <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button>
250+ </form>
251+ </td>
252+ </tr>
253+ }
254+ </tbody>
255+ </table>
256+ } else {
257+ <p class="lede dim">no passkeys yet.</p>
258+ }
259+ if p.Full {
260+ <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
261+ } else {
262+ <div class="passkey-add">
263+ <label for="passkey-name" class="sr">Name of the new passkey</label>
264+ <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
265+ <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
266+ </div>
267+ }
268+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
269+ }
270+}
@@ -182,3 +182,89 @@ templ Login(l Layout, p LoginPage) {
182 </section>182 </section>
183 }183 }
184 }184 }
185+
186+// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
187+// Without script the button is hidden and the page says what to do instead.
188+templ PasskeyLogin(l Layout, p PasskeyLoginPage) {
189+ @Shell(l, nil) {
190+ <section class="state" aria-labelledby="state-title">
191+ <h1 id="state-title">{ p.Title }</h1>
192+ <p class="state-act">
193+ <button type="button" class="btn primary" data-js data-passkey="login" data-begin={ p.Begin } data-finish={ p.Finish }>{ p.Button }</button>
194+ </p>
195+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
196+ for _, line := range p.Lines {
197+ <p class="dim">{ line }</p>
198+ }
199+ <p class="state-back"><a href="/">Back to home</a></p>
200+ </section>
201+ }
202+}
203+
204+// Passkeys lists the owner's passkeys, removes them and adds one.
205+templ Passkeys(l Layout, p PasskeysPage) {
206+ @Shell(l, nil) {
207+ <h2 class="rule-h"><span>Passkeys</span><span class="count">{ itoa(len(p.Rows)) }</span></h2>
208+ <p class="dim">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>
209+ if len(p.Rows) > 0 {
210+ <table class="list passkeys" aria-label="Passkeys">
211+ <thead>
212+ <tr>
213+ <th class="cur"></th>
214+ <th class="c-name">Name</th>
215+ <th class="c-when">Created</th>
216+ <th class="c-when">Last used</th>
217+ <th class="c-sync">Synced</th>
218+ <th class="c-act"><span class="sr">Remove</span></th>
219+ </tr>
220+ </thead>
221+ <tbody>
222+ for _, r := range p.Rows {
223+ <tr>
224+ <td class="cur" aria-hidden="true"></td>
225+ <td class="c-name">{ r.Name }</td>
226+ <td class="c-when nowrap">
227+ <time
228+ if r.CreatedAt != "" {
229+ datetime={ r.CreatedAt }
230+ }
231+ >{ r.Created }</time>
232+ </td>
233+ <td class="c-when nowrap">
234+ <time
235+ if r.LastUsedAt != "" {
236+ datetime={ r.LastUsedAt }
237+ }
238+ >{ r.LastUsed }</time>
239+ </td>
240+ <td class="c-sync">
241+ if r.Synced {
242+ yes
243+ } else {
244+ no
245+ }
246+ </td>
247+ <td class="c-act">
248+ <form method="post" action={ templ.SafeURL(r.Remove) }>
249+ <button type="submit" class="btn danger" aria-label={ "Remove passkey " + r.Name }>Remove</button>
250+ </form>
251+ </td>
252+ </tr>
253+ }
254+ </tbody>
255+ </table>
256+ } else {
257+ <p class="lede dim">no passkeys yet.</p>
258+ }
259+ if p.Full {
260+ <p class="dim">{ itoa(p.Max) } passkeys are registered, the most there can be. Remove one to add another.</p>
261+ } else {
262+ <div class="passkey-add">
263+ <label for="passkey-name" class="sr">Name of the new passkey</label>
264+ <input id="passkey-name" type="text" maxlength="64" placeholder="Name, for example Pixel 9" autocomplete="off" data-passkey-name/>
265+ <button type="button" class="btn primary" data-js data-passkey="register" data-begin={ p.Begin } data-finish={ p.Finish }>Add a passkey</button>
266+ </div>
267+ }
268+ <p class="dim" role="status" aria-live="polite" data-passkey-status></p>
269+ }
270+}
M internal/adapters/web/pages_templ.go +390 -0
@@ -1007,4 +1007,394 @@ func Login(l Layout, p LoginPage) templ.Component {
10071007 })
10081008 }
10091009
1010+// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
1011+// Without script the button is hidden and the page says what to do instead.
1012+func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1013+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1014+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1015+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1016+ return templ_7745c5c3_CtxErr
1017+ }
1018+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1019+ if !templ_7745c5c3_IsBuffer {
1020+ defer func() {
1021+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1022+ if templ_7745c5c3_Err == nil {
1023+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1024+ }
1025+ }()
1026+ }
1027+ ctx = templ.InitializeContext(ctx)
1028+ templ_7745c5c3_Var50 := templ.GetChildren(ctx)
1029+ if templ_7745c5c3_Var50 == nil {
1030+ templ_7745c5c3_Var50 = templ.NopComponent
1031+ }
1032+ ctx = templ.ClearChildren(ctx)
1033+ templ_7745c5c3_Var51 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1034+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1035+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1036+ if !templ_7745c5c3_IsBuffer {
1037+ defer func() {
1038+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1039+ if templ_7745c5c3_Err == nil {
1040+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1041+ }
1042+ }()
1043+ }
1044+ ctx = templ.InitializeContext(ctx)
1045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1046+ if templ_7745c5c3_Err != nil {
1047+ return templ_7745c5c3_Err
1048+ }
1049+ var templ_7745c5c3_Var52 string
1050+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1051+ if templ_7745c5c3_Err != nil {
1052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 33}
1053+ }
1054+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1055+ if templ_7745c5c3_Err != nil {
1056+ return templ_7745c5c3_Err
1057+ }
1058+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h1><p class=\"state-act\"><button type=\"button\" class=\"btn primary\" data-js data-passkey=\"login\" data-begin=\"")
1059+ if templ_7745c5c3_Err != nil {
1060+ return templ_7745c5c3_Err
1061+ }
1062+ var templ_7745c5c3_Var53 string
1063+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1064+ if templ_7745c5c3_Err != nil {
1065+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 95}
1066+ }
1067+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
1068+ if templ_7745c5c3_Err != nil {
1069+ return templ_7745c5c3_Err
1070+ }
1071+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" data-finish=\"")
1072+ if templ_7745c5c3_Err != nil {
1073+ return templ_7745c5c3_Err
1074+ }
1075+ var templ_7745c5c3_Var54 string
1076+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1077+ if templ_7745c5c3_Err != nil {
1078+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 120}
1079+ }
1080+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1081+ if templ_7745c5c3_Err != nil {
1082+ return templ_7745c5c3_Err
1083+ }
1084+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">")
1085+ if templ_7745c5c3_Err != nil {
1086+ return templ_7745c5c3_Err
1087+ }
1088+ var templ_7745c5c3_Var55 string
1089+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1090+ if templ_7745c5c3_Err != nil {
1091+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 133}
1092+ }
1093+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1094+ if templ_7745c5c3_Err != nil {
1095+ return templ_7745c5c3_Err
1096+ }
1097+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></p><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1098+ if templ_7745c5c3_Err != nil {
1099+ return templ_7745c5c3_Err
1100+ }
1101+ for _, line := range p.Lines {
1102+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<p class=\"dim\">")
1103+ if templ_7745c5c3_Err != nil {
1104+ return templ_7745c5c3_Err
1105+ }
1106+ var templ_7745c5c3_Var56 string
1107+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1108+ if templ_7745c5c3_Err != nil {
1109+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 25}
1110+ }
1111+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1112+ if templ_7745c5c3_Err != nil {
1113+ return templ_7745c5c3_Err
1114+ }
1115+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</p>")
1116+ if templ_7745c5c3_Err != nil {
1117+ return templ_7745c5c3_Err
1118+ }
1119+ }
1120+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1121+ if templ_7745c5c3_Err != nil {
1122+ return templ_7745c5c3_Err
1123+ }
1124+ return nil
1125+ })
1126+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var51), templ_7745c5c3_Buffer)
1127+ if templ_7745c5c3_Err != nil {
1128+ return templ_7745c5c3_Err
1129+ }
1130+ return nil
1131+ })
1132+}
1133+
1134+// Passkeys lists the owner's passkeys, removes them and adds one.
1135+func Passkeys(l Layout, p PasskeysPage) templ.Component {
1136+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1137+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1138+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1139+ return templ_7745c5c3_CtxErr
1140+ }
1141+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1142+ if !templ_7745c5c3_IsBuffer {
1143+ defer func() {
1144+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1145+ if templ_7745c5c3_Err == nil {
1146+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1147+ }
1148+ }()
1149+ }
1150+ ctx = templ.InitializeContext(ctx)
1151+ templ_7745c5c3_Var57 := templ.GetChildren(ctx)
1152+ if templ_7745c5c3_Var57 == nil {
1153+ templ_7745c5c3_Var57 = templ.NopComponent
1154+ }
1155+ ctx = templ.ClearChildren(ctx)
1156+ templ_7745c5c3_Var58 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1157+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1158+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1159+ if !templ_7745c5c3_IsBuffer {
1160+ defer func() {
1161+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1162+ if templ_7745c5c3_Err == nil {
1163+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1164+ }
1165+ }()
1166+ }
1167+ ctx = templ.InitializeContext(ctx)
1168+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
1169+ if templ_7745c5c3_Err != nil {
1170+ return templ_7745c5c3_Err
1171+ }
1172+ var templ_7745c5c3_Var59 string
1173+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1174+ if templ_7745c5c3_Err != nil {
1175+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 207, Col: 81}
1176+ }
1177+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1178+ if templ_7745c5c3_Err != nil {
1179+ return templ_7745c5c3_Err
1180+ }
1181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")
1182+ if templ_7745c5c3_Err != nil {
1183+ return templ_7745c5c3_Err
1184+ }
1185+ if len(p.Rows) > 0 {
1186+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"cur\"></th><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
1187+ if templ_7745c5c3_Err != nil {
1188+ return templ_7745c5c3_Err
1189+ }
1190+ for _, r := range p.Rows {
1191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<tr><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\">")
1192+ if templ_7745c5c3_Err != nil {
1193+ return templ_7745c5c3_Err
1194+ }
1195+ var templ_7745c5c3_Var60 string
1196+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1197+ if templ_7745c5c3_Err != nil {
1198+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 34}
1199+ }
1200+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1201+ if templ_7745c5c3_Err != nil {
1202+ return templ_7745c5c3_Err
1203+ }
1204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</td><td class=\"c-when nowrap\"><time")
1205+ if templ_7745c5c3_Err != nil {
1206+ return templ_7745c5c3_Err
1207+ }
1208+ if r.CreatedAt != "" {
1209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " datetime=\"")
1210+ if templ_7745c5c3_Err != nil {
1211+ return templ_7745c5c3_Err
1212+ }
1213+ var templ_7745c5c3_Var61 string
1214+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1215+ if templ_7745c5c3_Err != nil {
1216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 32}
1217+ }
1218+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
1219+ if templ_7745c5c3_Err != nil {
1220+ return templ_7745c5c3_Err
1221+ }
1222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"")
1223+ if templ_7745c5c3_Err != nil {
1224+ return templ_7745c5c3_Err
1225+ }
1226+ }
1227+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, ">")
1228+ if templ_7745c5c3_Err != nil {
1229+ return templ_7745c5c3_Err
1230+ }
1231+ var templ_7745c5c3_Var62 string
1232+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1233+ if templ_7745c5c3_Err != nil {
1234+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 20}
1235+ }
1236+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1237+ if templ_7745c5c3_Err != nil {
1238+ return templ_7745c5c3_Err
1239+ }
1240+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</time></td><td class=\"c-when nowrap\"><time")
1241+ if templ_7745c5c3_Err != nil {
1242+ return templ_7745c5c3_Err
1243+ }
1244+ if r.LastUsedAt != "" {
1245+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " datetime=\"")
1246+ if templ_7745c5c3_Err != nil {
1247+ return templ_7745c5c3_Err
1248+ }
1249+ var templ_7745c5c3_Var63 string
1250+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1251+ if templ_7745c5c3_Err != nil {
1252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 236, Col: 33}
1253+ }
1254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
1255+ if templ_7745c5c3_Err != nil {
1256+ return templ_7745c5c3_Err
1257+ }
1258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\"")
1259+ if templ_7745c5c3_Err != nil {
1260+ return templ_7745c5c3_Err
1261+ }
1262+ }
1263+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, ">")
1264+ if templ_7745c5c3_Err != nil {
1265+ return templ_7745c5c3_Err
1266+ }
1267+ var templ_7745c5c3_Var64 string
1268+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1269+ if templ_7745c5c3_Err != nil {
1270+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 238, Col: 21}
1271+ }
1272+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1273+ if templ_7745c5c3_Err != nil {
1274+ return templ_7745c5c3_Err
1275+ }
1276+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</time></td><td class=\"c-sync\">")
1277+ if templ_7745c5c3_Err != nil {
1278+ return templ_7745c5c3_Err
1279+ }
1280+ if r.Synced {
1281+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "yes")
1282+ if templ_7745c5c3_Err != nil {
1283+ return templ_7745c5c3_Err
1284+ }
1285+ } else {
1286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "no")
1287+ if templ_7745c5c3_Err != nil {
1288+ return templ_7745c5c3_Err
1289+ }
1290+ }
1291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
1292+ if templ_7745c5c3_Err != nil {
1293+ return templ_7745c5c3_Err
1294+ }
1295+ var templ_7745c5c3_Var65 templ.SafeURL
1296+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1297+ if templ_7745c5c3_Err != nil {
1298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 60}
1299+ }
1300+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1301+ if templ_7745c5c3_Err != nil {
1302+ return templ_7745c5c3_Err
1303+ }
1304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
1305+ if templ_7745c5c3_Err != nil {
1306+ return templ_7745c5c3_Err
1307+ }
1308+ var templ_7745c5c3_Var66 string
1309+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1310+ if templ_7745c5c3_Err != nil {
1311+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 89}
1312+ }
1313+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1314+ if templ_7745c5c3_Err != nil {
1315+ return templ_7745c5c3_Err
1316+ }
1317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">Remove</button></form></td></tr>")
1318+ if templ_7745c5c3_Err != nil {
1319+ return templ_7745c5c3_Err
1320+ }
1321+ }
1322+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</tbody></table>")
1323+ if templ_7745c5c3_Err != nil {
1324+ return templ_7745c5c3_Err
1325+ }
1326+ } else {
1327+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<p class=\"lede dim\">no passkeys yet.</p>")
1328+ if templ_7745c5c3_Err != nil {
1329+ return templ_7745c5c3_Err
1330+ }
1331+ }
1332+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ")
1333+ if templ_7745c5c3_Err != nil {
1334+ return templ_7745c5c3_Err
1335+ }
1336+ if p.Full {
1337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<p class=\"dim\">")
1338+ if templ_7745c5c3_Err != nil {
1339+ return templ_7745c5c3_Err
1340+ }
1341+ var templ_7745c5c3_Var67 string
1342+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1343+ if templ_7745c5c3_Err != nil {
1344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 260, Col: 31}
1345+ }
1346+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1347+ if templ_7745c5c3_Err != nil {
1348+ return templ_7745c5c3_Err
1349+ }
1350+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " passkeys are registered, the most there can be. Remove one to add another.</p>")
1351+ if templ_7745c5c3_Err != nil {
1352+ return templ_7745c5c3_Err
1353+ }
1354+ } else {
1355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<div class=\"passkey-add\"><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" maxlength=\"64\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"button\" class=\"btn primary\" data-js data-passkey=\"register\" data-begin=\"")
1356+ if templ_7745c5c3_Err != nil {
1357+ return templ_7745c5c3_Err
1358+ }
1359+ var templ_7745c5c3_Var68 string
1360+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1361+ if templ_7745c5c3_Err != nil {
1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 98}
1363+ }
1364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
1365+ if templ_7745c5c3_Err != nil {
1366+ return templ_7745c5c3_Err
1367+ }
1368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\" data-finish=\"")
1369+ if templ_7745c5c3_Err != nil {
1370+ return templ_7745c5c3_Err
1371+ }
1372+ var templ_7745c5c3_Var69 string
1373+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1374+ if templ_7745c5c3_Err != nil {
1375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 123}
1376+ }
1377+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1378+ if templ_7745c5c3_Err != nil {
1379+ return templ_7745c5c3_Err
1380+ }
1381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Add a passkey</button></div>")
1382+ if templ_7745c5c3_Err != nil {
1383+ return templ_7745c5c3_Err
1384+ }
1385+ }
1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1387+ if templ_7745c5c3_Err != nil {
1388+ return templ_7745c5c3_Err
1389+ }
1390+ return nil
1391+ })
1392+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var58), templ_7745c5c3_Buffer)
1393+ if templ_7745c5c3_Err != nil {
1394+ return templ_7745c5c3_Err
1395+ }
1396+ return nil
1397+ })
1398+}
1399+
10101400 var _ = templruntime.GeneratedTemplate
@@ -1007,4 +1007,394 @@ func Login(l Layout, p LoginPage) templ.Component {
1007 })1007 })
1008 }1008 }
1009 1009
1010+// PasskeyLogin is the sign-in page: a button the script (passkey.js) drives.
1011+// Without script the button is hidden and the page says what to do instead.
1012+func PasskeyLogin(l Layout, p PasskeyLoginPage) templ.Component {
1013+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1014+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1015+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1016+ return templ_7745c5c3_CtxErr
1017+ }
1018+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1019+ if !templ_7745c5c3_IsBuffer {
1020+ defer func() {
1021+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1022+ if templ_7745c5c3_Err == nil {
1023+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1024+ }
1025+ }()
1026+ }
1027+ ctx = templ.InitializeContext(ctx)
1028+ templ_7745c5c3_Var50 := templ.GetChildren(ctx)
1029+ if templ_7745c5c3_Var50 == nil {
1030+ templ_7745c5c3_Var50 = templ.NopComponent
1031+ }
1032+ ctx = templ.ClearChildren(ctx)
1033+ templ_7745c5c3_Var51 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1034+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1035+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1036+ if !templ_7745c5c3_IsBuffer {
1037+ defer func() {
1038+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1039+ if templ_7745c5c3_Err == nil {
1040+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1041+ }
1042+ }()
1043+ }
1044+ ctx = templ.InitializeContext(ctx)
1045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\">")
1046+ if templ_7745c5c3_Err != nil {
1047+ return templ_7745c5c3_Err
1048+ }
1049+ var templ_7745c5c3_Var52 string
1050+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
1051+ if templ_7745c5c3_Err != nil {
1052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 191, Col: 33}
1053+ }
1054+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1055+ if templ_7745c5c3_Err != nil {
1056+ return templ_7745c5c3_Err
1057+ }
1058+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</h1><p class=\"state-act\"><button type=\"button\" class=\"btn primary\" data-js data-passkey=\"login\" data-begin=\"")
1059+ if templ_7745c5c3_Err != nil {
1060+ return templ_7745c5c3_Err
1061+ }
1062+ var templ_7745c5c3_Var53 string
1063+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1064+ if templ_7745c5c3_Err != nil {
1065+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 95}
1066+ }
1067+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
1068+ if templ_7745c5c3_Err != nil {
1069+ return templ_7745c5c3_Err
1070+ }
1071+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" data-finish=\"")
1072+ if templ_7745c5c3_Err != nil {
1073+ return templ_7745c5c3_Err
1074+ }
1075+ var templ_7745c5c3_Var54 string
1076+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1077+ if templ_7745c5c3_Err != nil {
1078+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 120}
1079+ }
1080+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
1081+ if templ_7745c5c3_Err != nil {
1082+ return templ_7745c5c3_Err
1083+ }
1084+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">")
1085+ if templ_7745c5c3_Err != nil {
1086+ return templ_7745c5c3_Err
1087+ }
1088+ var templ_7745c5c3_Var55 string
1089+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button)
1090+ if templ_7745c5c3_Err != nil {
1091+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 193, Col: 133}
1092+ }
1093+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1094+ if templ_7745c5c3_Err != nil {
1095+ return templ_7745c5c3_Err
1096+ }
1097+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></p><p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1098+ if templ_7745c5c3_Err != nil {
1099+ return templ_7745c5c3_Err
1100+ }
1101+ for _, line := range p.Lines {
1102+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<p class=\"dim\">")
1103+ if templ_7745c5c3_Err != nil {
1104+ return templ_7745c5c3_Err
1105+ }
1106+ var templ_7745c5c3_Var56 string
1107+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(line)
1108+ if templ_7745c5c3_Err != nil {
1109+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 197, Col: 25}
1110+ }
1111+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1112+ if templ_7745c5c3_Err != nil {
1113+ return templ_7745c5c3_Err
1114+ }
1115+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</p>")
1116+ if templ_7745c5c3_Err != nil {
1117+ return templ_7745c5c3_Err
1118+ }
1119+ }
1120+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<p class=\"state-back\"><a href=\"/\">Back to home</a></p></section>")
1121+ if templ_7745c5c3_Err != nil {
1122+ return templ_7745c5c3_Err
1123+ }
1124+ return nil
1125+ })
1126+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var51), templ_7745c5c3_Buffer)
1127+ if templ_7745c5c3_Err != nil {
1128+ return templ_7745c5c3_Err
1129+ }
1130+ return nil
1131+ })
1132+}
1133+
1134+// Passkeys lists the owner's passkeys, removes them and adds one.
1135+func Passkeys(l Layout, p PasskeysPage) templ.Component {
1136+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1137+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1138+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1139+ return templ_7745c5c3_CtxErr
1140+ }
1141+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1142+ if !templ_7745c5c3_IsBuffer {
1143+ defer func() {
1144+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1145+ if templ_7745c5c3_Err == nil {
1146+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1147+ }
1148+ }()
1149+ }
1150+ ctx = templ.InitializeContext(ctx)
1151+ templ_7745c5c3_Var57 := templ.GetChildren(ctx)
1152+ if templ_7745c5c3_Var57 == nil {
1153+ templ_7745c5c3_Var57 = templ.NopComponent
1154+ }
1155+ ctx = templ.ClearChildren(ctx)
1156+ templ_7745c5c3_Var58 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1157+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1158+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1159+ if !templ_7745c5c3_IsBuffer {
1160+ defer func() {
1161+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1162+ if templ_7745c5c3_Err == nil {
1163+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1164+ }
1165+ }()
1166+ }
1167+ ctx = templ.InitializeContext(ctx)
1168+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<h2 class=\"rule-h\"><span>Passkeys</span><span class=\"count\">")
1169+ if templ_7745c5c3_Err != nil {
1170+ return templ_7745c5c3_Err
1171+ }
1172+ var templ_7745c5c3_Var59 string
1173+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Rows)))
1174+ if templ_7745c5c3_Err != nil {
1175+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 207, Col: 81}
1176+ }
1177+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1178+ if templ_7745c5c3_Err != nil {
1179+ return templ_7745c5c3_Err
1180+ }
1181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span></h2><p class=\"dim\">A passkey signs you in from this device or your phone, without a terminal. Removing one signs every browser out.</p>")
1182+ if templ_7745c5c3_Err != nil {
1183+ return templ_7745c5c3_Err
1184+ }
1185+ if len(p.Rows) > 0 {
1186+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<table class=\"list passkeys\" aria-label=\"Passkeys\"><thead><tr><th class=\"cur\"></th><th class=\"c-name\">Name</th><th class=\"c-when\">Created</th><th class=\"c-when\">Last used</th><th class=\"c-sync\">Synced</th><th class=\"c-act\"><span class=\"sr\">Remove</span></th></tr></thead> <tbody>")
1187+ if templ_7745c5c3_Err != nil {
1188+ return templ_7745c5c3_Err
1189+ }
1190+ for _, r := range p.Rows {
1191+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<tr><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\">")
1192+ if templ_7745c5c3_Err != nil {
1193+ return templ_7745c5c3_Err
1194+ }
1195+ var templ_7745c5c3_Var60 string
1196+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1197+ if templ_7745c5c3_Err != nil {
1198+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 225, Col: 34}
1199+ }
1200+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
1201+ if templ_7745c5c3_Err != nil {
1202+ return templ_7745c5c3_Err
1203+ }
1204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</td><td class=\"c-when nowrap\"><time")
1205+ if templ_7745c5c3_Err != nil {
1206+ return templ_7745c5c3_Err
1207+ }
1208+ if r.CreatedAt != "" {
1209+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " datetime=\"")
1210+ if templ_7745c5c3_Err != nil {
1211+ return templ_7745c5c3_Err
1212+ }
1213+ var templ_7745c5c3_Var61 string
1214+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.CreatedAt)
1215+ if templ_7745c5c3_Err != nil {
1216+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 229, Col: 32}
1217+ }
1218+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
1219+ if templ_7745c5c3_Err != nil {
1220+ return templ_7745c5c3_Err
1221+ }
1222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"")
1223+ if templ_7745c5c3_Err != nil {
1224+ return templ_7745c5c3_Err
1225+ }
1226+ }
1227+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, ">")
1228+ if templ_7745c5c3_Err != nil {
1229+ return templ_7745c5c3_Err
1230+ }
1231+ var templ_7745c5c3_Var62 string
1232+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(r.Created)
1233+ if templ_7745c5c3_Err != nil {
1234+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 231, Col: 20}
1235+ }
1236+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1237+ if templ_7745c5c3_Err != nil {
1238+ return templ_7745c5c3_Err
1239+ }
1240+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</time></td><td class=\"c-when nowrap\"><time")
1241+ if templ_7745c5c3_Err != nil {
1242+ return templ_7745c5c3_Err
1243+ }
1244+ if r.LastUsedAt != "" {
1245+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " datetime=\"")
1246+ if templ_7745c5c3_Err != nil {
1247+ return templ_7745c5c3_Err
1248+ }
1249+ var templ_7745c5c3_Var63 string
1250+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.LastUsedAt)
1251+ if templ_7745c5c3_Err != nil {
1252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 236, Col: 33}
1253+ }
1254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
1255+ if templ_7745c5c3_Err != nil {
1256+ return templ_7745c5c3_Err
1257+ }
1258+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\"")
1259+ if templ_7745c5c3_Err != nil {
1260+ return templ_7745c5c3_Err
1261+ }
1262+ }
1263+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, ">")
1264+ if templ_7745c5c3_Err != nil {
1265+ return templ_7745c5c3_Err
1266+ }
1267+ var templ_7745c5c3_Var64 string
1268+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastUsed)
1269+ if templ_7745c5c3_Err != nil {
1270+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 238, Col: 21}
1271+ }
1272+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1273+ if templ_7745c5c3_Err != nil {
1274+ return templ_7745c5c3_Err
1275+ }
1276+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</time></td><td class=\"c-sync\">")
1277+ if templ_7745c5c3_Err != nil {
1278+ return templ_7745c5c3_Err
1279+ }
1280+ if r.Synced {
1281+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "yes")
1282+ if templ_7745c5c3_Err != nil {
1283+ return templ_7745c5c3_Err
1284+ }
1285+ } else {
1286+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "no")
1287+ if templ_7745c5c3_Err != nil {
1288+ return templ_7745c5c3_Err
1289+ }
1290+ }
1291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td><td class=\"c-act\"><form method=\"post\" action=\"")
1292+ if templ_7745c5c3_Err != nil {
1293+ return templ_7745c5c3_Err
1294+ }
1295+ var templ_7745c5c3_Var65 templ.SafeURL
1296+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Remove))
1297+ if templ_7745c5c3_Err != nil {
1298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 248, Col: 60}
1299+ }
1300+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1301+ if templ_7745c5c3_Err != nil {
1302+ return templ_7745c5c3_Err
1303+ }
1304+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\"><button type=\"submit\" class=\"btn danger\" aria-label=\"")
1305+ if templ_7745c5c3_Err != nil {
1306+ return templ_7745c5c3_Err
1307+ }
1308+ var templ_7745c5c3_Var66 string
1309+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove passkey " + r.Name)
1310+ if templ_7745c5c3_Err != nil {
1311+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 249, Col: 89}
1312+ }
1313+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
1314+ if templ_7745c5c3_Err != nil {
1315+ return templ_7745c5c3_Err
1316+ }
1317+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">Remove</button></form></td></tr>")
1318+ if templ_7745c5c3_Err != nil {
1319+ return templ_7745c5c3_Err
1320+ }
1321+ }
1322+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</tbody></table>")
1323+ if templ_7745c5c3_Err != nil {
1324+ return templ_7745c5c3_Err
1325+ }
1326+ } else {
1327+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<p class=\"lede dim\">no passkeys yet.</p>")
1328+ if templ_7745c5c3_Err != nil {
1329+ return templ_7745c5c3_Err
1330+ }
1331+ }
1332+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ")
1333+ if templ_7745c5c3_Err != nil {
1334+ return templ_7745c5c3_Err
1335+ }
1336+ if p.Full {
1337+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<p class=\"dim\">")
1338+ if templ_7745c5c3_Err != nil {
1339+ return templ_7745c5c3_Err
1340+ }
1341+ var templ_7745c5c3_Var67 string
1342+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Max))
1343+ if templ_7745c5c3_Err != nil {
1344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 260, Col: 31}
1345+ }
1346+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1347+ if templ_7745c5c3_Err != nil {
1348+ return templ_7745c5c3_Err
1349+ }
1350+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, " passkeys are registered, the most there can be. Remove one to add another.</p>")
1351+ if templ_7745c5c3_Err != nil {
1352+ return templ_7745c5c3_Err
1353+ }
1354+ } else {
1355+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<div class=\"passkey-add\"><label for=\"passkey-name\" class=\"sr\">Name of the new passkey</label> <input id=\"passkey-name\" type=\"text\" maxlength=\"64\" placeholder=\"Name, for example Pixel 9\" autocomplete=\"off\" data-passkey-name> <button type=\"button\" class=\"btn primary\" data-js data-passkey=\"register\" data-begin=\"")
1356+ if templ_7745c5c3_Err != nil {
1357+ return templ_7745c5c3_Err
1358+ }
1359+ var templ_7745c5c3_Var68 string
1360+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Begin)
1361+ if templ_7745c5c3_Err != nil {
1362+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 98}
1363+ }
1364+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68)
1365+ if templ_7745c5c3_Err != nil {
1366+ return templ_7745c5c3_Err
1367+ }
1368+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\" data-finish=\"")
1369+ if templ_7745c5c3_Err != nil {
1370+ return templ_7745c5c3_Err
1371+ }
1372+ var templ_7745c5c3_Var69 string
1373+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Finish)
1374+ if templ_7745c5c3_Err != nil {
1375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 265, Col: 123}
1376+ }
1377+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
1378+ if templ_7745c5c3_Err != nil {
1379+ return templ_7745c5c3_Err
1380+ }
1381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Add a passkey</button></div>")
1382+ if templ_7745c5c3_Err != nil {
1383+ return templ_7745c5c3_Err
1384+ }
1385+ }
1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " <p class=\"dim\" role=\"status\" aria-live=\"polite\" data-passkey-status></p>")
1387+ if templ_7745c5c3_Err != nil {
1388+ return templ_7745c5c3_Err
1389+ }
1390+ return nil
1391+ })
1392+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var58), templ_7745c5c3_Buffer)
1393+ if templ_7745c5c3_Err != nil {
1394+ return templ_7745c5c3_Err
1395+ }
1396+ return nil
1397+ })
1398+}
1399+
1010 var _ = templruntime.GeneratedTemplate1400 var _ = templruntime.GeneratedTemplate
A internal/adapters/web/passkeys.go +304 -0
@@ -0,0 +1,304 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "io"
11+ "mime"
12+ "net/http"
13+ "strconv"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// The owner's passkeys on the web (ADR 0026).
24+//
25+// The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the
26+// owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js
27+// makes, each in two steps (begin, finish). Every POST passes the gate: an
28+// Origin equal to the public origin, and a session, except the two login
29+// endpoints, which are how a session starts. The finish endpoints also need
30+// "Content-Type: application/json", which a cross-site form cannot send.
31+
32+const (
33+ passkeyLoginPath = "/-/login"
34+ passkeysPath = "/-/passkeys"
35+
36+ loginBeginPath = passkeysPath + "/login/begin"
37+ loginFinishPath = passkeysPath + "/login/finish"
38+
39+ // maxCeremonyBytes bounds the body of a finish: a response of an
40+ // authenticator is well under 10 KiB.
41+ maxCeremonyBytes = 64 << 10
42+)
43+
44+// passkeysOn tells that the passkey routes exist.
45+func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil }
46+
47+func (s *Server) passkeyRoutes(mux *http.ServeMux) {
48+ mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage)
49+ mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin)
50+ mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish)
51+ mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage))
52+ mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin)
53+ mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish)
54+ mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove)
55+}
56+
57+// isPasskeyLoginPath tells whether u is exactly one of the two endpoints an
58+// anonymous browser may post to. Like isLoginPath it looks at the escaped path,
59+// which is what the router matches.
60+func isPasskeyLoginPath(path string) bool {
61+ return path == loginBeginPath || path == loginFinishPath
62+}
63+
64+// jsonReply writes a JSON answer that no cache keeps.
65+func jsonReply(w http.ResponseWriter, status int, v any) {
66+ h := w.Header()
67+ h.Set("Content-Type", "application/json")
68+ h.Set("Cache-Control", noStore)
69+ w.WriteHeader(status)
70+ _ = json.NewEncoder(w).Encode(v)
71+}
72+
73+type jsonMessage struct {
74+ Error string `json:"error"`
75+}
76+
77+func jsonError(w http.ResponseWriter, status int, msg string) {
78+ jsonReply(w, status, jsonMessage{Error: msg})
79+}
80+
81+// jsonInternal answers a failure that is not the caller's to fix: an id for the
82+// log, and no detail.
83+func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
84+ id := requestID()
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed")
87+ jsonError(w, http.StatusInternalServerError, "internal error, request id "+id)
88+}
89+
90+// jsonBody checks the content type of a finish and reads its body, bounded. It
91+// answers 415 or 413 and returns false when it cannot.
92+func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
93+ mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
94+ if err != nil || mt != "application/json" {
95+ jsonError(w, http.StatusUnsupportedMediaType, "send application/json")
96+ return nil, false
97+ }
98+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes))
99+ if err != nil {
100+ var tooBig *http.MaxBytesError
101+ if errors.As(err, &tooBig) {
102+ jsonError(w, http.StatusRequestEntityTooLarge, "request too large")
103+ } else {
104+ jsonError(w, http.StatusBadRequest, "request could not be read")
105+ }
106+ return nil, false
107+ }
108+ return body, true
109+}
110+
111+// ceremonyReply is the answer of a begin.
112+type ceremonyReply struct {
113+ Ceremony string `json:"ceremony"`
114+ Options json.RawMessage `json:"options"`
115+}
116+
117+// blocked answers 429 when the client has failed too many sign-ins.
118+func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) {
119+ addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
120+ if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is {
121+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
122+ jsonError(w, http.StatusTooManyRequests, "too many attempts")
123+ return addr, true
124+ }
125+ return addr, false
126+}
127+
128+// passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes
129+// home.
130+func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
131+ if SignedIn(r.Context()) {
132+ seeOther(w, r, "/")
133+ return
134+ }
135+ l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
136+ s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{
137+ Title: "sign in",
138+ Button: "Sign in with a passkey",
139+ Lines: []string{"or run `ermite web login` in a terminal."},
140+ Begin: loginBeginPath,
141+ Finish: loginFinishPath,
142+ }))
143+}
144+
145+// passkeyLoginBegin is POST /-/passkeys/login/begin.
146+func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147+ if _, blocked := s.blocked(w, r); blocked {
148+ return
149+ }
150+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
151+ defer cancel()
152+ ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx)
153+ if err != nil {
154+ s.jsonInternal(ctx, w, r, err)
155+ return
156+ }
157+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
158+}
159+
160+// finishRequest is the body of a finish.
161+type finishRequest struct {
162+ Ceremony string `json:"ceremony"`
163+ Name string `json:"name"`
164+ Credential json.RawMessage `json:"credential"`
165+}
166+
167+// passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response
168+// of the authenticator and sets the session cookie, the same as a login link.
169+// A refusal counts as a failed authentication of the client, whatever the
170+// reason.
171+func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
172+ body, ok := jsonBody(w, r)
173+ if !ok {
174+ return
175+ }
176+ addr, blocked := s.blocked(w, r)
177+ if blocked {
178+ return
179+ }
180+ var req finishRequest
181+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
182+ jsonError(w, http.StatusBadRequest, "malformed request")
183+ return
184+ }
185+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
186+ defer cancel()
187+ token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential)
188+ switch {
189+ case errors.Is(err, ports.ErrLinkRejected):
190+ s.cfg.Limiter.Fail(ctx, addr)
191+ jsonError(w, http.StatusUnauthorized, "passkey not accepted")
192+ return
193+ case err != nil:
194+ s.jsonInternal(ctx, w, r, err)
195+ return
196+ }
197+ h := w.Header()
198+ h.Del("Set-Cookie") // a stale cookie cleared by withSession
199+ http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
200+ h.Set("Cache-Control", noStore)
201+ w.WriteHeader(http.StatusNoContent)
202+}
203+
204+// passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404
205+// page, as for any page that does not exist for them.
206+func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) {
207+ if !SignedIn(ctx) {
208+ return nil, 0, errNotFound
209+ }
210+ list, err := s.cfg.Passkeys.List(ctx, principal(ctx))
211+ if err != nil {
212+ return nil, 0, err
213+ }
214+ now := s.cfg.Now()
215+ rows := make([]PasskeyRow, 0, len(list))
216+ for _, p := range list {
217+ rows = append(rows, PasskeyRow{
218+ Name: p.Name,
219+ Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt),
220+ LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt),
221+ Synced: p.BackupState,
222+ Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove",
223+ })
224+ }
225+ l := s.base()
226+ l.Title = "passkeys · " + s.instance
227+ l.Crumbs = []Crumb{{Label: "passkeys", Current: true}}
228+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
229+ return Passkeys(l, PasskeysPage{
230+ Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys,
231+ Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish",
232+ }), http.StatusOK, nil
233+}
234+
235+// registerError answers a refused registration step.
236+func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
237+ switch {
238+ case errors.Is(err, context.Canceled):
239+ // The browser is gone.
240+ case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx):
241+ jsonError(w, http.StatusForbidden, "forbidden")
242+ case errors.Is(err, domain.ErrAlreadyExists):
243+ jsonError(w, http.StatusConflict, "this passkey is already registered")
244+ case errors.Is(err, domain.ErrResourceExhausted):
245+ jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached"))
246+ case domain.IsInvalid(err):
247+ jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid"))
248+ case errors.Is(err, domain.ErrUnauthenticated):
249+ jsonError(w, http.StatusBadRequest, "passkey not accepted, try again")
250+ default:
251+ s.jsonInternal(ctx, w, r, err)
252+ }
253+}
254+
255+// passkeyRegisterBegin is POST /-/passkeys/register/begin.
256+func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
257+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
258+ defer cancel()
259+ ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx))
260+ if err != nil {
261+ s.registerError(ctx, w, r, err)
262+ return
263+ }
264+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
265+}
266+
267+// passkeyRegisterFinish is POST /-/passkeys/register/finish.
268+func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
269+ body, ok := jsonBody(w, r)
270+ if !ok {
271+ return
272+ }
273+ var req finishRequest
274+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
275+ jsonError(w, http.StatusBadRequest, "malformed request")
276+ return
277+ }
278+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
279+ defer cancel()
280+ if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Name, req.Credential); err != nil {
281+ s.registerError(ctx, w, r, err)
282+ return
283+ }
284+ w.Header().Set("Cache-Control", noStore)
285+ w.WriteHeader(http.StatusNoContent)
286+}
287+
288+// passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which
289+// signs every browser out, this one included: the answer sends it to the
290+// sign-in page.
291+func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) {
292+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
293+ defer cancel()
294+ id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id"))
295+ if err != nil || len(id) == 0 {
296+ s.actionFailed(ctx, w, r, errNotFound, passkeysPath)
297+ return
298+ }
299+ if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil {
300+ s.actionFailed(ctx, w, r, err, passkeysPath)
301+ return
302+ }
303+ seeOther(w, r, passkeyLoginPath)
304+}
@@ -0,0 +1,304 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "encoding/json"
9+ "errors"
10+ "io"
11+ "mime"
12+ "net/http"
13+ "strconv"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// The owner's passkeys on the web (ADR 0026).
24+//
25+// The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the
26+// owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js
27+// makes, each in two steps (begin, finish). Every POST passes the gate: an
28+// Origin equal to the public origin, and a session, except the two login
29+// endpoints, which are how a session starts. The finish endpoints also need
30+// "Content-Type: application/json", which a cross-site form cannot send.
31+
32+const (
33+ passkeyLoginPath = "/-/login"
34+ passkeysPath = "/-/passkeys"
35+
36+ loginBeginPath = passkeysPath + "/login/begin"
37+ loginFinishPath = passkeysPath + "/login/finish"
38+
39+ // maxCeremonyBytes bounds the body of a finish: a response of an
40+ // authenticator is well under 10 KiB.
41+ maxCeremonyBytes = 64 << 10
42+)
43+
44+// passkeysOn tells that the passkey routes exist.
45+func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil }
46+
47+func (s *Server) passkeyRoutes(mux *http.ServeMux) {
48+ mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage)
49+ mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin)
50+ mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish)
51+ mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage))
52+ mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin)
53+ mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish)
54+ mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove)
55+}
56+
57+// isPasskeyLoginPath tells whether u is exactly one of the two endpoints an
58+// anonymous browser may post to. Like isLoginPath it looks at the escaped path,
59+// which is what the router matches.
60+func isPasskeyLoginPath(path string) bool {
61+ return path == loginBeginPath || path == loginFinishPath
62+}
63+
64+// jsonReply writes a JSON answer that no cache keeps.
65+func jsonReply(w http.ResponseWriter, status int, v any) {
66+ h := w.Header()
67+ h.Set("Content-Type", "application/json")
68+ h.Set("Cache-Control", noStore)
69+ w.WriteHeader(status)
70+ _ = json.NewEncoder(w).Encode(v)
71+}
72+
73+type jsonMessage struct {
74+ Error string `json:"error"`
75+}
76+
77+func jsonError(w http.ResponseWriter, status int, msg string) {
78+ jsonReply(w, status, jsonMessage{Error: msg})
79+}
80+
81+// jsonInternal answers a failure that is not the caller's to fix: an id for the
82+// log, and no detail.
83+func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
84+ id := requestID()
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed")
87+ jsonError(w, http.StatusInternalServerError, "internal error, request id "+id)
88+}
89+
90+// jsonBody checks the content type of a finish and reads its body, bounded. It
91+// answers 415 or 413 and returns false when it cannot.
92+func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
93+ mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
94+ if err != nil || mt != "application/json" {
95+ jsonError(w, http.StatusUnsupportedMediaType, "send application/json")
96+ return nil, false
97+ }
98+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes))
99+ if err != nil {
100+ var tooBig *http.MaxBytesError
101+ if errors.As(err, &tooBig) {
102+ jsonError(w, http.StatusRequestEntityTooLarge, "request too large")
103+ } else {
104+ jsonError(w, http.StatusBadRequest, "request could not be read")
105+ }
106+ return nil, false
107+ }
108+ return body, true
109+}
110+
111+// ceremonyReply is the answer of a begin.
112+type ceremonyReply struct {
113+ Ceremony string `json:"ceremony"`
114+ Options json.RawMessage `json:"options"`
115+}
116+
117+// blocked answers 429 when the client has failed too many sign-ins.
118+func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) {
119+ addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
120+ if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is {
121+ w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
122+ jsonError(w, http.StatusTooManyRequests, "too many attempts")
123+ return addr, true
124+ }
125+ return addr, false
126+}
127+
128+// passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes
129+// home.
130+func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
131+ if SignedIn(r.Context()) {
132+ seeOther(w, r, "/")
133+ return
134+ }
135+ l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
136+ s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{
137+ Title: "sign in",
138+ Button: "Sign in with a passkey",
139+ Lines: []string{"or run `ermite web login` in a terminal."},
140+ Begin: loginBeginPath,
141+ Finish: loginFinishPath,
142+ }))
143+}
144+
145+// passkeyLoginBegin is POST /-/passkeys/login/begin.
146+func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147+ if _, blocked := s.blocked(w, r); blocked {
148+ return
149+ }
150+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
151+ defer cancel()
152+ ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx)
153+ if err != nil {
154+ s.jsonInternal(ctx, w, r, err)
155+ return
156+ }
157+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
158+}
159+
160+// finishRequest is the body of a finish.
161+type finishRequest struct {
162+ Ceremony string `json:"ceremony"`
163+ Name string `json:"name"`
164+ Credential json.RawMessage `json:"credential"`
165+}
166+
167+// passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response
168+// of the authenticator and sets the session cookie, the same as a login link.
169+// A refusal counts as a failed authentication of the client, whatever the
170+// reason.
171+func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
172+ body, ok := jsonBody(w, r)
173+ if !ok {
174+ return
175+ }
176+ addr, blocked := s.blocked(w, r)
177+ if blocked {
178+ return
179+ }
180+ var req finishRequest
181+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
182+ jsonError(w, http.StatusBadRequest, "malformed request")
183+ return
184+ }
185+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
186+ defer cancel()
187+ token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential)
188+ switch {
189+ case errors.Is(err, ports.ErrLinkRejected):
190+ s.cfg.Limiter.Fail(ctx, addr)
191+ jsonError(w, http.StatusUnauthorized, "passkey not accepted")
192+ return
193+ case err != nil:
194+ s.jsonInternal(ctx, w, r, err)
195+ return
196+ }
197+ h := w.Header()
198+ h.Del("Set-Cookie") // a stale cookie cleared by withSession
199+ http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
200+ h.Set("Cache-Control", noStore)
201+ w.WriteHeader(http.StatusNoContent)
202+}
203+
204+// passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404
205+// page, as for any page that does not exist for them.
206+func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) {
207+ if !SignedIn(ctx) {
208+ return nil, 0, errNotFound
209+ }
210+ list, err := s.cfg.Passkeys.List(ctx, principal(ctx))
211+ if err != nil {
212+ return nil, 0, err
213+ }
214+ now := s.cfg.Now()
215+ rows := make([]PasskeyRow, 0, len(list))
216+ for _, p := range list {
217+ rows = append(rows, PasskeyRow{
218+ Name: p.Name,
219+ Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt),
220+ LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt),
221+ Synced: p.BackupState,
222+ Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove",
223+ })
224+ }
225+ l := s.base()
226+ l.Title = "passkeys · " + s.instance
227+ l.Crumbs = []Crumb{{Label: "passkeys", Current: true}}
228+ l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
229+ return Passkeys(l, PasskeysPage{
230+ Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys,
231+ Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish",
232+ }), http.StatusOK, nil
233+}
234+
235+// registerError answers a refused registration step.
236+func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
237+ switch {
238+ case errors.Is(err, context.Canceled):
239+ // The browser is gone.
240+ case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx):
241+ jsonError(w, http.StatusForbidden, "forbidden")
242+ case errors.Is(err, domain.ErrAlreadyExists):
243+ jsonError(w, http.StatusConflict, "this passkey is already registered")
244+ case errors.Is(err, domain.ErrResourceExhausted):
245+ jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached"))
246+ case domain.IsInvalid(err):
247+ jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid"))
248+ case errors.Is(err, domain.ErrUnauthenticated):
249+ jsonError(w, http.StatusBadRequest, "passkey not accepted, try again")
250+ default:
251+ s.jsonInternal(ctx, w, r, err)
252+ }
253+}
254+
255+// passkeyRegisterBegin is POST /-/passkeys/register/begin.
256+func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
257+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
258+ defer cancel()
259+ ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx))
260+ if err != nil {
261+ s.registerError(ctx, w, r, err)
262+ return
263+ }
264+ jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
265+}
266+
267+// passkeyRegisterFinish is POST /-/passkeys/register/finish.
268+func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
269+ body, ok := jsonBody(w, r)
270+ if !ok {
271+ return
272+ }
273+ var req finishRequest
274+ if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
275+ jsonError(w, http.StatusBadRequest, "malformed request")
276+ return
277+ }
278+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
279+ defer cancel()
280+ if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Name, req.Credential); err != nil {
281+ s.registerError(ctx, w, r, err)
282+ return
283+ }
284+ w.Header().Set("Cache-Control", noStore)
285+ w.WriteHeader(http.StatusNoContent)
286+}
287+
288+// passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which
289+// signs every browser out, this one included: the answer sends it to the
290+// sign-in page.
291+func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) {
292+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
293+ defer cancel()
294+ id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id"))
295+ if err != nil || len(id) == 0 {
296+ s.actionFailed(ctx, w, r, errNotFound, passkeysPath)
297+ return
298+ }
299+ if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil {
300+ s.actionFailed(ctx, w, r, err, passkeysPath)
301+ return
302+ }
303+ seeOther(w, r, passkeyLoginPath)
304+}
A internal/adapters/web/passkeys_test.go +547 -0
@@ -0,0 +1,547 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+const forgeOrigin = "https://forge.example"
25+
26+// blockingLimiter is a FailureLimiter that can be told to block.
27+type blockingLimiter struct {
28+ blocked bool
29+ fails int
30+}
31+
32+func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) {
33+ if l.blocked {
34+ return true, 90 * time.Second
35+ }
36+ return false, 0
37+}
38+func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ }
39+
40+type passkeyFixture struct {
41+ srv *Server
42+ sessions *mocks.WebSessionService
43+ passkeys *mocks.WebPasskeyService
44+ lim *blockingLimiter
45+}
46+
47+func newPasskeyServer(t *testing.T) *passkeyFixture {
48+ t.Helper()
49+ f := &passkeyFixture{
50+ sessions: newSessions(t, "tok"),
51+ passkeys: mocks.NewWebPasskeyService(t),
52+ lim: &blockingLimiter{},
53+ }
54+ f.srv = newTestServer(t, Config{
55+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
56+ PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim,
57+ })
58+ return f
59+}
60+
61+type req struct {
62+ method, target string
63+ origin string // "" sends none
64+ cookie string
65+ contentType string
66+ body string
67+}
68+
69+func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder {
70+ t.Helper()
71+ hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body))
72+ hr.Host = "forge.example"
73+ if r.origin != "" {
74+ hr.Header.Set("Origin", r.origin)
75+ }
76+ if r.cookie != "" {
77+ hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie})
78+ }
79+ if r.contentType != "" {
80+ hr.Header.Set("Content-Type", r.contentType)
81+ }
82+ rec := httptest.NewRecorder()
83+ f.srv.ServeHTTP(rec, hr)
84+ return rec
85+}
86+
87+func postJSON(target, cookie, body string) req {
88+ return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body}
89+}
90+
91+const finishBody = `{"ceremony":"c1","name":"Pixel 9","credential":{"id":"x","type":"public-key"}}`
92+
93+var (
94+ beginLogin = "/-/passkeys/login/begin"
95+ finishLogin = "/-/passkeys/login/finish"
96+ beginRegister = "/-/passkeys/register/begin"
97+ finishRegister = "/-/passkeys/register/finish"
98+)
99+
100+// Every new POST needs the Origin of the public URL.
101+func TestPasskeyPostsNeedTheOrigin(t *testing.T) {
102+ t.Parallel()
103+ f := newPasskeyServer(t) // the mocks fail the test on any call
104+ removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove"
105+ for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} {
106+ for _, cookie := range []string{"", "tok"} {
107+ for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} {
108+ rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody})
109+ assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie)
110+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
111+ }
112+ }
113+ }
114+}
115+
116+// An anonymous browser may post to the two login endpoints only.
117+func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
118+ t.Parallel()
119+ f := newPasskeyServer(t)
120+ id := base64.RawURLEncoding.EncodeToString([]byte("a"))
121+ for _, target := range []string{
122+ beginRegister, finishRegister, "/-/passkeys/" + id + "/remove",
123+ "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x",
124+ "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin",
125+ "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin",
126+ "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f",
127+ } {
128+ rec := f.do(t, postJSON(target, "", finishBody))
129+ assert.Equal(t, http.StatusForbidden, rec.Code, target)
130+ }
131+ // A GET is never a login.
132+ rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin})
133+ assert.NotEqual(t, http.StatusOK, rec.Code)
134+}
135+
136+func TestPasskeyLoginBegin(t *testing.T) {
137+ t.Parallel()
138+ f := newPasskeyServer(t)
139+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
140+
141+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
142+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
143+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
144+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
145+ assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String())
146+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
147+}
148+
149+func TestPasskeyLoginBeginFailure(t *testing.T) {
150+ t.Parallel()
151+ f := newPasskeyServer(t)
152+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once()
153+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
154+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
155+ assert.NotContains(t, rec.Body.String(), "secret detail")
156+ assert.Contains(t, rec.Body.String(), "request id")
157+}
158+
159+func TestPasskeyLoginBlocked(t *testing.T) {
160+ t.Parallel()
161+ f := newPasskeyServer(t) // no use case call is expected
162+ f.lim.blocked = true
163+ for _, r := range []req{
164+ {method: "POST", target: beginLogin, origin: forgeOrigin},
165+ postJSON(finishLogin, "", finishBody),
166+ } {
167+ rec := f.do(t, r)
168+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, r.target)
169+ assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String())
170+ assert.Equal(t, "91", rec.Header().Get("Retry-After"))
171+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
172+ }
173+ assert.Zero(t, f.lim.fails, "a blocked client is not counted again")
174+}
175+
176+func TestPasskeyLoginFinishSetsTheSession(t *testing.T) {
177+ t.Parallel()
178+ f := newPasskeyServer(t)
179+ f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool {
180+ return string(b) == `{"id":"x","type":"public-key"}`
181+ })).Return("tok", 12*time.Hour, nil).Once()
182+
183+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
184+ require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
185+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
186+ assert.Zero(t, f.lim.fails)
187+
188+ cookies := rec.Result().Cookies()
189+ require.Len(t, cookies, 1)
190+ c := cookies[0]
191+ assert.Equal(t, CookieName, c.Name)
192+ assert.Equal(t, "tok", c.Value)
193+ assert.True(t, c.Secure)
194+ assert.True(t, c.HttpOnly)
195+ assert.Equal(t, http.SameSiteStrictMode, c.SameSite)
196+ assert.Equal(t, "/", c.Path)
197+ assert.Equal(t, 12*3600, c.MaxAge)
198+ assert.Empty(t, c.Domain)
199+ set := rec.Header().Get("Set-Cookie")
200+ for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} {
201+ assert.Contains(t, set, attr)
202+ }
203+}
204+
205+// A stale cookie that withSession cleared is replaced, not doubled.
206+func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) {
207+ t.Parallel()
208+ f := newPasskeyServer(t)
209+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
210+ rec := f.do(t, postJSON(finishLogin, "stale", finishBody))
211+ // A stale cookie is not a session: the login endpoint still lets it through.
212+ require.Equal(t, http.StatusNoContent, rec.Code)
213+ require.Len(t, rec.Result().Cookies(), 1)
214+ assert.Equal(t, "tok", rec.Result().Cookies()[0].Value)
215+}
216+
217+func TestPasskeyLoginFinishRefusal(t *testing.T) {
218+ t.Parallel()
219+ f := newPasskeyServer(t)
220+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice()
221+
222+ for range 2 {
223+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
224+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
225+ assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String())
226+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
227+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
228+ }
229+ assert.Equal(t, 2, f.lim.fails, "every refusal counts")
230+}
231+
232+func TestPasskeyLoginFinishInternalError(t *testing.T) {
233+ t.Parallel()
234+ f := newPasskeyServer(t)
235+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once()
236+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
237+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
238+ assert.NotContains(t, rec.Body.String(), "kv down")
239+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
240+ assert.Zero(t, f.lim.fails, "a server failure is not the client's")
241+}
242+
243+func TestPasskeyFinishNeedsJSON(t *testing.T) {
244+ t.Parallel()
245+ f := newPasskeyServer(t) // no use case call is expected
246+ for _, target := range []string{finishLogin, finishRegister} {
247+ for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} {
248+ cookie := ""
249+ if target == finishRegister {
250+ cookie = "tok"
251+ }
252+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody})
253+ assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct)
254+ }
255+ }
256+ assert.Zero(t, f.lim.fails)
257+}
258+
259+func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) {
260+ t.Parallel()
261+ f := newPasskeyServer(t)
262+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
263+ rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody})
264+ assert.Equal(t, http.StatusNoContent, rec.Code)
265+}
266+
267+func TestPasskeyFinishBodyLimit(t *testing.T) {
268+ t.Parallel()
269+ f := newPasskeyServer(t)
270+ big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}`
271+ for _, target := range []string{finishLogin, finishRegister} {
272+ rec := f.do(t, postJSON(target, "tok", big))
273+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target)
274+ }
275+ // Right at the limit is read (and then refused as malformed, not as too large).
276+ exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}`
277+ require.Len(t, exact, 64<<10)
278+ rec := f.do(t, postJSON(finishLogin, "", exact))
279+ assert.Equal(t, http.StatusBadRequest, rec.Code)
280+ assert.Zero(t, f.lim.fails, "a malformed body is not a failed authentication")
281+}
282+
283+func TestPasskeyFinishMalformed(t *testing.T) {
284+ t.Parallel()
285+ f := newPasskeyServer(t)
286+ for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} {
287+ for _, target := range []string{finishLogin, finishRegister} {
288+ rec := f.do(t, postJSON(target, "tok", body))
289+ assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body)
290+ }
291+ }
292+}
293+
294+// The registration endpoints are the owner's.
295+func TestPasskeyRegistration(t *testing.T) {
296+ t.Parallel()
297+ f := newPasskeyServer(t)
298+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
299+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
300+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
301+ assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
302+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
303+
304+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", "Pixel 9", mock.MatchedBy(func(b []byte) bool {
305+ return string(b) == `{"id":"x","type":"public-key"}`
306+ })).Return(nil).Once()
307+ rec = f.do(t, postJSON(finishRegister, "tok", finishBody))
308+ assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
309+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
310+ assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session")
311+}
312+
313+func TestPasskeyRegistrationErrors(t *testing.T) {
314+ t.Parallel()
315+ for name, tc := range map[string]struct {
316+ err error
317+ status int
318+ body string
319+ }{
320+ "duplicate": {domain.ErrAlreadyExists, 409, "already registered"},
321+ "limit": {domain.ErrResourceExhausted, 429, ""},
322+ "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"},
323+ "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"},
324+ "not owner": {domain.ErrForbidden, 403, ""},
325+ "server error": {errors.New("kv down"), 500, ""},
326+ } {
327+ t.Run(name, func(t *testing.T) {
328+ t.Parallel()
329+ f := newPasskeyServer(t)
330+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once()
331+ rec := f.do(t, postJSON(finishRegister, "tok", finishBody))
332+ assert.Equal(t, tc.status, rec.Code)
333+ assert.Contains(t, rec.Body.String(), `"error"`)
334+ assert.Contains(t, rec.Body.String(), tc.body)
335+ assert.NotContains(t, rec.Body.String(), "kv down")
336+ assert.Zero(t, f.lim.fails, "the owner's errors are not login failures")
337+ })
338+ }
339+
340+ f := newPasskeyServer(t)
341+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
342+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
343+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
344+}
345+
346+func TestPasskeysPage(t *testing.T) {
347+ t.Parallel()
348+ f := newPasskeyServer(t)
349+ now := time.Now()
350+ list := []domain.Passkey{
351+ {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true},
352+ {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)},
353+ }
354+ f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once()
355+
356+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
357+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
358+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
359+ body := rec.Body.String()
360+ assert.Contains(t, body, "Pixel &lt;9&gt;", "names are escaped")
361+ assert.NotContains(t, body, "Pixel <9>")
362+ assert.Contains(t, body, "yubikey")
363+ assert.Contains(t, body, "3d ago")
364+ assert.Contains(t, body, "never")
365+ assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
366+ assert.Contains(t, body, `data-passkey="register"`)
367+ assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
368+ assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
369+ assert.Contains(t, body, "data-passkey-status")
370+ assert.Contains(t, body, `aria-live="polite"`)
371+}
372+
373+func TestPasskeysPageFull(t *testing.T) {
374+ t.Parallel()
375+ f := newPasskeyServer(t)
376+ list := make([]domain.Passkey, domain.MaxPasskeys)
377+ for i := range list {
378+ list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: time.Now()}
379+ }
380+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once()
381+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
382+ require.Equal(t, http.StatusOK, rec.Code)
383+ assert.NotContains(t, rec.Body.String(), `data-passkey="register"`)
384+ assert.Contains(t, rec.Body.String(), "Remove one to add another")
385+}
386+
387+// Anybody but the owner gets the 404 page, and the list is not read.
388+func TestPasskeysPageIsTheOwners(t *testing.T) {
389+ t.Parallel()
390+ f := newPasskeyServer(t)
391+ for _, cookie := range []string{"", "stale"} {
392+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie})
393+ assert.Equal(t, http.StatusNotFound, rec.Code, cookie)
394+ assert.NotContains(t, rec.Body.String(), "Passkeys")
395+ assert.NotContains(t, rec.Body.String(), "data-passkey")
396+ }
397+}
398+
399+func TestPasskeyRemove(t *testing.T) {
400+ t.Parallel()
401+ f := newPasskeyServer(t)
402+ id := []byte{0xfb, 0xff, 'a'}
403+ target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove"
404+ f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once()
405+
406+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
407+ assert.Equal(t, http.StatusSeeOther, rec.Code)
408+ assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too")
409+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
410+}
411+
412+func TestPasskeyRemoveErrors(t *testing.T) {
413+ t.Parallel()
414+ f := newPasskeyServer(t)
415+ // Ids that are not unpadded base64url never reach the use case (the mock
416+ // fails the test on a call).
417+ for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} {
418+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"})
419+ assert.Equal(t, http.StatusNotFound, rec.Code, id)
420+ }
421+
422+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once()
423+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"})
424+ assert.Equal(t, http.StatusNotFound, rec.Code)
425+
426+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once()
427+ rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"})
428+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
429+ assert.NotContains(t, rec.Body.String(), "kv down")
430+}
431+
432+func TestPasskeyLoginPage(t *testing.T) {
433+ t.Parallel()
434+ f := newPasskeyServer(t)
435+ rec := f.do(t, req{method: "GET", target: "/-/login"})
436+ require.Equal(t, http.StatusOK, rec.Code)
437+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
438+ assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy"))
439+ body := rec.Body.String()
440+ assert.Contains(t, body, "<title>sign in")
441+ assert.Contains(t, body, "Sign in with a passkey")
442+ assert.Contains(t, body, `data-passkey="login"`)
443+ assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
444+ assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
445+ assert.Contains(t, body, "ermite web login")
446+ assert.NotContains(t, body, "<script>", "no inline script")
447+
448+ // A signed-in visitor goes home.
449+ rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"})
450+ assert.Equal(t, http.StatusSeeOther, rec.Code)
451+ assert.Equal(t, "/", rec.Header().Get("Location"))
452+}
453+
454+// The header links: "Sign in" for a visitor, "Passkeys" for the owner.
455+func TestPasskeyHeaderLinks(t *testing.T) {
456+ t.Parallel()
457+ f := newPasskeyServer(t)
458+ rec := f.do(t, req{method: "GET", target: "/-/login"})
459+ assert.Contains(t, rec.Body.String(), `href="/-/login"`)
460+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
461+ assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded")
462+
463+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once()
464+ rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
465+ assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`)
466+ assert.Contains(t, rec.Body.String(), `action="/-/logout"`)
467+ assert.NotContains(t, rec.Body.String(), `>Sign in</a>`)
468+}
469+
470+// Without the passkey service there is no route, no link and no script
471+// behavior to reach: the routes answer like any unknown path.
472+func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) {
473+ t.Parallel()
474+ lim := &blockingLimiter{}
475+ srv := newTestServer(t, Config{
476+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
477+ PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim,
478+ })
479+ do := func(method, target, cookie string) *httptest.ResponseRecorder {
480+ r := httptest.NewRequest(method, target, strings.NewReader(finishBody))
481+ r.Header.Set("Origin", forgeOrigin)
482+ r.Header.Set("Content-Type", "application/json")
483+ if cookie != "" {
484+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
485+ }
486+ rec := httptest.NewRecorder()
487+ srv.ServeHTTP(rec, r)
488+ return rec
489+ }
490+ for _, target := range []string{"/-/login", "/-/passkeys"} {
491+ assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target)
492+ assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target)
493+ }
494+ // A POST to a path without a route gets what any unknown path gets for a
495+ // POST (the catch-all page route is GET only).
496+ unknown := do("POST", "/-/nothing-here", "tok").Code
497+ assert.Equal(t, http.StatusMethodNotAllowed, unknown)
498+ for _, target := range []string{beginLogin, finishLogin} {
499+ assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST")
500+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
501+ }
502+ for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} {
503+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
504+ }
505+ // And no link in the header.
506+ rec := do("GET", "/", "")
507+ assert.NotContains(t, rec.Body.String(), `href="/-/login"`)
508+ rec = do("GET", "/", "tok")
509+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
510+}
511+
512+// Passkeys need sessions: with the service alone nothing is served.
513+func TestPasskeyRoutesNeedSessions(t *testing.T) {
514+ t.Parallel()
515+ srv := newTestServer(t, Config{
516+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
517+ PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t),
518+ })
519+ rec := httptest.NewRecorder()
520+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil))
521+ assert.Equal(t, http.StatusNotFound, rec.Code)
522+ r := httptest.NewRequest("POST", beginLogin, nil)
523+ r.Header.Set("Origin", forgeOrigin)
524+ rec = httptest.NewRecorder()
525+ srv.ServeHTTP(rec, r)
526+ assert.Equal(t, http.StatusForbidden, rec.Code)
527+}
528+
529+// The existing login links keep working next to the passkey routes.
530+func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) {
531+ t.Parallel()
532+ f := newPasskeyServer(t)
533+ rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA})
534+ assert.Equal(t, http.StatusOK, rec.Code)
535+ assert.Contains(t, rec.Body.String(), "sign in as the owner")
536+ rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin})
537+ assert.Equal(t, http.StatusSeeOther, rec.Code)
538+}
539+
540+func TestIsPasskeyLoginPath(t *testing.T) {
541+ t.Parallel()
542+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin"))
543+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish"))
544+ for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} {
545+ assert.False(t, isPasskeyLoginPath(p), p)
546+ }
547+}
@@ -0,0 +1,547 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+const forgeOrigin = "https://forge.example"
25+
26+// blockingLimiter is a FailureLimiter that can be told to block.
27+type blockingLimiter struct {
28+ blocked bool
29+ fails int
30+}
31+
32+func (l *blockingLimiter) Blocked(context.Context, string) (bool, time.Duration) {
33+ if l.blocked {
34+ return true, 90 * time.Second
35+ }
36+ return false, 0
37+}
38+func (l *blockingLimiter) Fail(context.Context, string) { l.fails++ }
39+
40+type passkeyFixture struct {
41+ srv *Server
42+ sessions *mocks.WebSessionService
43+ passkeys *mocks.WebPasskeyService
44+ lim *blockingLimiter
45+}
46+
47+func newPasskeyServer(t *testing.T) *passkeyFixture {
48+ t.Helper()
49+ f := &passkeyFixture{
50+ sessions: newSessions(t, "tok"),
51+ passkeys: mocks.NewWebPasskeyService(t),
52+ lim: &blockingLimiter{},
53+ }
54+ f.srv = newTestServer(t, Config{
55+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
56+ PublicURL: forgeOrigin, Sessions: f.sessions, Passkeys: f.passkeys, Limiter: f.lim,
57+ })
58+ return f
59+}
60+
61+type req struct {
62+ method, target string
63+ origin string // "" sends none
64+ cookie string
65+ contentType string
66+ body string
67+}
68+
69+func (f *passkeyFixture) do(t *testing.T, r req) *httptest.ResponseRecorder {
70+ t.Helper()
71+ hr := httptest.NewRequest(r.method, r.target, strings.NewReader(r.body))
72+ hr.Host = "forge.example"
73+ if r.origin != "" {
74+ hr.Header.Set("Origin", r.origin)
75+ }
76+ if r.cookie != "" {
77+ hr.AddCookie(&http.Cookie{Name: CookieName, Value: r.cookie})
78+ }
79+ if r.contentType != "" {
80+ hr.Header.Set("Content-Type", r.contentType)
81+ }
82+ rec := httptest.NewRecorder()
83+ f.srv.ServeHTTP(rec, hr)
84+ return rec
85+}
86+
87+func postJSON(target, cookie, body string) req {
88+ return req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: "application/json", body: body}
89+}
90+
91+const finishBody = `{"ceremony":"c1","name":"Pixel 9","credential":{"id":"x","type":"public-key"}}`
92+
93+var (
94+ beginLogin = "/-/passkeys/login/begin"
95+ finishLogin = "/-/passkeys/login/finish"
96+ beginRegister = "/-/passkeys/register/begin"
97+ finishRegister = "/-/passkeys/register/finish"
98+)
99+
100+// Every new POST needs the Origin of the public URL.
101+func TestPasskeyPostsNeedTheOrigin(t *testing.T) {
102+ t.Parallel()
103+ f := newPasskeyServer(t) // the mocks fail the test on any call
104+ removeTarget := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("a")) + "/remove"
105+ for _, target := range []string{beginLogin, finishLogin, beginRegister, finishRegister, removeTarget} {
106+ for _, cookie := range []string{"", "tok"} {
107+ for _, origin := range []string{"", "null", "https://evil.example", "http://forge.example", "https://forge.example.evil.example"} {
108+ rec := f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/json", body: finishBody})
109+ assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, origin, cookie)
110+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
111+ }
112+ }
113+ }
114+}
115+
116+// An anonymous browser may post to the two login endpoints only.
117+func TestPasskeyAnonymousPostsAreExact(t *testing.T) {
118+ t.Parallel()
119+ f := newPasskeyServer(t)
120+ id := base64.RawURLEncoding.EncodeToString([]byte("a"))
121+ for _, target := range []string{
122+ beginRegister, finishRegister, "/-/passkeys/" + id + "/remove",
123+ "/-/passkeys", "/-/passkeys/", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/login/begin/x",
124+ "/-/passkeys/login/finish/x", "/-/passkeys%2Flogin%2Fbegin", "/-%2Fpasskeys/login/begin",
125+ "/-/passkeys/login%2Ffinish", "/-/passkeys/login/./begin", "/-/passkeys/x/../login/begin",
126+ "/-/passkeys/%6Cogin/begin", "//-/passkeys/login/begin", "/-/passkeys/login/begin%2f",
127+ } {
128+ rec := f.do(t, postJSON(target, "", finishBody))
129+ assert.Equal(t, http.StatusForbidden, rec.Code, target)
130+ }
131+ // A GET is never a login.
132+ rec := f.do(t, req{method: "GET", target: beginLogin, origin: forgeOrigin})
133+ assert.NotEqual(t, http.StatusOK, rec.Code)
134+}
135+
136+func TestPasskeyLoginBegin(t *testing.T) {
137+ t.Parallel()
138+ f := newPasskeyServer(t)
139+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("cer1", []byte(`{"publicKey":{"challenge":"abc"}}`), nil).Once()
140+
141+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
142+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
143+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
144+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
145+ assert.JSONEq(t, `{"ceremony":"cer1","options":{"publicKey":{"challenge":"abc"}}}`, rec.Body.String())
146+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
147+}
148+
149+func TestPasskeyLoginBeginFailure(t *testing.T) {
150+ t.Parallel()
151+ f := newPasskeyServer(t)
152+ f.passkeys.EXPECT().BeginLogin(mock.Anything).Return("", nil, errors.New("secret detail")).Once()
153+ rec := f.do(t, req{method: "POST", target: beginLogin, origin: forgeOrigin})
154+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
155+ assert.NotContains(t, rec.Body.String(), "secret detail")
156+ assert.Contains(t, rec.Body.String(), "request id")
157+}
158+
159+func TestPasskeyLoginBlocked(t *testing.T) {
160+ t.Parallel()
161+ f := newPasskeyServer(t) // no use case call is expected
162+ f.lim.blocked = true
163+ for _, r := range []req{
164+ {method: "POST", target: beginLogin, origin: forgeOrigin},
165+ postJSON(finishLogin, "", finishBody),
166+ } {
167+ rec := f.do(t, r)
168+ assert.Equal(t, http.StatusTooManyRequests, rec.Code, r.target)
169+ assert.JSONEq(t, `{"error":"too many attempts"}`, rec.Body.String())
170+ assert.Equal(t, "91", rec.Header().Get("Retry-After"))
171+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
172+ }
173+ assert.Zero(t, f.lim.fails, "a blocked client is not counted again")
174+}
175+
176+func TestPasskeyLoginFinishSetsTheSession(t *testing.T) {
177+ t.Parallel()
178+ f := newPasskeyServer(t)
179+ f.passkeys.EXPECT().FinishLogin(mock.Anything, "c1", mock.MatchedBy(func(b []byte) bool {
180+ return string(b) == `{"id":"x","type":"public-key"}`
181+ })).Return("tok", 12*time.Hour, nil).Once()
182+
183+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
184+ require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
185+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
186+ assert.Zero(t, f.lim.fails)
187+
188+ cookies := rec.Result().Cookies()
189+ require.Len(t, cookies, 1)
190+ c := cookies[0]
191+ assert.Equal(t, CookieName, c.Name)
192+ assert.Equal(t, "tok", c.Value)
193+ assert.True(t, c.Secure)
194+ assert.True(t, c.HttpOnly)
195+ assert.Equal(t, http.SameSiteStrictMode, c.SameSite)
196+ assert.Equal(t, "/", c.Path)
197+ assert.Equal(t, 12*3600, c.MaxAge)
198+ assert.Empty(t, c.Domain)
199+ set := rec.Header().Get("Set-Cookie")
200+ for _, attr := range []string{"Secure", "HttpOnly", "SameSite=Strict", "Path=/"} {
201+ assert.Contains(t, set, attr)
202+ }
203+}
204+
205+// A stale cookie that withSession cleared is replaced, not doubled.
206+func TestPasskeyLoginFinishReplacesAStaleCookie(t *testing.T) {
207+ t.Parallel()
208+ f := newPasskeyServer(t)
209+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
210+ rec := f.do(t, postJSON(finishLogin, "stale", finishBody))
211+ // A stale cookie is not a session: the login endpoint still lets it through.
212+ require.Equal(t, http.StatusNoContent, rec.Code)
213+ require.Len(t, rec.Result().Cookies(), 1)
214+ assert.Equal(t, "tok", rec.Result().Cookies()[0].Value)
215+}
216+
217+func TestPasskeyLoginFinishRefusal(t *testing.T) {
218+ t.Parallel()
219+ f := newPasskeyServer(t)
220+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Twice()
221+
222+ for range 2 {
223+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
224+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
225+ assert.JSONEq(t, `{"error":"passkey not accepted"}`, rec.Body.String())
226+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
227+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
228+ }
229+ assert.Equal(t, 2, f.lim.fails, "every refusal counts")
230+}
231+
232+func TestPasskeyLoginFinishInternalError(t *testing.T) {
233+ t.Parallel()
234+ f := newPasskeyServer(t)
235+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("", time.Duration(0), errors.New("kv down")).Once()
236+ rec := f.do(t, postJSON(finishLogin, "", finishBody))
237+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
238+ assert.NotContains(t, rec.Body.String(), "kv down")
239+ assert.Empty(t, rec.Header().Values("Set-Cookie"))
240+ assert.Zero(t, f.lim.fails, "a server failure is not the client's")
241+}
242+
243+func TestPasskeyFinishNeedsJSON(t *testing.T) {
244+ t.Parallel()
245+ f := newPasskeyServer(t) // no use case call is expected
246+ for _, target := range []string{finishLogin, finishRegister} {
247+ for _, ct := range []string{"application/x-www-form-urlencoded", "text/plain", "multipart/form-data; boundary=x", "", "application/jsonx", "json"} {
248+ cookie := ""
249+ if target == finishRegister {
250+ cookie = "tok"
251+ }
252+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: cookie, contentType: ct, body: finishBody})
253+ assert.Equal(t, http.StatusUnsupportedMediaType, rec.Code, "%s %q", target, ct)
254+ }
255+ }
256+ assert.Zero(t, f.lim.fails)
257+}
258+
259+func TestPasskeyFinishAcceptsJSONWithCharset(t *testing.T) {
260+ t.Parallel()
261+ f := newPasskeyServer(t)
262+ f.passkeys.EXPECT().FinishLogin(mock.Anything, mock.Anything, mock.Anything).Return("tok", time.Hour, nil).Once()
263+ rec := f.do(t, req{method: "POST", target: finishLogin, origin: forgeOrigin, contentType: "application/json; charset=utf-8", body: finishBody})
264+ assert.Equal(t, http.StatusNoContent, rec.Code)
265+}
266+
267+func TestPasskeyFinishBodyLimit(t *testing.T) {
268+ t.Parallel()
269+ f := newPasskeyServer(t)
270+ big := `{"ceremony":"c1","credential":"` + strings.Repeat("a", 64<<10) + `"}`
271+ for _, target := range []string{finishLogin, finishRegister} {
272+ rec := f.do(t, postJSON(target, "tok", big))
273+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, target)
274+ }
275+ // Right at the limit is read (and then refused as malformed, not as too large).
276+ exact := `{"ceremony":"` + strings.Repeat("a", 64<<10-len(`{"ceremony":""}`)) + `"}`
277+ require.Len(t, exact, 64<<10)
278+ rec := f.do(t, postJSON(finishLogin, "", exact))
279+ assert.Equal(t, http.StatusBadRequest, rec.Code)
280+ assert.Zero(t, f.lim.fails, "a malformed body is not a failed authentication")
281+}
282+
283+func TestPasskeyFinishMalformed(t *testing.T) {
284+ t.Parallel()
285+ f := newPasskeyServer(t)
286+ for _, body := range []string{``, `not json`, `{}`, `{"ceremony":"c1"}`, `{"credential":{}}`, `{"ceremony":"","credential":{}}`, `[]`} {
287+ for _, target := range []string{finishLogin, finishRegister} {
288+ rec := f.do(t, postJSON(target, "tok", body))
289+ assert.Equal(t, http.StatusBadRequest, rec.Code, "%s %q", target, body)
290+ }
291+ }
292+}
293+
294+// The registration endpoints are the owner's.
295+func TestPasskeyRegistration(t *testing.T) {
296+ t.Parallel()
297+ f := newPasskeyServer(t)
298+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, domain.Owner()).Return("cer9", []byte(`{"publicKey":{}}`), nil).Once()
299+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
300+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
301+ assert.JSONEq(t, `{"ceremony":"cer9","options":{"publicKey":{}}}`, rec.Body.String())
302+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
303+
304+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, domain.Owner(), "c1", "Pixel 9", mock.MatchedBy(func(b []byte) bool {
305+ return string(b) == `{"id":"x","type":"public-key"}`
306+ })).Return(nil).Once()
307+ rec = f.do(t, postJSON(finishRegister, "tok", finishBody))
308+ assert.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
309+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
310+ assert.Empty(t, rec.Header().Values("Set-Cookie"), "registering does not touch the session")
311+}
312+
313+func TestPasskeyRegistrationErrors(t *testing.T) {
314+ t.Parallel()
315+ for name, tc := range map[string]struct {
316+ err error
317+ status int
318+ body string
319+ }{
320+ "duplicate": {domain.ErrAlreadyExists, 409, "already registered"},
321+ "limit": {domain.ErrResourceExhausted, 429, ""},
322+ "bad name": {&domain.TextError{Reason: "contains the control character U+0009"}, 400, "control character"},
323+ "refused": {domain.ErrUnauthenticated, 400, "passkey not accepted"},
324+ "not owner": {domain.ErrForbidden, 403, ""},
325+ "server error": {errors.New("kv down"), 500, ""},
326+ } {
327+ t.Run(name, func(t *testing.T) {
328+ t.Parallel()
329+ f := newPasskeyServer(t)
330+ f.passkeys.EXPECT().FinishRegistration(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tc.err).Once()
331+ rec := f.do(t, postJSON(finishRegister, "tok", finishBody))
332+ assert.Equal(t, tc.status, rec.Code)
333+ assert.Contains(t, rec.Body.String(), `"error"`)
334+ assert.Contains(t, rec.Body.String(), tc.body)
335+ assert.NotContains(t, rec.Body.String(), "kv down")
336+ assert.Zero(t, f.lim.fails, "the owner's errors are not login failures")
337+ })
338+ }
339+
340+ f := newPasskeyServer(t)
341+ f.passkeys.EXPECT().BeginRegistration(mock.Anything, mock.Anything).Return("", nil, domain.ErrResourceExhausted).Once()
342+ rec := f.do(t, req{method: "POST", target: beginRegister, origin: forgeOrigin, cookie: "tok"})
343+ assert.Equal(t, http.StatusTooManyRequests, rec.Code)
344+}
345+
346+func TestPasskeysPage(t *testing.T) {
347+ t.Parallel()
348+ f := newPasskeyServer(t)
349+ now := time.Now()
350+ list := []domain.Passkey{
351+ {ID: []byte{0xfb, 0xff, 'a'}, Name: "Pixel <9>", CreatedAt: now.Add(-72 * time.Hour), LastUsedAt: now.Add(-time.Hour), BackupState: true},
352+ {ID: []byte("hw"), Name: "yubikey", CreatedAt: now.Add(-time.Hour)},
353+ }
354+ f.passkeys.EXPECT().List(mock.Anything, domain.Owner()).Return(list, nil).Once()
355+
356+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
357+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
358+ assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
359+ body := rec.Body.String()
360+ assert.Contains(t, body, "Pixel &lt;9&gt;", "names are escaped")
361+ assert.NotContains(t, body, "Pixel <9>")
362+ assert.Contains(t, body, "yubikey")
363+ assert.Contains(t, body, "3d ago")
364+ assert.Contains(t, body, "never")
365+ assert.Contains(t, body, `action="/-/passkeys/`+base64.RawURLEncoding.EncodeToString(list[0].ID)+`/remove"`)
366+ assert.Contains(t, body, `data-passkey="register"`)
367+ assert.Contains(t, body, `data-begin="/-/passkeys/register/begin"`)
368+ assert.Contains(t, body, `data-finish="/-/passkeys/register/finish"`)
369+ assert.Contains(t, body, "data-passkey-status")
370+ assert.Contains(t, body, `aria-live="polite"`)
371+}
372+
373+func TestPasskeysPageFull(t *testing.T) {
374+ t.Parallel()
375+ f := newPasskeyServer(t)
376+ list := make([]domain.Passkey, domain.MaxPasskeys)
377+ for i := range list {
378+ list[i] = domain.Passkey{ID: []byte{byte(i + 1)}, Name: "k", CreatedAt: time.Now()}
379+ }
380+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(list, nil).Once()
381+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
382+ require.Equal(t, http.StatusOK, rec.Code)
383+ assert.NotContains(t, rec.Body.String(), `data-passkey="register"`)
384+ assert.Contains(t, rec.Body.String(), "Remove one to add another")
385+}
386+
387+// Anybody but the owner gets the 404 page, and the list is not read.
388+func TestPasskeysPageIsTheOwners(t *testing.T) {
389+ t.Parallel()
390+ f := newPasskeyServer(t)
391+ for _, cookie := range []string{"", "stale"} {
392+ rec := f.do(t, req{method: "GET", target: "/-/passkeys", cookie: cookie})
393+ assert.Equal(t, http.StatusNotFound, rec.Code, cookie)
394+ assert.NotContains(t, rec.Body.String(), "Passkeys")
395+ assert.NotContains(t, rec.Body.String(), "data-passkey")
396+ }
397+}
398+
399+func TestPasskeyRemove(t *testing.T) {
400+ t.Parallel()
401+ f := newPasskeyServer(t)
402+ id := []byte{0xfb, 0xff, 'a'}
403+ target := "/-/passkeys/" + base64.RawURLEncoding.EncodeToString(id) + "/remove"
404+ f.passkeys.EXPECT().Remove(mock.Anything, domain.Owner(), id).Return(nil).Once()
405+
406+ rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
407+ assert.Equal(t, http.StatusSeeOther, rec.Code)
408+ assert.Equal(t, "/-/login", rec.Header().Get("Location"), "the epoch bump signed this browser out too")
409+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
410+}
411+
412+func TestPasskeyRemoveErrors(t *testing.T) {
413+ t.Parallel()
414+ f := newPasskeyServer(t)
415+ // Ids that are not unpadded base64url never reach the use case (the mock
416+ // fails the test on a call).
417+ for _, id := range []string{"not%20base64%21", "%2B%2F8B", "YQ%3D%3D", "%C3%A9"} {
418+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + id + "/remove", origin: forgeOrigin, cookie: "tok"})
419+ assert.Equal(t, http.StatusNotFound, rec.Code, id)
420+ }
421+
422+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("zz")).Return(domain.ErrNotFound).Once()
423+ rec := f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("zz")) + "/remove", origin: forgeOrigin, cookie: "tok"})
424+ assert.Equal(t, http.StatusNotFound, rec.Code)
425+
426+ f.passkeys.EXPECT().Remove(mock.Anything, mock.Anything, []byte("yy")).Return(errors.New("kv down")).Once()
427+ rec = f.do(t, req{method: "POST", target: "/-/passkeys/" + base64.RawURLEncoding.EncodeToString([]byte("yy")) + "/remove", origin: forgeOrigin, cookie: "tok"})
428+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
429+ assert.NotContains(t, rec.Body.String(), "kv down")
430+}
431+
432+func TestPasskeyLoginPage(t *testing.T) {
433+ t.Parallel()
434+ f := newPasskeyServer(t)
435+ rec := f.do(t, req{method: "GET", target: "/-/login"})
436+ require.Equal(t, http.StatusOK, rec.Code)
437+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
438+ assert.Equal(t, "strict-origin", rec.Header().Get("Referrer-Policy"))
439+ body := rec.Body.String()
440+ assert.Contains(t, body, "<title>sign in")
441+ assert.Contains(t, body, "Sign in with a passkey")
442+ assert.Contains(t, body, `data-passkey="login"`)
443+ assert.Contains(t, body, `data-begin="/-/passkeys/login/begin"`)
444+ assert.Contains(t, body, `data-finish="/-/passkeys/login/finish"`)
445+ assert.Contains(t, body, "ermite web login")
446+ assert.NotContains(t, body, "<script>", "no inline script")
447+
448+ // A signed-in visitor goes home.
449+ rec = f.do(t, req{method: "GET", target: "/-/login", cookie: "tok"})
450+ assert.Equal(t, http.StatusSeeOther, rec.Code)
451+ assert.Equal(t, "/", rec.Header().Get("Location"))
452+}
453+
454+// The header links: "Sign in" for a visitor, "Passkeys" for the owner.
455+func TestPasskeyHeaderLinks(t *testing.T) {
456+ t.Parallel()
457+ f := newPasskeyServer(t)
458+ rec := f.do(t, req{method: "GET", target: "/-/login"})
459+ assert.Contains(t, rec.Body.String(), `href="/-/login"`)
460+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
461+ assert.Contains(t, rec.Body.String(), "passkey.", "the script is loaded")
462+
463+ f.passkeys.EXPECT().List(mock.Anything, mock.Anything).Return(nil, nil).Once()
464+ rec = f.do(t, req{method: "GET", target: "/-/passkeys", cookie: "tok"})
465+ assert.Contains(t, rec.Body.String(), `href="/-/passkeys"`)
466+ assert.Contains(t, rec.Body.String(), `action="/-/logout"`)
467+ assert.NotContains(t, rec.Body.String(), `>Sign in</a>`)
468+}
469+
470+// Without the passkey service there is no route, no link and no script
471+// behavior to reach: the routes answer like any unknown path.
472+func TestPasskeyRoutesAbsentWithoutTheService(t *testing.T) {
473+ t.Parallel()
474+ lim := &blockingLimiter{}
475+ srv := newTestServer(t, Config{
476+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
477+ PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: lim,
478+ })
479+ do := func(method, target, cookie string) *httptest.ResponseRecorder {
480+ r := httptest.NewRequest(method, target, strings.NewReader(finishBody))
481+ r.Header.Set("Origin", forgeOrigin)
482+ r.Header.Set("Content-Type", "application/json")
483+ if cookie != "" {
484+ r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
485+ }
486+ rec := httptest.NewRecorder()
487+ srv.ServeHTTP(rec, r)
488+ return rec
489+ }
490+ for _, target := range []string{"/-/login", "/-/passkeys"} {
491+ assert.Equal(t, http.StatusNotFound, do("GET", target, "").Code, target)
492+ assert.Equal(t, http.StatusNotFound, do("GET", target, "tok").Code, target)
493+ }
494+ // A POST to a path without a route gets what any unknown path gets for a
495+ // POST (the catch-all page route is GET only).
496+ unknown := do("POST", "/-/nothing-here", "tok").Code
497+ assert.Equal(t, http.StatusMethodNotAllowed, unknown)
498+ for _, target := range []string{beginLogin, finishLogin} {
499+ assert.Equal(t, http.StatusForbidden, do("POST", target, "").Code, target+": the gate still refuses an anonymous POST")
500+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
501+ }
502+ for _, target := range []string{beginRegister, finishRegister, "/-/passkeys/AQ/remove"} {
503+ assert.Equal(t, unknown, do("POST", target, "tok").Code, target)
504+ }
505+ // And no link in the header.
506+ rec := do("GET", "/", "")
507+ assert.NotContains(t, rec.Body.String(), `href="/-/login"`)
508+ rec = do("GET", "/", "tok")
509+ assert.NotContains(t, rec.Body.String(), `href="/-/passkeys"`)
510+}
511+
512+// Passkeys need sessions: with the service alone nothing is served.
513+func TestPasskeyRoutesNeedSessions(t *testing.T) {
514+ t.Parallel()
515+ srv := newTestServer(t, Config{
516+ Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t),
517+ PublicURL: forgeOrigin, Passkeys: mocks.NewWebPasskeyService(t),
518+ })
519+ rec := httptest.NewRecorder()
520+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/-/login", nil))
521+ assert.Equal(t, http.StatusNotFound, rec.Code)
522+ r := httptest.NewRequest("POST", beginLogin, nil)
523+ r.Header.Set("Origin", forgeOrigin)
524+ rec = httptest.NewRecorder()
525+ srv.ServeHTTP(rec, r)
526+ assert.Equal(t, http.StatusForbidden, rec.Code)
527+}
528+
529+// The existing login links keep working next to the passkey routes.
530+func TestPasskeyRoutesLeaveLoginLinksAlone(t *testing.T) {
531+ t.Parallel()
532+ f := newPasskeyServer(t)
533+ rec := f.do(t, req{method: "GET", target: "/-/login/" + nonceA})
534+ assert.Equal(t, http.StatusOK, rec.Code)
535+ assert.Contains(t, rec.Body.String(), "sign in as the owner")
536+ rec = f.do(t, req{method: "POST", target: "/-/login/" + nonceA, origin: forgeOrigin})
537+ assert.Equal(t, http.StatusSeeOther, rec.Code)
538+}
539+
540+func TestIsPasskeyLoginPath(t *testing.T) {
541+ t.Parallel()
542+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/begin"))
543+ assert.True(t, isPasskeyLoginPath("/-/passkeys/login/finish"))
544+ for _, p := range []string{"", "/-/passkeys/login", "/-/passkeys/login/begin/", "/-/passkeys/register/begin", "/-/passkeys/Login/begin", "/x/-/passkeys/login/begin"} {
545+ assert.False(t, isPasskeyLoginPath(p), p)
546+ }
547+}
M internal/adapters/web/session.go +12 -1
@@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
180180 denyForbidden(w)
181181 return false
182182 }
183- if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {
183+ if !SignedIn(r.Context()) && !s.anonymousPost(r) {
184184 denyForbidden(w)
185185 return false
186186 }
187187 return true
188188 }
189189
190+// anonymousPost tells whether a POST without a session may pass the gate: the
191+// requests that start a session. They are the confirmation of a login link and,
192+// when passkeys are on, the two endpoints of a passkey sign-in; each is matched
193+// exactly.
194+func (s *Server) anonymousPost(r *http.Request) bool {
195+ if r.Method != http.MethodPost {
196+ return false
197+ }
198+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+}
200+
190201 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
191202 // the right shape and nothing after it. It looks at the escaped path, which is
192203 // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
@@ -180,13 +180,24 @@ func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool {
180 denyForbidden(w)180 denyForbidden(w)
181 return false181 return false
182 }182 }
183- if !SignedIn(r.Context()) && (r.Method != http.MethodPost || !isLoginPath(r.URL)) {183+ if !SignedIn(r.Context()) && !s.anonymousPost(r) {
184 denyForbidden(w)184 denyForbidden(w)
185 return false185 return false
186 }186 }
187 return true187 return true
188 }188 }
189 189
190+// anonymousPost tells whether a POST without a session may pass the gate: the
191+// requests that start a session. They are the confirmation of a login link and,
192+// when passkeys are on, the two endpoints of a passkey sign-in; each is matched
193+// exactly.
194+func (s *Server) anonymousPost(r *http.Request) bool {
195+ if r.Method != http.MethodPost {
196+ return false
197+ }
198+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+}
200+
190 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of201 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
191 // the right shape and nothing after it. It looks at the escaped path, which is202 // the right shape and nothing after it. It looks at the escaped path, which is
192 // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass203 // what the router matches: the decoded path would let "/-%2Flogin%2Fx/..." pass
M internal/adapters/web/shell.templ +6 -0
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
2222 <script src={ l.Assets.SSE } defer></script>
2323 <script src={ l.Assets.App } defer></script>
2424 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Passkey } defer></script>
2526 </head>
2627 <body
2728 data-ref={ l.RefPath }
@@ -123,8 +124,13 @@ templ Header(l Layout) {
123124 if SignedIn(ctx) {
124125 <form class="owner" method="post" action="/-/logout">
125126 <span class="owner-badge" title="signed in as the owner">owner</span>
127+ if l.Passkeys {
128+ <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a>
129+ }
126130 <button type="submit" class="btn" title="sign out of this browser">Sign out</button>
127131 </form>
132+ } else if l.Passkeys {
133+ <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a>
128134 }
129135 </div>
130136 </div>
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
22 <script src={ l.Assets.SSE } defer></script>22 <script src={ l.Assets.SSE } defer></script>
23 <script src={ l.Assets.App } defer></script>23 <script src={ l.Assets.App } defer></script>
24 <script src={ l.Assets.Files } defer></script>24 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Passkey } defer></script>
25 </head>26 </head>
26 <body27 <body
27 data-ref={ l.RefPath }28 data-ref={ l.RefPath }
@@ -123,8 +124,13 @@ templ Header(l Layout) {
123 if SignedIn(ctx) {124 if SignedIn(ctx) {
124 <form class="owner" method="post" action="/-/logout">125 <form class="owner" method="post" action="/-/logout">
125 <span class="owner-badge" title="signed in as the owner">owner</span>126 <span class="owner-badge" title="signed in as the owner">owner</span>
127+ if l.Passkeys {
128+ <a class="btn" href="/-/passkeys" title="add or remove the passkeys that sign you in">Passkeys</a>
129+ }
126 <button type="submit" class="btn" title="sign out of this browser">Sign out</button>130 <button type="submit" class="btn" title="sign out of this browser">Sign out</button>
127 </form>131 </form>
132+ } else if l.Passkeys {
133+ <a class="btn" href="/-/login" title="sign in with a passkey">Sign in</a>
128 }134 }
129 </div>135 </div>
130 </div>136 </div>
M internal/adapters/web/shell_templ.go +170 -142 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +15 -0
@@ -47,6 +47,21 @@
4747 & .c-sub { width: auto; }
4848 & .c-who { width: 130px; }
4949 }
50+ .list.passkeys {
51+ & .c-name { width: auto; font-weight: 600; }
52+ & .c-when { width: 120px; font-size: var(--fs-s); }
53+ & .c-sync { width: 80px; font-size: var(--fs-s); }
54+ & .c-act { width: 110px; text-align: right; }
55+ }
56+ .passkey-add {
57+ display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4);
58+ & input {
59+ flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3);
60+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s);
61+ }
62+ & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; }
63+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
64+ }
5065 .list.commits, .list.refs {
5166 & .c-sha { width: 96px; }
5267 & .c-who { width: 140px; }
@@ -47,6 +47,21 @@
47 & .c-sub { width: auto; }47 & .c-sub { width: auto; }
48 & .c-who { width: 130px; }48 & .c-who { width: 130px; }
49 }49 }
50+ .list.passkeys {
51+ & .c-name { width: auto; font-weight: 600; }
52+ & .c-when { width: 120px; font-size: var(--fs-s); }
53+ & .c-sync { width: 80px; font-size: var(--fs-s); }
54+ & .c-act { width: 110px; text-align: right; }
55+ }
56+ .passkey-add {
57+ display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-top: var(--s4);
58+ & input {
59+ flex: 1; min-width: 12ch; max-width: 320px; height: 32px; padding: 0 var(--s3);
60+ background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r-s);
61+ }
62+ & input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; border-color: transparent; }
63+ & input::placeholder { color: var(--ink-mute); opacity: 1; }
64+ }
50 .list.commits, .list.refs {65 .list.commits, .list.refs {
51 & .c-sha { width: 96px; }66 & .c-sha { width: 96px; }
52 & .c-who { width: 140px; }67 & .c-who { width: 140px; }
A internal/adapters/web/static/js/passkey.js +97 -0
@@ -0,0 +1,97 @@
1+// passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
2+//
3+// One delegated click handler on [data-passkey="login"|"register"]. The button
4+// carries the two endpoints (data-begin, data-finish). The flow is the one of
5+// the WebAuthn JSON API: POST begin, hand options.publicKey to the browser
6+// (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the
7+// result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts
8+// from this origin. The core is tested in Node by jstest/passkey.test.mjs.
9+window.ermitePasskey = (() => {
10+ const UNSUPPORTED = 'this browser does not support passkeys';
11+
12+ // The parse function each ceremony needs; absent in a browser that predates
13+ // the WebAuthn JSON API.
14+ const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' };
15+ const supported = (kind) => {
16+ const pkc = window.PublicKeyCredential;
17+ return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials;
18+ };
19+
20+ const statusEl = () => document.querySelector('[data-passkey-status]');
21+ const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
22+
23+ // messageOf turns what a step threw into one line for the owner.
24+ function messageOf(e) {
25+ if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
26+ if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site';
27+ if (e && e.name === 'AbortError') return 'cancelled';
28+ return (e && e.message) || 'something went wrong; try again';
29+ }
30+
31+ // failure reads the error of a refused request: the server's one-line
32+ // reason when it sent one, the status otherwise.
33+ async function failure(res) {
34+ let reason = '';
35+ try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
36+ return new Error(reason || 'the server answered ' + res.status);
37+ }
38+
39+ async function run(btn) {
40+ const kind = btn.dataset.passkey;
41+ if (!parser[kind]) return;
42+ if (!supported(kind)) { say(UNSUPPORTED); return; }
43+ btn.disabled = true;
44+ say('');
45+ let done = false;
46+ try {
47+ const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' });
48+ if (!begun.ok) throw await failure(begun);
49+ const { ceremony, options } = await begun.json();
50+ const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
51+ const cred = kind === 'login'
52+ ? await navigator.credentials.get({ publicKey })
53+ : await navigator.credentials.create({ publicKey });
54+ if (!cred) throw new Error('no passkey was returned');
55+ const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
56+ const finished = await fetch(btn.dataset.finish, {
57+ method: 'POST',
58+ credentials: 'same-origin',
59+ headers: { 'Content-Type': 'application/json' },
60+ body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }),
61+ });
62+ if (!finished.ok) throw await failure(finished);
63+ done = true;
64+ if (kind === 'login') location.assign('/');
65+ else location.reload();
66+ } catch (e) {
67+ say(messageOf(e));
68+ } finally {
69+ if (!done) btn.disabled = false;
70+ }
71+ }
72+
73+ function click(ev) {
74+ const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
75+ if (!btn || btn.disabled) return;
76+ if (ev.preventDefault) ev.preventDefault();
77+ return run(btn);
78+ }
79+
80+ // init disables the buttons of a browser that cannot do passkeys, and says why.
81+ function init() {
82+ let missing = false;
83+ document.querySelectorAll('[data-passkey]').forEach((btn) => {
84+ if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) {
85+ btn.disabled = true;
86+ missing = true;
87+ }
88+ });
89+ if (missing) say(UNSUPPORTED);
90+ }
91+
92+ if (typeof document.addEventListener === 'function') {
93+ document.addEventListener('click', click);
94+ init();
95+ }
96+ return { click, init, run };
97+})();
@@ -0,0 +1,97 @@
1+// passkey.js: sign in with a passkey (/-/login) and add one (/-/passkeys).
2+//
3+// One delegated click handler on [data-passkey="login"|"register"]. The button
4+// carries the two endpoints (data-begin, data-finish). The flow is the one of
5+// the WebAuthn JSON API: POST begin, hand options.publicKey to the browser
6+// (parse*OptionsFromJSON, then navigator.credentials.get|create), POST the
7+// result (toJSON()) to finish. Nothing is inline: the CSP only allows scripts
8+// from this origin. The core is tested in Node by jstest/passkey.test.mjs.
9+window.ermitePasskey = (() => {
10+ const UNSUPPORTED = 'this browser does not support passkeys';
11+
12+ // The parse function each ceremony needs; absent in a browser that predates
13+ // the WebAuthn JSON API.
14+ const parser = { login: 'parseRequestOptionsFromJSON', register: 'parseCreationOptionsFromJSON' };
15+ const supported = (kind) => {
16+ const pkc = window.PublicKeyCredential;
17+ return !!pkc && typeof pkc[parser[kind]] === 'function' && !!navigator.credentials;
18+ };
19+
20+ const statusEl = () => document.querySelector('[data-passkey-status]');
21+ const say = (text) => { const s = statusEl(); if (s) s.textContent = text; };
22+
23+ // messageOf turns what a step threw into one line for the owner.
24+ function messageOf(e) {
25+ if (e && e.name === 'NotAllowedError') return 'cancelled, or it took too long; try again';
26+ if (e && e.name === 'InvalidStateError') return 'this authenticator already holds a passkey for this site';
27+ if (e && e.name === 'AbortError') return 'cancelled';
28+ return (e && e.message) || 'something went wrong; try again';
29+ }
30+
31+ // failure reads the error of a refused request: the server's one-line
32+ // reason when it sent one, the status otherwise.
33+ async function failure(res) {
34+ let reason = '';
35+ try { reason = (await res.json()).error || ''; } catch (_) { /* not JSON */ }
36+ return new Error(reason || 'the server answered ' + res.status);
37+ }
38+
39+ async function run(btn) {
40+ const kind = btn.dataset.passkey;
41+ if (!parser[kind]) return;
42+ if (!supported(kind)) { say(UNSUPPORTED); return; }
43+ btn.disabled = true;
44+ say('');
45+ let done = false;
46+ try {
47+ const begun = await fetch(btn.dataset.begin, { method: 'POST', credentials: 'same-origin' });
48+ if (!begun.ok) throw await failure(begun);
49+ const { ceremony, options } = await begun.json();
50+ const publicKey = window.PublicKeyCredential[parser[kind]](options.publicKey);
51+ const cred = kind === 'login'
52+ ? await navigator.credentials.get({ publicKey })
53+ : await navigator.credentials.create({ publicKey });
54+ if (!cred) throw new Error('no passkey was returned');
55+ const nameEl = kind === 'register' ? document.querySelector('[data-passkey-name]') : null;
56+ const finished = await fetch(btn.dataset.finish, {
57+ method: 'POST',
58+ credentials: 'same-origin',
59+ headers: { 'Content-Type': 'application/json' },
60+ body: JSON.stringify({ ceremony, name: nameEl ? nameEl.value.trim() : '', credential: cred.toJSON() }),
61+ });
62+ if (!finished.ok) throw await failure(finished);
63+ done = true;
64+ if (kind === 'login') location.assign('/');
65+ else location.reload();
66+ } catch (e) {
67+ say(messageOf(e));
68+ } finally {
69+ if (!done) btn.disabled = false;
70+ }
71+ }
72+
73+ function click(ev) {
74+ const btn = ev.target && ev.target.closest && ev.target.closest('[data-passkey]');
75+ if (!btn || btn.disabled) return;
76+ if (ev.preventDefault) ev.preventDefault();
77+ return run(btn);
78+ }
79+
80+ // init disables the buttons of a browser that cannot do passkeys, and says why.
81+ function init() {
82+ let missing = false;
83+ document.querySelectorAll('[data-passkey]').forEach((btn) => {
84+ if (parser[btn.dataset.passkey] && !supported(btn.dataset.passkey)) {
85+ btn.disabled = true;
86+ missing = true;
87+ }
88+ });
89+ if (missing) say(UNSUPPORTED);
90+ }
91+
92+ if (typeof document.addEventListener === 'function') {
93+ document.addEventListener('click', click);
94+ init();
95+ }
96+ return { click, init, run };
97+})();
M internal/adapters/web/view.go +33 -0
@@ -60,6 +60,9 @@ type Layout struct {
6060 // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
6161 // both are "" when the page has no commit to search.
6262 PathsURL, BlobBase string
63+ // Passkeys tells that the passkey routes exist: the header then links to
64+ // them.
65+ Passkeys bool
6366 }
6467
6568 // Link is a plain label + href pair.
@@ -190,3 +193,33 @@ type LoginPage struct {
190193 Action string // where the form posts
191194 Button string
192195 }
196+
197+// PasskeyLoginPage is the sign-in page: one button the script drives.
198+type PasskeyLoginPage struct {
199+ Title string
200+ Lines []string
201+ Button string
202+ // Begin and Finish are the endpoints the script posts to.
203+ Begin, Finish string
204+}
205+
206+// PasskeyRow is one passkey on the passkeys page.
207+type PasskeyRow struct {
208+ Name string
209+ // Created and LastUsed are relative ("3d ago"); the At fields are the
210+ // RFC 3339 forms, "" when never.
211+ Created, CreatedAt string
212+ LastUsed, LastUsedAt string
213+ Synced bool
214+ // Remove is where the remove form posts.
215+ Remove string
216+}
217+
218+// PasskeysPage lists the passkeys and adds one.
219+type PasskeysPage struct {
220+ Rows []PasskeyRow
221+ Begin, Finish string
222+ // Full tells that no more passkeys can be added.
223+ Full bool
224+ Max int
225+}
@@ -60,6 +60,9 @@ type Layout struct {
60 // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;60 // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with;
61 // both are "" when the page has no commit to search.61 // both are "" when the page has no commit to search.
62 PathsURL, BlobBase string62 PathsURL, BlobBase string
63+ // Passkeys tells that the passkey routes exist: the header then links to
64+ // them.
65+ Passkeys bool
63 }66 }
64 67
65 // Link is a plain label + href pair.68 // Link is a plain label + href pair.
@@ -190,3 +193,33 @@ type LoginPage struct {
190 Action string // where the form posts193 Action string // where the form posts
191 Button string194 Button string
192 }195 }
196+
197+// PasskeyLoginPage is the sign-in page: one button the script drives.
198+type PasskeyLoginPage struct {
199+ Title string
200+ Lines []string
201+ Button string
202+ // Begin and Finish are the endpoints the script posts to.
203+ Begin, Finish string
204+}
205+
206+// PasskeyRow is one passkey on the passkeys page.
207+type PasskeyRow struct {
208+ Name string
209+ // Created and LastUsed are relative ("3d ago"); the At fields are the
210+ // RFC 3339 forms, "" when never.
211+ Created, CreatedAt string
212+ LastUsed, LastUsedAt string
213+ Synced bool
214+ // Remove is where the remove form posts.
215+ Remove string
216+}
217+
218+// PasskeysPage lists the passkeys and adds one.
219+type PasskeysPage struct {
220+ Rows []PasskeyRow
221+ Begin, Finish string
222+ // Full tells that no more passkeys can be added.
223+ Full bool
224+ Max int
225+}
M internal/adapters/web/web.go +9 -0
@@ -73,6 +73,11 @@ type Config struct {
7373 // Sessions signs the owner in (ADR 0018). Without it there are no login
7474 // routes and every visitor is anonymous.
7575 Sessions ports.WebSessionService
76+ // Passkeys signs the owner in with a passkey and manages the passkeys
77+ // (ADR 0026). Without it, or without Sessions, there are no passkey
78+ // routes and no link to them. Leave it unset rather than assigning a typed
79+ // nil pointer.
80+ Passkeys ports.WebPasskeyService
7681 // Limiter counts the failed logins; required with Sessions. Share it with
7782 // the API and Git, so that a client has one budget of failures.
7883 Limiter ports.FailureLimiter
@@ -135,6 +140,9 @@ func (s *Server) routes() *http.ServeMux {
135140 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
136141 mux.HandleFunc("POST /-/logout", s.logout)
137142 }
143+ if s.passkeysOn() {
144+ s.passkeyRoutes(mux)
145+ }
138146 mux.HandleFunc("GET /{$}", s.page(s.home))
139147 mux.HandleFunc("GET /{repo}", s.page(s.summary))
140148 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -291,6 +299,7 @@ func (s *Server) base() Layout {
291299 Version: s.cfg.Version,
292300 SourceURL: s.cfg.SourceURL,
293301 Assets: s.assets.urls,
302+ Passkeys: s.passkeysOn(),
294303 }
295304 }
296305
@@ -73,6 +73,11 @@ type Config struct {
73 // Sessions signs the owner in (ADR 0018). Without it there are no login73 // Sessions signs the owner in (ADR 0018). Without it there are no login
74 // routes and every visitor is anonymous.74 // routes and every visitor is anonymous.
75 Sessions ports.WebSessionService75 Sessions ports.WebSessionService
76+ // Passkeys signs the owner in with a passkey and manages the passkeys
77+ // (ADR 0026). Without it, or without Sessions, there are no passkey
78+ // routes and no link to them. Leave it unset rather than assigning a typed
79+ // nil pointer.
80+ Passkeys ports.WebPasskeyService
76 // Limiter counts the failed logins; required with Sessions. Share it with81 // Limiter counts the failed logins; required with Sessions. Share it with
77 // the API and Git, so that a client has one budget of failures.82 // the API and Git, so that a client has one budget of failures.
78 Limiter ports.FailureLimiter83 Limiter ports.FailureLimiter
@@ -135,6 +140,9 @@ func (s *Server) routes() *http.ServeMux {
135 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)140 mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost)
136 mux.HandleFunc("POST /-/logout", s.logout)141 mux.HandleFunc("POST /-/logout", s.logout)
137 }142 }
143+ if s.passkeysOn() {
144+ s.passkeyRoutes(mux)
145+ }
138 mux.HandleFunc("GET /{$}", s.page(s.home))146 mux.HandleFunc("GET /{$}", s.page(s.home))
139 mux.HandleFunc("GET /{repo}", s.page(s.summary))147 mux.HandleFunc("GET /{repo}", s.page(s.summary))
140 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))148 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -291,6 +299,7 @@ func (s *Server) base() Layout {
291 Version: s.cfg.Version,299 Version: s.cfg.Version,
292 SourceURL: s.cfg.SourceURL,300 SourceURL: s.cfg.SourceURL,
293 Assets: s.assets.urls,301 Assets: s.assets.urls,
302+ Passkeys: s.passkeysOn(),
294 }303 }
295 }304 }
296 305
M internal/app/testdata/web/blob-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-code.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-image.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-markdown.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-svg.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-text.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-too-large.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/blob-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-big.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-binary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-merge.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify-split.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-modify.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-rename.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commit-trojan.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">25 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-page2.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-path.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/commits-small.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/home.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found-page.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="" data-sha="" data-sel="" data-nav="">25 <body data-ref="" data-sha="" data-sel="" data-nav="">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/refs.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/summary.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-nested.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-root.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-slash-ref.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">25 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/tree-subdir.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -19,6 +19,8 @@
19 </script>19 </script>
20 <script src="/-/static/js/files.HASH.js" defer>20 <script src="/-/static/js/files.HASH.js" defer>
21 </script>21 </script>
22+<script src="/-/static/js/passkey.HASH.js" defer>
23+</script>
22 </head>24 </head>
23 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">25 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
24 <a class="skip" href="#main">skip to content</a>26 <a class="skip" href="#main">skip to content</a>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close