@hyper.authio/server 0.1.1 → 0.8.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/README.md +60 -9
- package/dist/index.cjs +308 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +181 -18
- package/dist/index.d.ts +181 -18
- package/dist/index.js +306 -100
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -4,9 +4,45 @@ var client = require('@hyper.authio/client');
|
|
|
4
4
|
var jose = require('jose');
|
|
5
5
|
|
|
6
6
|
// src/user.ts
|
|
7
|
-
var
|
|
7
|
+
var accessTokenTypes = /* @__PURE__ */ new Set(["bearer", "api_key"]);
|
|
8
|
+
var AuthioUser = class _AuthioUser {
|
|
8
9
|
/** `sub`: the person's id, or the credential's for a machine caller. */
|
|
9
10
|
subject;
|
|
11
|
+
/**
|
|
12
|
+
* Who called: `user` (a person signed in), `client` (a client as itself), `consumer` (a machine consumer of a
|
|
13
|
+
* client) or `account_credential` (a credential of an Authio account).
|
|
14
|
+
*/
|
|
15
|
+
kind;
|
|
16
|
+
/**
|
|
17
|
+
* How a machine caller proved who it is (`client_secret`, `personal_access_token`, `api_key`,
|
|
18
|
+
* `consumer_api_key`); `undefined` for a person or a client.
|
|
19
|
+
*/
|
|
20
|
+
credentialForm;
|
|
21
|
+
/** `consumer_name`, for a consumer. */
|
|
22
|
+
consumerName;
|
|
23
|
+
/** `credential_name`, for an account credential. */
|
|
24
|
+
credentialName;
|
|
25
|
+
/** `owner_user_id`: the person who made the credential. */
|
|
26
|
+
ownerUserId;
|
|
27
|
+
/**
|
|
28
|
+
* `owner_username`: the person who made the credential. On an account credential Authio wrote it to
|
|
29
|
+
* `consumer_name` before, which is read when `owner_username` is absent.
|
|
30
|
+
*/
|
|
31
|
+
ownerUsername;
|
|
32
|
+
/** `credential_realm`: the one realm an account credential is limited to. */
|
|
33
|
+
credentialRealm;
|
|
34
|
+
/** `credential_client`: the one client an account credential is limited to. */
|
|
35
|
+
credentialClient;
|
|
36
|
+
/** Whether an administrator impersonates the subject. */
|
|
37
|
+
impersonated;
|
|
38
|
+
/** The administrator impersonating the subject. */
|
|
39
|
+
impersonator;
|
|
40
|
+
/** Whether a client acts for the subject through a delegation (`act` with `client_id`). */
|
|
41
|
+
delegated;
|
|
42
|
+
/** The clients acting for the subject, outermost first. */
|
|
43
|
+
actorChain;
|
|
44
|
+
/** `auth_time`, in seconds since the epoch. */
|
|
45
|
+
authTime;
|
|
10
46
|
/** The client the token was issued to (`azp`, else `client_id`). */
|
|
11
47
|
clientId;
|
|
12
48
|
/** `sid`. */
|
|
@@ -60,8 +96,11 @@ var AuthioUser = class {
|
|
|
60
96
|
* @param claims The token's payload, or the introspection answer's members.
|
|
61
97
|
* @param settings Where flat roles and groups are read from.
|
|
62
98
|
* @param token The access token, kept for forwarding and on-behalf-of.
|
|
99
|
+
* @param source Where the claims come from: a JWT (default) or an introspection answer. An introspection answer for
|
|
100
|
+
* a consumer that reports no `exp` and no `actor_type` is the consumer's API key (Authio reports no lifetime for a
|
|
101
|
+
* key), so its `credentialForm` is `consumer_api_key`.
|
|
63
102
|
*/
|
|
64
|
-
constructor(claims, settings = {}, token) {
|
|
103
|
+
constructor(claims, settings = {}, token, source = "token") {
|
|
65
104
|
const text = (name) => typeof claims[name] === "string" && claims[name] ? claims[name] : void 0;
|
|
66
105
|
this.claims = Object.freeze({ ...claims });
|
|
67
106
|
this.subject = text(client.AuthioClaimTypes.Subject) ?? "";
|
|
@@ -91,11 +130,29 @@ var AuthioUser = class {
|
|
|
91
130
|
this.preferredUsername = text(client.AuthioClaimTypes.PreferredUsername);
|
|
92
131
|
this.credentialType = text(client.AuthioClaimTypes.CredentialType);
|
|
93
132
|
this.credentialId = text(client.AuthioClaimTypes.CredentialId);
|
|
133
|
+
this.authTime = toSeconds(claims[client.AuthioClaimTypes.AuthTime]);
|
|
134
|
+
this.kind = this.credentialType === "client_consumer" ? "consumer" : this.credentialType === "account_secret" ? "account_credential" : this.authTime !== void 0 ? "user" : "client";
|
|
135
|
+
const machine = this.kind === "consumer" || this.kind === "account_credential";
|
|
136
|
+
const keyIntrospected = source === "introspection" && this.kind === "consumer" && claims[client.AuthioClaimTypes.Expiration] === void 0;
|
|
137
|
+
this.credentialForm = machine ? text(client.AuthioClaimTypes.ActorType) ?? (keyIntrospected ? "consumer_api_key" : "client_secret") : void 0;
|
|
138
|
+
this.consumerName = this.kind === "consumer" ? text(client.AuthioClaimTypes.ConsumerName) : void 0;
|
|
139
|
+
this.credentialName = text(client.AuthioClaimTypes.CredentialName);
|
|
140
|
+
this.ownerUserId = text(client.AuthioClaimTypes.OwnerUserId);
|
|
141
|
+
this.ownerUsername = text(client.AuthioClaimTypes.OwnerUsername) ?? (this.kind === "account_credential" ? text(client.AuthioClaimTypes.ConsumerName) : void 0);
|
|
142
|
+
this.credentialRealm = text(client.AuthioClaimTypes.CredentialRealm);
|
|
143
|
+
this.credentialClient = text(client.AuthioClaimTypes.CredentialClient);
|
|
144
|
+
this.delegated = this.actor?.clientId !== void 0;
|
|
145
|
+
this.actorChain = Object.freeze(chainOf(this.actor));
|
|
146
|
+
this.impersonated = !this.delegated && this.actor?.subject !== void 0;
|
|
147
|
+
this.impersonator = this.impersonated ? Object.freeze({ subject: this.actor.subject, name: this.actor.name }) : void 0;
|
|
94
148
|
Object.defineProperty(this, "token", { value: token, enumerable: false });
|
|
95
149
|
}
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Reads the caller from an introspection answer: `client_id` becomes `azp`, `username` becomes
|
|
152
|
+
* `preferred_username`, and `token_type` becomes `typ` unless it is `api_key` (a consumer's API key).
|
|
153
|
+
*/
|
|
154
|
+
static fromIntrospection(result, settings = {}, token) {
|
|
155
|
+
return new _AuthioUser(claimsOfIntrospection(result), settings, token, "introspection");
|
|
99
156
|
}
|
|
100
157
|
/** Whether the scope was granted. */
|
|
101
158
|
hasScope(scope) {
|
|
@@ -139,9 +196,20 @@ var AuthioUser = class {
|
|
|
139
196
|
/** Every member but the raw token. */
|
|
140
197
|
toJSON() {
|
|
141
198
|
const { flatRoles: _flatRoles, ...members } = this;
|
|
142
|
-
return { ...members, resourceAccess: this.resourceAccess.entries
|
|
199
|
+
return { ...members, resourceAccess: this.resourceAccess.entries };
|
|
143
200
|
}
|
|
144
201
|
};
|
|
202
|
+
function claimsOfIntrospection(result) {
|
|
203
|
+
const { active: _active, token_type: tokenType, username, ...members } = result;
|
|
204
|
+
const claims = { ...members };
|
|
205
|
+
if (typeof tokenType === "string" && claims.typ === void 0 && tokenType.toLowerCase() !== "api_key") claims.typ = tokenType;
|
|
206
|
+
if (typeof username === "string" && claims.preferred_username === void 0) claims.preferred_username = username;
|
|
207
|
+
if (typeof result.client_id === "string" && claims.azp === void 0) claims.azp = result.client_id;
|
|
208
|
+
return claims;
|
|
209
|
+
}
|
|
210
|
+
function isAccessTokenType(type) {
|
|
211
|
+
return accessTokenTypes.has(type.toLowerCase());
|
|
212
|
+
}
|
|
145
213
|
function valuesOf(value) {
|
|
146
214
|
if (value === void 0 || value === null) return [];
|
|
147
215
|
if (Array.isArray(value)) return value.flatMap((item) => item === null || item === void 0 ? [] : [toText(item)]);
|
|
@@ -170,11 +238,20 @@ function readActor(value, depth = 0) {
|
|
|
170
238
|
if (!actor || typeof actor !== "object" || Array.isArray(actor) || depth > 16) return void 0;
|
|
171
239
|
const record = actor;
|
|
172
240
|
return Object.freeze({
|
|
173
|
-
clientId: typeof record.client_id === "string" ? record.client_id : void 0,
|
|
174
|
-
subject: typeof record.sub === "string" ? record.sub : void 0,
|
|
241
|
+
clientId: typeof record.client_id === "string" && record.client_id ? record.client_id : void 0,
|
|
242
|
+
subject: typeof record.sub === "string" && record.sub ? record.sub : void 0,
|
|
243
|
+
name: typeof record.name === "string" && record.name ? record.name : void 0,
|
|
175
244
|
actor: readActor(record.act, depth + 1)
|
|
176
245
|
});
|
|
177
246
|
}
|
|
247
|
+
function chainOf(actor) {
|
|
248
|
+
const chain = [];
|
|
249
|
+
for (let current = actor; current; current = current.actor) if (current.clientId) chain.push(current.clientId);
|
|
250
|
+
return chain;
|
|
251
|
+
}
|
|
252
|
+
function toSeconds(value) {
|
|
253
|
+
return typeof value === "number" && Number.isFinite(value) ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : void 0;
|
|
254
|
+
}
|
|
178
255
|
function toDate(value) {
|
|
179
256
|
const seconds3 = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : void 0;
|
|
180
257
|
return seconds3 === void 0 ? void 0 : new Date(seconds3 * 1e3);
|
|
@@ -197,16 +274,19 @@ function distinctIgnoringCase(values) {
|
|
|
197
274
|
return true;
|
|
198
275
|
});
|
|
199
276
|
}
|
|
200
|
-
|
|
201
|
-
// src/rules.ts
|
|
202
277
|
function authorize(user, ...rules) {
|
|
203
278
|
const failures = rules.map((rule) => rule.check(user)).filter((failure) => failure !== void 0);
|
|
204
279
|
if (failures.length === 0) return void 0;
|
|
205
|
-
const
|
|
280
|
+
const stepUps = failures.filter((failure) => failure.error === "insufficient_user_authentication");
|
|
281
|
+
const bearer = stepUps[0] ?? failures.find((failure) => failure.error);
|
|
282
|
+
const acrValues = strongestAcr(stepUps.map((failure) => failure.acrValues));
|
|
283
|
+
const maxAges = stepUps.map((failure) => failure.maxAge).filter((value) => value !== void 0);
|
|
206
284
|
return {
|
|
207
285
|
message: failures.map((failure) => failure.message).join(" "),
|
|
208
286
|
...bearer?.error ? { error: bearer.error } : {},
|
|
209
|
-
...bearer?.scope ? { scope: bearer.scope } : {}
|
|
287
|
+
...bearer?.scope ? { scope: bearer.scope } : {},
|
|
288
|
+
...acrValues ? { acrValues } : {},
|
|
289
|
+
...maxAges.length > 0 ? { maxAge: Math.min(...maxAges) } : {}
|
|
210
290
|
};
|
|
211
291
|
}
|
|
212
292
|
function requireScope(scopes, match = "any") {
|
|
@@ -261,23 +341,60 @@ function requireOrganization(organization) {
|
|
|
261
341
|
}
|
|
262
342
|
};
|
|
263
343
|
}
|
|
264
|
-
function requireAuthenticationLevel(minimumLevel) {
|
|
344
|
+
function requireAuthenticationLevel(minimumLevel, options = {}) {
|
|
265
345
|
if (!Number.isInteger(minimumLevel) || minimumLevel < 0) throw new RangeError("The minimum level must be a non-negative integer.");
|
|
346
|
+
const recency = options.maxAge === void 0 ? void 0 : requireRecentAuthentication(options.maxAge, { clock: options.clock });
|
|
266
347
|
return {
|
|
267
|
-
description: `Authio authentication level >= ${minimumLevel}`,
|
|
268
|
-
check: (user) =>
|
|
348
|
+
description: `Authio authentication level >= ${minimumLevel}${recency ? `, ${recency.description}` : ""}`,
|
|
349
|
+
check: (user) => {
|
|
350
|
+
const levelMet = user.authenticationLevel !== void 0 && user.authenticationLevel >= minimumLevel;
|
|
351
|
+
const recent = recency?.check(user);
|
|
352
|
+
if (levelMet && !recent) return void 0;
|
|
353
|
+
return {
|
|
354
|
+
message: [levelMet ? void 0 : `The authentication level must be at least ${minimumLevel}.`, recent?.message].filter(Boolean).join(" "),
|
|
355
|
+
error: "insufficient_user_authentication",
|
|
356
|
+
acrValues: String(minimumLevel),
|
|
357
|
+
...options.maxAge !== void 0 ? { maxAge: options.maxAge } : {}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
269
360
|
};
|
|
270
361
|
}
|
|
271
|
-
|
|
362
|
+
var methodLevels = { mfa: "2", otp: "2", mca: "2", sms: "2", swk: "3", hwk: "3" };
|
|
363
|
+
function requireAuthenticationMethod(methods, match = "any", options = {}) {
|
|
272
364
|
const required = listOf(methods, "authentication method");
|
|
365
|
+
const levels = required.map((method) => methodLevels[method.toLowerCase()]).filter((level) => level !== void 0);
|
|
366
|
+
const acrValues = options.acrValues ?? (levels.length === 0 ? void 0 : match === "all" ? strongestAcr(levels) : weakestAcr(levels));
|
|
273
367
|
return {
|
|
274
368
|
description: `Authio authentication method (${match}): ${required.join(", ")}`,
|
|
275
369
|
check: (user) => {
|
|
276
370
|
const used = user.authenticationMethods.map((method) => method.toLowerCase());
|
|
277
|
-
return satisfies(required, match, (method) => used.includes(method.toLowerCase())) ? void 0 : {
|
|
371
|
+
return satisfies(required, match, (method) => used.includes(method.toLowerCase())) ? void 0 : {
|
|
372
|
+
message: `The user must authenticate with (${match}): ${required.join(", ")}.`,
|
|
373
|
+
error: "insufficient_user_authentication",
|
|
374
|
+
...acrValues ? { acrValues } : {}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function requireRecentAuthentication(maxAge, options = {}) {
|
|
380
|
+
if (!Number.isInteger(maxAge) || maxAge < 0) throw new RangeError("maxAge must be a non-negative whole number of seconds.");
|
|
381
|
+
const clock = options.clock ?? client.systemClock;
|
|
382
|
+
return {
|
|
383
|
+
description: `Authio sign-in at most ${maxAge} s old`,
|
|
384
|
+
check: (user) => user.authTime !== void 0 && clock.now() - user.authTime * 1e3 <= maxAge * 1e3 ? void 0 : {
|
|
385
|
+
message: user.authTime === void 0 ? `The token carries no sign-in time; a sign-in at most ${maxAge} seconds old is required.` : `The sign-in must be at most ${maxAge} seconds old.`,
|
|
386
|
+
error: "insufficient_user_authentication",
|
|
387
|
+
maxAge
|
|
278
388
|
}
|
|
279
389
|
};
|
|
280
390
|
}
|
|
391
|
+
function requireCallerKind(kinds) {
|
|
392
|
+
const required = listOf(kinds, "caller kind");
|
|
393
|
+
return {
|
|
394
|
+
description: `Authio caller kind: ${required.join(", ")}`,
|
|
395
|
+
check: (user) => required.includes(user.kind) ? void 0 : { message: `The caller is a ${user.kind}, not one of: ${required.join(", ")}.` }
|
|
396
|
+
};
|
|
397
|
+
}
|
|
281
398
|
function requireClient(clientIds) {
|
|
282
399
|
const required = listOf(clientIds, "client id");
|
|
283
400
|
return {
|
|
@@ -309,24 +426,49 @@ function listOf(values, name) {
|
|
|
309
426
|
function satisfies(required, match, holds) {
|
|
310
427
|
return match === "all" ? required.every(holds) : required.some(holds);
|
|
311
428
|
}
|
|
429
|
+
function strongestAcr(values) {
|
|
430
|
+
return rankAcr(values, (left, right) => right - left);
|
|
431
|
+
}
|
|
432
|
+
function weakestAcr(values) {
|
|
433
|
+
return rankAcr(values, (left, right) => left - right);
|
|
434
|
+
}
|
|
435
|
+
function rankAcr(values, order) {
|
|
436
|
+
const known = values.filter((value) => !!value);
|
|
437
|
+
const numeric = known.filter((value) => /^\d+$/.test(value)).sort((left, right) => order(Number(left), Number(right)));
|
|
438
|
+
return numeric[0] ?? known[0];
|
|
439
|
+
}
|
|
312
440
|
function describeSources(sources) {
|
|
313
441
|
return sources && sources.length > 0 ? ` from ${[...new Set(sources)].join(", ")}` : "";
|
|
314
442
|
}
|
|
315
|
-
var AuthioBearerError = class extends client.AuthioError {
|
|
443
|
+
var AuthioBearerError = class _AuthioBearerError extends client.AuthioError {
|
|
316
444
|
status = 401;
|
|
317
445
|
/** `invalid_token`, or `undefined` when the request had no token at all (RFC 6750 §3.1). */
|
|
318
446
|
error;
|
|
319
447
|
/** Why the token was refused, safe to send to the caller. */
|
|
320
448
|
description;
|
|
449
|
+
/** Why the token was refused, as a stable code; `undefined` when there was no token or Authio could not be asked. */
|
|
450
|
+
reason;
|
|
451
|
+
/** The API's protected resource metadata URL (RFC 9728), sent as `resource_metadata` when configured. */
|
|
452
|
+
resourceMetadata;
|
|
321
453
|
constructor(description, options = {}) {
|
|
322
454
|
super(description ?? "A valid Authio access token is required.", { cause: options.cause });
|
|
323
455
|
this.error = "error" in options ? options.error : "invalid_token";
|
|
324
456
|
this.description = description === void 0 ? void 0 : toHeaderText(description);
|
|
457
|
+
this.reason = options.reason;
|
|
458
|
+
this.resourceMetadata = options.resourceMetadata;
|
|
459
|
+
}
|
|
460
|
+
/** The same refusal, pointing at the API's protected resource metadata. */
|
|
461
|
+
withResourceMetadata(resourceMetadata) {
|
|
462
|
+
if (!resourceMetadata || resourceMetadata === this.resourceMetadata) return this;
|
|
463
|
+
return new _AuthioBearerError(this.description, { error: this.error, cause: this.cause, reason: this.reason, resourceMetadata });
|
|
325
464
|
}
|
|
326
465
|
/** The `WWW-Authenticate` value. */
|
|
327
466
|
get wwwAuthenticate() {
|
|
328
|
-
|
|
329
|
-
|
|
467
|
+
const parameters = [];
|
|
468
|
+
if (this.error) parameters.push(`error="${this.error}"`);
|
|
469
|
+
if (this.error && this.description) parameters.push(`error_description="${this.description}"`);
|
|
470
|
+
if (this.resourceMetadata) parameters.push(`resource_metadata="${escapeQuoted(this.resourceMetadata)}"`);
|
|
471
|
+
return parameters.length === 0 ? "Bearer" : `Bearer ${parameters.join(", ")}`;
|
|
330
472
|
}
|
|
331
473
|
};
|
|
332
474
|
var AuthioForbiddenError = class extends client.AuthioError {
|
|
@@ -343,8 +485,11 @@ var AuthioForbiddenError = class extends client.AuthioError {
|
|
|
343
485
|
};
|
|
344
486
|
function toForbiddenChallenge(failure) {
|
|
345
487
|
if (!failure.error) return void 0;
|
|
346
|
-
const
|
|
347
|
-
|
|
488
|
+
const parameters = [`error="${failure.error}"`, `error_description="${escapeQuoted(toHeaderText(failure.message))}"`];
|
|
489
|
+
if (failure.scope) parameters.push(`scope="${escapeQuoted(failure.scope)}"`);
|
|
490
|
+
if (failure.acrValues) parameters.push(`acr_values="${escapeQuoted(failure.acrValues)}"`);
|
|
491
|
+
if (failure.maxAge !== void 0) parameters.push(`max_age=${failure.maxAge}`);
|
|
492
|
+
return `Bearer ${parameters.join(", ")}`;
|
|
348
493
|
}
|
|
349
494
|
function toProblemDetails(error) {
|
|
350
495
|
if (error instanceof AuthioBearerError)
|
|
@@ -360,7 +505,9 @@ function toProblemDetails(error) {
|
|
|
360
505
|
status: 403,
|
|
361
506
|
detail: error.failure.message || "The caller is not allowed to access this resource.",
|
|
362
507
|
...error.failure.error ? { error: error.failure.error } : {},
|
|
363
|
-
...error.failure.scope ? { scope: error.failure.scope } : {}
|
|
508
|
+
...error.failure.scope ? { scope: error.failure.scope } : {},
|
|
509
|
+
...error.failure.acrValues ? { acr_values: error.failure.acrValues } : {},
|
|
510
|
+
...error.failure.maxAge !== void 0 ? { max_age: error.failure.maxAge } : {}
|
|
364
511
|
};
|
|
365
512
|
}
|
|
366
513
|
function toHeaderText(text) {
|
|
@@ -369,17 +516,54 @@ function toHeaderText(text) {
|
|
|
369
516
|
function escapeQuoted(value) {
|
|
370
517
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
371
518
|
}
|
|
519
|
+
function protectedResourceMetadataUrl(resource) {
|
|
520
|
+
const url = parseResource(resource);
|
|
521
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
522
|
+
return `${url.origin}${client.AuthioDefaults.ProtectedResourceMetadataPath}${path}`;
|
|
523
|
+
}
|
|
524
|
+
function protectedResourceMetadataPath(resource) {
|
|
525
|
+
return new URL(protectedResourceMetadataUrl(resource)).pathname;
|
|
526
|
+
}
|
|
527
|
+
function createProtectedResourceMetadata(options) {
|
|
528
|
+
parseResource(options.resource);
|
|
529
|
+
const document = {
|
|
530
|
+
...options.extra,
|
|
531
|
+
resource: options.resource,
|
|
532
|
+
authorization_servers: [client.resolveIssuer(options)],
|
|
533
|
+
bearer_methods_supported: ["header"]
|
|
534
|
+
};
|
|
535
|
+
if (options.scopesSupported && options.scopesSupported.length > 0) document.scopes_supported = [...options.scopesSupported];
|
|
536
|
+
if (options.resourceName) document.resource_name = options.resourceName;
|
|
537
|
+
if (options.resourceDocumentation) document.resource_documentation = options.resourceDocumentation;
|
|
538
|
+
return document;
|
|
539
|
+
}
|
|
540
|
+
function parseResource(resource) {
|
|
541
|
+
let url;
|
|
542
|
+
try {
|
|
543
|
+
url = new URL(resource);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
throw new client.AuthioConfigurationError(`The resource '${resource}' is not an absolute URL.`, { cause: error });
|
|
546
|
+
}
|
|
547
|
+
if (url.hash) throw new client.AuthioConfigurationError(`The resource '${resource}' must not carry a fragment (RFC 8707).`);
|
|
548
|
+
return url;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/verifier.ts
|
|
372
552
|
var AuthioBearerErrorDescriptions = {
|
|
373
553
|
Inactive: "Authio reports the token as not active to this API's client. It was revoked or has expired or its session ended; or this API's client is not the introspection client of the resource server the token is for.",
|
|
374
554
|
Unconfirmed: "Authio could not be reached to confirm that the token is active.",
|
|
375
555
|
Empty: "The token is empty.",
|
|
376
|
-
Encrypted: "The token is encrypted; validate it with introspection ('introspectionOnly').",
|
|
377
|
-
SigningKeysUnavailable: "Authio could not be reached to read its signing keys."
|
|
556
|
+
Encrypted: "The token is encrypted; set clientEncryptionKey to decrypt it, or validate it with introspection ('introspectionOnly').",
|
|
557
|
+
SigningKeysUnavailable: "Authio could not be reached to read its signing keys.",
|
|
558
|
+
Symmetric: "The token is signed with the client's secret (HS*): set accessTokenSignedResponseAlg and the client's clientSecret to validate it locally, or validate it with introspection ('introspectionOnly')."
|
|
378
559
|
};
|
|
379
560
|
var maxCachedAnswers = 1e4;
|
|
380
561
|
function createAuthioVerifier(options) {
|
|
381
|
-
const
|
|
382
|
-
|
|
562
|
+
const resource = options.resource?.trim() || void 0;
|
|
563
|
+
const given = typeof options.audiences === "string" ? [options.audiences] : [...options.audiences ?? []];
|
|
564
|
+
const audiences = [...new Set([...given, ...resource ? [resource] : []].filter((value) => value?.trim()))];
|
|
565
|
+
if (audiences.length === 0)
|
|
566
|
+
throw new client.AuthioConfigurationError("The Authio verifier needs at least one audience: the API's resource identifier or client id.");
|
|
383
567
|
const connection = client.resolveConnection(options);
|
|
384
568
|
const discovery = client.createAuthioDiscoveryClient(connection);
|
|
385
569
|
const introspection = options.introspection ?? "none";
|
|
@@ -388,13 +572,24 @@ function createAuthioVerifier(options) {
|
|
|
388
572
|
throw new client.AuthioConfigurationError(
|
|
389
573
|
`Authio introspection ('${introspection}') needs the API's confidential client: pass options.client with its clientId and clientSecret or private key.`
|
|
390
574
|
);
|
|
575
|
+
const resourceMetadataUrl = resolveResourceMetadata(options.resourceMetadata, resource);
|
|
391
576
|
const cacheDurationMs = client.seconds(options.introspectionCacheDuration, 30, "introspectionCacheDuration");
|
|
392
577
|
const clockSkew = client.seconds(options.clockSkew, 30, "clockSkew") / 1e3;
|
|
393
|
-
const algorithms = options.
|
|
578
|
+
const algorithms = resolveAccessTokenAlgorithms(options.accessTokenSignedResponseAlg);
|
|
579
|
+
const symmetric = algorithms?.filter((algorithm) => client.AuthioHmacAlgorithms.includes(algorithm)) ?? [];
|
|
580
|
+
const clientSecret = symmetric.length > 0 ? resolveClientSecret(options, symmetric) : void 0;
|
|
394
581
|
const rejectNonAccessTokens = options.rejectNonAccessTokens ?? true;
|
|
395
582
|
const claimSettings = { roleClaims: options.roleClaims, groupClaims: options.groupClaims };
|
|
396
583
|
const cache = /* @__PURE__ */ new Map();
|
|
397
|
-
|
|
584
|
+
const signingKeys = client.createAuthioSigningKeys(connection, discovery);
|
|
585
|
+
const encryptionKey = options.clientEncryptionKey ?? (options.clientEncryptionKeyPath ? client.encryptionKeyFromFile(options.clientEncryptionKeyPath) : void 0);
|
|
586
|
+
const decrypt = encryptionKey === void 0 ? void 0 : client.createAuthioTokenDecrypter(encryptionKey, {
|
|
587
|
+
setting: options.clientEncryptionKey === void 0 ? "clientEncryptionKeyPath" : "clientEncryptionKey",
|
|
588
|
+
allowLegacyEncryptionAlgorithms: options.allowLegacyEncryptionAlgorithms,
|
|
589
|
+
encryptionAlgorithms: options.encryptionAlgorithms,
|
|
590
|
+
encryptionMethods: options.encryptionMethods
|
|
591
|
+
});
|
|
592
|
+
const requiredScopes = Object.freeze([...new Set((options.requiredScopes ?? []).filter((scope) => scope?.trim()))]);
|
|
398
593
|
const introspect = async (token, key, signal) => {
|
|
399
594
|
const now = connection.clock.now();
|
|
400
595
|
const cached = cache.get(key);
|
|
@@ -409,37 +604,41 @@ function createAuthioVerifier(options) {
|
|
|
409
604
|
}
|
|
410
605
|
return result;
|
|
411
606
|
};
|
|
412
|
-
const signingKeys = async (signal) => {
|
|
413
|
-
const document = await discovery.get(signal);
|
|
414
|
-
const uri = document.jwks_uri;
|
|
415
|
-
if (!uri) throw new client.AuthioError("The Authio discovery document has no jwks_uri.");
|
|
416
|
-
if (!keys || keys.uri !== uri) keys = { uri, set: jose.createRemoteJWKSet(new URL(uri), { [jose.customFetch]: connection.fetch, timeoutDuration: connection.httpTimeoutMs }) };
|
|
417
|
-
return keys.set;
|
|
418
|
-
};
|
|
419
607
|
const verifyJwt = async (token, signal) => {
|
|
420
|
-
|
|
608
|
+
let signed = token;
|
|
609
|
+
if (client.isAuthioTokenEncrypted(token)) {
|
|
610
|
+
if (!decrypt) throw new AuthioBearerError(AuthioBearerErrorDescriptions.Encrypted, { reason: "decryption_failed" });
|
|
611
|
+
try {
|
|
612
|
+
signed = await decrypt(token);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (error instanceof client.AuthioTokenValidationError) throw new AuthioBearerError(error.message, { cause: error, reason: error.reason });
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
421
618
|
let keySet;
|
|
422
619
|
try {
|
|
423
|
-
keySet = await signingKeys(signal);
|
|
620
|
+
keySet = client.withClientSecret(await signingKeys(signal), clientSecret);
|
|
424
621
|
} catch (error) {
|
|
425
|
-
throw new AuthioBearerError(AuthioBearerErrorDescriptions.SigningKeysUnavailable, { cause: error });
|
|
622
|
+
throw new AuthioBearerError(AuthioBearerErrorDescriptions.SigningKeysUnavailable, { cause: error, reason: "signing_keys_unavailable" });
|
|
426
623
|
}
|
|
427
624
|
let payload;
|
|
428
625
|
try {
|
|
429
|
-
({ payload } = await
|
|
626
|
+
({ payload } = await client.verifyAuthioJwt(signed, keySet, {
|
|
430
627
|
issuer: connection.issuer,
|
|
431
|
-
|
|
628
|
+
audiences,
|
|
432
629
|
algorithms,
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
currentDate: new Date(connection.clock.now())
|
|
630
|
+
clockSkew,
|
|
631
|
+
now: connection.clock.now()
|
|
436
632
|
}));
|
|
437
633
|
} catch (error) {
|
|
438
|
-
|
|
634
|
+
if (error instanceof client.AuthioTokenValidationError && error.reason === "invalid_algorithm" && symmetric.length === 0 && isSymmetricToken(signed))
|
|
635
|
+
throw new AuthioBearerError(AuthioBearerErrorDescriptions.Symmetric, { cause: error, reason: error.reason });
|
|
636
|
+
if (error instanceof client.AuthioTokenValidationError) throw new AuthioBearerError(error.message, { cause: error, reason: error.reason });
|
|
637
|
+
throw error;
|
|
439
638
|
}
|
|
440
639
|
const type = typeof payload.typ === "string" ? payload.typ : void 0;
|
|
441
640
|
if (rejectNonAccessTokens && type !== void 0 && type.toLowerCase() !== "bearer")
|
|
442
|
-
throw new AuthioBearerError(`Only access tokens are accepted; this token's typ is '${type}'
|
|
641
|
+
throw new AuthioBearerError(`Only access tokens are accepted; this token's typ is '${type}'.`, { reason: "not_access_token" });
|
|
443
642
|
if (introspection === "activeCheck") await confirmActive(token, payload, signal);
|
|
444
643
|
return new AuthioUser(payload, claimSettings, token);
|
|
445
644
|
};
|
|
@@ -450,9 +649,9 @@ function createAuthioVerifier(options) {
|
|
|
450
649
|
result = await introspect(token, key, signal);
|
|
451
650
|
} catch (error) {
|
|
452
651
|
if (signal?.aborted) throw error;
|
|
453
|
-
throw new AuthioBearerError(AuthioBearerErrorDescriptions.Unconfirmed, { cause: error });
|
|
652
|
+
throw new AuthioBearerError(AuthioBearerErrorDescriptions.Unconfirmed, { cause: error, reason: "unconfirmed" });
|
|
454
653
|
}
|
|
455
|
-
if (!result) throw new AuthioBearerError(AuthioBearerErrorDescriptions.Inactive);
|
|
654
|
+
if (!result) throw new AuthioBearerError(AuthioBearerErrorDescriptions.Inactive, { reason: "inactive" });
|
|
456
655
|
};
|
|
457
656
|
const verifyByIntrospection = async (token, signal) => {
|
|
458
657
|
let result;
|
|
@@ -460,38 +659,42 @@ function createAuthioVerifier(options) {
|
|
|
460
659
|
result = await introspect(token, await keyForToken(token), signal);
|
|
461
660
|
} catch (error) {
|
|
462
661
|
if (signal?.aborted) throw error;
|
|
463
|
-
throw new AuthioBearerError(AuthioBearerErrorDescriptions.Unconfirmed, { cause: error });
|
|
662
|
+
throw new AuthioBearerError(AuthioBearerErrorDescriptions.Unconfirmed, { cause: error, reason: "unconfirmed" });
|
|
464
663
|
}
|
|
465
|
-
if (!result) throw new AuthioBearerError(AuthioBearerErrorDescriptions.Inactive);
|
|
466
|
-
if (result.iss && result.iss !== connection.issuer) throw new AuthioBearerError(`The issuer '${result.iss}' is invalid
|
|
664
|
+
if (!result) throw new AuthioBearerError(AuthioBearerErrorDescriptions.Inactive, { reason: "inactive" });
|
|
665
|
+
if (result.iss && result.iss !== connection.issuer) throw new AuthioBearerError(`The issuer '${result.iss}' is invalid`, { reason: "invalid_issuer" });
|
|
467
666
|
const tokenAudiences = [result.aud ?? []].flat();
|
|
468
|
-
if (!tokenAudiences.some((audience) => audiences.includes(audience)))
|
|
667
|
+
if (!tokenAudiences.some((audience) => audiences.includes(audience)))
|
|
668
|
+
throw new AuthioBearerError(describeAudience(tokenAudiences, audiences), { reason: "invalid_audience" });
|
|
469
669
|
if (typeof result.exp === "number" && (result.exp + clockSkew) * 1e3 <= connection.clock.now())
|
|
470
|
-
throw new AuthioBearerError(`The token expired at '${new Date(result.exp * 1e3).toISOString()}'
|
|
471
|
-
const
|
|
472
|
-
const claims = { ...members };
|
|
473
|
-
if (typeof tokenType === "string" && claims.typ === void 0) claims.typ = tokenType;
|
|
474
|
-
if (typeof username === "string" && claims.preferred_username === void 0) claims.preferred_username = username;
|
|
475
|
-
if (typeof result.client_id === "string" && claims.azp === void 0) claims.azp = result.client_id;
|
|
670
|
+
throw new AuthioBearerError(`The token expired at '${new Date(result.exp * 1e3).toISOString()}'`, { reason: "expired" });
|
|
671
|
+
const claims = claimsOfIntrospection(result);
|
|
476
672
|
const type = typeof claims.typ === "string" ? claims.typ : void 0;
|
|
477
|
-
if (rejectNonAccessTokens && type !== void 0 && type
|
|
478
|
-
throw new AuthioBearerError(`Only access tokens are accepted; this token's typ is '${type}'
|
|
479
|
-
return new AuthioUser(claims, claimSettings, token);
|
|
673
|
+
if (rejectNonAccessTokens && type !== void 0 && !isAccessTokenType(type))
|
|
674
|
+
throw new AuthioBearerError(`Only access tokens are accepted; this token's typ is '${type}'.`, { reason: "not_access_token" });
|
|
675
|
+
return new AuthioUser(claims, claimSettings, token, "introspection");
|
|
480
676
|
};
|
|
481
677
|
const verify = async (token, signal) => {
|
|
482
|
-
|
|
483
|
-
|
|
678
|
+
try {
|
|
679
|
+
if (typeof token !== "string" || !token.trim()) throw new AuthioBearerError(AuthioBearerErrorDescriptions.Empty, { reason: "malformed" });
|
|
680
|
+
return introspection === "introspectionOnly" ? await verifyByIntrospection(token.trim(), signal) : await verifyJwt(token.trim(), signal);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
throw error instanceof AuthioBearerError ? error.withResourceMetadata(resourceMetadataUrl) : error;
|
|
683
|
+
}
|
|
484
684
|
};
|
|
485
685
|
return {
|
|
486
686
|
connection,
|
|
487
687
|
discovery,
|
|
488
688
|
audiences,
|
|
689
|
+
resource,
|
|
690
|
+
resourceMetadataUrl,
|
|
691
|
+
requiredScopes,
|
|
489
692
|
introspection,
|
|
490
693
|
client: client$1,
|
|
491
694
|
verify,
|
|
492
695
|
async verifyAuthorizationHeader(header, signal) {
|
|
493
696
|
const token = readBearerToken(header);
|
|
494
|
-
if (token === void 0) throw new AuthioBearerError(void 0, { error: void 0 });
|
|
697
|
+
if (token === void 0) throw new AuthioBearerError(void 0, { error: void 0, resourceMetadata: resourceMetadataUrl });
|
|
495
698
|
return verify(token, signal);
|
|
496
699
|
}
|
|
497
700
|
};
|
|
@@ -504,6 +707,42 @@ async function keyForToken(token) {
|
|
|
504
707
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)));
|
|
505
708
|
return `token:${[...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
506
709
|
}
|
|
710
|
+
function resolveResourceMetadata(setting, resource) {
|
|
711
|
+
if (!setting) return void 0;
|
|
712
|
+
if (typeof setting === "string") return setting;
|
|
713
|
+
if (!resource)
|
|
714
|
+
throw new client.AuthioConfigurationError("resourceMetadata: true needs the API's resource identifier: set options.resource, or pass the metadata URL.");
|
|
715
|
+
return protectedResourceMetadataUrl(resource);
|
|
716
|
+
}
|
|
717
|
+
function resolveAccessTokenAlgorithms(setting) {
|
|
718
|
+
const algorithms = [...new Set((typeof setting === "string" ? [setting] : [...setting ?? []]).map((algorithm) => algorithm.trim()).filter(Boolean))];
|
|
719
|
+
const known = [...client.AuthioAsymmetricAlgorithms, ...client.AuthioHmacAlgorithms];
|
|
720
|
+
for (const algorithm of algorithms)
|
|
721
|
+
if (!known.includes(algorithm))
|
|
722
|
+
throw new client.AuthioConfigurationError(`The Authio accessTokenSignedResponseAlg '${algorithm}' is not one of ${known.join(", ")}.`);
|
|
723
|
+
return algorithms.length > 0 ? algorithms : void 0;
|
|
724
|
+
}
|
|
725
|
+
function resolveClientSecret(options, symmetric) {
|
|
726
|
+
const given = options.client;
|
|
727
|
+
const source = given && !isClient(given) ? given.clientSecret : void 0;
|
|
728
|
+
if (!source)
|
|
729
|
+
throw new client.AuthioConfigurationError(
|
|
730
|
+
`accessTokenSignedResponseAlg ${symmetric.join(", ")} is verified with the client secret: pass options.client as { clientId, clientSecret } (an AuthioClient does not expose its secret).`
|
|
731
|
+
);
|
|
732
|
+
if (typeof source === "string") for (const algorithm of symmetric) client.requireHmacSecretLength(algorithm, new TextEncoder().encode(source).length);
|
|
733
|
+
return async () => {
|
|
734
|
+
const value = typeof source === "function" ? await source() : source;
|
|
735
|
+
if (typeof value !== "string" || !value) throw new client.AuthioConfigurationError("The Authio clientSecret function returned no secret.");
|
|
736
|
+
return value;
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function isSymmetricToken(token) {
|
|
740
|
+
try {
|
|
741
|
+
return client.AuthioHmacAlgorithms.includes(String(jose.decodeProtectedHeader(token).alg));
|
|
742
|
+
} catch {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
507
746
|
function resolveClient(options) {
|
|
508
747
|
const given = options.client;
|
|
509
748
|
if (!given) return void 0;
|
|
@@ -523,44 +762,11 @@ function resolveClient(options) {
|
|
|
523
762
|
function isClient(value) {
|
|
524
763
|
return typeof value.introspect === "function" && typeof value.tokens === "object";
|
|
525
764
|
}
|
|
526
|
-
function describeJwtFailure(error, audiences, token) {
|
|
527
|
-
if (error instanceof jose.errors.JWTExpired) {
|
|
528
|
-
const exp = error.payload.exp;
|
|
529
|
-
return typeof exp === "number" ? `The token expired at '${new Date(exp * 1e3).toISOString()}'` : "The token has expired";
|
|
530
|
-
}
|
|
531
|
-
if (error instanceof jose.errors.JWTClaimValidationFailed) {
|
|
532
|
-
switch (error.claim) {
|
|
533
|
-
case "aud":
|
|
534
|
-
return describeAudience([error.payload.aud ?? []].flat(), audiences);
|
|
535
|
-
case "iss":
|
|
536
|
-
return `The issuer '${error.payload.iss ?? ""}' is invalid`;
|
|
537
|
-
case "nbf":
|
|
538
|
-
return typeof error.payload.nbf === "number" ? `The token is not valid before '${new Date(error.payload.nbf * 1e3).toISOString()}'` : "The token is not valid yet";
|
|
539
|
-
case "exp":
|
|
540
|
-
return "The token has no expiration";
|
|
541
|
-
default:
|
|
542
|
-
return `The token's ${error.claim} claim is invalid`;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
if (error instanceof jose.errors.JWSSignatureVerificationFailed) return "The signature is invalid";
|
|
546
|
-
if (error instanceof jose.errors.JWKSNoMatchingKey) return "The signature key was not found";
|
|
547
|
-
if (error instanceof jose.errors.JOSEAlgNotAllowed) return `The algorithm '${algorithmOf(token)}' is not available`;
|
|
548
|
-
if (error instanceof jose.errors.JWKSTimeout || error instanceof jose.errors.JWKSInvalid) return AuthioBearerErrorDescriptions.SigningKeysUnavailable;
|
|
549
|
-
if (error instanceof jose.errors.JWTInvalid || error instanceof jose.errors.JWSInvalid) return "The token is malformed";
|
|
550
|
-
return void 0;
|
|
551
|
-
}
|
|
552
765
|
function describeAudience(tokenAudiences, audiences) {
|
|
553
766
|
const known = tokenAudiences.length === 0 ? "" : ` ${tokenAudiences.map((audience) => `'${audience}'`).join(" and ")}`;
|
|
554
767
|
const accepted = audiences.map((audience) => `'${audience}'`).join(" or ");
|
|
555
768
|
return audiences.length === 1 ? `The token is not addressed to this API: its audience${known} is not ${accepted}. Request the token for that resource (RFC 8707).` : `The token is not addressed to this API: its audience${known} is not ${accepted}. Request the token for one of those resources (RFC 8707).`;
|
|
556
769
|
}
|
|
557
|
-
function algorithmOf(token) {
|
|
558
|
-
try {
|
|
559
|
-
return String(jose.decodeProtectedHeader(token).alg ?? "");
|
|
560
|
-
} catch {
|
|
561
|
-
return "";
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
770
|
function createAuthioWebhooks(options) {
|
|
565
771
|
const secrets = [...new Set([options.secret, ...options.secrets ?? []].filter((secret) => !!secret?.trim()))];
|
|
566
772
|
if (secrets.length === 0) throw new client.AuthioConfigurationError("Authio webhooks need the webhook's secret.");
|
|
@@ -723,14 +929,19 @@ exports.createAuthioWebhooks = createAuthioWebhooks;
|
|
|
723
929
|
exports.createForwardingFetch = createForwardingFetch;
|
|
724
930
|
exports.createMemoryDeduplicationStore = createMemoryDeduplicationStore;
|
|
725
931
|
exports.createOnBehalfOf = createOnBehalfOf;
|
|
932
|
+
exports.createProtectedResourceMetadata = createProtectedResourceMetadata;
|
|
933
|
+
exports.protectedResourceMetadataPath = protectedResourceMetadataPath;
|
|
934
|
+
exports.protectedResourceMetadataUrl = protectedResourceMetadataUrl;
|
|
726
935
|
exports.readBearerToken = readBearerToken;
|
|
727
936
|
exports.requireAuthenticationLevel = requireAuthenticationLevel;
|
|
728
937
|
exports.requireAuthenticationMethod = requireAuthenticationMethod;
|
|
938
|
+
exports.requireCallerKind = requireCallerKind;
|
|
729
939
|
exports.requireClaim = requireClaim;
|
|
730
940
|
exports.requireClient = requireClient;
|
|
731
941
|
exports.requireGroup = requireGroup;
|
|
732
942
|
exports.requireOrganization = requireOrganization;
|
|
733
943
|
exports.requirePermission = requirePermission;
|
|
944
|
+
exports.requireRecentAuthentication = requireRecentAuthentication;
|
|
734
945
|
exports.requireRole = requireRole;
|
|
735
946
|
exports.requireScope = requireScope;
|
|
736
947
|
exports.toForbiddenChallenge = toForbiddenChallenge;
|