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.
- package/db/migrations/133_app_user_passkeys_stepup.sql +54 -0
- package/db/migrations/134_idp_mfa.sql +46 -0
- package/docs/apex-feature-parity.md +9 -3
- package/docs/guide/03-builder.md +5 -1
- package/docs/guide/08-security.md +81 -8
- package/docs/guide/12-development.md +10 -4
- package/docs/guide/13-rest-api.md +4 -0
- package/package.json +1 -1
- package/public/app.js +63 -0
- package/public/builder.css +6 -0
- package/public/builder.js +1 -62
- package/src/account-mfa.ts +94 -3
- package/src/api.ts +9 -2
- package/src/builder/api.ts +3 -1
- package/src/builder/components.ts +1 -0
- package/src/builder/i18n/de.ts +13 -0
- package/src/builder/i18n/en.ts +13 -0
- package/src/builder/i18n/es.ts +13 -0
- package/src/builder/i18n/fr.ts +13 -0
- package/src/builder/i18n/nl.ts +13 -0
- package/src/builder/i18n/pt.ts +13 -0
- package/src/builder/login-page.ts +20 -17
- package/src/builder/rest-modules.ts +1 -1
- package/src/builder/routes.ts +24 -8
- package/src/builder/security.ts +3 -19
- package/src/builder/shared.ts +5 -3
- package/src/builder/users.ts +24 -5
- package/src/i18n/ar.ts +31 -0
- package/src/i18n/cs.ts +31 -0
- package/src/i18n/da.ts +31 -0
- package/src/i18n/de.ts +31 -0
- package/src/i18n/el.ts +31 -0
- package/src/i18n/es.ts +31 -0
- package/src/i18n/fi.ts +31 -0
- package/src/i18n/fr.ts +31 -0
- package/src/i18n/he.ts +31 -0
- package/src/i18n/it.ts +31 -0
- package/src/i18n/ja.ts +31 -0
- package/src/i18n/ko.ts +31 -0
- package/src/i18n/nb.ts +31 -0
- package/src/i18n/pl.ts +31 -0
- package/src/i18n/pt.ts +31 -0
- package/src/i18n/ru.ts +31 -0
- package/src/i18n/sv.ts +31 -0
- package/src/i18n/tr.ts +31 -0
- package/src/i18n/uk.ts +31 -0
- package/src/i18n/zh.ts +31 -0
- package/src/i18n.ts +62 -0
- package/src/metadata.ts +8 -2
- package/src/mfa.ts +18 -13
- package/src/runtime/account.ts +6 -3
- package/src/runtime/authz.ts +23 -4
- package/src/runtime/routes.ts +50 -4
- package/src/runtime/two-step.ts +400 -73
- package/src/saml.ts +34 -11
- package/src/sso.ts +72 -11
- 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
|
-
|
|
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
|
-
|
|
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
|
@@ -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>;
|