pgkiln 0.60.0 → 0.61.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 +2 -1
- package/db/migrations/129_developer_passkeys.sql +49 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/01-installation.md +2 -1
- package/docs/guide/03-builder.md +28 -7
- package/package.json +1 -1
- package/public/builder.js +61 -0
- package/src/builder/i18n/de.ts +33 -9
- package/src/builder/i18n/en.ts +33 -9
- package/src/builder/i18n/es.ts +33 -9
- package/src/builder/i18n/fr.ts +33 -9
- package/src/builder/i18n/nl.ts +33 -9
- package/src/builder/i18n/pt.ts +33 -9
- package/src/builder/routes.ts +82 -6
- package/src/builder/security.ts +125 -15
- package/src/instance.ts +2 -1
- package/src/mfa.ts +75 -2
- package/src/webauthn.ts +205 -0
package/src/mfa.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { decryptSecret, encryptSecret } from './secrets.ts';
|
|
|
9
9
|
// setting; it can only be required while two administrators (or every administrator, when there are fewer)
|
|
10
10
|
// have a working second factor, so it never locks the instance out. The command line resets a developer's
|
|
11
11
|
// factors and turns the policy off without signing in (src/cli/main.ts, `pgkiln admin`).
|
|
12
|
+
// (sprint 127) Passkeys and security keys (WebAuthn, src/webauthn.ts) are the second kind of factor, and a
|
|
13
|
+
// developer may trust a browser for a number of days (meta.setting mfa_trust_days; 0 = never).
|
|
12
14
|
|
|
13
15
|
export const TOTP_STEP = 30;
|
|
14
16
|
export const TOTP_DIGITS = 6;
|
|
@@ -92,10 +94,10 @@ export const hashCode = (code: string) => createHash('sha256').update(normaliseC
|
|
|
92
94
|
|
|
93
95
|
// ---------------------------------------------------------------- the developer's factors
|
|
94
96
|
|
|
95
|
-
export interface Factor { id: number; kind: 'totp'; name: string; created_at: string; last_used_at: string | null }
|
|
97
|
+
export interface Factor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
|
|
96
98
|
|
|
97
99
|
export async function factorsOf(username: string, db: Pick<pg.ClientBase, 'query'> = owner.pool): Promise<Factor[]> {
|
|
98
|
-
return (await db.query<Factor>('select id, kind, name, created_at, last_used_at from meta.developer_factor where username = $1 order by id', [username])).rows;
|
|
100
|
+
return (await db.query<Factor>('select id, kind, name, created_at, last_used_at, rp_id from meta.developer_factor where username = $1 order by kind desc, id', [username])).rows;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/** Recovery codes left (confirmed, unused). */
|
|
@@ -116,6 +118,76 @@ export async function addTotp(username: string, name: string, secret: string, st
|
|
|
116
118
|
);
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// ---------------------------------------------------------------- passkeys (sprint 127)
|
|
122
|
+
|
|
123
|
+
export interface Passkey { id: number; name: string; credential_id: string; public_key: Record<string, string>; alg: number; sign_count: string; transports: string[] }
|
|
124
|
+
|
|
125
|
+
/** Add a passkey (after src/webauthn.ts checked the browser's answer). */
|
|
126
|
+
export async function addPasskey(username: string, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
|
|
127
|
+
await owner.query(
|
|
128
|
+
`insert into meta.developer_factor (username, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
|
|
129
|
+
values ($1, 'passkey', $2, $3, $4, $5, $6, $7, $8, now())`,
|
|
130
|
+
[username, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
|
|
131
|
+
p.transports.filter((t) => /^[a-z-]{1,20}$/.test(t)).slice(0, 8)],
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The developer's passkeys for this relying party (a passkey only works on the domain it was made for). */
|
|
136
|
+
export async function passkeysOf(username: string, rpId: string): Promise<Passkey[]> {
|
|
137
|
+
return (await owner.query<Passkey>(
|
|
138
|
+
`select id, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
|
|
139
|
+
where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
|
|
143
|
+
export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
|
|
144
|
+
return !!(await owner.query(
|
|
145
|
+
'update meta.developer_factor set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint',
|
|
146
|
+
[id, oldCount, newCount])).rowCount;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------- trusted browsers (sprint 127)
|
|
150
|
+
|
|
151
|
+
/** Days a browser may skip the second step after one (0 = never). */
|
|
152
|
+
export async function trustDays(): Promise<number> {
|
|
153
|
+
const v = Number((await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_trust_days'`))?.value);
|
|
154
|
+
return Number.isInteger(v) && v >= 0 && v <= 90 ? v : 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function setTrustDays(days: number) {
|
|
158
|
+
await owner.query(`insert into meta.setting (name, value) values ('mfa_trust_days', $1) on conflict (name) do update set value = excluded.value`, [String(days)]);
|
|
159
|
+
if (days === 0) await owner.query('delete from meta.developer_trusted_browser');
|
|
160
|
+
else await owner.query(`delete from meta.developer_trusted_browser where expires_at > now() + make_interval(days => $1)`, [days]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const tokenHash = (token: string) => createHash('sha256').update(token).digest('hex');
|
|
164
|
+
|
|
165
|
+
/** Trust this browser: a random token for the cookie (only its hash is stored). */
|
|
166
|
+
export async function trustBrowser(username: string, days: number, userAgent: string): Promise<string> {
|
|
167
|
+
const token = randomBytes(32).toString('base64url');
|
|
168
|
+
await owner.query(
|
|
169
|
+
`insert into meta.developer_trusted_browser (token_hash, username, expires_at, user_agent) values ($1, $2, now() + make_interval(days => $3), $4)`,
|
|
170
|
+
[tokenHash(token), username, days, userAgent.slice(0, 200)]);
|
|
171
|
+
return token;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Whether this browser's token is trusted for this developer (and trusting is still on). */
|
|
175
|
+
export async function isTrustedBrowser(username: string, token: string | undefined): Promise<boolean> {
|
|
176
|
+
if (!token || token.length > 100) return false;
|
|
177
|
+
return !!(await owner.one(
|
|
178
|
+
`select 1 as ok from meta.developer_trusted_browser t where token_hash = $1 and username = $2 and expires_at > now()
|
|
179
|
+
and exists (select 1 from meta.setting where name = 'mfa_trust_days' and value <> '0')`,
|
|
180
|
+
[tokenHash(token), username]));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function trustedBrowsers(username: string): Promise<number> {
|
|
184
|
+
return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_trusted_browser where username = $1 and expires_at > now()', [username]))!.n;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function forgetBrowsers(username: string) {
|
|
188
|
+
await owner.query('delete from meta.developer_trusted_browser where username = $1', [username]);
|
|
189
|
+
}
|
|
190
|
+
|
|
119
191
|
/** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
|
|
120
192
|
export async function newRecoveryCodes(username: string): Promise<string[]> {
|
|
121
193
|
const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
|
|
@@ -230,6 +302,7 @@ export async function resetFactors(username: string): Promise<boolean> {
|
|
|
230
302
|
if (!known) return false;
|
|
231
303
|
await c.query('delete from meta.developer_factor where username = $1', [username]);
|
|
232
304
|
await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
|
|
305
|
+
await c.query('delete from meta.developer_trusted_browser where username = $1', [username]);
|
|
233
306
|
await c.query('delete from meta.session where app_id is null and username = $1', [username]);
|
|
234
307
|
return true;
|
|
235
308
|
});
|
package/src/webauthn.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// (sprint 127, SEC-2) Passkeys and security keys (WebAuthn level 2) for developers, without a library: the
|
|
4
|
+
// server makes a one-time challenge, the browser asks the authenticator (navigator.credentials), and the
|
|
5
|
+
// server checks what comes back. Registration takes attestation "none": pgkiln stores the credential's public
|
|
6
|
+
// key (as a JWK) and trusts the authenticator the developer chose, as most sites do. Sign-in checks the
|
|
7
|
+
// challenge, the origin, the relying party (RP ID hash), user presence, the signature over authenticatorData
|
|
8
|
+
// and the hash of clientDataJSON, and the signature counter (a clone gives itself away when it goes back).
|
|
9
|
+
// Algorithms: ES256 (-7), EdDSA (-8) and RS256 (-257).
|
|
10
|
+
|
|
11
|
+
export const ALGORITHMS = [-7, -8, -257];
|
|
12
|
+
|
|
13
|
+
export class WebAuthnError extends Error {}
|
|
14
|
+
|
|
15
|
+
export const b64url = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64url');
|
|
16
|
+
export const unb64url = (s: string) => Buffer.from(String(s ?? ''), 'base64url');
|
|
17
|
+
export const newChallenge = () => b64url(randomBytes(32));
|
|
18
|
+
|
|
19
|
+
// ------------------------------------------------------------------ CBOR (RFC 8949), only what WebAuthn uses
|
|
20
|
+
|
|
21
|
+
type Cbor = number | bigint | string | Buffer | boolean | null | undefined | Cbor[] | Map<Cbor, Cbor>;
|
|
22
|
+
|
|
23
|
+
/** Decode one CBOR item from `buf` at `pos`; returns the item and the position after it. */
|
|
24
|
+
export function cborDecode(buf: Buffer, pos = 0, depth = 0): [Cbor, number] {
|
|
25
|
+
if (depth > 16) throw new WebAuthnError('CBOR nested too deep');
|
|
26
|
+
if (pos >= buf.length) throw new WebAuthnError('CBOR ends early');
|
|
27
|
+
const first = buf[pos++];
|
|
28
|
+
const major = first >> 5;
|
|
29
|
+
const info = first & 31;
|
|
30
|
+
let len: number | bigint;
|
|
31
|
+
if (info < 24) len = info;
|
|
32
|
+
else if (info === 24) len = buf.readUInt8(pos), pos += 1;
|
|
33
|
+
else if (info === 25) len = buf.readUInt16BE(pos), pos += 2;
|
|
34
|
+
else if (info === 26) len = buf.readUInt32BE(pos), pos += 4;
|
|
35
|
+
else if (info === 27) len = buf.readBigUInt64BE(pos), pos += 8;
|
|
36
|
+
else throw new WebAuthnError('CBOR: indefinite lengths are not used by WebAuthn');
|
|
37
|
+
const n = () => {
|
|
38
|
+
if (typeof len === 'bigint') throw new WebAuthnError('CBOR length too large');
|
|
39
|
+
if (pos + len > buf.length && (major === 2 || major === 3)) throw new WebAuthnError('CBOR ends early');
|
|
40
|
+
return len;
|
|
41
|
+
};
|
|
42
|
+
switch (major) {
|
|
43
|
+
case 0: return [typeof len === 'bigint' ? len : len, pos];
|
|
44
|
+
case 1: return [typeof len === 'bigint' ? -1n - len : -1 - len, pos];
|
|
45
|
+
case 2: { const l = n(); return [buf.subarray(pos, pos + l), pos + l]; }
|
|
46
|
+
case 3: { const l = n(); return [buf.subarray(pos, pos + l).toString('utf8'), pos + l]; }
|
|
47
|
+
case 4: {
|
|
48
|
+
const l = n();
|
|
49
|
+
if (l > 1000) throw new WebAuthnError('CBOR array too long');
|
|
50
|
+
const out: Cbor[] = [];
|
|
51
|
+
for (let i = 0; i < l; i++) { const [v, p] = cborDecode(buf, pos, depth + 1); out.push(v); pos = p; }
|
|
52
|
+
return [out, pos];
|
|
53
|
+
}
|
|
54
|
+
case 5: {
|
|
55
|
+
const l = n();
|
|
56
|
+
if (l > 1000) throw new WebAuthnError('CBOR map too long');
|
|
57
|
+
const out = new Map<Cbor, Cbor>();
|
|
58
|
+
for (let i = 0; i < l; i++) {
|
|
59
|
+
const [k, p1] = cborDecode(buf, pos, depth + 1);
|
|
60
|
+
const [v, p2] = cborDecode(buf, p1, depth + 1);
|
|
61
|
+
out.set(k, v); pos = p2;
|
|
62
|
+
}
|
|
63
|
+
return [out, pos];
|
|
64
|
+
}
|
|
65
|
+
case 7:
|
|
66
|
+
if (info === 20) return [false, pos];
|
|
67
|
+
if (info === 21) return [true, pos];
|
|
68
|
+
if (info === 22) return [null, pos];
|
|
69
|
+
if (info === 23) return [undefined, pos];
|
|
70
|
+
throw new WebAuthnError('CBOR: floats are not used by WebAuthn');
|
|
71
|
+
default:
|
|
72
|
+
throw new WebAuthnError('CBOR: tags are not used by WebAuthn');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ------------------------------------------------------------------ COSE keys (RFC 9053) → Node key objects
|
|
77
|
+
|
|
78
|
+
/** A credential public key (COSE_Key) as a JWK, with its algorithm. */
|
|
79
|
+
export function coseToJwk(cose: Map<Cbor, Cbor>): { alg: number; jwk: Record<string, string> } {
|
|
80
|
+
const kty = cose.get(1);
|
|
81
|
+
const alg = Number(cose.get(3));
|
|
82
|
+
const bytes = (k: number) => {
|
|
83
|
+
const v = cose.get(k);
|
|
84
|
+
if (!Buffer.isBuffer(v)) throw new WebAuthnError('the key is missing a part');
|
|
85
|
+
return b64url(v);
|
|
86
|
+
};
|
|
87
|
+
if (kty === 2 && alg === -7 && cose.get(-1) === 1) return { alg, jwk: { kty: 'EC', crv: 'P-256', x: bytes(-2), y: bytes(-3) } };
|
|
88
|
+
if (kty === 1 && alg === -8 && cose.get(-1) === 6) return { alg, jwk: { kty: 'OKP', crv: 'Ed25519', x: bytes(-2) } };
|
|
89
|
+
if (kty === 3 && alg === -257) return { alg, jwk: { kty: 'RSA', n: bytes(-1), e: bytes(-2) } };
|
|
90
|
+
throw new WebAuthnError(`unsupported key type or algorithm (${String(kty)}, ${alg})`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const keyOf = (jwk: Record<string, string>): KeyObject => createPublicKey({ key: jwk, format: 'jwk' });
|
|
94
|
+
|
|
95
|
+
// ------------------------------------------------------------------ authenticator data
|
|
96
|
+
|
|
97
|
+
interface AuthData {
|
|
98
|
+
rpIdHash: Buffer;
|
|
99
|
+
flags: number;
|
|
100
|
+
signCount: number;
|
|
101
|
+
credentialId?: Buffer;
|
|
102
|
+
publicKey?: Map<Cbor, Cbor>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const FLAG_UP = 0x01; // user present
|
|
106
|
+
export const FLAG_UV = 0x04; // user verified
|
|
107
|
+
const FLAG_AT = 0x40; // attested credential data included
|
|
108
|
+
|
|
109
|
+
export function parseAuthData(buf: Buffer): AuthData {
|
|
110
|
+
if (buf.length < 37) throw new WebAuthnError('authenticator data too short');
|
|
111
|
+
const out: AuthData = { rpIdHash: buf.subarray(0, 32), flags: buf[32], signCount: buf.readUInt32BE(33) };
|
|
112
|
+
if (out.flags & FLAG_AT) {
|
|
113
|
+
if (buf.length < 55) throw new WebAuthnError('authenticator data too short');
|
|
114
|
+
const idLen = buf.readUInt16BE(53);
|
|
115
|
+
if (idLen > 1023 || buf.length < 55 + idLen) throw new WebAuthnError('bad credential id');
|
|
116
|
+
out.credentialId = buf.subarray(55, 55 + idLen);
|
|
117
|
+
const [key] = cborDecode(buf, 55 + idLen);
|
|
118
|
+
if (!(key instanceof Map)) throw new WebAuthnError('bad credential public key');
|
|
119
|
+
out.publicKey = key;
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ------------------------------------------------------------------ checks
|
|
125
|
+
|
|
126
|
+
export interface Expected { challenge: string; origin: string; rpId: string }
|
|
127
|
+
|
|
128
|
+
function checkClientData(clientDataJSON: Buffer, type: string, exp: Expected) {
|
|
129
|
+
let c: { type?: string; challenge?: string; origin?: string; crossOrigin?: boolean };
|
|
130
|
+
try {
|
|
131
|
+
c = JSON.parse(clientDataJSON.toString('utf8'));
|
|
132
|
+
} catch {
|
|
133
|
+
throw new WebAuthnError('client data is not JSON');
|
|
134
|
+
}
|
|
135
|
+
if (c.type !== type) throw new WebAuthnError(`expected ${type}`);
|
|
136
|
+
const a = Buffer.from(String(c.challenge ?? ''));
|
|
137
|
+
const b = Buffer.from(exp.challenge);
|
|
138
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new WebAuthnError('the challenge does not match');
|
|
139
|
+
if (c.origin !== exp.origin) throw new WebAuthnError(`the origin ${c.origin} is not ${exp.origin}`);
|
|
140
|
+
if (c.crossOrigin) throw new WebAuthnError('cross-origin use is not allowed');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const rpHash = (rpId: string) => createHash('sha256').update(rpId).digest();
|
|
144
|
+
|
|
145
|
+
/** A new passkey from navigator.credentials.create(): the credential to store. */
|
|
146
|
+
export function verifyRegistration(input: { clientDataJSON: string; attestationObject: string }, exp: Expected) {
|
|
147
|
+
const clientData = unb64url(input.clientDataJSON);
|
|
148
|
+
checkClientData(clientData, 'webauthn.create', exp);
|
|
149
|
+
const [att] = cborDecode(unb64url(input.attestationObject));
|
|
150
|
+
if (!(att instanceof Map)) throw new WebAuthnError('bad attestation object');
|
|
151
|
+
const authData = att.get('authData');
|
|
152
|
+
if (!Buffer.isBuffer(authData)) throw new WebAuthnError('bad attestation object');
|
|
153
|
+
const a = parseAuthData(authData);
|
|
154
|
+
if (!a.rpIdHash.equals(rpHash(exp.rpId))) throw new WebAuthnError('the passkey is for another site');
|
|
155
|
+
if (!(a.flags & FLAG_UP)) throw new WebAuthnError('the user was not present');
|
|
156
|
+
if (!a.credentialId || !a.publicKey) throw new WebAuthnError('no credential in the response');
|
|
157
|
+
const { alg, jwk } = coseToJwk(a.publicKey);
|
|
158
|
+
keyOf(jwk); // a key Node can read
|
|
159
|
+
return { credentialId: b64url(a.credentialId), alg, jwk, signCount: a.signCount, userVerified: !!(a.flags & FLAG_UV) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A sign-in from navigator.credentials.get(): the new signature counter, or an error. */
|
|
163
|
+
export function verifyAssertion(
|
|
164
|
+
input: { clientDataJSON: string; authenticatorData: string; signature: string },
|
|
165
|
+
stored: { alg: number; jwk: Record<string, string>; signCount: number },
|
|
166
|
+
exp: Expected,
|
|
167
|
+
) {
|
|
168
|
+
const clientData = unb64url(input.clientDataJSON);
|
|
169
|
+
checkClientData(clientData, 'webauthn.get', exp);
|
|
170
|
+
const authData = unb64url(input.authenticatorData);
|
|
171
|
+
const a = parseAuthData(authData);
|
|
172
|
+
if (!a.rpIdHash.equals(rpHash(exp.rpId))) throw new WebAuthnError('the passkey is for another site');
|
|
173
|
+
if (!(a.flags & FLAG_UP)) throw new WebAuthnError('the user was not present');
|
|
174
|
+
const data = Buffer.concat([authData, createHash('sha256').update(clientData).digest()]);
|
|
175
|
+
const sig = unb64url(input.signature);
|
|
176
|
+
const ok = stored.alg === -8 ? verify(null, data, keyOf(stored.jwk), sig) : verify('sha256', data, keyOf(stored.jwk), sig);
|
|
177
|
+
if (!ok) throw new WebAuthnError('the signature does not match');
|
|
178
|
+
// counters only go up; 0 and 0 means the authenticator keeps none (synced passkeys)
|
|
179
|
+
if ((a.signCount !== 0 || stored.signCount !== 0) && a.signCount <= stored.signCount) throw new WebAuthnError('the signature counter went back (a cloned key?)');
|
|
180
|
+
return { signCount: a.signCount, userVerified: !!(a.flags & FLAG_UV) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------------ the relying party
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The relying party a passkey belongs to and the origin the browser must report. With PUBLIC_URL set (a
|
|
187
|
+
* production server), that address; otherwise the address the browser used (development, tests).
|
|
188
|
+
* PGKILN_RP_ID may widen the RP ID to a parent domain (builder.example.com → example.com), which must be a
|
|
189
|
+
* suffix of the origin's host; passkeys made for one RP ID don't work for another, so changing the domain
|
|
190
|
+
* means adding them again (authenticator apps and recovery codes keep working).
|
|
191
|
+
*/
|
|
192
|
+
export function relyingParty(req: { protocol: string; host: string }): { rpId: string; origin: string } {
|
|
193
|
+
let origin = `${req.protocol}://${req.host}`;
|
|
194
|
+
if (process.env.PUBLIC_URL) {
|
|
195
|
+
try {
|
|
196
|
+
origin = new URL(process.env.PUBLIC_URL).origin;
|
|
197
|
+
} catch {
|
|
198
|
+
// a bad PUBLIC_URL: the browser's address
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const host = new URL(origin).hostname;
|
|
202
|
+
const wanted = (process.env.PGKILN_RP_ID ?? '').trim().toLowerCase();
|
|
203
|
+
const rpId = wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
|
|
204
|
+
return { rpId, origin };
|
|
205
|
+
}
|