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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | 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 | 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 | 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 | 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 | 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` | | |
| @@ -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/" | |||
| 23 | 23 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 24 | 24 | // the content, so they can be cached for ever. | |
| 25 | 25 | type Assets struct { | |
| 26 | - | CSS, HTMX, Live, SSE, App, Files, Icon string | |
| 26 | + | CSS, HTMX, Live, SSE, App, Files, Passkey, Icon string | |
| 27 | 27 | // Font is the UI font, preloaded by every page. | |
| 28 | 28 | Font string | |
| 29 | 29 | // Rules is the speculation rules file named by the Speculation-Rules header. | |
| @@ -56,6 +56,7 @@ var scripts = []struct { | |||
| 56 | 56 | {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }}, | |
| 57 | 57 | {"ermite.js", func(a *Assets, u string) { a.App = u }}, | |
| 58 | 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 | 62 | // 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 of | 23 | // 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 string | 26 | + | 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 string | 28 | 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) { | |||
| 182 | 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 | + | } | |
| @@ -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 { | |||
| 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 | 1400 | 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.GeneratedTemplate | 1400 | 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 <9>", "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 <9>", "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 { | |||
| 180 | 180 | denyForbidden(w) | |
| 181 | 181 | 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 | 184 | denyForbidden(w) | |
| 185 | 185 | return false | |
| 186 | 186 | } | |
| 187 | 187 | 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 | 201 | // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of | |
| 191 | 202 | // the right shape and nothing after it. It looks at the escaped path, which is | |
| 192 | 203 | // 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 false | 181 | 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 false | 185 | return false | ||
| 186 | } | 186 | } | ||
| 187 | return true | 187 | 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 of | 201 | // 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 is | 202 | // 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/..." pass | 203 | // 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) { | |||
| 22 | 22 | <script src={ l.Assets.SSE } defer></script> | |
| 23 | 23 | <script src={ l.Assets.App } defer></script> | |
| 24 | 24 | <script src={ l.Assets.Files } defer></script> | |
| 25 | + | <script src={ l.Assets.Passkey } defer></script> | |
| 25 | 26 | </head> | |
| 26 | 27 | <body | |
| 27 | 28 | data-ref={ l.RefPath } | |
| @@ -123,8 +124,13 @@ templ Header(l Layout) { | |||
| 123 | 124 | if SignedIn(ctx) { | |
| 124 | 125 | <form class="owner" method="post" action="/-/logout"> | |
| 125 | 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 | 130 | <button type="submit" class="btn" title="sign out of this browser">Sign out</button> | |
| 127 | 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 | 135 | </div> | |
| 130 | 136 | </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 | <body | 27 | <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 @@ | |||
| 47 | 47 | & .c-sub { width: auto; } | |
| 48 | 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 | 65 | .list.commits, .list.refs { | |
| 51 | 66 | & .c-sha { width: 96px; } | |
| 52 | 67 | & .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 { | |||
| 60 | 60 | // (/{repo}/paths/{sha}) and BlobBase the URL its file links start with; | |
| 61 | 61 | // both are "" when the page has no commit to search. | |
| 62 | 62 | 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 | 68 | // Link is a plain label + href pair. | |
| @@ -190,3 +193,33 @@ type LoginPage struct { | |||
| 190 | 193 | Action string // where the form posts | |
| 191 | 194 | 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 | + | } | |
| @@ -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 string | 62 | 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 posts | 193 | Action string // where the form posts | ||
| 191 | Button string | 194 | 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 { | |||
| 73 | 73 | // Sessions signs the owner in (ADR 0018). Without it there are no login | |
| 74 | 74 | // routes and every visitor is anonymous. | |
| 75 | 75 | 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 | 81 | // Limiter counts the failed logins; required with Sessions. Share it with | |
| 77 | 82 | // the API and Git, so that a client has one budget of failures. | |
| 78 | 83 | Limiter ports.FailureLimiter | |
| @@ -135,6 +140,9 @@ func (s *Server) routes() *http.ServeMux { | |||
| 135 | 140 | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | |
| 136 | 141 | mux.HandleFunc("POST /-/logout", s.logout) | |
| 137 | 142 | } | |
| 143 | + | if s.passkeysOn() { | |
| 144 | + | s.passkeyRoutes(mux) | |
| 145 | + | } | |
| 138 | 146 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 139 | 147 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 140 | 148 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| @@ -291,6 +299,7 @@ func (s *Server) base() Layout { | |||
| 291 | 299 | Version: s.cfg.Version, | |
| 292 | 300 | SourceURL: s.cfg.SourceURL, | |
| 293 | 301 | Assets: s.assets.urls, | |
| 302 | + | Passkeys: s.passkeysOn(), | |
| 294 | 303 | } | |
| 295 | 304 | } | |
| 296 | 305 | ||
| @@ -73,6 +73,11 @@ type Config struct { | |||||
| 73 | // Sessions signs the owner in (ADR 0018). Without it there are no login | 73 | // 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.WebSessionService | 75 | 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 with | 81 | // 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.FailureLimiter | 83 | 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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 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 | 26 | <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 @@ | |||
| 19 | 19 | </script> | |
| 20 | 20 | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | 21 | </script> | |
| 22 | + | <script src="/-/static/js/passkey.HASH.js" defer> | |
| 23 | + | </script> | |
| 22 | 24 | </head> | |
| 23 | 25 | <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main"> | |
| 24 | 26 | <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> | ||