pgkiln 0.61.0 → 0.63.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.
@@ -27,10 +27,16 @@ import { appFromFileRoutes } from './appfromfile.ts';
27
27
  import { clearDevbar } from '../runtime/devbar.ts';
28
28
  import { EMAIL } from './find-workspace.ts';
29
29
  import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
30
- import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeysOf, trustBrowser, trustDays, usePasskey } from '../mfa.ts';
30
+ import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeyByCredential, passkeySignin, passkeysOf, trustBrowser, trustDays, usePasskey, userHandle } from '../mfa.ts';
31
+ import { isIP } from 'node:net';
31
32
  import { giveChallenge, takeChallenge } from './security.ts';
32
33
  import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
33
34
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
35
+ import { builderSsoRoutes, leavesNoBreakGlass } from './builder-sso.ts';
36
+ import { builderDirectory, builderProvider, builderSources, linkDeveloper, resolveDeveloper, ssoRequired } from '../devsso.ts';
37
+ import { ldapAuthenticate, LdapError } from '../ldap.ts';
38
+ import { onBuilderSso, SsoError, startSignIn } from '../sso.ts';
39
+ import { startSamlSignIn } from '../saml.ts';
34
40
 
35
41
  // Builder pages: sign-in, workspace and app home, settings, activity and
36
42
  // developers. Shared Components, the page designer and the SQL Workshop
@@ -67,6 +73,10 @@ export async function builderRoutes(app: FastifyInstance) {
67
73
  await saveState(s);
68
74
  const r = remembered(req);
69
75
  const next = safeNext(req.query?.next);
76
+ // (sprint 128) a passkey alone, when the instance allows it and the address is a host name
77
+ const alone = (await passkeySignin()) && !isIP(relyingParty(req).rpId);
78
+ // (sprint 129, SEC-1) identity providers and directories that sign developers in
79
+ const sources = await builderSources();
70
80
  const field = (name: string, label: string, ic: string, value: string, opts: { type?: string; required?: boolean; auto?: string; placeholder?: string; reveal?: boolean }) =>
71
81
  html`<div class="field signin-field"><label class="sr-only" for="f_${name}">${label}</label>
72
82
  <div class="signin-input">${icon(ic)}<input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value}" placeholder="${opts.placeholder ?? label}"${opts.required ? raw(' required') : ''}${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
@@ -88,10 +98,18 @@ export async function builderRoutes(app: FastifyInstance) {
88
98
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
89
99
  ${csrf(s)}<input type="hidden" name="next" value="${next}">
90
100
  ${field('workspace', tx('signin.workspace'), 'database', r?.w ?? '', { placeholder: tx('signin.workspace_placeholder'), auto: 'organization' })}
101
+ ${sources.directories.length ? html`<div class="field signin-field"><label class="sr-only" for="f_method">${tx('signin.method')}</label>
102
+ <div class="signin-input">${icon('server')}<select id="f_method" name="method" aria-label="${tx('signin.method')}">
103
+ <option value="">${tx('signin.method_pgkiln')}</option>
104
+ ${sources.directories.map((d) => html`<option value="ldap:${d.name}">${d.display_name}</option>`)}</select></div></div>` : ''}
91
105
  ${field('username', tx('signin.username'), 'user', r?.u ?? '', { required: true, auto: 'username' })}
92
106
  ${field('password', tx('signin.password'), 'key', '', { type: 'password', required: true, auto: 'current-password', reveal: true })}
93
107
  <label class="check signin-remember"><input type="checkbox" name="remember" value="1"${r ? raw(' checked') : ''}> ${tx('signin.remember')}</label>
94
108
  <button class="btn btn-hot signin-submit">${tx('signin.submit')}</button>
109
+ ${alone ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature"><input type="hidden" name="userHandle">
110
+ <button class="btn signin-submit signin-passkey" hidden data-passkey="${BASE}/login/passkey/options" data-passkey-submit="${BASE}/login/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('signin.passkey')}</button>
111
+ <p class="small" role="status" data-passkey-status></p>` : ''}
112
+ ${sources.providers.map((p) => html`<a class="btn signin-submit signin-idp" href="${BASE}/sso/${p.name}${next === `${BASE}/home` ? '' : `?next=${encodeURIComponent(next)}`}">${icon('log-in')} ${tx('signin.with', { provider: p.display_name })}</a>`)}
95
113
  <p class="muted small signin-hint">${tx('signin.hint')}</p>
96
114
  <nav class="signin-actions" aria-label="${tx('signin.quick')}">
97
115
  <a href="${BASE}/find-workspace">${icon('search')} ${tx('signin.find_ws')}</a>
@@ -129,45 +147,196 @@ export async function builderRoutes(app: FastifyInstance) {
129
147
  flash(s, tx('signin.throttled'), 'error');
130
148
  return back(reply, s, again);
131
149
  }
132
- // bcrypt always runs (dummy salt for unknown users) so timing does not reveal accounts
133
- const dev = await owner.one(
134
- `select d.username, d.language
135
- from (select $1::text as u) x
136
- left join meta.developer d on d.username = x.u
137
- where crypt($2, coalesce(d.password_hash, gen_salt('bf', 10))) = d.password_hash`,
138
- [username, password],
139
- );
150
+ // (sprint 129, SEC-1) an LDAP directory checks the password; the developer is the one linked to the entry
151
+ const method = String(req.body?.method ?? '');
152
+ const directory = method.startsWith('ldap:') ? await builderDirectory(method.slice(5)) : undefined;
153
+ if (method && !directory) {
154
+ flash(s, tx('signin.expired'), 'error');
155
+ return back(reply, s, again);
156
+ }
157
+ let dev: { username: string; language: string | null } | undefined;
158
+ if (directory) {
159
+ let why = 'invalid';
160
+ try {
161
+ const u = await ldapAuthenticate(directory, username, password);
162
+ if (u) {
163
+ const name = await resolveDeveloper({ kind: 'directory', ...directory }, { subject: u.subject, username: u.username, name: u.displayName, email: u.email, mayLink: true, groups: u.groups });
164
+ dev = await owner.one('select username, language from meta.developer where username = $1', [name]);
165
+ }
166
+ } catch (e) {
167
+ if (!(e instanceof SsoError)) req.log.warn({ err: e, directory: directory.name }, 'builder ldap sign-in failed');
168
+ why = e instanceof SsoError ? e.message : e instanceof LdapError ? e.message : 'unreachable';
169
+ }
170
+ if (!dev) {
171
+ await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: `builder ldap:${directory.name}: ${why}` });
172
+ flash(s, why === 'invalid' ? tx('signin.invalid') : why === 'unreachable' ? tx('signin.ldap_unavailable', { directory: directory.display_name }) : why, 'error');
173
+ return back(reply, s, again);
174
+ }
175
+ } else
176
+ // bcrypt always runs (dummy salt for unknown users) so timing does not reveal accounts
177
+ dev = await owner.one(
178
+ `select d.username, d.language
179
+ from (select $1::text as u) x
180
+ left join meta.developer d on d.username = x.u
181
+ where crypt($2, coalesce(d.password_hash, gen_salt('bf', 10))) = d.password_hash`,
182
+ [username, password],
183
+ );
140
184
  // a workspace, when given, must be one of the developer's (administrators: any); the same answer otherwise
141
- const ws = dev && workspace
142
- ? await owner.one<{ id: number }>(
143
- `select w.id from meta.workspace w
144
- where lower(w.name) = lower($1)
145
- and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
146
- or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
147
- [workspace, dev.username],
148
- )
149
- : undefined;
185
+ const ws = dev && workspace ? await workspaceOf(workspace, dev.username) : undefined;
150
186
  if (!dev || (workspace && !ws)) {
151
- await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: workspace ? 'builder (workspace)' : 'builder' });
187
+ await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: directory ? `builder ldap:${directory.name} (workspace)` : workspace ? 'builder (workspace)' : 'builder' });
152
188
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
153
189
  return back(reply, s, again);
154
190
  }
191
+ // (sprint 129) a workspace of the developer requires single sign-on: not the pgkiln password (break-glass excepted)
192
+ if (!directory && (await ssoRequired(dev.username))) {
193
+ await logActivity({ username: dev.username, event: 'login_failed', ip, detail: 'builder: single sign-on required' });
194
+ flash(s, tx('signin.sso_required'), 'error');
195
+ return back(reply, s, again);
196
+ }
197
+ const weak = !directory && (password === 'admin' || password === dev.username);
198
+ const detail = directory ? `builder ldap:${directory.name}` : 'builder';
155
199
  // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
156
200
  // (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
157
201
  if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
158
- s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
202
+ s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak, at: Date.now() });
159
203
  await saveState(s);
160
204
  return reply.redirect(`${BASE}/login/2fa`, 303);
161
205
  }
162
- return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, picked, detail: 'builder' });
206
+ return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak, picked, detail });
207
+ });
208
+
209
+ /** A workspace by name, when it is one of the developer's (administrators: any). */
210
+ const workspaceOf = (workspace: string, username: string) =>
211
+ owner.one<{ id: number }>(
212
+ `select w.id from meta.workspace w
213
+ where lower(w.name) = lower($1)
214
+ and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
215
+ or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
216
+ [workspace, username],
217
+ );
218
+
219
+ // (sprint 129, SEC-1) signing in through an identity provider (OpenID Connect or SAML; devsso.ts). The
220
+ // provider returns to the runtime's callback (/sso/…, registered at the provider for applications too),
221
+ // which hands sign-ins without an application to the function below.
222
+ const SSO_COOKIE = 'pgkiln_sso';
223
+ app.get(`${BASE}/sso/:provider`, async (req: Req, reply) => {
224
+ const p = await builderProvider(String(req.params.provider));
225
+ if (!p) return reply.code(404).send('Not found');
226
+ try {
227
+ const next = safeNext(req.query?.next);
228
+ const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, null, next) : await startSignIn(p, null, next);
229
+ reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
230
+ return reply.redirect(url);
231
+ } catch (e) {
232
+ req.log.warn({ err: e }, 'builder sso start failed');
233
+ const s = await getSession(req, reply, null, BASE);
234
+ flash(s, translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req))('signin.sso_unavailable', { provider: p.display_name }), 'error');
235
+ return back(reply, s, `${BASE}/login`);
236
+ }
237
+ });
238
+ /** Linking an identity from the Security page (security.ts): the signed-in developer starts it. */
239
+ app.post(`${BASE}/security/link/:provider`, async (req: Req, reply) => {
240
+ const s = await developer(req, reply);
241
+ if (!s) return;
242
+ const p = await builderProvider(String(req.params.provider));
243
+ if (!p) return reply.code(404).send('Not found');
244
+ try {
245
+ const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, null, `${BASE}/security`, s.username!) : await startSignIn(p, null, `${BASE}/security`, s.username!);
246
+ reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
247
+ return reply.redirect(url, 303);
248
+ } catch (e) {
249
+ req.log.warn({ err: e }, 'builder sso link failed');
250
+ flash(s, tr(s)('signin.sso_unavailable', { provider: p.display_name }), 'error');
251
+ return back(reply, s, `${BASE}/security`);
252
+ }
253
+ });
254
+ // the end of both (the runtime's callback; SsoError → an error page there)
255
+ onBuilderSso(async (req: Req, reply, p, result, ip) => {
256
+ if (!p.builder || !result.identity) throw new SsoError('This identity provider does not sign developers in.');
257
+ const src = { kind: 'provider' as const, ...p };
258
+ if (result.link) {
259
+ // the browser that started this was signed in as result.link (the start needs the session and its CSRF token)
260
+ await linkDeveloper(src, result.identity.subject, result.link);
261
+ logActivity({ username: result.link, event: 'sso_linked', ip, detail: p.name });
262
+ return reply.redirect(`${BASE}/security?linked=${encodeURIComponent(p.name)}`, 303);
263
+ }
264
+ const username = await resolveDeveloper(src, { ...result.identity, username: result.username, groups: result.groups });
265
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [username]);
266
+ if (!dev) throw new SsoError('The developer was removed.');
267
+ return finishSignIn(req, reply, null, dev, { ws: null, workspace: '', next: safeNext(result.next), remember: false, weak: false, picked: null, detail: `builder sso:${p.name}`, sso: p.name });
163
268
  });
164
269
 
165
- /** The session after a successful sign-in (password, and the second factor when the developer has one). */
166
- async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
167
- o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
270
+ // (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
271
+ // passkeys it holds for this site (discoverable credentials); the authenticator must verify the developer
272
+ // (fingerprint, face, PIN), so the passkey counts as both factors. Failures count with wrong passwords.
273
+ app.post(`${BASE}/login/passkey/options`, async (req: Req, reply) => {
274
+ const s = await getSession(req, reply, null, BASE);
275
+ if (req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Reload the page.');
276
+ if (!(await passkeySignin())) return reply.code(404).send('Passkey sign-in is off.');
277
+ const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
278
+ await saveState(s);
279
+ reply.header('cache-control', 'no-store');
280
+ return reply.send({ mode: 'get', challenge, rpId: relyingParty(req).rpId, allow: [], uv: 'required' });
281
+ });
282
+ app.post(`${BASE}/login/passkey`, async (req: Req, reply) => {
283
+ const s = await getSession(req, reply, null, BASE);
284
+ const ip = clientIp(req);
285
+ const picked = validBuilderLanguage(s.state.__BLANG);
286
+ const tx = translatorFor(picked ?? cookieLanguage(req));
287
+ const workspace = String(req.body?.workspace ?? '').trim().slice(0, 60);
288
+ const next = safeNext(req.body?.next);
289
+ const again = next === `${BASE}/home` ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
290
+ const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
291
+ if (req.body?.__csrf !== s.csrf_token || !(await passkeySignin())) {
292
+ flash(s, tx('signin.expired'), 'error');
293
+ return back(reply, s, again);
294
+ }
295
+ const rp = relyingParty(req);
296
+ const key = await passkeyByCredential(String(req.body?.credentialId ?? ''), rp.rpId);
297
+ const who = key?.username ?? '';
298
+ if (await loginThrottled(null, who, ip)) {
299
+ logActivity({ username: who, event: 'login_locked', ip, detail: 'builder passkey' });
300
+ flash(s, tx('signin.throttled'), 'error');
301
+ return back(reply, s, again);
302
+ }
303
+ let why = key ? '' : 'unknown passkey';
304
+ if (key && !challenge) why = 'no or an expired challenge';
305
+ else if (key && req.body?.userHandle && req.body.userHandle !== userHandle(key.username)) why = 'the user handle does not match';
306
+ else if (key && challenge) {
307
+ try {
308
+ const r = verifyAssertion(
309
+ { clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
310
+ { alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
311
+ if (!r.userVerified) why = 'the authenticator did not verify the user';
312
+ else if (!(await usePasskey(key.id, key.sign_count, r.signCount))) why = 'used at the same time elsewhere';
313
+ } catch (e) {
314
+ why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
315
+ }
316
+ }
317
+ const ws = !why && workspace ? await workspaceOf(workspace, who) : undefined;
318
+ if (!why && workspace && !ws) why = 'not a workspace of the developer';
319
+ // (sprint 129) a passkey alone is not single sign-on
320
+ if (!why && (await ssoRequired(who))) why = 'single sign-on required';
321
+ if (why) {
322
+ await logActivity({ username: who.slice(0, 100), event: 'login_failed', ip, detail: `builder passkey: ${why}` });
323
+ flash(s, tx(why === 'not a workspace of the developer' ? 'signin.invalid_ws' : why === 'single sign-on required' ? 'signin.sso_required' : 'mfa.passkey_failed'), 'error');
324
+ return back(reply, s, again);
325
+ }
326
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [who]);
327
+ if (!dev) return back(reply, s, again);
328
+ return finishSignIn(req, reply, s, dev, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: false, picked, detail: 'builder passkey' });
329
+ });
330
+
331
+ /**
332
+ * The session after a successful sign-in (password, and the second factor when the developer has one;
333
+ * or an identity provider, sso: its name; s null: the sign-in page's session is not at hand there).
334
+ */
335
+ async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>> | null, dev: { username: string; language: string | null },
336
+ o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string; sso?: string }) {
168
337
  const ip = clientIp(req);
169
338
  const { picked, next, workspace } = o;
170
- await destroySession(reply, s, BASE);
339
+ if (s) await destroySession(reply, s, BASE);
171
340
  const ns = await createSession(reply, null, BASE, dev.username);
172
341
  // the builder theme chosen earlier on this device
173
342
  const theme = validTheme(req.cookies?.[THEME_COOKIE]);
@@ -180,8 +349,12 @@ export async function builderRoutes(app: FastifyInstance) {
180
349
  if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
181
350
  }
182
351
  if (o.weak) ns.state.__WEAK = '1';
352
+ // (sprint 129) the identity provider decided on the second factor: no enrolment in pgkiln (ui.ts)
353
+ if (o.sso) ns.state.__SSO = o.sso;
183
354
  await saveState(ns);
184
- if (o.remember)
355
+ if (o.sso) {
356
+ // the sign-in page's "remember" stays as it was
357
+ } else if (o.remember)
185
358
  reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
186
359
  path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
187
360
  });
@@ -380,6 +553,7 @@ export async function builderRoutes(app: FastifyInstance) {
380
553
  await workspaceRoutes(app);
381
554
  await instanceRoutes(app);
382
555
  await securityRoutes(app);
556
+ await builderSsoRoutes(app);
383
557
  await wizardRoutes(app);
384
558
  await appFromFileRoutes(app);
385
559
 
@@ -861,7 +1035,10 @@ export async function builderRoutes(app: FastifyInstance) {
861
1035
  app.get(`${BASE}/developers`, async (req: Req, reply) => {
862
1036
  const s = await developer(req, reply);
863
1037
  if (!s) return;
864
- const devs = (await owner.query(`select username, is_admin, email, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa
1038
+ const devs = (await owner.query(`select username, is_admin, email, break_glass, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa,
1039
+ (select string_agg(coalesce(p.display_name, l.display_name), ', ' order by 1) from meta.developer_identity i
1040
+ left join meta.auth_provider p on p.id = i.provider_id left join meta.ldap_directory l on l.id = i.directory_id
1041
+ where i.username = d.username) as identities
865
1042
  from meta.developer d order by 1`)).rows;
866
1043
  const admin = devs.some((d) => d.username === s.username && d.is_admin);
867
1044
  const me = devs.find((d) => d.username === s.username);
@@ -880,7 +1057,7 @@ export async function builderRoutes(app: FastifyInstance) {
880
1057
  ${region(tx('dev.accounts'), html`
881
1058
  <p class="muted u-mt0">${tx('dev.accounts_help')}</p>
882
1059
  <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th>${tx('mfa.column')}</th><th></th></tr></thead><tbody>
883
- ${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}</td>
1060
+ ${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}${d.break_glass ? html` <span class="badge badge-warn" title="${tx('bsso.break_glass_help')}">${tx('bsso.break_glass')}</span>` : ''}${d.identities ? html`<br><span class="muted small">${icon('log-in')} ${d.identities}</span>` : ''}</td>
884
1061
  <td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
885
1062
  ? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="${tx('dev.email_of', { user: d.username })}" placeholder="${tx('dev.none')}"><button class="btn">${tx('dev.save')}</button></form>`
886
1063
  : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
@@ -888,6 +1065,7 @@ export async function builderRoutes(app: FastifyInstance) {
888
1065
  ${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
889
1066
  <button class="btn btn-sm btn-danger">${tx('mfa.reset_btn')}</button></form></div></details>` : ''}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
890
1067
  <form method="post" action="${BASE}/developers/admin" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="is_admin" value="${d.is_admin ? 'false' : 'true'}"><button class="link-button">${d.is_admin ? tx('dev.make_dev') : tx('dev.make_admin')}</button></form>
1068
+ ${d.is_admin ? html`· <form method="post" action="${BASE}/developers/break-glass" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="break_glass" value="${d.break_glass ? 'false' : 'true'}"><button class="link-button" title="${tx('bsso.break_glass_help')}">${d.break_glass ? tx('bsso.break_glass_remove') : tx('bsso.break_glass_make')}</button></form>` : ''}
891
1069
  · <form method="post" action="${BASE}/developers/delete" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><button class="link-button" data-confirm="${tx('dev.remove_confirm', { user: d.username })}">${tx('dev.remove')}</button></form>`}</td></tr>`)}
892
1070
  </tbody></table></div>
893
1071
  ${admin
@@ -975,7 +1153,12 @@ export async function builderRoutes(app: FastifyInstance) {
975
1153
  flash(s, tr(s)('mfa.keep_admins'), 'error');
976
1154
  return back(reply, s, `${BASE}/developers`);
977
1155
  }
978
- await owner.query('update meta.developer set is_admin = $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
1156
+ // (sprint 129) break-glass is for administrators: a developer loses it, unless they are the last one needed
1157
+ if (req.body.is_admin !== 'true' && (await leavesNoBreakGlass(req.body.username))) {
1158
+ flash(s, tr(s)('bsso.keep_break_glass'), 'error');
1159
+ return back(reply, s, `${BASE}/developers`);
1160
+ }
1161
+ await owner.query('update meta.developer set is_admin = $2, break_glass = break_glass and $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
979
1162
  flash(s, tr(s)('dev.role_changed'));
980
1163
  }
981
1164
  return back(reply, s, `${BASE}/developers`);
@@ -986,7 +1169,7 @@ export async function builderRoutes(app: FastifyInstance) {
986
1169
  if (!s) return;
987
1170
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
988
1171
  if (req.body?.username && req.body.username !== s.username) {
989
- if (await leavesTooFewAdmins(req.body.username)) {
1172
+ if ((await leavesTooFewAdmins(req.body.username)) || (await leavesNoBreakGlass(req.body.username))) {
990
1173
  flash(s, tr(s)('mfa.keep_admins'), 'error');
991
1174
  return back(reply, s, `${BASE}/developers`);
992
1175
  }
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { isIP } from 'node:net';
3
2
  import type { FastifyInstance } from 'fastify';
4
3
  import { owner } from '../db.ts';
@@ -6,15 +5,16 @@ import { html, raw, type Raw } from '../html.ts';
6
5
  import { icon } from '../icons.ts';
7
6
  import {
8
7
  addPasskey, addTotp, adminEmails, adminFactors, codesLeft, confirmRecoveryCodes, factorsOf, forgetBrowsers, hasFactor, matchTotp, MFA_POLICIES, mfaPolicy,
9
- newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requiredFor, resetFactors, setPolicy, setTrustDays, trustDays,
10
- trustedBrowsers, unbase32, type MfaPolicy,
8
+ newRecoveryCodes, newTotpSecret, notifyMfa, otpauthUri, passkeysOf, policyProblem, requirement, resetFactors, setPolicy, setTrustDays, trustDays,
9
+ trustedBrowsers, unbase32, userHandle, passkeySignin, setPasskeySignin, type MfaPolicy,
11
10
  } from '../mfa.ts';
12
11
  import { qrSvg } from '../qrcode.ts';
13
12
  import { secretKeyConfigured } from '../secrets.ts';
14
13
  import { clientIp, logActivity, saveState, type Session } from '../session.ts';
15
- import { ALGORITHMS, b64url, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
14
+ import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
16
15
  import { tr } from './i18n.ts';
17
16
  import { isAdmin } from './locks.ts';
17
+ import { identitiesRegion } from './builder-sso.ts';
18
18
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
19
19
 
20
20
  // (sprint 126, SEC-2 and SEC-6) Developer › Security: a developer's second factors (authenticator apps, TOTP)
@@ -59,9 +59,9 @@ export async function securityRoutes(app: FastifyInstance) {
59
59
  if (!s) return;
60
60
  const tx = tr(s);
61
61
  const me = s.username!;
62
- const [factors, left, pol, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), mfaPolicy(), isAdmin(me), trustDays(), trustedBrowsers(me)]);
62
+ const [factors, left, admin, days, trusted] = await Promise.all([factorsOf(me), codesLeft(me), isAdmin(me), trustDays(), trustedBrowsers(me)]);
63
63
  const rp = relyingParty(req);
64
- const required = requiredFor(pol.policy, admin);
64
+ const { required } = await requirement(me, admin);
65
65
  const pending = (() => {
66
66
  try {
67
67
  const p = JSON.parse(String(s.state.__TOTP_NEW ?? ''));
@@ -136,7 +136,8 @@ export async function securityRoutes(app: FastifyInstance) {
136
136
  ${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
137
137
  <p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
138
138
  ${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
139
- </div>`;
139
+ </div>
140
+ ${await identitiesRegion(s, typeof req.query?.linked === 'string' ? req.query.linked : undefined)}`;
140
141
  return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
141
142
  });
142
143
 
@@ -222,7 +223,7 @@ export async function securityRoutes(app: FastifyInstance) {
222
223
  if (last) {
223
224
  const pol = await mfaPolicy();
224
225
  const admin = await isAdmin(me);
225
- if (requiredFor(pol.policy, admin)) {
226
+ if ((await requirement(me, admin)).required) {
226
227
  flash(s, tx('mfa.cannot_remove_required'), 'error');
227
228
  return back(reply, s, `${BASE}/security`);
228
229
  }
@@ -257,7 +258,7 @@ export async function securityRoutes(app: FastifyInstance) {
257
258
  return reply.send({
258
259
  mode: 'create', challenge, rp: { id: rp.rpId, name: 'pgkiln' }, algs: ALGORITHMS,
259
260
  // a stable handle that isn't the user name (the authenticator may show it, but it says nothing)
260
- user: { id: b64url(createHash('sha256').update(`pgkiln-developer:${me}`).digest().subarray(0, 16)), name: me, displayName: me },
261
+ user: { id: userHandle(me), name: me, displayName: me },
261
262
  exclude: (await passkeysOf(me, rp.rpId)).map((p) => p.credential_id),
262
263
  });
263
264
  });
@@ -341,9 +342,11 @@ export async function securityRoutes(app: FastifyInstance) {
341
342
  flash(s, tx('mfa.policy_needs_admins'), 'error');
342
343
  return back(reply, s, `${BASE}/instance#mfa`);
343
344
  }
345
+ const alone = req.body?.passkey_signin === '1';
344
346
  await setPolicy(policy, grace);
345
347
  await setTrustDays(trust);
346
- await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days` });
348
+ await setPasskeySignin(alone);
349
+ await logActivity({ username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `${policy}, grace ${grace} days, trust browsers ${trust} days, passkey sign-in ${alone ? 'on' : 'off'}` });
347
350
  await notifyMfa(await adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${s.username} set the two-factor policy to ${policy} (grace period ${grace} days).`);
348
351
  flash(s, tx('mfa.policy_saved'));
349
352
  return back(reply, s, `${BASE}/instance#mfa`);
@@ -353,8 +356,8 @@ export async function securityRoutes(app: FastifyInstance) {
353
356
  /** The Instance settings region for the policy (administrators). */
354
357
  export async function policyRegion(s: Session): Promise<Raw> {
355
358
  const tx = tr(s);
356
- const [pol, af, days, without] = await Promise.all([
357
- mfaPolicy(), adminFactors(), trustDays(),
359
+ const [pol, af, days, alone, without] = await Promise.all([
360
+ mfaPolicy(), adminFactors(), trustDays(), passkeySignin(),
358
361
  owner.query<{ username: string; is_admin: boolean }>('select username, is_admin from meta.developer d where not exists (select 1 from meta.developer_factor f where f.username = d.username) order by username'),
359
362
  ]);
360
363
  const opt = (v: MfaPolicy) => html`<option value="${v}"${pol.policy === v ? raw(' selected') : ''}>${tx(({ off: 'mfa.policy_off', admins: 'mfa.policy_admins', all: 'mfa.policy_all' } as const)[v])}</option>`;
@@ -364,6 +367,8 @@ export async function policyRegion(s: Session): Promise<Raw> {
364
367
  ${input('grace_days', tx('mfa.grace'), pol.graceDays, { type: 'number', help: tx('mfa.grace_help') })}
365
368
  ${input('trust_days', tx('mfa.trust_days'), days, { type: 'number', help: tx('mfa.trust_days_help') })}
366
369
  </div>
370
+ <div class="field"><label class="check"><input type="checkbox" name="passkey_signin" value="1"${alone ? raw(' checked') : ''}> ${tx('mfa.passkey_signin')}</label>
371
+ <small class="help">${tx('mfa.passkey_signin_help')}</small></div>
367
372
  <p class="muted small">${tx('mfa.policy_help', { enrolled: af.enrolled, admins: af.admins })}</p>
368
373
  <div class="buttons"><button class="btn btn-hot">${tx('mfa.policy_save')}</button></div></form>
369
374
  ${without.rows.length ? html`<p class="small">${tx('mfa.without', { list: without.rows.map((d) => `${d.username}${d.is_admin ? ' (admin)' : ''}`).join(', ') })}</p>` : ''}
package/src/builder/ui.ts CHANGED
@@ -12,7 +12,7 @@ import { ensureDevbar } from '../runtime/devbar.ts';
12
12
  import { noteChange } from './app-updates.ts';
13
13
  import { clientTexts, tr } from './i18n.ts';
14
14
  import { languageMenu, loadLanguage } from './language.ts';
15
- import { enrolmentDue } from '../mfa.ts';
15
+ import { enrolmentDue, requirement } from '../mfa.ts';
16
16
 
17
17
  // Shared building blocks of the builder UI.
18
18
 
@@ -323,9 +323,20 @@ export async function developer(req: Req, reply: FastifyReply) {
323
323
  if (s.state.__BLANG === undefined) await loadLanguage(s);
324
324
  // (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
325
325
  delete s.state.__MFA_DUE;
326
- const due = await enrolmentDue(s.username, await isAdmin(s.username));
326
+ const admin = await isAdmin(s.username);
327
+ // (sprint 128) signed in with the default password while a second factor is required: change it first
328
+ // (on Developers), then the enrolment below
329
+ const weakFirst = !!s.state.__WEAK && (await requirement(s.username, admin)).required;
330
+ if (weakFirst && !/^\/builder\/(developers|logout|language|theme)(?:[/?#]|$)/.test(req.url)) {
331
+ flash(s, tr(s)('mfa.weak_required'), 'error');
332
+ await saveState(s);
333
+ reply.redirect(`${BASE}/developers`);
334
+ return null;
335
+ }
336
+ // (sprint 129) signed in through an identity provider: it decides on the second factor
337
+ const due = s.state.__SSO ? null : await enrolmentDue(s.username, admin);
327
338
  if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
328
- else if (due && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
339
+ else if (due && !weakFirst && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
329
340
  reply.redirect(`${BASE}/security`);
330
341
  return null;
331
342
  }
@@ -7,6 +7,7 @@ import { accountSettings, clearAccountSettings, passwordProblem } from '../accou
7
7
  import { loginMaxFailuresPerUser, loginWindowMinutes } from '../security.ts';
8
8
  import { acsUrl, metadataUrl, spEntityId } from '../saml.ts';
9
9
  import { discover, publicUrl, redirectUri } from '../sso.ts';
10
+ import { builderSignInRegion } from './builder-sso.ts';
10
11
  import { administrator, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
11
12
 
12
13
  // The workspace user directory (like APEX's workspace users with
@@ -476,6 +477,7 @@ export async function usersRoutes(app: FastifyInstance) {
476
477
  <p class="muted">${tx.rich('idp.public_url', { env: html`<code>PUBLIC_URL</code>` })}</p>
477
478
  <p>${tx.rich('idp.grant', { type: html`<b>${tx('idp.auth_code')}</b>`, scopes: html`<code>${pr.scopes}</code>`, claim: html`<code>${pr.groups_claim}</code>` })}</p>
478
479
  <form method="post" action="${BASE}/users/providers/${pr.id}/test">${csrf(s)}<button class="btn">${tx('idp.test')}</button></form>`)}
480
+ ${await builderSignInRegion(s, 'provider', pr)}
479
481
  </div>`;
480
482
  return send(reply, s, shell(s, pr.display_name, [['Users', `${BASE}/users`], [tx('idp.title'), `${BASE}/users/providers`], [pr.display_name]], main, 'users'));
481
483
  });
@@ -8,6 +8,8 @@ import { html, raw } from '../html.ts';
8
8
  import { icon } from '../icons.ts';
9
9
  import { clientIp, logActivity, type BuilderWorkspace, type Session } from '../session.ts';
10
10
  import { isAdmin } from './locks.ts';
11
+ import { setWorkspaceRequired } from '../mfa.ts';
12
+ import { breakGlassAdmins } from '../devsso.ts';
11
13
  import { back, BANNER_COLORS, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
12
14
 
13
15
  // Workspaces (064; APEX: workspaces). A workspace groups applications and the
@@ -215,7 +217,7 @@ function serviceRegion(s: Session, w: { id: number; message: string | null; bann
215
217
  }
216
218
 
217
219
  /** (093) Workspace preferences: the SQL Workshop and the applications' REST modules on or off. */
218
- function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null }) {
220
+ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null; mfa_required_since: Date | null; sso_only: boolean }, without: string[], unlinked: string[]) {
219
221
  const check = (name: string, on: boolean, label: string, help: string) =>
220
222
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
221
223
  const tx = tr(s);
@@ -223,7 +225,17 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
223
225
  ${check('sql_workshop', w.sql_workshop, tx('wsp.sql_on'), tx('wsp.sql_on_help'))}
224
226
  ${check('rest_services', w.rest_services, tx('wsp.rest_on'), tx('wsp.rest_on_help', { path: '/a/<alias>/rest/…' }))}
225
227
  ${select('builder_home', tx('wsp.builder_home'), w.builder_home === 'home' ? 'home' : 'apps', [['apps', tx('wsp.home.apps')], ['home', tx('wsp.home.home')]], tx('wsp.builder_home_help'))}
226
- <div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>`, '', 'preferences');
228
+ <div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>
229
+ <h3 id="mfa">${tx('wsp.mfa')}</h3>
230
+ <form method="post" action="${BASE}/workspaces/${w.id}/mfa">${csrf(s)}
231
+ ${check('required', !!w.mfa_required_since, tx('wsp.mfa_required'), tx('wsp.mfa_required_help'))}
232
+ ${w.mfa_required_since ? html`<p class="small">${tx('wsp.mfa_since', { date: new Date(w.mfa_required_since).toISOString().slice(0, 10) })}${without.length ? html` ${tx('wsp.mfa_without', { list: without.join(', ') })}` : ''}</p>` : ''}
233
+ <div class="buttons"><button class="btn">${tx('wsp.mfa_save')}</button></div></form>
234
+ <h3 id="sso">${tx('wsp.sso')}</h3>
235
+ <form method="post" action="${BASE}/workspaces/${w.id}/sso">${csrf(s)}
236
+ ${check('sso_only', w.sso_only, tx('wsp.sso_only'), tx('wsp.sso_only_help'))}
237
+ ${w.sso_only && unlinked.length ? html`<p class="small">${tx('wsp.sso_unlinked', { list: unlinked.join(', ') })}</p>` : ''}
238
+ <div class="buttons"><button class="btn">${tx('wsp.sso_save')}</button></div></form>`, '', 'preferences');
227
239
  }
228
240
 
229
241
  /** (093) Extension links: links to other tools in the builder's Extensions menu. */
@@ -341,6 +353,15 @@ export async function workspaceRoutes(app: FastifyInstance) {
341
353
  ]);
342
354
  const roles = rolesQ;
343
355
  const links = (await owner.query('select id, label, url, description, position from meta.workspace_link where workspace_id = $1 order by position, lower(label)', [w.id])).rows;
356
+ // (sprint 128) the members still without a second factor
357
+ const without = (await owner.query<{ username: string }>(
358
+ `select m.username from meta.workspace_member m where m.workspace_id = $1
359
+ and not exists (select 1 from meta.developer_factor f where f.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
360
+ // (sprint 129) the members who could not sign in while single sign-on is required: no linked identity, not break-glass
361
+ const unlinked = (await owner.query<{ username: string }>(
362
+ `select m.username from meta.workspace_member m join meta.developer d on d.username = m.username
363
+ where m.workspace_id = $1 and not d.break_glass
364
+ and not exists (select 1 from meta.developer_identity i where i.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
344
365
  const main = html`
345
366
  <div class="title-row"><h1>${w.name}</h1></div>
346
367
  <div class="columns wide-left">
@@ -365,7 +386,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
365
386
  </div>
366
387
  ${serviceRegion(s, w)}
367
388
  ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
368
- <div class="columns">${preferencesRegion(s, w)}${linksRegion(s, w, links)}</div>
389
+ <div class="columns">${preferencesRegion(s, w, without, unlinked)}${linksRegion(s, w, links)}</div>
369
390
  ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
370
391
  ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
371
392
  <thead><tr><th>${tx('wsp.application')}</th><th>${tx('wsp.move_to')}</th></tr></thead>
@@ -559,6 +580,40 @@ export async function workspaceRoutes(app: FastifyInstance) {
559
580
  return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
560
581
  });
561
582
 
583
+ // (sprint 128, SEC-2) a workspace requires a second factor of its developers (the instance's grace period from now)
584
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/mfa`, async (req: Req, reply) => {
585
+ const s = await developer(req, reply);
586
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
587
+ const on = req.body?.required === 'true';
588
+ if (!(await setWorkspaceRequired(Number(req.params.id), on))) return reply.code(404).send('Not found');
589
+ await logActivity({ appId: null, username: s.username, event: '2fa_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: two-factor sign-in ${on ? 'required' : 'not required'}` });
590
+ flash(s, tr(s)(on ? 'wsp.mfa_on' : 'wsp.mfa_off'));
591
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#mfa`);
592
+ });
593
+
594
+ // (sprint 129, SEC-1) a workspace requires single sign-on of its developers: not while no break-glass
595
+ // administrator could sign in with the password, and not by someone who would lock themselves out
596
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/sso`, async (req: Req, reply) => {
597
+ const s = await developer(req, reply);
598
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
599
+ const tx = tr(s);
600
+ const on = req.body?.sso_only === 'true';
601
+ if (on && !(await breakGlassAdmins()).length) {
602
+ flash(s, tx('wsp.sso_no_break_glass'), 'error');
603
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
604
+ }
605
+ if (on && !(await owner.one(`select 1 as ok from meta.developer d where d.username = $1
606
+ and (d.break_glass or exists (select 1 from meta.developer_identity i where i.username = d.username))`, [s.username]))) {
607
+ flash(s, tx('wsp.sso_link_first'), 'error');
608
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
609
+ }
610
+ const r = await owner.query('update meta.workspace set sso_only = $2 where id = $1', [req.params.id, on]);
611
+ if (!r.rowCount) return reply.code(404).send('Not found');
612
+ await logActivity({ appId: null, username: s.username, event: 'sso_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: single sign-on ${on ? 'required' : 'not required'}` });
613
+ flash(s, tx(on ? 'wsp.sso_on' : 'wsp.sso_off'));
614
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
615
+ });
616
+
562
617
  // (093) extension links
563
618
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/links`, async (req: Req, reply) => {
564
619
  const s = await developer(req, reply);