@fluixi/oauth2 1.0.0-alpha.1

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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api-guard.cjs +185 -0
  3. package/dist/api-guard.d.ts +94 -0
  4. package/dist/api-guard.d.ts.map +1 -0
  5. package/dist/api-guard.js +127 -0
  6. package/dist/api-guard.mjs +141 -0
  7. package/dist/browser-FDUOEUM2.mjs +3544 -0
  8. package/dist/browser-ONMHX3Z3.mjs +3545 -0
  9. package/dist/browser.cjs +735 -0
  10. package/dist/browser.d.ts +3 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +448 -0
  13. package/dist/browser.mjs +704 -0
  14. package/dist/callback.cjs +125 -0
  15. package/dist/callback.d.ts +113 -0
  16. package/dist/callback.d.ts.map +1 -0
  17. package/dist/callback.js +183 -0
  18. package/dist/callback.mjs +106 -0
  19. package/dist/chunk-25WE6UWI.mjs +10 -0
  20. package/dist/chunk-4NSOGPLM.mjs +15 -0
  21. package/dist/chunk-7P6ASYW6.mjs +9 -0
  22. package/dist/chunk-OGSFNTKA.mjs +13 -0
  23. package/dist/client-auth.cjs +115 -0
  24. package/dist/client-auth.d.ts +43 -0
  25. package/dist/client-auth.d.ts.map +1 -0
  26. package/dist/client-auth.js +112 -0
  27. package/dist/client-auth.mjs +84 -0
  28. package/dist/discovery.cjs +86 -0
  29. package/dist/discovery.d.ts +15 -0
  30. package/dist/discovery.d.ts.map +1 -0
  31. package/dist/discovery.js +51 -0
  32. package/dist/discovery.mjs +63 -0
  33. package/dist/dpop-LVPWWADA.mjs +80 -0
  34. package/dist/dpop-NS7NKOWM.mjs +79 -0
  35. package/dist/dpop-store.cjs +152 -0
  36. package/dist/dpop-store.d.ts +37 -0
  37. package/dist/dpop-store.d.ts.map +1 -0
  38. package/dist/dpop-store.js +119 -0
  39. package/dist/dpop-store.mjs +121 -0
  40. package/dist/dpop.cjs +117 -0
  41. package/dist/dpop.d.ts +72 -0
  42. package/dist/dpop.d.ts.map +1 -0
  43. package/dist/dpop.js +114 -0
  44. package/dist/dpop.mjs +86 -0
  45. package/dist/env-config.cjs +366 -0
  46. package/dist/env-config.d.ts +103 -0
  47. package/dist/env-config.d.ts.map +1 -0
  48. package/dist/env-config.js +184 -0
  49. package/dist/env-config.mjs +343 -0
  50. package/dist/env-credentials.cjs +82 -0
  51. package/dist/env-credentials.d.ts +68 -0
  52. package/dist/env-credentials.d.ts.map +1 -0
  53. package/dist/env-credentials.js +76 -0
  54. package/dist/env-credentials.mjs +51 -0
  55. package/dist/index.cjs +940 -0
  56. package/dist/index.d.ts +104 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +101 -0
  59. package/dist/index.mjs +909 -0
  60. package/dist/pkce.cjs +87 -0
  61. package/dist/pkce.d.ts +58 -0
  62. package/dist/pkce.d.ts.map +1 -0
  63. package/dist/pkce.js +112 -0
  64. package/dist/pkce.mjs +66 -0
  65. package/dist/presets.cjs +240 -0
  66. package/dist/presets.d.ts +140 -0
  67. package/dist/presets.d.ts.map +1 -0
  68. package/dist/presets.js +182 -0
  69. package/dist/presets.mjs +217 -0
  70. package/dist/return-to.cjs +46 -0
  71. package/dist/return-to.d.ts +49 -0
  72. package/dist/return-to.d.ts.map +1 -0
  73. package/dist/return-to.js +71 -0
  74. package/dist/return-to.mjs +25 -0
  75. package/dist/roles.cjs +56 -0
  76. package/dist/roles.d.ts +35 -0
  77. package/dist/roles.d.ts.map +1 -0
  78. package/dist/roles.js +37 -0
  79. package/dist/roles.mjs +35 -0
  80. package/dist/server-client.cjs +134 -0
  81. package/dist/server-client.d.ts +12 -0
  82. package/dist/server-client.d.ts.map +1 -0
  83. package/dist/server-client.js +107 -0
  84. package/dist/server-client.mjs +111 -0
  85. package/dist/server.cjs +470 -0
  86. package/dist/server.d.ts +89 -0
  87. package/dist/server.d.ts.map +1 -0
  88. package/dist/server.js +360 -0
  89. package/dist/server.mjs +441 -0
  90. package/dist/service-client.cjs +244 -0
  91. package/dist/service-client.d.ts +89 -0
  92. package/dist/service-client.d.ts.map +1 -0
  93. package/dist/service-client.js +112 -0
  94. package/dist/service-client.mjs +213 -0
  95. package/dist/service.cjs +903 -0
  96. package/dist/service.d.ts +96 -0
  97. package/dist/service.d.ts.map +1 -0
  98. package/dist/service.js +129 -0
  99. package/dist/service.mjs +874 -0
  100. package/dist/tokens.cjs +29 -0
  101. package/dist/tokens.d.ts +13 -0
  102. package/dist/tokens.d.ts.map +1 -0
  103. package/dist/tokens.js +21 -0
  104. package/dist/tokens.mjs +8 -0
  105. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  106. package/dist/types.cjs +33 -0
  107. package/dist/types.d.ts +239 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +23 -0
  110. package/dist/types.mjs +12 -0
  111. package/dist/verify.cjs +724 -0
  112. package/dist/verify.d.ts +205 -0
  113. package/dist/verify.d.ts.map +1 -0
  114. package/dist/verify.js +371 -0
  115. package/dist/verify.mjs +590 -0
  116. package/package.json +121 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fluixi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __esm = (fn, res) => function __init() {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ };
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
22
+
23
+ // src/types.ts
24
+ var OAuthError;
25
+ var init_types = __esm({
26
+ "src/types.ts"() {
27
+ "use strict";
28
+ OAuthError = class extends Error {
29
+ constructor(message, code, description) {
30
+ super(message);
31
+ this.code = code;
32
+ this.description = description;
33
+ this.name = "OAuthError";
34
+ }
35
+ };
36
+ }
37
+ });
38
+
39
+ // src/api-guard.ts
40
+ var api_guard_exports = {};
41
+ __export(api_guard_exports, {
42
+ createApiGuard: () => createApiGuard,
43
+ rolesFrom: () => rolesFrom
44
+ });
45
+ module.exports = __toCommonJS(api_guard_exports);
46
+
47
+ // src/client-auth.ts
48
+ init_types();
49
+
50
+ // src/discovery.ts
51
+ init_types();
52
+
53
+ // src/verify.ts
54
+ init_types();
55
+ var TokenError = class extends OAuthError {
56
+ constructor(message, code = "invalid_token") {
57
+ super(message, code);
58
+ this.name = "TokenError";
59
+ }
60
+ };
61
+
62
+ // src/roles.ts
63
+ var rolesFrom = {
64
+ /** Keycloak, realm roles. Where it puts them unless a client mapper is configured. */
65
+ keycloakRealm: (claims) => {
66
+ const realm = claims.realm_access;
67
+ return realm?.roles ?? [];
68
+ },
69
+ /** Keycloak, roles scoped to one client rather than the realm. */
70
+ keycloakClient: (clientId) => (claims) => {
71
+ const all = claims.resource_access;
72
+ return all?.[clientId]?.roles ?? [];
73
+ },
74
+ /** Okta, and anything else using a groups claim. Needs a groups claim on the token. */
75
+ groups: (claims) => Array.isArray(claims.groups) ? claims.groups : [],
76
+ /** Cognito, which namespaces the claim. */
77
+ cognito: (claims) => {
78
+ const groups = claims["cognito:groups"];
79
+ return Array.isArray(groups) ? groups : [];
80
+ },
81
+ /**
82
+ * Auth0, which requires a namespaced custom claim added by an action.
83
+ *
84
+ * `rolesFrom.namespaced('https://adafri.com/roles')`. Auth0 silently drops a custom claim
85
+ * that is not a URI, so an empty list here usually means the namespace, not the action.
86
+ */
87
+ namespaced: (claim) => (claims) => {
88
+ const value = claims[claim];
89
+ return Array.isArray(value) ? value : [];
90
+ },
91
+ /** The `scope` claim read as a role list, for a provider issuing no roles at all. */
92
+ scope: (claims) => typeof claims.scope === "string" ? claims.scope.split(" ") : []
93
+ };
94
+
95
+ // src/api-guard.ts
96
+ var asList = (value) => value === void 0 ? [] : Array.isArray(value) ? value : [value];
97
+ function refuse(refusal) {
98
+ const body = { error: refusal.code };
99
+ if (refusal.detail) body.detail = refusal.detail;
100
+ return new Response(JSON.stringify(body), {
101
+ status: refusal.status,
102
+ headers: { "content-type": "application/json" }
103
+ });
104
+ }
105
+ function createApiGuard(options) {
106
+ const answer = (refusal, request) => {
107
+ const response = options.onRefused ? options.onRefused(refusal, request) : refuse(refusal);
108
+ if (refusal.status === 401 && !response.headers.has("www-authenticate")) {
109
+ response.headers.set("www-authenticate", `Bearer error="${refusal.code}"`);
110
+ }
111
+ return response;
112
+ };
113
+ const rolesOf = (claims, what) => {
114
+ if (!options.roles) {
115
+ throw new Error(
116
+ `[fluixi/oauth2] this route requires ${what}, but createApiGuard was given no \`roles\`. Providers disagree on where roles live, so there is no default: pass one of \`rolesFrom\`, or a function reading the claim your provider uses.`
117
+ );
118
+ }
119
+ return options.roles(claims);
120
+ };
121
+ return (handler, requirements = {}) => async (request) => {
122
+ let claims;
123
+ try {
124
+ claims = await options.verify.fromRequest(request);
125
+ } catch (cause) {
126
+ const code = cause instanceof TokenError ? cause.code : "invalid_token";
127
+ return answer({ status: 401, code }, request);
128
+ }
129
+ const wanted = asList(requirements.role);
130
+ if (wanted.length) {
131
+ const held = rolesOf(claims, "a role");
132
+ if (!wanted.some((role) => held.includes(role))) {
133
+ return answer(
134
+ {
135
+ status: 403,
136
+ code: "insufficient_role",
137
+ detail: `needs ${wanted.length > 1 ? "one of " : ""}${wanted.join(", ")}`,
138
+ claims
139
+ },
140
+ request
141
+ );
142
+ }
143
+ }
144
+ if (requirements.allRoles?.length) {
145
+ const held = rolesOf(claims, "roles");
146
+ const missing = requirements.allRoles.filter((role) => !held.includes(role));
147
+ if (missing.length) {
148
+ return answer(
149
+ { status: 403, code: "insufficient_role", detail: `needs ${missing.join(", ")}`, claims },
150
+ request
151
+ );
152
+ }
153
+ }
154
+ const scopes = asList(requirements.scope);
155
+ if (scopes.length) {
156
+ const held = typeof claims.scope === "string" ? claims.scope.split(" ") : [];
157
+ if (!scopes.some((scope) => held.includes(scope))) {
158
+ return answer(
159
+ {
160
+ status: 403,
161
+ code: "insufficient_scope",
162
+ detail: `needs ${scopes.length > 1 ? "one of " : ""}${scopes.join(", ")}`,
163
+ claims
164
+ },
165
+ request
166
+ );
167
+ }
168
+ }
169
+ if (requirements.require) {
170
+ const verdict = requirements.require(claims, request);
171
+ if (verdict !== true) {
172
+ return answer(
173
+ {
174
+ status: 403,
175
+ code: "forbidden",
176
+ detail: typeof verdict === "string" ? verdict : void 0,
177
+ claims
178
+ },
179
+ request
180
+ );
181
+ }
182
+ }
183
+ return handler(request, claims);
184
+ };
185
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Refusing a request at this app's own API.
3
+ *
4
+ * `createTokenVerifier` says whether a token holds. What every app then wrote by hand is the
5
+ * part around it: catch the refusal, answer 401 with a `WWW-Authenticate` header, answer 403
6
+ * when the token is genuine but not enough, and hand the claims to the route. Thirty-odd
7
+ * lines, identical everywhere, and the header is not decoration: `browser.ts` reads
8
+ * `error="..."` out of it to tell an expired token from an endpoint that is down, which is
9
+ * what makes a refresh happen instead of a sign out.
10
+ *
11
+ * So the two halves of that contract now come from the same package. An app that writes the
12
+ * header itself has to match a format nothing told it about.
13
+ *
14
+ * Authentication and authorization stay separate here. The verifier decides whether the token
15
+ * is genuine; `requirements` decide whether it is enough for one route. Conflating them is how
16
+ * a 403 ends up reported as a 401 and the client refreshes a perfectly good token in a loop.
17
+ */
18
+ import { type TokenClaims, type TokenVerifier } from './verify.js';
19
+ export { rolesFrom } from './roles.js';
20
+ /** Why a request was refused. */
21
+ export interface Refusal {
22
+ /** 401 when the token is not acceptable, 403 when it is genuine but insufficient. */
23
+ status: 401 | 403;
24
+ /** A machine-readable code, and what goes in `WWW-Authenticate` for a 401. */
25
+ code: string;
26
+ /** What was missing, for a 403. Never sent for a 401: it would describe a token we rejected. */
27
+ detail?: string;
28
+ /** The claims, when there were any. Absent for a 401. */
29
+ claims?: TokenClaims;
30
+ }
31
+ /** What a route demands beyond a valid token. */
32
+ export interface Requirements {
33
+ /** Any one of these roles is enough. */
34
+ role?: string | string[];
35
+ /** Every one of these is required. */
36
+ allRoles?: string[];
37
+ /** Any one of these scopes is enough, read from the `scope` claim. */
38
+ scope?: string | string[];
39
+ /**
40
+ * Anything else: a tenant claim, a plan, an ownership check against the URL.
41
+ *
42
+ * Return `true` to allow, `false` to refuse, or a string to refuse and say what was wrong.
43
+ */
44
+ require?: (claims: TokenClaims, request: Request) => boolean | string;
45
+ }
46
+ export interface ApiGuardOptions {
47
+ /** The verifier this app configured. */
48
+ verify: TokenVerifier;
49
+ /**
50
+ * How this provider names roles. See `rolesFrom` for the shapes in the wild.
51
+ *
52
+ * Left out, `role` and `allRoles` requirements throw rather than silently refusing
53
+ * everyone: a guard that cannot read roles and quietly answers 403 looks like a
54
+ * permissions problem for as long as it takes to find.
55
+ */
56
+ roles?: (claims: TokenClaims) => string[];
57
+ /**
58
+ * Answer a refusal in this app's own shape.
59
+ *
60
+ * The default is `{ error, detail? }` as JSON. Override for an API with its own error
61
+ * envelope. Returning a 401 without `WWW-Authenticate` breaks the client's ability to tell
62
+ * an expired token from a dead endpoint, so the default header is added to whatever comes
63
+ * back unless it already carries one.
64
+ */
65
+ onRefused?: (refusal: Refusal, request: Request) => Response;
66
+ }
67
+ /** Wraps a route handler so only an acceptable request reaches it. */
68
+ export type ApiGuard = (handler: (request: Request, claims: TokenClaims) => Response | Promise<Response>, requirements?: Requirements) => (request: Request) => Promise<Response>;
69
+ /**
70
+ * A guard for this app's API routes.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * // src/services/auth/verify.ts, the whole file
75
+ * import { createTokenVerifier, createApiGuard, rolesFrom } from '@fluixi/oauth2/verify';
76
+ * import { env } from '../../config/env.js';
77
+ *
78
+ * export const verify = createTokenVerifier({
79
+ * issuer: env.oidcIssuer,
80
+ * audience: false,
81
+ * authorizedParty: env.clientId,
82
+ * });
83
+ *
84
+ * export const guard = createApiGuard({ verify, roles: rolesFrom.keycloakRealm });
85
+ * ```
86
+ *
87
+ * ```ts
88
+ * // src/api/reports.ts
89
+ * export const GET = guard(async (request, claims) => Response.json(await reportsFor(claims.sub)));
90
+ * export const DELETE = guard(handler, { role: 'admin' });
91
+ * ```
92
+ */
93
+ export declare function createApiGuard(options: ApiGuardOptions): ApiGuard;
94
+ //# sourceMappingURL=api-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-guard.d.ts","sourceRoot":"","sources":["../src/api-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EAAc,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAE/E,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,iCAAiC;AACjC,MAAM,WAAW,OAAO;IACtB,qFAAqF;IACrF,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAClB,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,gGAAgG;IAChG,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,iDAAiD;AACjD,MAAM,WAAW,YAAY;IAC3B,wCAAwC;IACxC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IACzB,sCAAsC;IACtC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC1B;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,GAAG,MAAM,CAAC;CACvE;AAED,MAAM,WAAW,eAAe;IAC9B,wCAAwC;IACxC,MAAM,EAAE,aAAa,CAAC;IACtB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,EAAE,CAAC;IAC1C;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,KAAK,QAAQ,CAAC;CAC9D;AAED,sEAAsE;AACtE,MAAM,MAAM,QAAQ,GAAG,CACrB,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,EAChF,YAAY,CAAC,EAAE,YAAY,KACxB,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAe7C;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,QAAQ,CA6FjE"}
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Refusing a request at this app's own API.
3
+ *
4
+ * `createTokenVerifier` says whether a token holds. What every app then wrote by hand is the
5
+ * part around it: catch the refusal, answer 401 with a `WWW-Authenticate` header, answer 403
6
+ * when the token is genuine but not enough, and hand the claims to the route. Thirty-odd
7
+ * lines, identical everywhere, and the header is not decoration: `browser.ts` reads
8
+ * `error="..."` out of it to tell an expired token from an endpoint that is down, which is
9
+ * what makes a refresh happen instead of a sign out.
10
+ *
11
+ * So the two halves of that contract now come from the same package. An app that writes the
12
+ * header itself has to match a format nothing told it about.
13
+ *
14
+ * Authentication and authorization stay separate here. The verifier decides whether the token
15
+ * is genuine; `requirements` decide whether it is enough for one route. Conflating them is how
16
+ * a 403 ends up reported as a 401 and the client refreshes a perfectly good token in a loop.
17
+ */
18
+ import { TokenError } from './verify.js';
19
+ export { rolesFrom } from './roles.js';
20
+ const asList = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
21
+ /** The default refusal body. */
22
+ function refuse(refusal) {
23
+ const body = { error: refusal.code };
24
+ if (refusal.detail)
25
+ body.detail = refusal.detail;
26
+ return new Response(JSON.stringify(body), {
27
+ status: refusal.status,
28
+ headers: { 'content-type': 'application/json' },
29
+ });
30
+ }
31
+ /**
32
+ * A guard for this app's API routes.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // src/services/auth/verify.ts, the whole file
37
+ * import { createTokenVerifier, createApiGuard, rolesFrom } from '@fluixi/oauth2/verify';
38
+ * import { env } from '../../config/env.js';
39
+ *
40
+ * export const verify = createTokenVerifier({
41
+ * issuer: env.oidcIssuer,
42
+ * audience: false,
43
+ * authorizedParty: env.clientId,
44
+ * });
45
+ *
46
+ * export const guard = createApiGuard({ verify, roles: rolesFrom.keycloakRealm });
47
+ * ```
48
+ *
49
+ * ```ts
50
+ * // src/api/reports.ts
51
+ * export const GET = guard(async (request, claims) => Response.json(await reportsFor(claims.sub)));
52
+ * export const DELETE = guard(handler, { role: 'admin' });
53
+ * ```
54
+ */
55
+ export function createApiGuard(options) {
56
+ const answer = (refusal, request) => {
57
+ const response = options.onRefused ? options.onRefused(refusal, request) : refuse(refusal);
58
+ // Added rather than overwritten: a custom refusal may already say it better.
59
+ if (refusal.status === 401 && !response.headers.has('www-authenticate')) {
60
+ response.headers.set('www-authenticate', `Bearer error="${refusal.code}"`);
61
+ }
62
+ return response;
63
+ };
64
+ const rolesOf = (claims, what) => {
65
+ if (!options.roles) {
66
+ throw new Error(`[fluixi/oauth2] this route requires ${what}, but createApiGuard was given no \`roles\`. ` +
67
+ 'Providers disagree on where roles live, so there is no default: pass one of `rolesFrom`, ' +
68
+ 'or a function reading the claim your provider uses.');
69
+ }
70
+ return options.roles(claims);
71
+ };
72
+ return (handler, requirements = {}) => async (request) => {
73
+ let claims;
74
+ try {
75
+ claims = await options.verify.fromRequest(request);
76
+ }
77
+ catch (cause) {
78
+ // The code travels; the message does not. A refusal that explains why a token failed
79
+ // tells an attacker which guesses are close.
80
+ const code = cause instanceof TokenError ? cause.code : 'invalid_token';
81
+ return answer({ status: 401, code }, request);
82
+ }
83
+ const wanted = asList(requirements.role);
84
+ if (wanted.length) {
85
+ const held = rolesOf(claims, 'a role');
86
+ if (!wanted.some((role) => held.includes(role))) {
87
+ return answer({
88
+ status: 403,
89
+ code: 'insufficient_role',
90
+ detail: `needs ${wanted.length > 1 ? 'one of ' : ''}${wanted.join(', ')}`,
91
+ claims,
92
+ }, request);
93
+ }
94
+ }
95
+ if (requirements.allRoles?.length) {
96
+ const held = rolesOf(claims, 'roles');
97
+ const missing = requirements.allRoles.filter((role) => !held.includes(role));
98
+ if (missing.length) {
99
+ return answer({ status: 403, code: 'insufficient_role', detail: `needs ${missing.join(', ')}`, claims }, request);
100
+ }
101
+ }
102
+ const scopes = asList(requirements.scope);
103
+ if (scopes.length) {
104
+ const held = typeof claims.scope === 'string' ? claims.scope.split(' ') : [];
105
+ if (!scopes.some((scope) => held.includes(scope))) {
106
+ return answer({
107
+ status: 403,
108
+ code: 'insufficient_scope',
109
+ detail: `needs ${scopes.length > 1 ? 'one of ' : ''}${scopes.join(', ')}`,
110
+ claims,
111
+ }, request);
112
+ }
113
+ }
114
+ if (requirements.require) {
115
+ const verdict = requirements.require(claims, request);
116
+ if (verdict !== true) {
117
+ return answer({
118
+ status: 403,
119
+ code: 'forbidden',
120
+ detail: typeof verdict === 'string' ? verdict : undefined,
121
+ claims,
122
+ }, request);
123
+ }
124
+ }
125
+ return handler(request, claims);
126
+ };
127
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ OAuthError
3
+ } from "./chunk-OGSFNTKA.mjs";
4
+ import "./chunk-7P6ASYW6.mjs";
5
+
6
+ // src/verify.ts
7
+ var TokenError = class extends OAuthError {
8
+ constructor(message, code = "invalid_token") {
9
+ super(message, code);
10
+ this.name = "TokenError";
11
+ }
12
+ };
13
+
14
+ // src/roles.ts
15
+ var rolesFrom = {
16
+ /** Keycloak, realm roles. Where it puts them unless a client mapper is configured. */
17
+ keycloakRealm: (claims) => {
18
+ const realm = claims.realm_access;
19
+ return realm?.roles ?? [];
20
+ },
21
+ /** Keycloak, roles scoped to one client rather than the realm. */
22
+ keycloakClient: (clientId) => (claims) => {
23
+ const all = claims.resource_access;
24
+ return all?.[clientId]?.roles ?? [];
25
+ },
26
+ /** Okta, and anything else using a groups claim. Needs a groups claim on the token. */
27
+ groups: (claims) => Array.isArray(claims.groups) ? claims.groups : [],
28
+ /** Cognito, which namespaces the claim. */
29
+ cognito: (claims) => {
30
+ const groups = claims["cognito:groups"];
31
+ return Array.isArray(groups) ? groups : [];
32
+ },
33
+ /**
34
+ * Auth0, which requires a namespaced custom claim added by an action.
35
+ *
36
+ * `rolesFrom.namespaced('https://adafri.com/roles')`. Auth0 silently drops a custom claim
37
+ * that is not a URI, so an empty list here usually means the namespace, not the action.
38
+ */
39
+ namespaced: (claim) => (claims) => {
40
+ const value = claims[claim];
41
+ return Array.isArray(value) ? value : [];
42
+ },
43
+ /** The `scope` claim read as a role list, for a provider issuing no roles at all. */
44
+ scope: (claims) => typeof claims.scope === "string" ? claims.scope.split(" ") : []
45
+ };
46
+
47
+ // src/api-guard.ts
48
+ var asList = (value) => value === void 0 ? [] : Array.isArray(value) ? value : [value];
49
+ function refuse(refusal) {
50
+ const body = { error: refusal.code };
51
+ if (refusal.detail) body.detail = refusal.detail;
52
+ return new Response(JSON.stringify(body), {
53
+ status: refusal.status,
54
+ headers: { "content-type": "application/json" }
55
+ });
56
+ }
57
+ function createApiGuard(options) {
58
+ const answer = (refusal, request) => {
59
+ const response = options.onRefused ? options.onRefused(refusal, request) : refuse(refusal);
60
+ if (refusal.status === 401 && !response.headers.has("www-authenticate")) {
61
+ response.headers.set("www-authenticate", `Bearer error="${refusal.code}"`);
62
+ }
63
+ return response;
64
+ };
65
+ const rolesOf = (claims, what) => {
66
+ if (!options.roles) {
67
+ throw new Error(
68
+ `[fluixi/oauth2] this route requires ${what}, but createApiGuard was given no \`roles\`. Providers disagree on where roles live, so there is no default: pass one of \`rolesFrom\`, or a function reading the claim your provider uses.`
69
+ );
70
+ }
71
+ return options.roles(claims);
72
+ };
73
+ return (handler, requirements = {}) => async (request) => {
74
+ let claims;
75
+ try {
76
+ claims = await options.verify.fromRequest(request);
77
+ } catch (cause) {
78
+ const code = cause instanceof TokenError ? cause.code : "invalid_token";
79
+ return answer({ status: 401, code }, request);
80
+ }
81
+ const wanted = asList(requirements.role);
82
+ if (wanted.length) {
83
+ const held = rolesOf(claims, "a role");
84
+ if (!wanted.some((role) => held.includes(role))) {
85
+ return answer(
86
+ {
87
+ status: 403,
88
+ code: "insufficient_role",
89
+ detail: `needs ${wanted.length > 1 ? "one of " : ""}${wanted.join(", ")}`,
90
+ claims
91
+ },
92
+ request
93
+ );
94
+ }
95
+ }
96
+ if (requirements.allRoles?.length) {
97
+ const held = rolesOf(claims, "roles");
98
+ const missing = requirements.allRoles.filter((role) => !held.includes(role));
99
+ if (missing.length) {
100
+ return answer(
101
+ { status: 403, code: "insufficient_role", detail: `needs ${missing.join(", ")}`, claims },
102
+ request
103
+ );
104
+ }
105
+ }
106
+ const scopes = asList(requirements.scope);
107
+ if (scopes.length) {
108
+ const held = typeof claims.scope === "string" ? claims.scope.split(" ") : [];
109
+ if (!scopes.some((scope) => held.includes(scope))) {
110
+ return answer(
111
+ {
112
+ status: 403,
113
+ code: "insufficient_scope",
114
+ detail: `needs ${scopes.length > 1 ? "one of " : ""}${scopes.join(", ")}`,
115
+ claims
116
+ },
117
+ request
118
+ );
119
+ }
120
+ }
121
+ if (requirements.require) {
122
+ const verdict = requirements.require(claims, request);
123
+ if (verdict !== true) {
124
+ return answer(
125
+ {
126
+ status: 403,
127
+ code: "forbidden",
128
+ detail: typeof verdict === "string" ? verdict : void 0,
129
+ claims
130
+ },
131
+ request
132
+ );
133
+ }
134
+ }
135
+ return handler(request, claims);
136
+ };
137
+ }
138
+ export {
139
+ createApiGuard,
140
+ rolesFrom
141
+ };