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