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/SECURITY.md +4 -1
- package/db/migrations/130_passkey_signin_workspace_mfa.sql +20 -0
- package/db/migrations/131_builder_sso.sql +69 -0
- package/docs/apex-feature-parity.md +5 -4
- package/docs/guide/03-builder.md +63 -1
- package/docs/guide/08-security.md +2 -1
- package/docs/guide/12-development.md +3 -0
- package/docs/guide/18-cli.md +2 -1
- package/package.json +1 -1
- package/public/builder.css +5 -0
- package/public/builder.js +3 -1
- package/src/builder/builder-sso.ts +135 -0
- package/src/builder/i18n/de.ts +62 -0
- package/src/builder/i18n/en.ts +62 -0
- package/src/builder/i18n/es.ts +62 -0
- package/src/builder/i18n/fr.ts +62 -0
- package/src/builder/i18n/nl.ts +62 -0
- package/src/builder/i18n/pt.ts +62 -0
- package/src/builder/ldap.ts +2 -0
- package/src/builder/routes.ts +213 -30
- package/src/builder/security.ts +17 -12
- package/src/builder/ui.ts +14 -3
- package/src/builder/users.ts +2 -0
- package/src/builder/workspaces.ts +58 -3
- package/src/cli/main.ts +25 -9
- package/src/devsso.ts +149 -0
- package/src/mfa.ts +58 -3
- package/src/runtime/routes.ts +14 -3
- package/src/saml.ts +13 -7
- package/src/sso.ts +37 -7
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
|
|
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
|
|
481
|
-
'admins and all need at least two administrators
|
|
482
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
293
|
-
if (!
|
|
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
|
|
package/src/runtime/routes.ts
CHANGED
|
@@ -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:
|
|
1354
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
/**
|
|
90
|
-
|
|
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
|
-
|
|
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
|
}
|