pgkiln 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
  2. package/db/migrations/134_idp_mfa.sql +46 -0
  3. package/docs/apex-feature-parity.md +9 -3
  4. package/docs/guide/03-builder.md +5 -1
  5. package/docs/guide/08-security.md +81 -8
  6. package/docs/guide/12-development.md +10 -4
  7. package/docs/guide/13-rest-api.md +4 -0
  8. package/package.json +1 -1
  9. package/public/app.js +63 -0
  10. package/public/builder.css +6 -0
  11. package/public/builder.js +1 -62
  12. package/src/account-mfa.ts +94 -3
  13. package/src/api.ts +9 -2
  14. package/src/builder/api.ts +3 -1
  15. package/src/builder/components.ts +1 -0
  16. package/src/builder/i18n/de.ts +13 -0
  17. package/src/builder/i18n/en.ts +13 -0
  18. package/src/builder/i18n/es.ts +13 -0
  19. package/src/builder/i18n/fr.ts +13 -0
  20. package/src/builder/i18n/nl.ts +13 -0
  21. package/src/builder/i18n/pt.ts +13 -0
  22. package/src/builder/login-page.ts +20 -17
  23. package/src/builder/rest-modules.ts +1 -1
  24. package/src/builder/routes.ts +24 -8
  25. package/src/builder/security.ts +3 -19
  26. package/src/builder/shared.ts +5 -3
  27. package/src/builder/users.ts +24 -5
  28. package/src/i18n/ar.ts +31 -0
  29. package/src/i18n/cs.ts +31 -0
  30. package/src/i18n/da.ts +31 -0
  31. package/src/i18n/de.ts +31 -0
  32. package/src/i18n/el.ts +31 -0
  33. package/src/i18n/es.ts +31 -0
  34. package/src/i18n/fi.ts +31 -0
  35. package/src/i18n/fr.ts +31 -0
  36. package/src/i18n/he.ts +31 -0
  37. package/src/i18n/it.ts +31 -0
  38. package/src/i18n/ja.ts +31 -0
  39. package/src/i18n/ko.ts +31 -0
  40. package/src/i18n/nb.ts +31 -0
  41. package/src/i18n/pl.ts +31 -0
  42. package/src/i18n/pt.ts +31 -0
  43. package/src/i18n/ru.ts +31 -0
  44. package/src/i18n/sv.ts +31 -0
  45. package/src/i18n/tr.ts +31 -0
  46. package/src/i18n/uk.ts +31 -0
  47. package/src/i18n/zh.ts +31 -0
  48. package/src/i18n.ts +62 -0
  49. package/src/metadata.ts +8 -2
  50. package/src/mfa.ts +18 -13
  51. package/src/runtime/account.ts +6 -3
  52. package/src/runtime/authz.ts +23 -4
  53. package/src/runtime/routes.ts +50 -4
  54. package/src/runtime/two-step.ts +400 -73
  55. package/src/saml.ts +34 -11
  56. package/src/sso.ts +72 -11
  57. package/src/webauthn.ts +20 -0
package/src/saml.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { SAML, ValidateInResponseTo, type CacheProvider, type Profile } from '@node-saml/node-saml';
3
3
  import { owner } from './db.ts';
4
- import { publicUrl, resolveAccount, sha256, SsoError, usableUsername, type Provider, type SsoResult } from './sso.ts';
4
+ import { MFA_REFUSED, mfaRequest, provedMfa, publicUrl, resolveAccount, sha256, SsoError, stepUpResult, usableUsername, type Provider, type SsoResult } from './sso.ts';
5
5
 
6
6
  // SAML 2.0 sign-in (APEX: SAML Sign-In), service-provider initiated:
7
7
  //
@@ -18,6 +18,9 @@ import { publicUrl, resolveAccount, sha256, SsoError, usableUsername, type Provi
18
18
  // answer an AuthnRequest of this flow, once. On top: the assertion's issuer
19
19
  // (node-saml doesn't check it for responses), the one-time state and the browser
20
20
  // that started the flow (as for OpenID Connect).
21
+ // (sprint 132, SEC-4) The assertion's AuthnContextClassRef tells how the user signed in: with mfa_required it must
22
+ // be one of the provider's mfa_values; mfa_request asks for those (RequestedAuthnContext); a step-up adds ForceAuthn
23
+ // and checks the AuthnInstant.
21
24
 
22
25
  const PENDING_MINUTES = 10;
23
26
  const b64url = (b: Buffer) => b.toString('base64url');
@@ -54,8 +57,9 @@ function requestCache(onSave: (id: string) => void, only?: string): CacheProvide
54
57
  };
55
58
  }
56
59
 
57
- function client(p: Provider, cache: CacheProvider) {
60
+ function client(p: Provider, cache: CacheProvider, stepup = false) {
58
61
  if (!p.idp_sso_url || !p.idp_cert) throw new SsoError('The SAML provider is not configured completely.');
62
+ const requested = mfaRequest(p);
59
63
  return new SAML({
60
64
  entryPoint: p.idp_sso_url,
61
65
  issuer: spEntityId(p),
@@ -69,37 +73,51 @@ function client(p: Provider, cache: CacheProvider) {
69
73
  requestIdExpirationPeriodMs: PENDING_MINUTES * 60_000,
70
74
  cacheProvider: cache,
71
75
  identifierFormat: null,
72
- disableRequestedAuthnContext: true,
76
+ // (sprint 132) a RequestedAuthnContext only when the administrator names the classes
77
+ disableRequestedAuthnContext: !requested.length,
78
+ authnContext: requested,
79
+ racComparison: 'exact',
80
+ forceAuthn: stepup,
73
81
  acceptedClockSkewMs: 60_000,
74
82
  signatureAlgorithm: 'sha256',
75
83
  });
76
84
  }
77
85
 
78
86
  /** Start: the IdP URL with the AuthnRequest, and the browser-binding cookie value. */
79
- export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
87
+ export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string, stepup?: string) {
80
88
  const state = b64url(randomBytes(32));
81
89
  const browserKey = b64url(randomBytes(32));
82
90
  await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
83
91
  await owner.query(`delete from meta.saml_request where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
84
92
  let requestId = '';
85
- const url = await client(p, requestCache((id) => (requestId = id))).getAuthorizeUrlAsync(state, undefined, {});
93
+ const url = await client(p, requestCache((id) => (requestId = id)), !!stepup).getAuthorizeUrlAsync(state, undefined, {});
86
94
  if (!requestId) throw new SsoError('The AuthnRequest has no ID.');
87
95
  // code_verifier is unused for SAML; nonce holds the AuthnRequest ID the response must answer
88
96
  await owner.query(
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],
97
+ `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username, stepup_session)
98
+ values ($1, $2, $3, '', $4, $5, $6, $7, $8)`,
99
+ [state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null, stepup ?? null],
92
100
  );
93
101
  return { url, browserKey };
94
102
  }
95
103
 
104
+ /** (sprint 132) The signed assertion's AuthnContextClassRefs and AuthnInstant (ms), read after node-saml checked it. */
105
+ export function authnContext(assertionXml: string): { classes: string[]; at: number | null } {
106
+ // comments are outside the signature (exclusive c14n drops them): text split by one is read as one
107
+ assertionXml = assertionXml.replace(/<!--[\s\S]*?-->/g, '');
108
+ const classes = [...assertionXml.matchAll(/<(?:[\w-]+:)?AuthnContextClassRef\b[^>]*>\s*([^<\s]+)\s*</g)].map((m) => m[1]);
109
+ const instant = /<(?:[\w-]+:)?AuthnStatement\b[^>]*\bAuthnInstant="([^"]+)"/.exec(assertionXml)?.[1];
110
+ const at = instant ? Date.parse(instant) : NaN;
111
+ return { classes, at: Number.isFinite(at) ? at : null };
112
+ }
113
+
96
114
  const values = (v: unknown): string[] => (v === undefined || v === null ? [] : (Array.isArray(v) ? v : [v]).map(String).filter(Boolean));
97
115
 
98
116
  /** Finish: check state, browser and the response; the account and its groups. */
99
117
  export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: string; RelayState?: string }, browserKey: string | undefined): Promise<SsoResult> {
100
118
  const pending = await owner.one(
101
119
  `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, link_username`,
120
+ returning app_id, nonce, browser_hash, next, link_username, stepup_session`,
103
121
  [body.RelayState ?? '', p.id, PENDING_MINUTES],
104
122
  );
105
123
  if (!pending) throw new SsoError('This sign-in has expired or was already used. Please try again.');
@@ -115,6 +133,11 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
115
133
  // node-saml checks the issuer of logout messages only: the assertion must come from the configured IdP
116
134
  if (profile?.issuer !== p.issuer) throw new SsoError(`The SAML response comes from ${profile?.issuer ?? 'an unknown issuer'}, not from ${p.issuer}.`);
117
135
  if (!profile?.nameID) throw new SsoError('The SAML response names no user.');
136
+ // (sprint 132, SEC-4) how the user signed in
137
+ const how = authnContext(profile.getAssertionXml?.() ?? '');
138
+ const mfa = provedMfa(p, how.classes);
139
+ if (pending.stepup_session) return stepUpResult(p, pending, profile.nameID, mfa, how.at);
140
+ if (p.mfa_required && !mfa) throw new SsoError(MFA_REFUSED);
118
141
  const username = p.username_claim === 'nameID' ? profile.nameID : values(profile[p.username_claim])[0];
119
142
  if (!usableUsername(username)) throw new SsoError(`The SAML response has no usable "${p.username_claim}" attribute.`);
120
143
  const name = values(profile.displayName ?? profile.cn)[0];
@@ -122,9 +145,9 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
122
145
  const groups = values(profile[p.groups_claim]);
123
146
  if (pending.app_id === null)
124
147
  // 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 } };
148
+ return { appId: null, next: pending.next, username, groups, link: pending.link_username, mfa, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
126
149
  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 };
150
+ return { appId: pending.app_id, next: pending.next, username: account, groups, mfa };
128
151
  }
129
152
 
130
153
  /** Service provider metadata (entity ID, ACS) to register pgkiln at the IdP. */
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
- url.search = new URLSearchParams({
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
- }).toString();
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
@@ -16,6 +16,26 @@ export const b64url = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base6
16
16
  export const unb64url = (s: string) => Buffer.from(String(s ?? ''), 'base64url');
17
17
  export const newChallenge = () => b64url(randomBytes(32));
18
18
 
19
+ const CHALLENGE_SECONDS = 300;
20
+ type WithState = { state: Record<string, unknown> };
21
+
22
+ /** A one-time challenge kept in the session (for this purpose only), or null when there is none or it expired. */
23
+ export function takeChallenge(s: WithState, purpose: 'create' | 'get'): string | null {
24
+ try {
25
+ const p = JSON.parse(String(s.state.__PK ?? ''));
26
+ delete s.state.__PK;
27
+ return p && p.purpose === purpose && Date.now() - Number(p.at) < CHALLENGE_SECONDS * 1000 && typeof p.c === 'string' ? p.c : null;
28
+ } catch {
29
+ delete s.state.__PK;
30
+ return null;
31
+ }
32
+ }
33
+ export function giveChallenge(s: WithState, purpose: 'create' | 'get'): string {
34
+ const c = newChallenge();
35
+ s.state.__PK = JSON.stringify({ c, purpose, at: Date.now() });
36
+ return c;
37
+ }
38
+
19
39
  // ------------------------------------------------------------------ CBOR (RFC 8949), only what WebAuthn uses
20
40
 
21
41
  type Cbor = number | bigint | string | Buffer | boolean | null | undefined | Cbor[] | Map<Cbor, Cbor>;