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/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
@@ -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
- const rpId = wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
224
- return { rpId, origin };
228
+ return wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
225
229
  }