slotlock 0.8.0 → 0.10.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/CHANGELOG.md +542 -0
- package/NOTICE +2 -0
- package/README.md +4 -2
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +1068 -68
- package/application-api.d.ts +28 -1
- package/application-api.js +789 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +566 -0
- package/booking-rules.d.ts +103 -5
- package/booking-rules.js +263 -15
- package/config.d.ts +50 -0
- package/config.js +107 -0
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard-scope.d.ts +7 -0
- package/dashboard-scope.js +21 -0
- package/dashboard.d.ts +152 -8
- package/dashboard.js +3868 -399
- package/ddl-identity.d.ts +25 -0
- package/ddl-identity.js +881 -0
- package/ddl-scim.d.ts +5 -0
- package/ddl-scim.js +740 -0
- package/ddl-sso.d.ts +15 -0
- package/ddl-sso.js +1033 -0
- package/ddl.d.ts +44 -9
- package/ddl.js +2872 -47
- package/domains.d.ts +117 -0
- package/domains.js +340 -0
- package/event-feed.d.ts +2 -2
- package/event-feed.js +24 -34
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +56 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +209 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +24 -5
- package/index.js +17 -3
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/invitation-email.d.ts +26 -0
- package/invitation-email.js +73 -0
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +26 -2
- package/maintenance.js +70 -2
- package/metrics.js +3 -0
- package/oidc.d.ts +135 -0
- package/oidc.js +452 -0
- package/openapi.json +20172 -9850
- package/package.json +2 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/scim-server.d.ts +19 -0
- package/scim-server.js +243 -0
- package/scim.d.ts +180 -0
- package/scim.js +416 -0
- package/self-host.d.ts +19 -6
- package/self-host.js +483 -28
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +637 -23
- package/store.js +2529 -147
- package/types.d.ts +230 -0
- package/types.js +68 -1
- package/workspace-domains.d.ts +146 -0
- package/workspace-domains.js +249 -0
- package/workspace-roles.d.ts +47 -0
- package/workspace-roles.js +304 -0
- package/workspace-scim.d.ts +89 -0
- package/workspace-scim.js +157 -0
- package/workspace-sso.d.ts +131 -0
- package/workspace-sso.js +180 -0
- package/workspaces.d.ts +240 -0
- package/workspaces.js +433 -0
package/oidc.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// OpenID Connect for teams' single sign-on (teams phase 5.3), and for Google's sign-in: an ID
|
|
2
|
+
// token checked as OpenID Connect Core 1.0 §3.1.3.7 says, its keys from the issuer's JWKS, and
|
|
3
|
+
// every URL a team's Owner can influence fetched as hostile (OWASP's SSRF guidance). WebCrypto
|
|
4
|
+
// only, so the same code runs on a Worker and on Node.
|
|
5
|
+
//
|
|
6
|
+
// What it refuses, and why:
|
|
7
|
+
// - `alg: none` and every HMAC algorithm: an HS256 token would make the client secret a signing key.
|
|
8
|
+
// - A key whose type, curve or `use` does not fit the token's algorithm, and RSA keys under 2048 bits.
|
|
9
|
+
// - An issuer that is not exactly the one configured, an audience without the client (or, with
|
|
10
|
+
// others, an `azp` that is not it), an expired token (60 seconds' skew), one not yet valid, one
|
|
11
|
+
// issued more than 5 minutes ago or more than 60 seconds ahead, and a nonce that is not the one
|
|
12
|
+
// sent.
|
|
13
|
+
/** The algorithms Slotlock verifies: RSA PKCS#1 v1.5, RSA-PSS and ECDSA P-256, all with SHA-256. */
|
|
14
|
+
export const SLOTLOCK_OIDC_ALGORITHMS = Object.freeze(['RS256', 'PS256', 'ES256']);
|
|
15
|
+
const SKEW_SECONDS = 60;
|
|
16
|
+
const ISSUED_WITHIN_SECONDS = 300;
|
|
17
|
+
const textEncoder = new TextEncoder();
|
|
18
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
19
|
+
/** RFC 4648 base64url, without padding, to bytes; null when it is not that. */
|
|
20
|
+
export function slotlockBase64UrlBytes(text) {
|
|
21
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1)
|
|
22
|
+
return null;
|
|
23
|
+
const padded = text.replaceAll('-', '+').replaceAll('_', '/') + '='.repeat((4 - (text.length % 4)) % 4);
|
|
24
|
+
try {
|
|
25
|
+
const binary = atob(padded);
|
|
26
|
+
const bytes = new Uint8Array(binary.length);
|
|
27
|
+
for (let index = 0; index < binary.length; index++)
|
|
28
|
+
bytes[index] = binary.charCodeAt(index);
|
|
29
|
+
return bytes;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Bytes as RFC 4648 base64url, without padding. */
|
|
36
|
+
export function slotlockBase64Url(bytes) {
|
|
37
|
+
let binary = '';
|
|
38
|
+
for (const byte of bytes)
|
|
39
|
+
binary += String.fromCharCode(byte);
|
|
40
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
41
|
+
}
|
|
42
|
+
function jsonOf(part) {
|
|
43
|
+
const bytes = slotlockBase64UrlBytes(part);
|
|
44
|
+
if (!bytes)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
const value = JSON.parse(textDecoder.decode(bytes));
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const isAlgorithm = (value) => typeof value === 'string' && SLOTLOCK_OIDC_ALGORITHMS.includes(value);
|
|
55
|
+
/**
|
|
56
|
+
* A published key as a verification key for `alg`, or null when it does not fit it: RSA (at least
|
|
57
|
+
* 2048 bits) for RS256 and PS256, EC P-256 for ES256; `use`, when given, `sig`; `alg`, when given,
|
|
58
|
+
* the token's.
|
|
59
|
+
*/
|
|
60
|
+
export async function importSlotlockJwk(jwk, alg) {
|
|
61
|
+
if (jwk.use !== undefined && jwk.use !== 'sig')
|
|
62
|
+
return null;
|
|
63
|
+
if (jwk.alg !== undefined && jwk.alg !== alg)
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
if (alg === 'ES256') {
|
|
67
|
+
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string')
|
|
68
|
+
return null;
|
|
69
|
+
return await crypto.subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y, ext: true }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
70
|
+
}
|
|
71
|
+
if (jwk.kty !== 'RSA' || typeof jwk.n !== 'string' || typeof jwk.e !== 'string')
|
|
72
|
+
return null;
|
|
73
|
+
const modulus = slotlockBase64UrlBytes(jwk.n);
|
|
74
|
+
// The modulus's significant bytes: 256 or more is 2048 bits or more.
|
|
75
|
+
const significant = modulus ? modulus.length - (modulus.findIndex((byte) => byte !== 0) === -1 ? modulus.length : modulus.findIndex((byte) => byte !== 0)) : 0;
|
|
76
|
+
if (significant < 256)
|
|
77
|
+
return null;
|
|
78
|
+
const algorithm = alg === 'RS256' ? { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } : { name: 'RSA-PSS', hash: 'SHA-256' };
|
|
79
|
+
return await crypto.subtle.importKey('jwk', { kty: 'RSA', n: jwk.n, e: jwk.e, ext: true }, algorithm, false, ['verify']);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function constantTimeEqual(a, b) {
|
|
86
|
+
const left = textEncoder.encode(a);
|
|
87
|
+
const right = textEncoder.encode(b);
|
|
88
|
+
let difference = left.length ^ right.length;
|
|
89
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
90
|
+
difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
|
|
91
|
+
}
|
|
92
|
+
return difference === 0;
|
|
93
|
+
}
|
|
94
|
+
/** Check an ID token: its signature first, then its claims, in OpenID Connect Core's order. */
|
|
95
|
+
export async function verifySlotlockIdToken(token, expected) {
|
|
96
|
+
const parts = token.split('.');
|
|
97
|
+
if (parts.length !== 3 || token.length > 16_384)
|
|
98
|
+
return { refusal: 'malformed' };
|
|
99
|
+
const [headerPart, payloadPart, signaturePart] = parts;
|
|
100
|
+
const header = jsonOf(headerPart);
|
|
101
|
+
const claims = jsonOf(payloadPart);
|
|
102
|
+
const signature = slotlockBase64UrlBytes(signaturePart);
|
|
103
|
+
if (!header || !claims || !signature)
|
|
104
|
+
return { refusal: 'malformed' };
|
|
105
|
+
// Only the algorithms this issuer was set up with, from the three Slotlock verifies: never none,
|
|
106
|
+
// never an HMAC.
|
|
107
|
+
if (!isAlgorithm(header.alg) || !expected.algorithms.includes(header.alg))
|
|
108
|
+
return { refusal: 'algorithm' };
|
|
109
|
+
if (header.crit !== undefined)
|
|
110
|
+
return { refusal: 'malformed' };
|
|
111
|
+
const alg = header.alg;
|
|
112
|
+
const key = await expected.keys(typeof header.kid === 'string' ? header.kid : null, alg);
|
|
113
|
+
if (!key)
|
|
114
|
+
return { refusal: 'key' };
|
|
115
|
+
const signed = textEncoder.encode(`${headerPart}.${payloadPart}`);
|
|
116
|
+
const algorithm = alg === 'RS256'
|
|
117
|
+
? { name: 'RSASSA-PKCS1-v1_5' }
|
|
118
|
+
: alg === 'PS256'
|
|
119
|
+
? { name: 'RSA-PSS', saltLength: 32 }
|
|
120
|
+
: { name: 'ECDSA', hash: 'SHA-256' };
|
|
121
|
+
if (alg === 'ES256' && signature.length !== 64)
|
|
122
|
+
return { refusal: 'signature' };
|
|
123
|
+
let valid = false;
|
|
124
|
+
try {
|
|
125
|
+
valid = await crypto.subtle.verify(algorithm, key, signature, signed);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
valid = false;
|
|
129
|
+
}
|
|
130
|
+
if (!valid)
|
|
131
|
+
return { refusal: 'signature' };
|
|
132
|
+
if (typeof claims.iss !== 'string' || !expected.issuers.includes(claims.iss))
|
|
133
|
+
return { refusal: 'issuer' };
|
|
134
|
+
const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
135
|
+
if (!audience.includes(expected.clientId))
|
|
136
|
+
return { refusal: 'audience' };
|
|
137
|
+
if ((audience.length > 1 || claims.azp !== undefined) && claims.azp !== expected.clientId)
|
|
138
|
+
return { refusal: 'audience' };
|
|
139
|
+
const seconds = expected.now / 1000;
|
|
140
|
+
if (typeof claims.exp !== 'number' || claims.exp + SKEW_SECONDS <= seconds)
|
|
141
|
+
return { refusal: 'expired' };
|
|
142
|
+
if (claims.nbf !== undefined && (typeof claims.nbf !== 'number' || claims.nbf - SKEW_SECONDS > seconds)) {
|
|
143
|
+
return { refusal: 'not_yet_valid' };
|
|
144
|
+
}
|
|
145
|
+
if (claims.iat !== undefined) {
|
|
146
|
+
if (typeof claims.iat !== 'number' || claims.iat > seconds + SKEW_SECONDS || claims.iat < seconds - ISSUED_WITHIN_SECONDS - SKEW_SECONDS) {
|
|
147
|
+
return { refusal: 'issued_at' };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (typeof claims.nonce !== 'string' || !constantTimeEqual(claims.nonce, expected.nonce))
|
|
151
|
+
return { refusal: 'nonce' };
|
|
152
|
+
if (typeof claims.sub !== 'string' || claims.sub.length === 0 || claims.sub.length > 255)
|
|
153
|
+
return { refusal: 'subject' };
|
|
154
|
+
return { claims };
|
|
155
|
+
}
|
|
156
|
+
/** Why a URL an Owner gave, or a document fetched from one, was refused. */
|
|
157
|
+
export class SlotlockOidcFetchError extends Error {
|
|
158
|
+
code;
|
|
159
|
+
constructor(code, message) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.code = code;
|
|
162
|
+
this.name = 'SlotlockOidcFetchError';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const IP_LITERAL = /^(\d{1,3}(\.\d{1,3}){3}|\[.*\])$/;
|
|
166
|
+
const PRIVATE_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa', '.lan', '.intranet', '.corp'];
|
|
167
|
+
/**
|
|
168
|
+
* Whether a URL is one Slotlock fetches for a team: https on the default port, a host name (never
|
|
169
|
+
* an IP literal), never localhost, a single label, or a private-use suffix; no credentials.
|
|
170
|
+
*/
|
|
171
|
+
export function slotlockOidcUrlAllowed(value) {
|
|
172
|
+
let url;
|
|
173
|
+
try {
|
|
174
|
+
url = new URL(value);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const host = url.hostname.toLowerCase();
|
|
180
|
+
if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '')
|
|
181
|
+
return null;
|
|
182
|
+
if (IP_LITERAL.test(host) || host === 'localhost' || !host.includes('.') || host.endsWith('.'))
|
|
183
|
+
return null;
|
|
184
|
+
if (PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix)))
|
|
185
|
+
return null;
|
|
186
|
+
return url;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* GET a JSON document from a URL an Owner influenced: refused unless slotlockOidcUrlAllowed; no
|
|
190
|
+
* redirect followed (any 3xx refuses); 5 seconds; 256 KB; a JSON content type. On Node, pass a
|
|
191
|
+
* fetch that refuses private addresses (webhooks.ts createSlotlockGuardedFetch).
|
|
192
|
+
*/
|
|
193
|
+
export async function slotlockFetchOidcJson(value, options = {}) {
|
|
194
|
+
const url = slotlockOidcUrlAllowed(value);
|
|
195
|
+
if (!url)
|
|
196
|
+
throw new SlotlockOidcFetchError('url', 'the URL must be https on a public host name');
|
|
197
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
198
|
+
const maxBytes = options.maxBytes ?? 262_144;
|
|
199
|
+
let response;
|
|
200
|
+
try {
|
|
201
|
+
response = await fetchImpl(url.href, {
|
|
202
|
+
headers: { accept: 'application/json' },
|
|
203
|
+
redirect: 'manual',
|
|
204
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 5_000),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new SlotlockOidcFetchError('network', 'the URL did not answer');
|
|
209
|
+
}
|
|
210
|
+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
|
|
211
|
+
throw new SlotlockOidcFetchError('redirect', 'the URL redirected');
|
|
212
|
+
}
|
|
213
|
+
if (!response.ok)
|
|
214
|
+
throw new SlotlockOidcFetchError('status', `the URL answered HTTP ${response.status}`);
|
|
215
|
+
const type = (response.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
|
|
216
|
+
if (type !== 'application/json' && !type.endsWith('+json'))
|
|
217
|
+
throw new SlotlockOidcFetchError('type', 'the URL did not answer JSON');
|
|
218
|
+
const body = await slotlockReadJsonBody(response, maxBytes);
|
|
219
|
+
const maxAge = Number(/max-age=(\d+)/.exec(response.headers.get('cache-control') ?? '')?.[1]);
|
|
220
|
+
return { body, maxAge: Number.isFinite(maxAge) ? maxAge : null };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A JSON object answered by a host an Owner chose, read to at most maxBytes (the declared length
|
|
224
|
+
* first, then as it streams), so a provider cannot make the server buffer an endless answer.
|
|
225
|
+
*/
|
|
226
|
+
export async function slotlockReadJsonBody(response, maxBytes) {
|
|
227
|
+
const declared = Number(response.headers.get('content-length'));
|
|
228
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
229
|
+
await response.body?.cancel().catch(() => undefined);
|
|
230
|
+
throw new SlotlockOidcFetchError('size', 'the answer is too large');
|
|
231
|
+
}
|
|
232
|
+
const reader = response.body?.getReader();
|
|
233
|
+
const chunks = [];
|
|
234
|
+
let size = 0;
|
|
235
|
+
if (reader) {
|
|
236
|
+
for (;;) {
|
|
237
|
+
const { done, value: chunk } = await reader.read();
|
|
238
|
+
if (done)
|
|
239
|
+
break;
|
|
240
|
+
size += chunk.byteLength;
|
|
241
|
+
if (size > maxBytes) {
|
|
242
|
+
await reader.cancel().catch(() => undefined);
|
|
243
|
+
throw new SlotlockOidcFetchError('size', 'the answer is too large');
|
|
244
|
+
}
|
|
245
|
+
chunks.push(chunk);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const bytes = new Uint8Array(size);
|
|
249
|
+
let offset = 0;
|
|
250
|
+
for (const chunk of chunks) {
|
|
251
|
+
bytes.set(chunk, offset);
|
|
252
|
+
offset += chunk.byteLength;
|
|
253
|
+
}
|
|
254
|
+
let body;
|
|
255
|
+
try {
|
|
256
|
+
body = JSON.parse(new TextDecoder().decode(bytes));
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
throw new SlotlockOidcFetchError('json', 'the answer is not JSON');
|
|
260
|
+
}
|
|
261
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
262
|
+
throw new SlotlockOidcFetchError('json', 'the answer is not a JSON object');
|
|
263
|
+
return body;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* An issuer's discovery document (OpenID Connect Discovery 1.0): its `issuer` must be exactly the
|
|
267
|
+
* one entered, its endpoints https on public host names, and it must sign with an algorithm Slotlock
|
|
268
|
+
* verifies.
|
|
269
|
+
*/
|
|
270
|
+
export async function discoverSlotlockOidc(issuer, options = {}) {
|
|
271
|
+
if (!slotlockOidcUrlAllowed(issuer))
|
|
272
|
+
throw new SlotlockOidcFetchError('url', 'the issuer must be https on a public host name');
|
|
273
|
+
const { body } = await slotlockFetchOidcJson(`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, options);
|
|
274
|
+
if (body.issuer !== issuer)
|
|
275
|
+
throw new SlotlockOidcFetchError('json', 'the discovery document names another issuer');
|
|
276
|
+
const endpoints = [body.authorization_endpoint, body.token_endpoint, body.jwks_uri];
|
|
277
|
+
if (!endpoints.every((endpoint) => typeof endpoint === 'string' && slotlockOidcUrlAllowed(endpoint))) {
|
|
278
|
+
throw new SlotlockOidcFetchError('url', 'the discovery document names an endpoint that is not https on a public host name');
|
|
279
|
+
}
|
|
280
|
+
const advertised = Array.isArray(body.id_token_signing_alg_values_supported) ? body.id_token_signing_alg_values_supported : ['RS256'];
|
|
281
|
+
const algorithms = SLOTLOCK_OIDC_ALGORITHMS.filter((alg) => advertised.includes(alg));
|
|
282
|
+
if (algorithms.length === 0)
|
|
283
|
+
throw new SlotlockOidcFetchError('json', 'the issuer signs with no algorithm Slotlock verifies (RS256, PS256, ES256)');
|
|
284
|
+
return {
|
|
285
|
+
issuer,
|
|
286
|
+
authorizationEndpoint: body.authorization_endpoint,
|
|
287
|
+
tokenEndpoint: body.token_endpoint,
|
|
288
|
+
jwksUri: body.jwks_uri,
|
|
289
|
+
algorithms,
|
|
290
|
+
issParameter: body.authorization_response_iss_parameter_supported === true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** Microsoft's consumer-account tenant (MSA): never a team's. */
|
|
294
|
+
const ENTRA_CONSUMER_TENANT = '9188040d-6c67-4c5b-b112-36a304b66dad';
|
|
295
|
+
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
296
|
+
/**
|
|
297
|
+
* An Entra ID tenant's v2.0 issuer, pinned to its GUID: never `common`, `organizations`,
|
|
298
|
+
* `consumers`, a domain name or the consumer tenant, so the multi-tenant `{tenantid}` issuer never
|
|
299
|
+
* exists in Slotlock. Null when the tenant is not one.
|
|
300
|
+
*/
|
|
301
|
+
export function slotlockEntraIssuer(tenant) {
|
|
302
|
+
const id = tenant.trim().toLowerCase();
|
|
303
|
+
if (!GUID.test(id) || id === ENTRA_CONSUMER_TENANT)
|
|
304
|
+
return null;
|
|
305
|
+
return `https://login.microsoftonline.com/${id}/v2.0`;
|
|
306
|
+
}
|
|
307
|
+
/** Google's issuer, in the two spellings it documents. */
|
|
308
|
+
export const SLOTLOCK_GOOGLE_ISSUERS = Object.freeze(['https://accounts.google.com', 'accounts.google.com']);
|
|
309
|
+
/**
|
|
310
|
+
* Issuers' keys, by JWKS URL: kept for the answer's max-age (5 minutes to 24 hours). A key id not
|
|
311
|
+
* among them is fetched again at most once a minute per URL, one fetch at a time; while the URL
|
|
312
|
+
* fails, a set fetched within 24 hours still serves the keys it holds. At most `maxUrls` URLs and 20
|
|
313
|
+
* keys each are kept, only the fields verifying needs: the URLs are Owners' to choose.
|
|
314
|
+
*/
|
|
315
|
+
export function createSlotlockJwksCache(options) {
|
|
316
|
+
const now = options.now ?? Date.now;
|
|
317
|
+
const maxUrls = options.maxUrls ?? 100;
|
|
318
|
+
const fetchJson = options.fetchJson ?? ((url) => slotlockFetchOidcJson(url, options.fetch ? { fetch: options.fetch } : {}));
|
|
319
|
+
const cache = new Map();
|
|
320
|
+
const imported = new Map();
|
|
321
|
+
/** Mark a URL as just used; past maxUrls, forget the least recently used and its imported keys. */
|
|
322
|
+
function touch(url, entry) {
|
|
323
|
+
cache.delete(url);
|
|
324
|
+
cache.set(url, entry);
|
|
325
|
+
while (cache.size > maxUrls) {
|
|
326
|
+
const oldest = cache.keys().next().value;
|
|
327
|
+
cache.delete(oldest);
|
|
328
|
+
for (const name of [...imported.keys()])
|
|
329
|
+
if (name.startsWith(`${oldest} `))
|
|
330
|
+
imported.delete(name);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async function refresh(url, entry) {
|
|
334
|
+
const current = entry ?? { keys: [], fetchedAt: 0, until: 0, lastFetch: 0, inflight: null };
|
|
335
|
+
if (!entry)
|
|
336
|
+
touch(url, current);
|
|
337
|
+
if (!current.inflight) {
|
|
338
|
+
current.lastFetch = now();
|
|
339
|
+
current.inflight = (async () => {
|
|
340
|
+
try {
|
|
341
|
+
const { body, maxAge } = await fetchJson(url);
|
|
342
|
+
// Only what verifying needs, of at most 20 keys: never the whole answer.
|
|
343
|
+
const fields = ['kty', 'kid', 'use', 'alg', 'n', 'e', 'crv', 'x', 'y'];
|
|
344
|
+
const keys = Array.isArray(body.keys)
|
|
345
|
+
? body.keys.filter((key) => typeof key === 'object' && key !== null).slice(0, 20).map((jwk) => {
|
|
346
|
+
const kept = {};
|
|
347
|
+
for (const field of fields)
|
|
348
|
+
if (typeof jwk[field] === 'string')
|
|
349
|
+
kept[field] = jwk[field];
|
|
350
|
+
return kept;
|
|
351
|
+
})
|
|
352
|
+
: [];
|
|
353
|
+
const lifetime = Math.min(Math.max((maxAge ?? 3_600) * 1000, 300_000), 86_400_000);
|
|
354
|
+
current.keys = keys;
|
|
355
|
+
current.fetchedAt = now();
|
|
356
|
+
current.until = now() + lifetime;
|
|
357
|
+
for (const name of [...imported.keys()])
|
|
358
|
+
if (name.startsWith(`${url} `))
|
|
359
|
+
imported.delete(name);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// A failed fetch keeps what was there; the caller decides whether it is too old.
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
current.inflight = null;
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
}
|
|
369
|
+
await current.inflight;
|
|
370
|
+
return cache.get(url);
|
|
371
|
+
}
|
|
372
|
+
async function keyFrom(url, entry, kid, alg) {
|
|
373
|
+
if (now() - entry.fetchedAt > 86_400_000)
|
|
374
|
+
return null;
|
|
375
|
+
const candidates = entry.keys.filter((jwk) => (kid === null ? true : jwk.kid === kid));
|
|
376
|
+
// Without a key id, only a set of one key names it.
|
|
377
|
+
if (kid === null && candidates.length !== 1)
|
|
378
|
+
return null;
|
|
379
|
+
const jwk = candidates[0];
|
|
380
|
+
if (!jwk)
|
|
381
|
+
return null;
|
|
382
|
+
const name = `${url} ${String(jwk.kid ?? '')} ${alg}`;
|
|
383
|
+
const cached = imported.get(name);
|
|
384
|
+
if (cached)
|
|
385
|
+
return cached;
|
|
386
|
+
const key = await importSlotlockJwk(jwk, alg);
|
|
387
|
+
if (key)
|
|
388
|
+
imported.set(name, key);
|
|
389
|
+
return key;
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
/** The key `kid` names at `url` for `alg`, or null. */
|
|
393
|
+
async key(url, kid, alg) {
|
|
394
|
+
let entry = cache.get(url);
|
|
395
|
+
if (entry)
|
|
396
|
+
touch(url, entry);
|
|
397
|
+
if (!entry || entry.until <= now())
|
|
398
|
+
entry = await refresh(url, entry);
|
|
399
|
+
if (!entry)
|
|
400
|
+
return null;
|
|
401
|
+
const found = await keyFrom(url, entry, kid, alg);
|
|
402
|
+
if (found)
|
|
403
|
+
return found;
|
|
404
|
+
// An unknown key id: the issuer may have rotated. Once a minute at most.
|
|
405
|
+
if (now() - entry.lastFetch < 60_000)
|
|
406
|
+
return null;
|
|
407
|
+
entry = await refresh(url, entry);
|
|
408
|
+
return entry ? keyFrom(url, entry, kid, alg) : null;
|
|
409
|
+
},
|
|
410
|
+
/** A lookup bound to one JWKS URL, for verifySlotlockIdToken. */
|
|
411
|
+
lookup(url) {
|
|
412
|
+
return (kid, alg) => this.key(url, kid, alg);
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Seals and opens a team's client secret: AES-256-GCM under a key derived (HKDF-SHA-256) from
|
|
418
|
+
* SLOTLOCK_SSO_SECRET, a random 12-byte IV each time, and the team and connection as associated
|
|
419
|
+
* data, so a sealed secret moved to another row does not open.
|
|
420
|
+
*/
|
|
421
|
+
export function createSlotlockSecretBox(secret) {
|
|
422
|
+
if (secret.length < 32)
|
|
423
|
+
throw new Error('Slotlock SSO: the secret must be at least 32 characters');
|
|
424
|
+
let derived = null;
|
|
425
|
+
const key = () => {
|
|
426
|
+
derived ??= crypto.subtle
|
|
427
|
+
.importKey('raw', textEncoder.encode(secret), 'HKDF', false, ['deriveKey'])
|
|
428
|
+
.then((material) => crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: textEncoder.encode('slotlock-sso-v1'), info: textEncoder.encode('client-secret') }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']));
|
|
429
|
+
return derived;
|
|
430
|
+
};
|
|
431
|
+
return {
|
|
432
|
+
async seal(plaintext, associated) {
|
|
433
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
434
|
+
const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: textEncoder.encode(associated) }, await key(), textEncoder.encode(plaintext));
|
|
435
|
+
return `v1.${slotlockBase64Url(iv)}.${slotlockBase64Url(new Uint8Array(sealed))}`;
|
|
436
|
+
},
|
|
437
|
+
async open(sealed, associated) {
|
|
438
|
+
const [version, ivText, bodyText, ...rest] = sealed.split('.');
|
|
439
|
+
const iv = slotlockBase64UrlBytes(ivText ?? '');
|
|
440
|
+
const body = slotlockBase64UrlBytes(bodyText ?? '');
|
|
441
|
+
if (version !== 'v1' || rest.length > 0 || !iv || iv.length !== 12 || !body)
|
|
442
|
+
return null;
|
|
443
|
+
try {
|
|
444
|
+
const opened = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, additionalData: textEncoder.encode(associated) }, await key(), body);
|
|
445
|
+
return new TextDecoder().decode(opened);
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|