pgkiln 0.64.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
  2. package/docs/apex-feature-parity.md +5 -3
  3. package/docs/guide/08-security.md +41 -6
  4. package/docs/guide/12-development.md +10 -4
  5. package/docs/guide/13-rest-api.md +4 -0
  6. package/package.json +1 -1
  7. package/public/app.js +63 -0
  8. package/public/builder.js +1 -62
  9. package/src/account-mfa.ts +94 -3
  10. package/src/api.ts +9 -2
  11. package/src/builder/api.ts +3 -1
  12. package/src/builder/components.ts +1 -0
  13. package/src/builder/login-page.ts +10 -5
  14. package/src/builder/rest-modules.ts +1 -1
  15. package/src/builder/security.ts +3 -19
  16. package/src/builder/shared.ts +5 -3
  17. package/src/i18n/ar.ts +31 -0
  18. package/src/i18n/cs.ts +31 -0
  19. package/src/i18n/da.ts +31 -0
  20. package/src/i18n/de.ts +31 -0
  21. package/src/i18n/el.ts +31 -0
  22. package/src/i18n/es.ts +31 -0
  23. package/src/i18n/fi.ts +31 -0
  24. package/src/i18n/fr.ts +31 -0
  25. package/src/i18n/he.ts +31 -0
  26. package/src/i18n/it.ts +31 -0
  27. package/src/i18n/ja.ts +31 -0
  28. package/src/i18n/ko.ts +31 -0
  29. package/src/i18n/nb.ts +31 -0
  30. package/src/i18n/pl.ts +31 -0
  31. package/src/i18n/pt.ts +31 -0
  32. package/src/i18n/ru.ts +31 -0
  33. package/src/i18n/sv.ts +31 -0
  34. package/src/i18n/tr.ts +31 -0
  35. package/src/i18n/uk.ts +31 -0
  36. package/src/i18n/zh.ts +31 -0
  37. package/src/i18n.ts +62 -0
  38. package/src/metadata.ts +8 -2
  39. package/src/mfa.ts +18 -13
  40. package/src/runtime/account.ts +6 -3
  41. package/src/runtime/authz.ts +23 -4
  42. package/src/runtime/routes.ts +11 -1
  43. package/src/runtime/two-step.ts +379 -72
  44. package/src/webauthn.ts +20 -0
@@ -1,8 +1,12 @@
1
+ import { isIP } from 'node:net';
1
2
  import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
3
  import {
3
- accountCodesLeft, accountFactors, accountHasFactor, accountId, addAccountTotp, checkAccountCode, confirmAccountCodes, mfaOn, mfaRequired,
4
- newAccountCodes, removeAccountFactor, secondStep, withSecondStep,
4
+ accountCodesLeft, accountFactors, accountHasFactor, accountId, accountMustUseMfa, accountPasskeys, accountUserHandle, addAccountPasskey,
5
+ addAccountTotp, checkAccountCode, confirmAccountCodes, EMAIL_CODE_MINUTES, emailCodeHash, emailCodeMatches, emailCodeTarget, markSecondStep,
6
+ mfaOn, mfaRequired, newAccountCodes, newEmailCode, queueEmailCode, removeAccountFactor, secondStep, secondStepWithin, TOKEN_STEP_MINUTES,
7
+ useAccountPasskey, withSecondStep,
5
8
  } from '../account-mfa.ts';
9
+ import { issueApiToken, MAX_TOKEN_HOURS } from '../api.ts';
6
10
  import { html, raw, type Raw } from '../html.ts';
7
11
  import { documentShell } from '../layout.ts';
8
12
  import { accountRoles, type App } from '../metadata.ts';
@@ -11,6 +15,7 @@ import { qrSvg } from '../qrcode.ts';
11
15
  import { secretKeyConfigured } from '../secrets.ts';
12
16
  import { loginWindowMinutes } from '../security.ts';
13
17
  import { clientIp, getSession, loginThrottled, logActivity, saveState, type Session } from '../session.ts';
18
+ import { ALGORITHMS, giveChallenge, relyingParty, takeChallenge, verifyAssertion, verifyRegistration, WebAuthnError } from '../webauthn.ts';
14
19
  import type { PageContext } from './context.ts';
15
20
  import type { Locale } from './locale.ts';
16
21
  import { pwaHead } from './pwa.ts';
@@ -24,10 +29,16 @@ import { appWithLocale, completeLogin, loadContext, rootAttrs, safeNext, signIn,
24
29
  // the step and the user has no app yet, the same page shows a QR code first, then the recovery codes once. Wrong
25
30
  // codes count as failed sign-ins (the same lockout). The sign-in's method gets "+2fa", so a remembered or shared
26
31
  // sign-in made from it may skip the step later.
32
+ //
33
+ // (sprint 131, SEC-3 part 2) Passkeys (WebAuthn, src/webauthn.ts; public/app.js [data-passkey]) at the second step
34
+ // and on My account, also as the first factor a user adds while signing in; a code by e-mail when the application
35
+ // allows it, for users who have another factor; step-up: a signed-in user whose page needs a more recent second step
36
+ // comes here with ?next= and goes back after it (the session's __MFA_AT); and My account → REST API token.
27
37
 
28
38
  const PENDING = '__MFA';
29
39
  const STEP_SECONDS = 300;
30
40
  const ENROL_SECONDS = 600;
41
+ const EMAILS_PER_STEP = 3;
31
42
 
32
43
  interface Pending {
33
44
  u: string;
@@ -39,21 +50,31 @@ interface Pending {
39
50
  detail?: string;
40
51
  at: number;
41
52
  enrol?: boolean;
53
+ /** (sprint 131) a signed-in user asked again (step-up): no new session, back to `next` */
54
+ stepup?: boolean;
42
55
  /** while adding an app: the new secret, then (after its first code) the recovery codes to show once */
43
56
  secret?: string;
44
57
  codes?: string[];
58
+ /** (sprint 131) what was added while signing in, for the activity log */
59
+ added?: 'app' | 'passkey';
60
+ /** (sprint 131) a code sent by e-mail: its hash, when, and how many were sent in this step */
61
+ mail?: { h: string; at: number; n: number };
45
62
  }
46
63
 
47
64
  function pendingOf(s: Session): Pending | null {
48
65
  try {
49
66
  const p = JSON.parse(String(s.state[PENDING] ?? '')) as Pending;
50
67
  const limit = (p?.enrol ? ENROL_SECONDS : STEP_SECONDS) * 1000;
51
- return p && typeof p.u === 'string' && Date.now() - Number(p.at) < limit ? p : null;
68
+ if (!p || typeof p.u !== 'string' || Date.now() - Number(p.at) >= limit) return null;
69
+ // a step-up belongs to the signed-in user; a sign-in's step to a signed-out session
70
+ return !!p.stepup === !!s.username && (!p.stepup || p.u === s.username) ? p : null;
52
71
  } catch {
53
72
  return null;
54
73
  }
55
74
  }
56
75
 
76
+ const keep = (s: Session, p: Pending) => { s.state[PENDING] = JSON.stringify(p); };
77
+
57
78
  interface PasswordSignIn {
58
79
  next?: string;
59
80
  remember?: boolean;
@@ -71,20 +92,19 @@ export async function passwordSignIn(req: FastifyRequest, reply: FastifyReply, a
71
92
  const roles = [...(await accountRoles(a.id, username)), ...(opts.extraRoles ?? []).map((r) => r.toLowerCase())];
72
93
  const need = await secondStep(a, username, roles);
73
94
  if (!need) return completeLogin(req, reply, a, session, username, opts);
74
- const p: Pending = { u: username, ...opts, at: Date.now(), enrol: need === 'enrol' };
75
- session.state[PENDING] = JSON.stringify(p);
95
+ keep(session, { u: username, ...opts, at: Date.now(), enrol: need === 'enrol' });
76
96
  await saveState(session);
77
97
  return reply.redirect(`/a/${a.alias}/login/2fa`, 303);
78
98
  }
79
99
 
80
- function card(a: App, locale: Locale, title: string, body: Raw) {
100
+ function card(a: App, locale: Locale, title: string, body: Raw, back = `/a/${a.alias}/login`) {
81
101
  return documentShell(
82
102
  `${title} · ${a.name}`,
83
103
  html`<main class="login">
84
104
  <div class="card login-card">
85
105
  <h1>${title}</h1>
86
106
  ${body}
87
- <a class="login-forgot" href="/a/${a.alias}/login">${locale.t('reset.back')}</a>
107
+ <a class="login-forgot" href="${back}">${locale.t('reset.back')}</a>
88
108
  </div>
89
109
  </main>`,
90
110
  'login-body',
@@ -95,86 +115,150 @@ function card(a: App, locale: Locale, title: string, body: Raw) {
95
115
  }
96
116
 
97
117
  const csrfField = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
98
- const alert = (error?: string) => html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}`;
118
+ const alert = (error?: string, kind = 'error') => html`${error ? html`<div class="alert alert-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${error}</div>` : ''}`;
99
119
  const grouped = (secret: string) => secret.replace(/(.{4})/g, '$1 ').trim();
100
120
 
101
121
  function codesList(codes: string[]) {
102
122
  return html`<ol class="mfa-codes">${codes.map((c) => html`<li><code>${c}</code></li>`)}</ol>`;
103
123
  }
104
124
 
125
+ /** The hidden fields public/app.js fills with the browser's WebAuthn answer. */
126
+ const passkeyFields = (names: string[]) => html`${names.map((n) => html`<input type="hidden" name="${n}">`)}`;
127
+ const ASSERTION = ['credentialId', 'clientDataJSON', 'authenticatorData', 'signature', 'userHandle'];
128
+ const ATTESTATION = ['clientDataJSON', 'attestationObject', 'transports'];
129
+
130
+ /** What the second step's page offers besides a code. */
131
+ interface Offers { passkey: boolean; mail: boolean; newPasskey: boolean; notice?: string }
132
+
105
133
  /** The second step's page: a code; or a new app (QR code, then its first code); or the recovery codes, once. */
106
- function stepPage(a: App, locale: Locale, s: Session, p: Pending, error?: string) {
134
+ function stepPage(a: App, locale: Locale, s: Session, p: Pending, offers: Offers, error?: string) {
107
135
  const t = locale.t;
108
136
  const action = `/a/${a.alias}/login/2fa`;
137
+ const back = p.stepup ? safeNext(a, p.next) : undefined;
109
138
  if (p.codes)
110
139
  return card(a, locale, t('mfa.codes_title'), html`<p>${t('mfa.codes_once')}</p>${codesList(p.codes)}
111
140
  <form method="post" action="${action}/done" class="login-form">${csrfField(s)}
112
141
  <label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
113
142
  <button class="btn btn-hot">${t('mfa.continue')}</button></form>`);
114
- if (p.enrol && p.secret)
143
+ if (p.enrol && (p.secret || offers.newPasskey))
115
144
  return card(a, locale, t('mfa.enrol_title'), html`<p class="muted">${t('mfa.enrol_intro')}</p>${alert(error)}
116
- <ol class="mfa-steps">
117
- <li>${t('mfa.scan')}</li>
118
- <li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(p.secret)}</code></li>
119
- <li>${t('mfa.enter_code')}</li>
120
- </ol>
121
- <div class="mfa-qr">${raw(qrSvg(otpauthUri(p.secret, p.u, a.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
122
- <form method="post" action="${action}" class="login-form">${csrfField(s)}
123
- <div class="field"><label class="label" for="code">${t('mfa.code')}</label><input id="code" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="7" autofocus></div>
124
- <button class="btn btn-hot">${t('mfa.verify')}</button></form>`);
125
- return card(a, locale, t('mfa.step_title'), html`<p class="muted">${t('mfa.step_intro')}</p>${alert(error)}
145
+ ${p.secret
146
+ ? html`<ol class="mfa-steps">
147
+ <li>${t('mfa.scan')}</li>
148
+ <li>${t('mfa.or_enter')} <code class="mfa-secret">${grouped(p.secret)}</code></li>
149
+ <li>${t('mfa.enter_code')}</li>
150
+ </ol>
151
+ <div class="mfa-qr">${raw(qrSvg(otpauthUri(p.secret, p.u, a.name), { label: t('mfa.qr_label'), px: 4 }) ?? '')}</div>
152
+ <form method="post" action="${action}" class="login-form">${csrfField(s)}
153
+ <div class="field"><label class="label" for="code">${t('mfa.code')}</label><input id="code" name="code" required inputmode="numeric" autocomplete="one-time-code" maxlength="7" autofocus></div>
154
+ <button class="btn btn-hot">${t('mfa.verify')}</button></form>`
155
+ : ''}
156
+ ${offers.newPasskey
157
+ ? html`<form method="post" action="${action}/passkey/new" class="login-form mfa-passkey">${csrfField(s)}${passkeyFields(ATTESTATION)}
158
+ <p class="muted">${p.secret ? t('mfa.or_passkey') : t('mfa.passkey_intro')}</p>
159
+ <button class="btn${p.secret ? '' : ' btn-hot'}" hidden data-passkey="${action}/passkey/new/options" data-passkey-error="${t('mfa.passkey_failed')}">${t('mfa.add_passkey')}</button>
160
+ <p class="small" role="status" data-passkey-status></p></form>`
161
+ : ''}`);
162
+ return card(a, locale, p.stepup ? t('mfa.stepup_title') : t('mfa.step_title'), html`<p class="muted">${p.stepup ? t('mfa.stepup_intro') : t('mfa.step_intro')}</p>
163
+ ${alert(offers.notice, 'success')}${alert(error)}
126
164
  <form method="post" action="${action}" class="login-form">${csrfField(s)}
127
165
  <div class="field"><label class="label" for="code">${t('mfa.code')}</label><input id="code" name="code" required autocomplete="one-time-code" maxlength="20" spellcheck="false" autocapitalize="off" autofocus></div>
128
- <button class="btn btn-hot">${t('mfa.verify')}</button></form>`);
166
+ <button class="btn btn-hot">${t('mfa.verify')}</button></form>
167
+ ${offers.passkey
168
+ ? html`<form method="post" action="${action}/passkey" class="login-form mfa-passkey">${csrfField(s)}${passkeyFields(ASSERTION)}
169
+ <button class="btn" hidden data-passkey="${action}/passkey/options" data-passkey-error="${t('mfa.passkey_failed')}">${t('mfa.use_passkey')}</button>
170
+ <p class="small" role="status" data-passkey-status></p></form>`
171
+ : ''}
172
+ ${offers.mail
173
+ ? html`<form method="post" action="${action}/email" class="login-form">${csrfField(s)}<button class="link-button">${t('mfa.email_send')}</button></form>`
174
+ : ''}`, back);
129
175
  }
130
176
 
131
- /** The signed-out session's pending sign-in, or a page that says it expired. */
177
+ /** The signed-out session's pending sign-in (or a signed-in user's step-up), or a page that says it expired. */
132
178
  async function start(req: Req, reply: FastifyReply) {
133
179
  const loaded = await appWithLocale(req, req.params.alias);
134
180
  if (!loaded) return null;
135
181
  const session = await getSession(req, reply, loaded.app.id, `/a/${loaded.app.alias}`);
136
182
  const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
137
- const p = session.username ? null : pendingOf(session);
138
- return { a, locale, session, p };
183
+ return { a, locale, session, p: pendingOf(session) };
139
184
  }
140
185
 
141
186
  const expired = async (reply: FastifyReply, a: App, locale: Locale, s: Session) => {
187
+ const wasStepUp = !!s.username;
142
188
  delete s.state[PENDING];
143
189
  await saveState(s);
190
+ if (wasStepUp) return reply.redirect(`/a/${a.alias}/${a.home_page}`, 303);
144
191
  return reply.code(410).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('mfa.expired'))));
145
192
  };
146
193
 
147
- /** Sign in with the pending sign-in's options, now with "+2fa" in its method. */
194
+ /** What the page offers this user here: their passkeys for this site, an e-mail code, a new passkey while enrolling. */
195
+ async function offersFor(req: FastifyRequest, a: App, p: Pending, id: number, notice?: string): Promise<Offers> {
196
+ const rp = relyingParty(req);
197
+ const host = !isIP(rp.rpId);
198
+ const [keys, mail] = await Promise.all([accountPasskeys(id, rp.rpId), p.enrol ? null : emailCodeTarget(a, id)]);
199
+ return { passkey: host && keys.length > 0, mail: !!mail && (p.mail?.n ?? 0) < EMAILS_PER_STEP, newPasskey: host && !!p.enrol && !p.codes, notice };
200
+ }
201
+
202
+ /** Signed in (or, for a step-up, confirmed) with the pending sign-in's options, now with "+2fa" in its method. */
148
203
  async function finish(req: Req, reply: FastifyReply, a: App, s: Session, p: Pending, detail: string, next?: string, flash?: string) {
204
+ if (p.stepup) {
205
+ delete s.state[PENDING];
206
+ markSecondStep(s.state);
207
+ if (flash) s.state.__FLASH = flash;
208
+ await saveState(s);
209
+ await logActivity({ appId: a.id, username: p.u, event: '2fa_stepup', ip: clientIp(req), detail });
210
+ return reply.redirect(safeNext(a, p.next), 303);
211
+ }
149
212
  const n = await signIn(req, reply, a, s, p.u, {
150
213
  extraRoles: p.extraRoles, groups: p.groups, remember: p.remember, method: withSecondStep(p.method),
151
214
  detail: `${p.detail ?? 'password'} ${detail}`,
152
215
  });
153
- if (flash) {
154
- n.state.__FLASH = flash;
155
- await saveState(n);
156
- }
216
+ markSecondStep(n.state);
217
+ if (flash) n.state.__FLASH = flash;
218
+ await saveState(n);
157
219
  return reply.redirect(next ?? safeNext(a, p.next), 303);
158
220
  }
159
221
 
222
+ /** Too many wrong codes: the same lockout as wrong passwords. Returns a reply when locked. */
223
+ async function locked(reply: FastifyReply, a: App, locale: Locale, s: Session, p: Pending, ip: string, what: string) {
224
+ if (!(await loginThrottled(a.id, p.u, ip))) return null;
225
+ logActivity({ appId: a.id, username: p.u, event: 'login_locked', ip, detail: what });
226
+ delete s.state[PENDING];
227
+ await saveState(s);
228
+ return reply.code(429).type('text/html').send(card(a, locale, locale.t('mfa.step_title'), alert(locale.t('login.throttled', { minutes: loginWindowMinutes() }))));
229
+ }
230
+
160
231
  export async function twoStepRoutes(app: FastifyInstance) {
161
232
  app.get('/a/:alias/login/2fa', async (req: Req, reply) => {
162
233
  const r = await start(req, reply);
163
234
  if (!r) return reply.code(404).send('Not found');
164
- const { a, locale, session: s, p } = r;
235
+ const { a, locale, session: s } = r;
236
+ let p = r.p;
237
+ // (sprint 131) step-up: a signed-in user sent here by a page that needs a more recent second step
238
+ const next = typeof (req.query as { next?: unknown }).next === 'string' ? String((req.query as { next: string }).next).slice(0, 2000) : undefined;
239
+ if (s.username && (!p || (next && p.next !== next))) {
240
+ const id = await accountId(s.username);
241
+ if (id === null || !(await accountHasFactor(id)))
242
+ return reply.code(403).type('text/html').send(card(a, locale, locale.t('mfa.stepup_title'),
243
+ html`${alert(locale.t('mfa.stepup_none'))}<p><a class="btn" href="/a/${a.alias}/account#two-step">${locale.t('mfa.my_account')}</a></p>`, `/a/${a.alias}/${a.home_page}`));
244
+ p = { u: s.username, next, at: Date.now(), stepup: true };
245
+ keep(s, p);
246
+ }
165
247
  if (!p) return expired(reply, a, locale, s);
166
248
  const id = await accountId(p.u);
167
249
  if (id === null) return expired(reply, a, locale, s);
250
+ const offers = await offersFor(req, a, p, id);
168
251
  if (p.enrol && !p.codes && !(await accountHasFactor(id))) {
169
- // fail closed: without the instance key there is nowhere safe to keep the app's secret
170
- if (!secretKeyConfigured()) return reply.code(503).type('text/html').send(card(a, locale, locale.t('mfa.enrol_title'), alert(locale.t('mfa.no_key'))));
171
- if (!p.secret) {
252
+ // fail closed: without the instance key there is nowhere safe to keep the app's secret (a passkey needs none)
253
+ if (!secretKeyConfigured() && !offers.newPasskey)
254
+ return reply.code(503).type('text/html').send(card(a, locale, locale.t('mfa.enrol_title'), alert(locale.t('mfa.no_key'))));
255
+ if (!p.secret && secretKeyConfigured()) {
172
256
  p.secret = newTotpSecret();
173
- s.state[PENDING] = JSON.stringify(p);
257
+ keep(s, p);
174
258
  }
175
259
  }
176
260
  await saveState(s);
177
- return reply.type('text/html').send(stepPage(a, locale, s, p));
261
+ return reply.header('cache-control', 'no-store').type('text/html').send(stepPage(a, locale, s, p, offers));
178
262
  });
179
263
 
180
264
  app.post('/a/:alias/login/2fa', async (req: Req, reply) => {
@@ -186,53 +270,163 @@ export async function twoStepRoutes(app: FastifyInstance) {
186
270
  const ip = clientIp(req);
187
271
  const id = await accountId(p.u);
188
272
  if (id === null) return expired(reply, a, locale, s);
189
- if (await loginThrottled(a.id, p.u, ip)) {
190
- logActivity({ appId: a.id, username: p.u, event: 'login_locked', ip, detail: '2fa' });
191
- delete s.state[PENDING];
192
- await saveState(s);
193
- return reply.code(429).type('text/html').send(card(a, locale, t('mfa.step_title'), alert(t('login.throttled', { minutes: loginWindowMinutes() }))));
194
- }
273
+ const lock = await locked(reply, a, locale, s, p, ip, '2fa');
274
+ if (lock) return lock;
195
275
  const code = String(req.body?.code ?? '').slice(0, 40);
276
+ const wrong = async (detail: string) => {
277
+ await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail });
278
+ return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id), t('mfa.wrong')));
279
+ };
196
280
  // adding the first app while signing in: its first code proves it works
197
281
  if (p.enrol && p.secret && !p.codes) {
198
282
  const step = matchTotp(unbase32(p.secret), code.replace(/\s/g, ''), null);
199
- if (step === null) {
200
- await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: '2fa (new app)' });
201
- return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, t('mfa.wrong')));
202
- }
283
+ if (step === null) return wrong('2fa (new app)');
203
284
  await addAccountTotp(id, t('mfa.app_default'), p.secret, step);
204
285
  await logActivity({ appId: a.id, username: p.u, event: '2fa_enrolled', ip, detail: 'authenticator app (sign-in)' });
205
286
  p.codes = await newAccountCodes(id);
287
+ p.added = 'app';
206
288
  delete p.secret;
207
- s.state[PENDING] = JSON.stringify(p);
289
+ keep(s, p);
208
290
  await saveState(s);
209
- return reply.type('text/html').send(stepPage(a, locale, s, p));
291
+ return reply.type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id)));
210
292
  }
211
- const ok = await checkAccountCode(id, code);
212
- if (!ok) {
213
- await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: '2fa' });
214
- return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, t('mfa.wrong')));
293
+ if (p.enrol) return expired(reply, a, locale, s);
294
+ // (sprint 131) the code sent by e-mail, valid for some minutes and once
295
+ if (p.mail && Date.now() - p.mail.at < EMAIL_CODE_MINUTES * 60_000 && emailCodeMatches(p.mail.h, code)) {
296
+ delete p.mail;
297
+ keep(s, p);
298
+ return finish(req, reply, a, s, p, '2fa (e-mail code)');
215
299
  }
300
+ const ok = await checkAccountCode(id, code);
301
+ if (!ok) return wrong('2fa');
216
302
  if (ok.kind === 'recovery') {
217
303
  const left = await accountCodesLeft(id);
218
304
  await logActivity({ appId: a.id, username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
219
305
  // to My account: one code fewer, maybe time for new ones
220
- return finish(req, reply, a, s, p, '2fa (recovery code)', `/a/${a.alias}/account#two-step`, t('mfa.recovery_used', { n: left }));
306
+ return finish(req, reply, a, s, p, '2fa (recovery code)', p.stepup ? undefined : `/a/${a.alias}/account#two-step`, t('mfa.recovery_used', { n: left }));
221
307
  }
222
308
  return finish(req, reply, a, s, p, '2fa');
223
309
  });
224
310
 
225
- // the recovery codes were shown once after adding the first app while signing in
311
+ // (sprint 131) a code by e-mail, for a user who has an authenticator app or a passkey (never the only factor)
312
+ app.post('/a/:alias/login/2fa/email', async (req: Req, reply) => {
313
+ const r = await start(req, reply);
314
+ if (!r) return reply.code(404).send('Not found');
315
+ const { a, locale, session: s, p } = r;
316
+ const t = locale.t;
317
+ if (!p || p.enrol || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
318
+ const ip = clientIp(req);
319
+ const id = await accountId(p.u);
320
+ if (id === null) return expired(reply, a, locale, s);
321
+ const lock = await locked(reply, a, locale, s, p, ip, '2fa e-mail');
322
+ if (lock) return lock;
323
+ const target = (p.mail?.n ?? 0) < EMAILS_PER_STEP ? await emailCodeTarget(a, id) : null;
324
+ const code = newEmailCode();
325
+ const sent = !!target && (await queueEmailCode(a, p.u, target,
326
+ t('mfa.email_subject', { app: a.name }), t('mfa.email_body', { app: a.name, code, minutes: EMAIL_CODE_MINUTES })));
327
+ if (sent) {
328
+ p.mail = { h: emailCodeHash(code), at: Date.now(), n: (p.mail?.n ?? 0) + 1 };
329
+ keep(s, p);
330
+ await logActivity({ appId: a.id, username: p.u, event: '2fa_email', ip, detail: 'code sent' });
331
+ }
332
+ await saveState(s);
333
+ const offers = await offersFor(req, a, p, id, sent ? t('mfa.email_sent', { minutes: EMAIL_CODE_MINUTES }) : undefined);
334
+ return reply.code(sent ? 200 : 429).type('text/html').send(stepPage(a, locale, s, p, offers, sent ? undefined : t('mfa.email_failed')));
335
+ });
336
+
337
+ // (sprint 131) the second step with a passkey: options for navigator.credentials.get(), then the browser's answer
338
+ app.post('/a/:alias/login/2fa/passkey/options', async (req: Req, reply) => {
339
+ const r = await start(req, reply);
340
+ if (!r) return reply.code(404).send('Not found');
341
+ const { session: s, p } = r;
342
+ if (!p || p.enrol || req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Sign in again.');
343
+ const id = await accountId(p.u);
344
+ const rp = relyingParty(req);
345
+ const keys = id === null ? [] : await accountPasskeys(id, rp.rpId);
346
+ if (!keys.length) return reply.code(404).send('No passkey for this site.');
347
+ const challenge = giveChallenge(s, 'get');
348
+ await saveState(s);
349
+ return reply.header('cache-control', 'no-store').send({ mode: 'get', challenge, rpId: rp.rpId, allow: keys.map((k) => ({ id: k.credential_id, transports: k.transports })) });
350
+ });
351
+
352
+ app.post('/a/:alias/login/2fa/passkey', async (req: Req, reply) => {
353
+ const r = await start(req, reply);
354
+ if (!r) return reply.code(404).send('Not found');
355
+ const { a, locale, session: s, p } = r;
356
+ const challenge = takeChallenge(s, 'get');
357
+ if (!p || p.enrol || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
358
+ const ip = clientIp(req);
359
+ const id = await accountId(p.u);
360
+ if (id === null) return expired(reply, a, locale, s);
361
+ const lock = await locked(reply, a, locale, s, p, ip, '2fa passkey');
362
+ if (lock) return lock;
363
+ const rp = relyingParty(req);
364
+ const key = (await accountPasskeys(id, rp.rpId)).find((k) => k.credential_id === String(req.body?.credentialId ?? ''));
365
+ let why = key ? '' : 'unknown passkey';
366
+ if (key && !challenge) why = 'no or an expired challenge';
367
+ if (key && challenge) {
368
+ try {
369
+ const v = verifyAssertion(
370
+ { clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
371
+ { alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
372
+ if (!(await useAccountPasskey(key.id, key.sign_count, v.signCount))) why = 'used at the same time elsewhere';
373
+ } catch (e) {
374
+ why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
375
+ }
376
+ }
377
+ if (why) {
378
+ await logActivity({ appId: a.id, username: p.u, event: 'login_failed', ip, detail: `2fa passkey: ${why}` });
379
+ await saveState(s);
380
+ return reply.code(401).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id), locale.t('mfa.passkey_failed')));
381
+ }
382
+ return finish(req, reply, a, s, p, '2fa (passkey)');
383
+ });
384
+
385
+ // (sprint 131) adding a passkey as the first factor while signing in (the application requires one)
386
+ app.post('/a/:alias/login/2fa/passkey/new/options', async (req: Req, reply) => {
387
+ const r = await start(req, reply);
388
+ if (!r) return reply.code(404).send('Not found');
389
+ const { a, session: s, p } = r;
390
+ if (!p?.enrol || p.codes || req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Sign in again.');
391
+ const id = await accountId(p.u);
392
+ if (id === null) return reply.code(403).send('Sign in again.');
393
+ return reply.header('cache-control', 'no-store').send(await createOptions(req, s, a, id, p.u));
394
+ });
395
+
396
+ app.post('/a/:alias/login/2fa/passkey/new', async (req: Req, reply) => {
397
+ const r = await start(req, reply);
398
+ if (!r) return reply.code(404).send('Not found');
399
+ const { a, locale, session: s, p } = r;
400
+ const t = locale.t;
401
+ const challenge = takeChallenge(s, 'create');
402
+ if (!p?.enrol || p.codes || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
403
+ const id = await accountId(p.u);
404
+ if (id === null) return expired(reply, a, locale, s);
405
+ const ip = clientIp(req);
406
+ const added = challenge ? await registerPasskey(req, a, id, p.u, challenge, t('mfa.passkey_default'), ip, 'sign-in') : 'expired';
407
+ if (added !== 'ok') {
408
+ await saveState(s);
409
+ return reply.code(422).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id), t(added === 'known' ? 'mfa.passkey_known' : 'mfa.passkey_failed')));
410
+ }
411
+ p.codes = await newAccountCodes(id);
412
+ p.added = 'passkey';
413
+ delete p.secret;
414
+ keep(s, p);
415
+ await saveState(s);
416
+ return reply.type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id)));
417
+ });
418
+
419
+ // the recovery codes were shown once after adding the first app or passkey while signing in
226
420
  app.post('/a/:alias/login/2fa/done', async (req: Req, reply) => {
227
421
  const r = await start(req, reply);
228
422
  if (!r) return reply.code(404).send('Not found');
229
423
  const { a, locale, session: s, p } = r;
230
424
  if (!p?.codes || req.body?.__csrf !== s.csrf_token) return expired(reply, a, locale, s);
231
- if (req.body?.saved !== '1') return reply.code(422).type('text/html').send(stepPage(a, locale, s, p));
232
425
  const id = await accountId(p.u);
233
426
  if (id === null) return expired(reply, a, locale, s);
427
+ if (req.body?.saved !== '1') return reply.code(422).type('text/html').send(stepPage(a, locale, s, p, await offersFor(req, a, p, id)));
234
428
  await confirmAccountCodes(id);
235
- return finish(req, reply, a, s, p, '2fa (new app)');
429
+ return finish(req, reply, a, s, p, `2fa (new ${p.added === 'passkey' ? 'passkey' : 'app'})`);
236
430
  });
237
431
 
238
432
  // ---------------------------------------------------------------- My account → Two-step sign-in
@@ -247,10 +441,10 @@ export async function twoStepRoutes(app: FastifyInstance) {
247
441
  }
248
442
  return { ctx, id };
249
443
  };
250
- const done = async (reply: FastifyReply, ctx: PageContext, flash?: string, error?: boolean) => {
444
+ const done = async (reply: FastifyReply, ctx: PageContext, flash?: string, error?: boolean, anchor = 'two-step') => {
251
445
  if (flash) ctx.session.state[error ? '__FLASH_ERROR' : '__FLASH'] = flash;
252
446
  await saveState(ctx.session);
253
- return reply.redirect(`${ctx.base}/account#two-step`, 303);
447
+ return reply.redirect(`${ctx.base}/account#${anchor}`, 303);
254
448
  };
255
449
 
256
450
  app.post('/a/:alias/account/2fa/start', async (req: Req, reply) => {
@@ -287,6 +481,28 @@ export async function twoStepRoutes(app: FastifyInstance) {
287
481
  return done(reply, ctx, t('mfa.added'));
288
482
  });
289
483
 
484
+ // (sprint 131) a passkey on My account: the options for navigator.credentials.create(), then the browser's answer
485
+ app.post('/a/:alias/account/2fa/passkey/options', async (req: Req, reply) => {
486
+ const r = await signedIn(req, reply);
487
+ if (!r) return;
488
+ await saveState(r.ctx.session);
489
+ return reply.header('cache-control', 'no-store').send(await createOptions(req, r.ctx.session, r.ctx.app, r.id, r.ctx.user));
490
+ });
491
+
492
+ app.post('/a/:alias/account/2fa/passkey', async (req: Req, reply) => {
493
+ const r = await signedIn(req, reply);
494
+ if (!r) return;
495
+ const { ctx, id } = r;
496
+ const t = ctx.locale.t;
497
+ const challenge = takeChallenge(ctx.session, 'create');
498
+ if (!challenge) return done(reply, ctx, t('mfa.expired'), true);
499
+ const first = !(await accountHasFactor(id));
500
+ const added = await registerPasskey(req, ctx.app, id, ctx.user, challenge, String(req.body?.pk_name ?? '') || t('mfa.passkey_default'), ctx.ip, 'My account');
501
+ if (added !== 'ok') return done(reply, ctx, t(added === 'known' ? 'mfa.passkey_known' : 'mfa.passkey_failed'), true);
502
+ if (first || !(await accountCodesLeft(id))) ctx.session.state.__CODES_NEW = JSON.stringify(await newAccountCodes(id));
503
+ return done(reply, ctx, t('mfa.passkey_added'));
504
+ });
505
+
290
506
  app.post('/a/:alias/account/2fa/codes', async (req: Req, reply) => {
291
507
  const r = await signedIn(req, reply);
292
508
  if (!r) return;
@@ -305,7 +521,7 @@ export async function twoStepRoutes(app: FastifyInstance) {
305
521
  return done(reply, r.ctx);
306
522
  });
307
523
 
308
- // removing an app asks for a code (from any app, or a recovery code): a session left open is not enough
524
+ // removing an app or a passkey asks for a code (from any app, or a recovery code): a session left open is not enough
309
525
  app.post('/a/:alias/account/2fa/:id/delete', async (req: Req, reply) => {
310
526
  const r = await signedIn(req, reply);
311
527
  if (!r) return;
@@ -317,16 +533,73 @@ export async function twoStepRoutes(app: FastifyInstance) {
317
533
  }
318
534
  const factors = await accountFactors(id);
319
535
  const factorId = Number((req.params as { id?: string }).id);
320
- if (!factors.some((f) => f.id === factorId)) return done(reply, ctx);
536
+ const factor = factors.find((f) => f.id === factorId);
537
+ if (!factor) return done(reply, ctx);
321
538
  if (factors.length === 1 && mfaRequired(ctx.app, ctx.roles)) return done(reply, ctx, t('mfa.keep_one'), true);
322
539
  if (!(await checkAccountCode(id, String(req.body?.code ?? '').slice(0, 40)))) {
323
540
  await logActivity({ appId: ctx.app.id, username: ctx.user, event: 'login_failed', ip: ctx.ip, detail: '2fa remove' });
324
541
  return done(reply, ctx, t('mfa.wrong'), true);
325
542
  }
326
543
  await removeAccountFactor(id, factorId);
327
- await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_removed', ip: ctx.ip, detail: 'authenticator app' });
328
- return done(reply, ctx, t('mfa.removed'));
544
+ await logActivity({ appId: ctx.app.id, username: ctx.user, event: '2fa_removed', ip: ctx.ip, detail: factor.kind === 'passkey' ? 'passkey' : 'authenticator app' });
545
+ return done(reply, ctx, t(factor.kind === 'passkey' ? 'mfa.passkey_removed' : 'mfa.removed'));
329
546
  });
547
+
548
+ // ---------------------------------------------------------------- My account → REST API token (sprint 131)
549
+ // Only when the application lets users create their own (meta.app.api_self_tokens). A user the application
550
+ // requires a second step of gives one first, unless the session had one in the last 15 minutes. The token is
551
+ // kept in the session until the page has shown it once.
552
+ app.post('/a/:alias/account/token', async (req: Req, reply) => {
553
+ const ctx = await loadContext(req, reply, { pageNo: 'home' });
554
+ if (!ctx) return;
555
+ const t = ctx.locale.t;
556
+ if (req.body?.__csrf !== ctx.session.csrf_token || !ctx.app.api_self_tokens || ctx.user === 'nobody') return reply.redirect(`${ctx.base}/account`, 303);
557
+ if ((await accountMustUseMfa(ctx.app, ctx.user)) && !secondStepWithin(ctx.session.state, TOKEN_STEP_MINUTES)) {
558
+ ctx.session.state.__FLASH = t('token.step_first');
559
+ await saveState(ctx.session);
560
+ return reply.redirect(`${ctx.base}/login/2fa?next=${encodeURIComponent(`${ctx.base}/account#api-token`)}`, 303);
561
+ }
562
+ try {
563
+ const issued = await issueApiToken(ctx.app.id, ctx.user, Number(req.body?.hours) || 8, { self: true });
564
+ ctx.session.state.__TOKEN_NEW = JSON.stringify({ token: issued.token, hours: issued.expiresInHours });
565
+ await logActivity({ appId: ctx.app.id, username: issued.username, event: 'api_token', ip: ctx.ip, detail: `by the user on My account, ${issued.expiresInHours} h` });
566
+ return done(reply, ctx, undefined, false, 'api-token');
567
+ } catch (e) {
568
+ return done(reply, ctx, (e as Error).message, true, 'api-token');
569
+ }
570
+ });
571
+ }
572
+
573
+ /** WebAuthn options for adding a passkey to an account (a one-time challenge kept in the session). */
574
+ async function createOptions(req: FastifyRequest, s: Session, a: App, id: number, username: string) {
575
+ const rp = relyingParty(req);
576
+ const challenge = giveChallenge(s, 'create');
577
+ await saveState(s);
578
+ return {
579
+ mode: 'create', challenge, rp: { id: rp.rpId, name: a.name }, algs: ALGORITHMS,
580
+ // a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
581
+ user: { id: accountUserHandle(id), name: username, displayName: username },
582
+ exclude: (await accountPasskeys(id, rp.rpId)).map((k) => k.credential_id),
583
+ };
584
+ }
585
+
586
+ /** Check the browser's answer and keep the passkey: 'ok', 'known' (added already) or 'failed'. */
587
+ async function registerPasskey(req: Req, a: App, id: number, username: string, challenge: string, name: string, ip: string, where: string) {
588
+ const rp = relyingParty(req);
589
+ let reg;
590
+ try {
591
+ reg = verifyRegistration({ clientDataJSON: String(req.body?.clientDataJSON ?? ''), attestationObject: String(req.body?.attestationObject ?? '') }, { challenge, ...rp });
592
+ } catch (e) {
593
+ await logActivity({ appId: a.id, username, event: '2fa_refused', ip, detail: `passkey: ${e instanceof WebAuthnError ? e.message : 'unreadable answer'}` });
594
+ return 'failed' as const;
595
+ }
596
+ try {
597
+ await addAccountPasskey(id, name, { ...reg, rpId: rp.rpId, transports: String(req.body?.transports ?? '').split(',').filter(Boolean) });
598
+ } catch {
599
+ return 'known' as const;
600
+ }
601
+ await logActivity({ appId: a.id, username, event: '2fa_enrolled', ip, detail: `passkey (${rp.rpId}, ${where})` });
602
+ return 'ok' as const;
330
603
  }
331
604
 
332
605
  /** The app secret being added on My account (kept in the session for some minutes), or null. */
@@ -339,8 +612,18 @@ function newSecret(s: Session, minutes: number): string | null {
339
612
  }
340
613
  }
341
614
 
615
+ /** Something kept in the session to show once (recovery codes, a token), taken out. */
616
+ function takeOnce<T>(s: Session, key: string): T | null {
617
+ try {
618
+ const v = JSON.parse(String(s.state[key] ?? ''));
619
+ return v ?? null;
620
+ } catch {
621
+ return null;
622
+ }
623
+ }
624
+
342
625
  /** My account → Two-step sign-in (when the application has a second step). */
343
- export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
626
+ export async function twoStepSection(ctx: PageContext, rpId = ''): Promise<Raw | ''> {
344
627
  if (!mfaOn(ctx.app)) return '';
345
628
  const id = await accountId(ctx.user);
346
629
  if (id === null) return '';
@@ -348,13 +631,7 @@ export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
348
631
  const [factors, left] = await Promise.all([accountFactors(id), accountCodesLeft(id)]);
349
632
  const required = mfaRequired(ctx.app, ctx.roles);
350
633
  const pending = newSecret(ctx.session, 10);
351
- let shown: string[] | null = null;
352
- try {
353
- const c = JSON.parse(String(ctx.session.state.__CODES_NEW ?? ''));
354
- shown = Array.isArray(c) ? c : null;
355
- } catch {
356
- shown = null;
357
- }
634
+ const shown = takeOnce<string[]>(ctx.session, '__CODES_NEW');
358
635
  const error = typeof ctx.session.state.__FLASH_ERROR === 'string' ? ctx.session.state.__FLASH_ERROR : '';
359
636
  delete ctx.session.state.__FLASH_ERROR;
360
637
  const base = `${ctx.base}/account/2fa`;
@@ -362,15 +639,16 @@ export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
362
639
  const when = (v: string | null) => (v ? String(v).slice(0, 10) : '');
363
640
  return html`<section class="region region-standard col-6" id="two-step"><header class="region-header"><h2>${t('mfa.step_title')}</h2></header><div class="region-body">
364
641
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
365
- <p class="muted u-mt0">${factors.length ? t('mfa.on') : required ? t('mfa.required_you') : t('mfa.off')}</p>
366
- ${shown
642
+ <p class="muted u-mt0">${factors.length ? t('mfa.on_any') : required ? t('mfa.required_you') : t('mfa.off')}</p>
643
+ ${Array.isArray(shown)
367
644
  ? html`<h3>${t('mfa.codes_title')}</h3><p>${t('mfa.codes_once')}</p>${codesList(shown)}
368
645
  <form method="post" action="${base}/codes/confirm">${csrf}
369
646
  <label class="check"><input type="checkbox" name="saved" value="1" required> ${t('mfa.codes_saved')}</label>
370
647
  <div class="buttons"><button class="btn btn-hot">${t('mfa.continue')}</button></div></form>`
371
648
  : ''}
372
649
  ${factors.length
373
- ? html`<ul class="mfa-factors">${factors.map((f) => html`<li><strong>${f.name}</strong> <span class="muted">${when(f.created_at)}</span>
650
+ ? html`<ul class="mfa-factors">${factors.map((f) => html`<li><strong>${f.name}</strong> <span class="muted">${f.kind === 'passkey' ? t('mfa.kind_passkey') : t('mfa.kind_app')} · ${when(f.created_at)}</span>
651
+ ${f.kind === 'passkey' && rpId && f.rp_id !== rpId ? html` <span class="badge">${f.rp_id}</span>` : ''}
374
652
  <details><summary class="link-button">${t('mfa.remove')}</summary>
375
653
  <form method="post" action="${base}/${f.id}/delete">${csrf}
376
654
  <div class="field"><label class="label" for="rm_${f.id}">${t('mfa.remove_code')}</label><input id="rm_${f.id}" name="code" required autocomplete="one-time-code" maxlength="20"></div>
@@ -390,6 +668,35 @@ export async function twoStepSection(ctx: PageContext): Promise<Raw | ''> {
390
668
  : secretKeyConfigured()
391
669
  ? html`<form method="post" action="${base}/start">${csrf}<div class="buttons"><button class="btn${factors.length ? '' : ' btn-hot'}">${t('mfa.add_app')}</button></div></form>`
392
670
  : html`<p class="alert alert-info" role="status">${t('mfa.no_key')}</p>`}
671
+ ${pending
672
+ ? ''
673
+ : html`<h3>${t('mfa.kind_passkey')}</h3><p class="muted u-mt0">${t('mfa.passkey_intro')}</p>
674
+ ${rpId && isIP(rpId)
675
+ ? html`<p class="alert alert-info" role="status">${t('mfa.passkey_needs_host')}</p>`
676
+ : html`<form method="post" action="${base}/passkey" class="mfa-passkey">${csrf}${passkeyFields(ATTESTATION)}
677
+ <div class="field"><label class="label" for="pk_name">${t('mfa.app_name')}</label><input id="pk_name" name="pk_name" value="${t('mfa.passkey_default')}" required maxlength="60"></div>
678
+ <div class="buttons"><button class="btn" hidden data-passkey="${base}/passkey/options" data-passkey-error="${t('mfa.passkey_failed')}">${t('mfa.add_passkey')}</button></div>
679
+ <p class="small" role="status" data-passkey-status></p>
680
+ <noscript><p class="muted small">${t('mfa.passkey_needs_js')}</p></noscript></form>`}`}
393
681
  </div></section>`;
394
682
  }
395
683
 
684
+ /** (sprint 131) My account → REST API token, when the application lets users create their own. */
685
+ export async function tokenSection(ctx: PageContext): Promise<Raw | ''> {
686
+ if (!ctx.app.api_self_tokens || (await accountId(ctx.user)) === null) return '';
687
+ const t = ctx.locale.t;
688
+ const issued = takeOnce<{ token: string; hours: number }>(ctx.session, '__TOKEN_NEW');
689
+ delete ctx.session.state.__TOKEN_NEW;
690
+ const needsStep = await accountMustUseMfa(ctx.app, ctx.user);
691
+ const csrf = html`<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">`;
692
+ return html`<section class="region region-standard col-6" id="api-token"><header class="region-header"><h2>${t('token.title')}</h2></header><div class="region-body">
693
+ <p class="muted u-mt0">${t('token.intro')}${needsStep ? html` ${t('token.needs_step')}` : ''}</p>
694
+ ${issued?.token
695
+ ? html`<div class="field"><label class="label" for="api_token">${t('token.shown_once', { hours: issued.hours })}</label>
696
+ <textarea id="api_token" rows="4" readonly spellcheck="false">${issued.token}</textarea></div>`
697
+ : ''}
698
+ <form method="post" action="${ctx.base}/account/token">${csrf}
699
+ <div class="field"><label class="label" for="token_hours">${t('token.hours')}</label><input id="token_hours" name="hours" type="number" min="1" max="${MAX_TOKEN_HOURS}" value="8" required></div>
700
+ <div class="buttons"><button class="btn">${t('token.create')}</button></div></form>
701
+ </div></section>`;
702
+ }