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.
package/src/cli/main.ts CHANGED
@@ -471,15 +471,18 @@ const COMMANDS: Record<string, Command> = {
471
471
  },
472
472
  // (sprint 126, SEC-6) lockout recovery on the server, when nobody can sign in to the builder
473
473
  admin: {
474
- usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all [options]',
475
- summary: 'lockout recovery: reset a developer\'s two-factor sign-in, set the two-factor policy',
474
+ usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all | builder-sign-in password [options]',
475
+ summary: 'lockout recovery: reset a developer\'s two-factor sign-in, set the two-factor policy, allow the password again',
476
476
  details:
477
477
  'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
478
- 'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n\n' +
478
+ 'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n' +
479
+ 'pgkiln admin builder-sign-in password\n\n' +
479
480
  "reset-2fa removes the developer's authenticator apps and recovery codes and ends their builder\n" +
480
- 'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works;\n' +
481
- 'admins and all need at least two administrators with two-factor sign-in (or every one, when there\n' +
482
- 'are fewer). Both are written to the activity log and mailed to the administrators.',
481
+ 'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works\n' +
482
+ "(and clears the workspaces' own two-factor rules); admins and all need at least two administrators\n" +
483
+ 'with two-factor sign-in (or every one, when there are fewer). builder-sign-in password turns off the\n' +
484
+ "workspaces' single sign-on rule, for when the identity provider fails and no break-glass administrator\n" +
485
+ 'can sign in. All are written to the activity log and mailed to the administrators.',
483
486
  options: { reason: { type: 'string' }, grace: { type: 'string' } },
484
487
  optionHelp: [
485
488
  ['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
@@ -509,12 +512,25 @@ const COMMANDS: Record<string, Command> = {
509
512
  const policy = arg as 'off' | 'admins' | 'all';
510
513
  if (await mfa.policyProblem(policy)) throw new Error('at least two administrators (or every one, when there are fewer) need two-factor sign-in first');
511
514
  await mfa.setPolicy(policy, grace);
512
- await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days (${by})` });
515
+ // (sprint 128) off means off: no workspace requires a second factor either
516
+ const cleared = policy === 'off' ? await mfa.clearWorkspaceRequirements() : 0;
517
+ await logActivity({ event: '2fa_policy', ip: 'cli', detail: `${policy}, grace ${grace} days${cleared ? `, ${cleared} workspace rule(s) cleared` : ''} (${by})` });
513
518
  const sent = await mfa.notifyMfa(await mfa.adminEmails(), `pgkiln: two-factor sign-in policy is now ${policy}`, `${by} set the two-factor policy to ${policy} on the server.`);
514
- out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
519
+ out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${cleared ? ` ${cleared} workspace(s) no longer require it.` : ''}${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
515
520
  return EXIT.ok;
516
521
  }
517
- throw new UsageError(`unknown action ${action}: use reset-2fa or set-2fa-policy`);
522
+ // (sprint 129, SEC-1) every workspace allows the pgkiln password again
523
+ if (action === 'builder-sign-in') {
524
+ if (arg !== 'password') throw new UsageError('builder-sign-in takes password');
525
+ const { clearSsoOnly } = await import('../devsso.ts');
526
+ const cleared = await clearSsoOnly();
527
+ await logActivity({ event: 'sso_policy', ip: 'cli', detail: `password sign-in allowed again, ${cleared} workspace rule(s) cleared (${by})` });
528
+ const sent = await mfa.notifyMfa(await mfa.adminEmails(), 'pgkiln: the builder password works again',
529
+ `${by} turned off the single sign-on rule of ${cleared} workspace(s) on the server: developers may sign in with the pgkiln password.`);
530
+ out(`Developers may sign in with the pgkiln password again; ${cleared} workspace(s) no longer require single sign-on.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
531
+ return EXIT.ok;
532
+ }
533
+ throw new UsageError(`unknown action ${action}: use reset-2fa, set-2fa-policy or builder-sign-in`);
518
534
  } finally {
519
535
  await closePools();
520
536
  }
package/src/devsso.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { owner } from './db.ts';
3
+ import { SsoError } from './sso.ts';
4
+
5
+ // (sprint 129, SEC-1) Developers sign in to the builder through an identity
6
+ // provider (OpenID Connect, SAML) or an LDAP directory that has "builder"
7
+ // turned on. The rules, as for application accounts (sso.ts, ldap.ts):
8
+ // * a developer is found by the provider's stable subject
9
+ // (meta.developer_identity), never by a name the provider sends alone;
10
+ // * an existing developer with the same username is linked on the first
11
+ // sign-in only when the source allows it (builder_link_existing), with a
12
+ // verified e-mail address when that is the username, and never a
13
+ // break-glass developer; otherwise the developer links the identity
14
+ // themselves on the Security page, signed in;
15
+ // * a new developer is created when the source allows it
16
+ // (builder_auto_create), for members of builder_group when one is set;
17
+ // * builder_admin_group, when set, decides on every sign-in who is an
18
+ // instance administrator (break-glass developers and the last
19
+ // administrator are never demoted).
20
+ // A workspace can require these sign-ins of its developers (sso_only);
21
+ // break-glass developers may still use the pgkiln password.
22
+
23
+ export interface BuilderSource {
24
+ kind: 'provider' | 'directory';
25
+ id: number;
26
+ name: string;
27
+ builder_auto_create: boolean;
28
+ builder_link_existing: boolean;
29
+ builder_group: string | null;
30
+ builder_admin_group: string | null;
31
+ }
32
+
33
+ export interface BuilderIdentity {
34
+ subject: string;
35
+ username: string;
36
+ name?: string | null;
37
+ email?: string | null;
38
+ /** false: the username is an unverified e-mail address and links nothing */
39
+ mayLink: boolean;
40
+ groups: string[];
41
+ }
42
+
43
+ const column = (k: BuilderSource['kind']) => (k === 'provider' ? 'provider_id' : 'directory_id');
44
+ const validEmail = (v: unknown): v is string => typeof v === 'string' && /^[^@\s<>"(),;:\\]+@[^@\s<>"(),;:\\]+\.[^@\s<>"(),;:\\]+$/.test(v) && v.length <= 254;
45
+
46
+ /** The providers and directories developers can sign in with. */
47
+ export async function builderSources() {
48
+ const providers = (await owner.query<{ name: string; display_name: string }>('select name, display_name from meta.auth_provider where enabled and builder order by display_name')).rows;
49
+ const directories = (await owner.query<{ name: string; display_name: string }>('select name, display_name from meta.ldap_directory where enabled and builder order by display_name')).rows;
50
+ return { providers, directories };
51
+ }
52
+
53
+ export async function builderProvider(name: string) {
54
+ return owner.one('select * from meta.auth_provider where enabled and builder and name = $1', [name]);
55
+ }
56
+ export async function builderDirectory(name: string) {
57
+ return owner.one('select * from meta.ldap_directory where enabled and builder and name = $1', [name]);
58
+ }
59
+
60
+ /** The developer for an identity (see the rules above); throws SsoError when there is none. */
61
+ export async function resolveDeveloper(src: BuilderSource, id: BuilderIdentity): Promise<string> {
62
+ const col = column(src.kind);
63
+ return owner.tx(async (c) => {
64
+ let username: string | undefined = (await c.query(`select username from meta.developer_identity where ${col} = $1 and subject = $2`, [src.id, id.subject])).rows[0]?.username;
65
+ if (!username) {
66
+ const existing = (
67
+ await c.query(
68
+ `select d.username, d.break_glass, exists (select 1 from meta.developer_identity i where i.username = d.username and i.${col} = $2) as linked
69
+ from meta.developer d where lower(d.username) = lower($1)`,
70
+ [id.username, src.id],
71
+ )
72
+ ).rows[0];
73
+ if (existing) {
74
+ if (existing.linked) throw new SsoError('This developer is already linked to another identity here.');
75
+ if (!src.builder_link_existing || !id.mayLink || existing.break_glass)
76
+ throw new SsoError(`A developer "${existing.username}" exists but is not linked to this identity. Sign in with the password and link it on the Security page.`);
77
+ username = existing.username as string;
78
+ } else {
79
+ const member = !src.builder_group || id.groups.includes(src.builder_group) || (!!src.builder_admin_group && id.groups.includes(src.builder_admin_group));
80
+ if (!src.builder_auto_create || !member) throw new SsoError(`There is no developer "${id.username}". Ask an administrator for access.`);
81
+ // a password nobody knows: the developer signs in through the provider (an administrator can set one)
82
+ username = (
83
+ await c.query(
84
+ `insert into meta.developer (username, password_hash, is_admin, email) values ($1, crypt($2, gen_salt('bf', 10)), false, $3) returning username`,
85
+ [id.username, randomBytes(32).toString('base64url'), validEmail(id.email) ? id.email : null],
86
+ )
87
+ ).rows[0].username;
88
+ }
89
+ await c.query(`insert into meta.developer_identity (${col}, subject, username) values ($1, $2, $3)`, [src.id, id.subject, username]);
90
+ }
91
+ if (src.builder_admin_group) {
92
+ const admin = id.groups.includes(src.builder_admin_group);
93
+ // never the last administrator either: the instance would have none
94
+ await c.query(
95
+ `update meta.developer set is_admin = $2 where username = $1 and is_admin <> $2
96
+ and ($2 or (not break_glass and (select count(*) from meta.developer where is_admin) > 1))`,
97
+ [username, admin],
98
+ );
99
+ }
100
+ return username!;
101
+ });
102
+ }
103
+
104
+ /** Link an identity to a signed-in developer (Security page). */
105
+ export async function linkDeveloper(src: Pick<BuilderSource, 'kind' | 'id'>, subject: string, username: string) {
106
+ const col = column(src.kind);
107
+ const other = await owner.one(`select username from meta.developer_identity where ${col} = $1 and (subject = $2 or username = $3)`, [src.id, subject, username]);
108
+ if (other) throw new SsoError(other.username === username ? 'You already linked an identity here; unlink it first.' : 'This identity is linked to another developer.');
109
+ await owner.query(`insert into meta.developer_identity (${col}, subject, username) values ($1, $2, $3)`, [src.id, subject, username]);
110
+ }
111
+
112
+ /** A developer's linked identities (Security page, provider pages). */
113
+ export async function identitiesOf(username: string) {
114
+ return (
115
+ await owner.query<{ id: number; source: string; display_name: string; kind: 'provider' | 'directory'; linked_at: string }>(
116
+ `select i.id, coalesce(p.name, d.name) as source, coalesce(p.display_name, d.display_name) as display_name,
117
+ case when i.provider_id is null then 'directory' else 'provider' end as kind, i.linked_at
118
+ from meta.developer_identity i
119
+ left join meta.auth_provider p on p.id = i.provider_id
120
+ left join meta.ldap_directory d on d.id = i.directory_id
121
+ where i.username = $1 order by 3`,
122
+ [username],
123
+ )
124
+ ).rows;
125
+ }
126
+
127
+ /**
128
+ * Must the developer sign in through an identity provider? A member of a workspace that requires it,
129
+ * unless break-glass.
130
+ */
131
+ export async function ssoRequired(username: string) {
132
+ const r = await owner.one(
133
+ `select not d.break_glass and exists (select 1 from meta.workspace_member m join meta.workspace w on w.id = m.workspace_id
134
+ where m.username = d.username and w.sso_only) as required
135
+ from meta.developer d where d.username = $1`,
136
+ [username],
137
+ );
138
+ return !!r?.required;
139
+ }
140
+
141
+ /** Instance administrators who may still sign in with the password. */
142
+ export async function breakGlassAdmins() {
143
+ return (await owner.query<{ username: string }>('select username from meta.developer where is_admin and break_glass order by username')).rows.map((r) => r.username);
144
+ }
145
+
146
+ /** `pgkiln admin builder-sign-in password`: every workspace allows the password again. */
147
+ export async function clearSsoOnly() {
148
+ return (await owner.query('update meta.workspace set sso_only = false where sso_only')).rowCount ?? 0;
149
+ }
package/src/mfa.ts CHANGED
@@ -11,6 +11,8 @@ import { decryptSecret, encryptSecret } from './secrets.ts';
11
11
  // factors and turns the policy off without signing in (src/cli/main.ts, `pgkiln admin`).
12
12
  // (sprint 127) Passkeys and security keys (WebAuthn, src/webauthn.ts) are the second kind of factor, and a
13
13
  // developer may trust a browser for a number of days (meta.setting mfa_trust_days; 0 = never).
14
+ // (sprint 128) A passkey the authenticator verified the developer with may stand in for both the password and
15
+ // the second factor (mfa_passkey_signin), and a workspace may require a second factor of its developers.
14
16
 
15
17
  export const TOTP_STEP = 30;
16
18
  export const TOTP_DIGITS = 6;
@@ -139,6 +141,26 @@ export async function passkeysOf(username: string, rpId: string): Promise<Passke
139
141
  where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
140
142
  }
141
143
 
144
+ /** (sprint 128) The passkey with this credential id for this relying party, and whose it is (sign-in without a user name). */
145
+ export async function passkeyByCredential(credentialId: string, rpId: string): Promise<(Passkey & { username: string }) | null> {
146
+ if (!credentialId || credentialId.length > 1400) return null;
147
+ return (await owner.one<Passkey & { username: string }>(
148
+ `select id, username, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
149
+ where kind = 'passkey' and credential_id = $1 and rp_id = $2`, [credentialId, rpId])) ?? null;
150
+ }
151
+
152
+ /** The user handle a developer's passkeys carry: stable, and not the user name. */
153
+ export const userHandle = (username: string) => createHash('sha256').update(`pgkiln-developer:${username}`).digest().subarray(0, 16).toString('base64url');
154
+
155
+ /** (sprint 128) Whether a passkey alone (user verified) may sign a developer in. */
156
+ export async function passkeySignin(): Promise<boolean> {
157
+ return (await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_passkey_signin'`))?.value !== 'off';
158
+ }
159
+
160
+ export async function setPasskeySignin(on: boolean) {
161
+ await owner.query(`insert into meta.setting (name, value) values ('mfa_passkey_signin', $1) on conflict (name) do update set value = excluded.value`, [on ? 'on' : 'off']);
162
+ }
163
+
142
164
  /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
143
165
  export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
144
166
  return !!(await owner.query(
@@ -284,14 +306,47 @@ export async function setPolicy(policy: MfaPolicy, graceDays: number) {
284
306
  });
285
307
  }
286
308
 
309
+ /**
310
+ * (sprint 128) Whether a second factor is required of this developer, by the instance's policy or by one of
311
+ * their workspaces, and since when (the grace period counts from the earliest).
312
+ */
313
+ export async function requirement(username: string, isAdmin: boolean): Promise<{ required: boolean; since: Date | null; graceDays: number; workspaces: string[] }> {
314
+ const [{ policy, since, graceDays }, ws] = await Promise.all([
315
+ mfaPolicy(),
316
+ owner.query<{ name: string; since: Date }>(
317
+ `select w.name, w.mfa_required_since as since from meta.workspace w join meta.workspace_member m on m.workspace_id = w.id
318
+ where m.username = $1 and w.mfa_required_since is not null order by w.mfa_required_since`, [username]),
319
+ ]);
320
+ const byInstance = requiredFor(policy, isAdmin);
321
+ const times = [...(byInstance ? [since ?? new Date()] : []), ...ws.rows.map((r) => new Date(r.since))];
322
+ return {
323
+ required: times.length > 0,
324
+ since: times.length ? new Date(Math.min(...times.map((t) => t.getTime()))) : null,
325
+ graceDays,
326
+ workspaces: ws.rows.map((r) => r.name),
327
+ };
328
+ }
329
+
330
+ /** (sprint 128) Require a second factor of a workspace's developers (from now), or stop requiring it. */
331
+ export async function setWorkspaceRequired(workspaceId: number, on: boolean): Promise<boolean> {
332
+ return !!(await owner.query(
333
+ `update meta.workspace set mfa_required_since = case when $2 then coalesce(mfa_required_since, now()) end where id = $1`,
334
+ [workspaceId, on])).rowCount;
335
+ }
336
+
337
+ /** The command line's "off": no workspace requires a second factor either. */
338
+ export async function clearWorkspaceRequirements(): Promise<number> {
339
+ return (await owner.query('update meta.workspace set mfa_required_since = null where mfa_required_since is not null')).rowCount ?? 0;
340
+ }
341
+
287
342
  /**
288
343
  * A developer without a factor while the policy requires one: 'grace' until the grace period ends (a
289
344
  * banner), then 'enrol' (only the Security page and signing out until they enrol); null otherwise.
290
345
  */
291
346
  export async function enrolmentDue(username: string, isAdmin: boolean): Promise<{ due: 'grace' | 'enrol'; until: Date } | null> {
292
- const { policy, since, graceDays } = await mfaPolicy();
293
- if (!requiredFor(policy, isAdmin) || (await hasFactor(username))) return null;
294
- const until = new Date((since ?? new Date()).getTime() + graceDays * 86_400_000);
347
+ const r = await requirement(username, isAdmin);
348
+ if (!r.required || (await hasFactor(username))) return null;
349
+ const until = new Date((r.since ?? new Date()).getTime() + r.graceDays * 86_400_000);
295
350
  return { due: Date.now() < until.getTime() ? 'grace' : 'enrol', until };
296
351
  }
297
352
 
@@ -16,7 +16,7 @@ import { accountRoles, loadApp, loadPage, type App, type Page, type Region } fro
16
16
  import { passwordDaysLeft, passwordProblem } from '../accounts.ts';
17
17
  import { english, type Translate } from '../i18n.ts';
18
18
  import { checksumValid, loginWindowMinutes, urlChecksum } from '../security.ts';
19
- import { enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type SsoResult } from '../sso.ts';
19
+ import { builderSsoFinish, enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type Provider, type SsoResult } from '../sso.ts';
20
20
  import { sessionLimits, clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash, type Session } from '../session.ts';
21
21
  import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
22
22
  import { bindValues, dbg, publicError, stripSemicolon, timed, toState, writeOut, type PageContext } from './context.ts';
@@ -1350,8 +1350,19 @@ export async function runtimeRoutes(app: FastifyInstance) {
1350
1350
  });
1351
1351
 
1352
1352
  /** The end of a single sign-on (OpenID Connect or SAML): the app's access rules, then the session. */
1353
- const finishSso = (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) =>
1354
- withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1353
+ const finishSso = (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
1354
+ // (sprint 129) a sign-in to the builder
1355
+ if (result.appId === null) {
1356
+ const builder = builderSsoFinish();
1357
+ if (!builder) return simplePage(reply, 404, english('error.not_found'), english('login.method_unavailable'));
1358
+ return builder(req, reply, p, result, ip).catch((e) => {
1359
+ if (!(e instanceof SsoError)) throw e;
1360
+ logActivity({ username: result.link ?? result.username.slice(0, 100), event: 'login_failed', ip, detail: `builder sso:${p.name}: ${e.message}` });
1361
+ return simplePage(reply, 403, english('login.sso_failed'), e.message, result.link ? '/builder/security' : '/builder/login');
1362
+ });
1363
+ }
1364
+ return withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1365
+ };
1355
1366
  const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1356
1367
  const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
1357
1368
  const loaded = alias ? await appWithLocale(req, alias) : undefined;
package/src/saml.ts CHANGED
@@ -76,7 +76,7 @@ function client(p: Provider, cache: CacheProvider) {
76
76
  }
77
77
 
78
78
  /** Start: the IdP URL with the AuthnRequest, and the browser-binding cookie value. */
79
- export async function startSamlSignIn(p: Provider, appId: number, next: string | undefined) {
79
+ export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
80
80
  const state = b64url(randomBytes(32));
81
81
  const browserKey = b64url(randomBytes(32));
82
82
  await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
@@ -86,9 +86,9 @@ export async function startSamlSignIn(p: Provider, appId: number, next: string |
86
86
  if (!requestId) throw new SsoError('The AuthnRequest has no ID.');
87
87
  // code_verifier is unused for SAML; nonce holds the AuthnRequest ID the response must answer
88
88
  await owner.query(
89
- `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next)
90
- values ($1, $2, $3, '', $4, $5, $6)`,
91
- [state, appId, p.id, requestId, sha256(browserKey), next ?? null],
89
+ `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
90
+ values ($1, $2, $3, '', $4, $5, $6, $7)`,
91
+ [state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null],
92
92
  );
93
93
  return { url, browserKey };
94
94
  }
@@ -99,7 +99,7 @@ const values = (v: unknown): string[] => (v === undefined || v === null ? [] : (
99
99
  export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: string; RelayState?: string }, browserKey: string | undefined): Promise<SsoResult> {
100
100
  const pending = await owner.one(
101
101
  `delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
102
- returning app_id, nonce, browser_hash, next`,
102
+ returning app_id, nonce, browser_hash, next, link_username`,
103
103
  [body.RelayState ?? '', p.id, PENDING_MINUTES],
104
104
  );
105
105
  if (!pending) throw new SsoError('This sign-in has expired or was already used. Please try again.');
@@ -117,8 +117,14 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
117
117
  if (!profile?.nameID) throw new SsoError('The SAML response names no user.');
118
118
  const username = p.username_claim === 'nameID' ? profile.nameID : values(profile[p.username_claim])[0];
119
119
  if (!usableUsername(username)) throw new SsoError(`The SAML response has no usable "${p.username_claim}" attribute.`);
120
- const account = await resolveAccount(p, { sub: profile.nameID, name: values(profile.displayName ?? profile.cn)[0], email: profile.email ?? profile.mail }, username);
121
- return { appId: pending.app_id, next: pending.next, username: account, groups: values(profile[p.groups_claim]) };
120
+ const name = values(profile.displayName ?? profile.cn)[0];
121
+ const email = values(profile.email ?? profile.mail)[0];
122
+ const groups = values(profile[p.groups_claim]);
123
+ if (pending.app_id === null)
124
+ // the builder: devsso.ts finds the developer (sprint 129)
125
+ return { appId: null, next: pending.next, username, groups, link: pending.link_username, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
126
+ const account = await resolveAccount(p, { sub: profile.nameID, name, email: profile.email ?? profile.mail }, username);
127
+ return { appId: pending.app_id, next: pending.next, username: account, groups };
122
128
  }
123
129
 
124
130
  /** Service provider metadata (entity ID, ACS) to register pgkiln at the IdP. */
package/src/sso.ts CHANGED
@@ -34,6 +34,12 @@ export interface Provider {
34
34
  protocol: 'oidc' | 'saml';
35
35
  idp_sso_url: string | null;
36
36
  idp_cert: string | null;
37
+ /** (131) developers sign in to the builder with this provider too (devsso.ts) */
38
+ builder: boolean;
39
+ builder_auto_create: boolean;
40
+ builder_link_existing: boolean;
41
+ builder_group: string | null;
42
+ builder_admin_group: string | null;
37
43
  }
38
44
 
39
45
  interface Discovery {
@@ -86,8 +92,11 @@ function jwks(doc: Discovery) {
86
92
  return set;
87
93
  }
88
94
 
89
- /** Start: returns the provider URL to redirect to, and the browser-binding cookie value. */
90
- export async function startSignIn(p: Provider, appId: number, next: string | undefined) {
95
+ /**
96
+ * Start: returns the provider URL to redirect to, and the browser-binding cookie value.
97
+ * appId null: a sign-in to the builder; link: the signed-in developer linking this identity.
98
+ */
99
+ export async function startSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
91
100
  const doc = await discover(p.issuer);
92
101
  const state = b64url(randomBytes(32));
93
102
  const nonce = b64url(randomBytes(32));
@@ -95,9 +104,9 @@ export async function startSignIn(p: Provider, appId: number, next: string | und
95
104
  const browserKey = b64url(randomBytes(32));
96
105
  await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
97
106
  await owner.query(
98
- `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next)
99
- values ($1, $2, $3, $4, $5, $6, $7)`,
100
- [state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null],
107
+ `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
108
+ values ($1, $2, $3, $4, $5, $6, $7, $8)`,
109
+ [state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null, link ?? null],
101
110
  );
102
111
  const url = new URL(doc.authorization_endpoint);
103
112
  url.search = new URLSearchParams({
@@ -113,16 +122,29 @@ export async function startSignIn(p: Provider, appId: number, next: string | und
113
122
  return { url: url.toString(), browserKey };
114
123
  }
115
124
 
125
+ // (sprint 129) the builder finishes its own sign-ins (app_id null); the callback routes are the runtime's
126
+ type BuilderFinish = (req: any, reply: any, p: Provider, result: SsoResult, ip: string) => Promise<unknown>;
127
+ let builderFinish: BuilderFinish | null = null;
128
+ export const onBuilderSso = (f: BuilderFinish) => {
129
+ builderFinish = f;
130
+ };
131
+ export const builderSsoFinish = () => builderFinish;
132
+
116
133
  /** Read a claim by name or dot path (e.g. realm_access.roles). */
117
134
  function claim(payload: JWTPayload, path: string): unknown {
118
135
  return path.split('.').reduce<any>((v, k) => (v && typeof v === 'object' ? v[k] : undefined), payload);
119
136
  }
120
137
 
121
138
  export interface SsoResult {
122
- appId: number;
139
+ /** null: a sign-in to the builder (131); username is then the provider's, not resolved to an account */
140
+ appId: number | null;
123
141
  next: string | null;
124
142
  username: string;
125
143
  groups: string[];
144
+ /** builder sign-ins: the identity, for devsso.ts */
145
+ identity?: { subject: string; name: string | null; email: string | null; mayLink: boolean };
146
+ /** builder: the signed-in developer who links this identity (Security page) */
147
+ link?: string | null;
126
148
  }
127
149
 
128
150
  /** Callback: validate state/browser, exchange the code, verify the ID token, resolve the account. */
@@ -132,7 +154,7 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
132
154
  const code = params.get('code') ?? '';
133
155
  const pending = await owner.one(
134
156
  `delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
135
- returning app_id, code_verifier, nonce, browser_hash, next`,
157
+ returning app_id, code_verifier, nonce, browser_hash, next, link_username`,
136
158
  [state, p.id, PENDING_MINUTES],
137
159
  );
138
160
  if (!pending) throw new SsoError('This sign-in link has expired or was already used. Please try again.');
@@ -181,6 +203,14 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
181
203
 
182
204
  // an unverified e-mail address never links an existing account
183
205
  const verified = p.username_claim !== 'email' || payload.email_verified === true;
206
+ if (pending.app_id === null) {
207
+ // the builder: devsso.ts finds the developer (sprint 129)
208
+ const email = payload.email_verified === false ? null : typeof payload.email === 'string' ? payload.email : null;
209
+ return {
210
+ appId: null, next: pending.next, username, groups, link: pending.link_username,
211
+ identity: { subject: payload.sub, name: typeof payload.name === 'string' ? payload.name : null, email, mayLink: verified },
212
+ };
213
+ }
184
214
  const account = await resolveAccount(p, payload, username, { mayLink: verified });
185
215
  return { appId: pending.app_id, next: pending.next, username: account, groups };
186
216
  }