pgkiln 0.65.0 → 0.67.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 +1 -0
- package/db/migrations/134_idp_mfa.sql +46 -0
- package/docs/apex-feature-parity.md +10 -1
- package/docs/guide/01-installation.md +5 -2
- package/docs/guide/03-builder.md +40 -3
- package/docs/guide/08-security.md +41 -3
- package/docs/guide/12-development.md +8 -2
- package/docs/guide/18-cli.md +3 -0
- package/docs/guide/19-rest-data-sources.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +6 -0
- package/src/builder/i18n/de.ts +15 -0
- package/src/builder/i18n/en.ts +15 -0
- package/src/builder/i18n/es.ts +15 -0
- package/src/builder/i18n/fr.ts +15 -0
- package/src/builder/i18n/nl.ts +15 -0
- package/src/builder/i18n/pt.ts +15 -0
- package/src/builder/instance.ts +27 -0
- package/src/builder/login-page.ts +14 -16
- package/src/builder/routes.ts +24 -8
- package/src/builder/users.ts +24 -5
- package/src/builder/workspace-home.ts +7 -1
- package/src/cli/main.ts +58 -7
- package/src/instance-checks.ts +260 -0
- package/src/instance.ts +1 -1
- package/src/release-check.ts +10 -0
- package/src/runtime/routes.ts +39 -3
- package/src/runtime/two-step.ts +21 -1
- package/src/saml.ts +34 -11
- package/src/secrets.ts +16 -6
- package/src/server.ts +10 -0
- package/src/sso.ts +72 -11
- package/src/webauthn.ts +6 -2
package/src/sso.ts
CHANGED
|
@@ -15,7 +15,9 @@ import { owner } from './db.ts';
|
|
|
15
15
|
// existing account is linked by username only when the provider allows
|
|
16
16
|
// it (link_existing), while it has no identity at that provider yet, and
|
|
17
17
|
// for the e-mail claim only with email_verified = true;
|
|
18
|
-
// * client secrets are read with the owner connection only
|
|
18
|
+
// * client secrets are read with the owner connection only;
|
|
19
|
+
// * (sprint 132, SEC-4) with mfa_required, a sign-in counts only when the ID token's amr or acr names a second
|
|
20
|
+
// factor (mfa_values); a step-up asks the provider to sign the user in again and checks auth_time too.
|
|
19
21
|
|
|
20
22
|
export interface Provider {
|
|
21
23
|
id: number;
|
|
@@ -40,6 +42,14 @@ export interface Provider {
|
|
|
40
42
|
builder_link_existing: boolean;
|
|
41
43
|
builder_group: string | null;
|
|
42
44
|
builder_admin_group: string | null;
|
|
45
|
+
/** (134, SEC-4) refuse sign-ins the provider does not mark as multi-factor */
|
|
46
|
+
mfa_required: boolean;
|
|
47
|
+
/** (134) acr_values / AuthnContextClassRef values to request, space separated */
|
|
48
|
+
mfa_request: string | null;
|
|
49
|
+
/** (134) amr / acr / AuthnContextClassRef values that count as a second factor */
|
|
50
|
+
mfa_values: string[];
|
|
51
|
+
/** (134) pgkiln asks its own second step after a sign-in through this provider */
|
|
52
|
+
local_second_step: boolean;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
interface Discovery {
|
|
@@ -54,6 +64,8 @@ export class SsoError extends Error {}
|
|
|
54
64
|
|
|
55
65
|
const ALGORITHMS = ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512', 'EdDSA'];
|
|
56
66
|
const PENDING_MINUTES = 10;
|
|
67
|
+
/** (sprint 132) A step-up through the provider: how old its new sign-in may be. */
|
|
68
|
+
export const STEPUP_AUTH_SECONDS = 600;
|
|
57
69
|
const b64url = (b: Buffer) => b.toString('base64url');
|
|
58
70
|
export const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
|
59
71
|
|
|
@@ -92,11 +104,23 @@ function jwks(doc: Discovery) {
|
|
|
92
104
|
return set;
|
|
93
105
|
}
|
|
94
106
|
|
|
107
|
+
/** (sprint 132) What the provider is asked for: its acr values (or AuthnContextClassRefs), split. */
|
|
108
|
+
export const mfaRequest = (p: Pick<Provider, 'mfa_request'>) => (p.mfa_request ?? '').split(/\s+/).filter(Boolean);
|
|
109
|
+
|
|
110
|
+
/** (sprint 132, SEC-4) Whether the provider says a second factor was used: one of its mfa_values among these. */
|
|
111
|
+
export function provedMfa(p: Pick<Provider, 'mfa_values'>, said: string[]): boolean {
|
|
112
|
+
const accepted = new Set(p.mfa_values ?? []);
|
|
113
|
+
return said.some((v) => accepted.has(v));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const MFA_REFUSED = 'The identity provider did not confirm a second factor (multi-factor sign-in), which this sign-in requires. Ask an administrator how to set one up at the identity provider.';
|
|
117
|
+
|
|
95
118
|
/**
|
|
96
119
|
* 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
|
|
120
|
+
* appId null: a sign-in to the builder; link: the signed-in developer linking this identity;
|
|
121
|
+
* stepup (sprint 132): the signed-in session a fresh multi-factor sign-in at the provider confirms.
|
|
98
122
|
*/
|
|
99
|
-
export async function startSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
|
|
123
|
+
export async function startSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string, stepup?: string) {
|
|
100
124
|
const doc = await discover(p.issuer);
|
|
101
125
|
const state = b64url(randomBytes(32));
|
|
102
126
|
const nonce = b64url(randomBytes(32));
|
|
@@ -104,12 +128,12 @@ export async function startSignIn(p: Provider, appId: number | null, next: strin
|
|
|
104
128
|
const browserKey = b64url(randomBytes(32));
|
|
105
129
|
await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
|
|
106
130
|
await owner.query(
|
|
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],
|
|
131
|
+
`insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username, stepup_session)
|
|
132
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
133
|
+
[state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null, link ?? null, stepup ?? null],
|
|
110
134
|
);
|
|
111
135
|
const url = new URL(doc.authorization_endpoint);
|
|
112
|
-
|
|
136
|
+
const params = new URLSearchParams({
|
|
113
137
|
response_type: 'code',
|
|
114
138
|
client_id: p.client_id,
|
|
115
139
|
redirect_uri: redirectUri(p),
|
|
@@ -118,7 +142,15 @@ export async function startSignIn(p: Provider, appId: number | null, next: strin
|
|
|
118
142
|
nonce,
|
|
119
143
|
code_challenge: b64url(createHash('sha256').update(verifier).digest()),
|
|
120
144
|
code_challenge_method: 'S256',
|
|
121
|
-
})
|
|
145
|
+
});
|
|
146
|
+
// (sprint 132) what the provider should ask for; a step-up signs the user in again
|
|
147
|
+
const acr = mfaRequest(p);
|
|
148
|
+
if (acr.length) params.set('acr_values', acr.join(' '));
|
|
149
|
+
if (stepup) {
|
|
150
|
+
params.set('prompt', 'login');
|
|
151
|
+
params.set('max_age', '0');
|
|
152
|
+
}
|
|
153
|
+
url.search = params.toString();
|
|
122
154
|
return { url: url.toString(), browserKey };
|
|
123
155
|
}
|
|
124
156
|
|
|
@@ -145,6 +177,10 @@ export interface SsoResult {
|
|
|
145
177
|
identity?: { subject: string; name: string | null; email: string | null; mayLink: boolean };
|
|
146
178
|
/** builder: the signed-in developer who links this identity (Security page) */
|
|
147
179
|
link?: string | null;
|
|
180
|
+
/** (sprint 132) the provider said a second factor was used (amr / acr, AuthnContextClassRef) */
|
|
181
|
+
mfa?: boolean;
|
|
182
|
+
/** (sprint 132) a step-up: the signed-in session this sign-in confirms (username: that session's account) */
|
|
183
|
+
stepup?: string | null;
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
/** Callback: validate state/browser, exchange the code, verify the ID token, resolve the account. */
|
|
@@ -154,7 +190,7 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
154
190
|
const code = params.get('code') ?? '';
|
|
155
191
|
const pending = await owner.one(
|
|
156
192
|
`delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
|
|
157
|
-
returning app_id, code_verifier, nonce, browser_hash, next, link_username`,
|
|
193
|
+
returning app_id, code_verifier, nonce, browser_hash, next, link_username, stepup_session`,
|
|
158
194
|
[state, p.id, PENDING_MINUTES],
|
|
159
195
|
);
|
|
160
196
|
if (!pending) throw new SsoError('This sign-in link has expired or was already used. Please try again.');
|
|
@@ -198,6 +234,12 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
198
234
|
const username = claim(payload, p.username_claim);
|
|
199
235
|
if (!usableUsername(username)) throw new SsoError(`The identity token has no usable "${p.username_claim}" claim.`);
|
|
200
236
|
if (p.username_claim === 'email' && payload.email_verified === false) throw new SsoError('Your e-mail address is not verified at the identity provider.');
|
|
237
|
+
// (sprint 132, SEC-4) how the user signed in: RFC 8176 amr values and the acr
|
|
238
|
+
const amr = Array.isArray(payload.amr) ? payload.amr.filter((v): v is string => typeof v === 'string') : [];
|
|
239
|
+
const mfa = provedMfa(p, typeof payload.acr === 'string' ? [...amr, payload.acr] : amr);
|
|
240
|
+
if (pending.stepup_session)
|
|
241
|
+
return stepUpResult(p, pending, payload.sub, mfa, typeof payload.auth_time === 'number' ? payload.auth_time * 1000 : null);
|
|
242
|
+
if (p.mfa_required && !mfa) throw new SsoError(MFA_REFUSED);
|
|
201
243
|
const rawGroups = claim(payload, p.groups_claim);
|
|
202
244
|
const groups = Array.isArray(rawGroups) ? rawGroups.filter((g): g is string => typeof g === 'string').map((g) => g.replace(/^\//, '')) : [];
|
|
203
245
|
|
|
@@ -207,12 +249,31 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
|
|
|
207
249
|
// the builder: devsso.ts finds the developer (sprint 129)
|
|
208
250
|
const email = payload.email_verified === false ? null : typeof payload.email === 'string' ? payload.email : null;
|
|
209
251
|
return {
|
|
210
|
-
appId: null, next: pending.next, username, groups, link: pending.link_username,
|
|
252
|
+
appId: null, next: pending.next, username, groups, link: pending.link_username, mfa,
|
|
211
253
|
identity: { subject: payload.sub, name: typeof payload.name === 'string' ? payload.name : null, email, mayLink: verified },
|
|
212
254
|
};
|
|
213
255
|
}
|
|
214
256
|
const account = await resolveAccount(p, payload, username, { mayLink: verified });
|
|
215
|
-
return { appId: pending.app_id, next: pending.next, username: account, groups };
|
|
257
|
+
return { appId: pending.app_id, next: pending.next, username: account, groups, mfa };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* (sprint 132) The end of a step-up through the provider (OpenID Connect or SAML): a new sign-in (at most
|
|
262
|
+
* STEPUP_AUTH_SECONDS old) with a second factor, by the identity already linked to the session's account.
|
|
263
|
+
* Nothing is created or linked here.
|
|
264
|
+
*/
|
|
265
|
+
export async function stepUpResult(
|
|
266
|
+
p: Pick<Provider, 'id' | 'mfa_values'>, pending: { app_id: number | null; next: string | null; stepup_session: string },
|
|
267
|
+
subject: string, mfa: boolean, authAt: number | null,
|
|
268
|
+
): Promise<SsoResult> {
|
|
269
|
+
if (!mfa) throw new SsoError(MFA_REFUSED);
|
|
270
|
+
if (authAt === null || Date.now() - authAt > STEPUP_AUTH_SECONDS * 1000 + 60_000)
|
|
271
|
+
throw new SsoError('The identity provider did not ask you to sign in again. Please try again.');
|
|
272
|
+
const acc = await owner.one<{ username: string }>(
|
|
273
|
+
`select a.username from meta.account_identity i join meta.account a on a.id = i.account_id
|
|
274
|
+
where i.provider_id = $1 and i.subject = $2 and a.active`, [p.id, subject]);
|
|
275
|
+
if (!acc) throw new SsoError('This identity is not linked to an account here.');
|
|
276
|
+
return { appId: pending.app_id, next: pending.next, username: acc.username, groups: [], mfa: true, stepup: pending.stepup_session };
|
|
216
277
|
}
|
|
217
278
|
|
|
218
279
|
/** A username from an identity provider: text without white space or colons, at most 100 characters. */
|
package/src/webauthn.ts
CHANGED
|
@@ -218,8 +218,12 @@ export function relyingParty(req: { protocol: string; host: string }): { rpId: s
|
|
|
218
218
|
// a bad PUBLIC_URL: the browser's address
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
+
return { rpId: rpIdOf(origin), origin };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The RP ID of an origin: its host, or PGKILN_RP_ID when that is the host or a parent domain of it. */
|
|
225
|
+
export function rpIdOf(origin: string): string {
|
|
221
226
|
const host = new URL(origin).hostname;
|
|
222
227
|
const wanted = (process.env.PGKILN_RP_ID ?? '').trim().toLowerCase();
|
|
223
|
-
|
|
224
|
-
return { rpId, origin };
|
|
228
|
+
return wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
|
|
225
229
|
}
|