@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/dist/dpop.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ /** A key a client holds, with the parts a proof and a binding check need. */
2
+ export interface DpopKey {
3
+ /** The private half, used to sign. Never leaves this process. */
4
+ privateKey: CryptoKey;
5
+ /** The public half, embedded in every proof so a service can verify it. */
6
+ publicJwk: Record<string, unknown>;
7
+ /**
8
+ * The JWK thumbprint, RFC 7638.
9
+ *
10
+ * What a provider puts in a token's `cnf.jkt`, and therefore what a service compares a
11
+ * proof's key against.
12
+ */
13
+ thumbprint: string;
14
+ /** The signing algorithm, which a provider must list in `dpop_signing_alg_values_supported`. */
15
+ alg: string;
16
+ }
17
+ /**
18
+ * A key for this client.
19
+ *
20
+ * `ES256` by default: the keys are small, signing is fast, and every provider advertising
21
+ * DPoP support lists it. A proof is signed on every request, so the cost is paid often.
22
+ *
23
+ * `extractable: false` where the platform allows it, so the private half cannot be read
24
+ * out of the process even by code running in it. That is most of the value on a page: an
25
+ * attacker who can run script can use the key while they are there, and cannot take it
26
+ * away to use later.
27
+ */
28
+ export declare function createDpopKey(alg?: string): Promise<DpopKey>;
29
+ /** The thumbprint of a public JWK, for comparing against a token's `cnf.jkt`. */
30
+ export declare function thumbprintOf(jwk: Record<string, unknown>): Promise<string>;
31
+ export interface ProofOptions {
32
+ /** The HTTP method, uppercased into `htm`. */
33
+ method: string;
34
+ /**
35
+ * The URL, without query or fragment, into `htu`.
36
+ *
37
+ * Stripped because the spec says so and because a query string would make a proof
38
+ * unusable for the same endpoint called with different parameters.
39
+ */
40
+ url: string;
41
+ /**
42
+ * The access token this proof accompanies, hashed into `ath`.
43
+ *
44
+ * Present when calling a resource with a token, absent on the token request itself,
45
+ * where no token exists yet. It binds the proof to one token, so a proof captured
46
+ * alongside one cannot be reused with another.
47
+ */
48
+ accessToken?: string;
49
+ /** A nonce the provider demanded, into `nonce`. */
50
+ nonce?: string;
51
+ }
52
+ /**
53
+ * A proof for one request.
54
+ *
55
+ * Single use by construction: `jti` is fresh and `iat` is now, so a captured proof is
56
+ * worth nothing beyond the few seconds a service allows for clock skew. `htm` and `htu`
57
+ * bind it to one method and one endpoint, so it cannot be replayed elsewhere.
58
+ */
59
+ export declare function proofFor(key: DpopKey, options: ProofOptions): Promise<string>;
60
+ /** The nonce a provider asked for, from a response that refused the last proof. */
61
+ export declare function nonceFrom(response: Response): string | null;
62
+ /** Whether a refusal is a provider asking for a nonce rather than rejecting the proof. */
63
+ export declare function wantsNonce(error: unknown, response?: Response): boolean;
64
+ /**
65
+ * The key a token is bound to, from its `cnf.jkt` claim.
66
+ *
67
+ * Read without verifying the signature, because a service verifies the token separately
68
+ * and this only says which key to expect. A token with no `cnf` is not bound to one, and a
69
+ * service told to require binding should refuse it rather than accept it as unbound.
70
+ */
71
+ export declare function boundKeyOf(claims: Record<string, unknown>): string | null;
72
+ //# sourceMappingURL=dpop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dpop.d.ts","sourceRoot":"","sources":["../src/dpop.ts"],"names":[],"mappings":"AAkBA,6EAA6E;AAC7E,MAAM,WAAW,OAAO;IACtB,iEAAiE;IACjE,UAAU,EAAE,SAAS,CAAC;IACtB,2EAA2E;IAC3E,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,gGAAgG;IAChG,GAAG,EAAE,MAAM,CAAC;CACb;AAcD;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CAAC,GAAG,SAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAWnE;AAED,iFAAiF;AACjF,wBAAsB,YAAY,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAGhF;AAED,MAAM,WAAW,YAAY;IAC3B,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAsCnF;AAED,mFAAmF;AACnF,wBAAgB,SAAS,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAE3D;AAED,0FAA0F;AAC1F,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,CAGvE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CAGzE"}
package/dist/dpop.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * DPoP, RFC 9449: a token bound to a key the client holds.
3
+ *
4
+ * A bearer token is a password. Whoever has it can use it, so a copy taken from storage,
5
+ * a log or a proxy is as good as the original. DPoP changes that: the token carries the
6
+ * thumbprint of a public key, the client signs a fresh proof for every request with the
7
+ * private half, and a service checks that the proof matches the token. A stolen token is
8
+ * then worth nothing without the key, which never leaves the client.
9
+ *
10
+ * Two halves, and one without the other is worse than neither. A client that sends proofs
11
+ * to a service that does not check them has gained nothing while looking protected.
12
+ * `createDpopKey` and `proofFor` are the client half; `thumbprintOf` and the verifier's
13
+ * DPoP mode are the service half.
14
+ *
15
+ * The key is per client and never sent. Only its public half travels, inside each proof.
16
+ */
17
+ import { OAuthError } from './types.js';
18
+ /** `jose`, loaded only when a client actually uses DPoP. */
19
+ async function loadJose() {
20
+ try {
21
+ return await import('jose');
22
+ }
23
+ catch {
24
+ throw new OAuthError('DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.', 'jose_missing');
25
+ }
26
+ }
27
+ /**
28
+ * A key for this client.
29
+ *
30
+ * `ES256` by default: the keys are small, signing is fast, and every provider advertising
31
+ * DPoP support lists it. A proof is signed on every request, so the cost is paid often.
32
+ *
33
+ * `extractable: false` where the platform allows it, so the private half cannot be read
34
+ * out of the process even by code running in it. That is most of the value on a page: an
35
+ * attacker who can run script can use the key while they are there, and cannot take it
36
+ * away to use later.
37
+ */
38
+ export async function createDpopKey(alg = 'ES256') {
39
+ const jose = await loadJose();
40
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, { extractable: false });
41
+ const publicJwk = { ...(await jose.exportJWK(publicKey)) };
42
+ return {
43
+ privateKey: privateKey,
44
+ publicJwk,
45
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, 'sha256'),
46
+ alg,
47
+ };
48
+ }
49
+ /** The thumbprint of a public JWK, for comparing against a token's `cnf.jkt`. */
50
+ export async function thumbprintOf(jwk) {
51
+ const jose = await loadJose();
52
+ return jose.calculateJwkThumbprint(jwk, 'sha256');
53
+ }
54
+ /**
55
+ * A proof for one request.
56
+ *
57
+ * Single use by construction: `jti` is fresh and `iat` is now, so a captured proof is
58
+ * worth nothing beyond the few seconds a service allows for clock skew. `htm` and `htu`
59
+ * bind it to one method and one endpoint, so it cannot be replayed elsewhere.
60
+ */
61
+ export async function proofFor(key, options) {
62
+ const jose = await loadJose();
63
+ // Query and fragment removed, per the spec.
64
+ const htu = (() => {
65
+ try {
66
+ const url = new URL(options.url);
67
+ url.search = '';
68
+ url.hash = '';
69
+ return url.toString();
70
+ }
71
+ catch {
72
+ return options.url;
73
+ }
74
+ })();
75
+ const claims = {
76
+ htm: options.method.toUpperCase(),
77
+ htu,
78
+ jti: crypto.randomUUID(),
79
+ };
80
+ if (options.accessToken) {
81
+ // `ath`: the base64url SHA-256 of the token, not the token itself.
82
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(options.accessToken));
83
+ let binary = '';
84
+ for (const byte of new Uint8Array(digest))
85
+ binary += String.fromCharCode(byte);
86
+ claims.ath = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
87
+ }
88
+ if (options.nonce)
89
+ claims.nonce = options.nonce;
90
+ return new jose.SignJWT(claims)
91
+ .setProtectedHeader({ typ: 'dpop+jwt', alg: key.alg, jwk: key.publicJwk })
92
+ .setIssuedAt()
93
+ .sign(key.privateKey);
94
+ }
95
+ /** The nonce a provider asked for, from a response that refused the last proof. */
96
+ export function nonceFrom(response) {
97
+ return response.headers.get('dpop-nonce');
98
+ }
99
+ /** Whether a refusal is a provider asking for a nonce rather than rejecting the proof. */
100
+ export function wantsNonce(error, response) {
101
+ const code = typeof error === 'string' ? error : error?.error;
102
+ return code === 'use_dpop_nonce' || (!!response && !!nonceFrom(response));
103
+ }
104
+ /**
105
+ * The key a token is bound to, from its `cnf.jkt` claim.
106
+ *
107
+ * Read without verifying the signature, because a service verifies the token separately
108
+ * and this only says which key to expect. A token with no `cnf` is not bound to one, and a
109
+ * service told to require binding should refuse it rather than accept it as unbound.
110
+ */
111
+ export function boundKeyOf(claims) {
112
+ const cnf = claims.cnf;
113
+ return typeof cnf?.jkt === 'string' ? cnf.jkt : null;
114
+ }
package/dist/dpop.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import "./chunk-7P6ASYW6.mjs";
2
+
3
+ // src/types.ts
4
+ var OAuthError = class extends Error {
5
+ constructor(message, code, description) {
6
+ super(message);
7
+ this.code = code;
8
+ this.description = description;
9
+ this.name = "OAuthError";
10
+ }
11
+ };
12
+
13
+ // src/dpop.ts
14
+ async function loadJose() {
15
+ try {
16
+ return await import("./browser-FDUOEUM2.mjs");
17
+ } catch {
18
+ throw new OAuthError(
19
+ "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
20
+ "jose_missing"
21
+ );
22
+ }
23
+ }
24
+ async function createDpopKey(alg = "ES256") {
25
+ const jose = await loadJose();
26
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, { extractable: false });
27
+ const publicJwk = { ...await jose.exportJWK(publicKey) };
28
+ return {
29
+ privateKey,
30
+ publicJwk,
31
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
32
+ alg
33
+ };
34
+ }
35
+ async function thumbprintOf(jwk) {
36
+ const jose = await loadJose();
37
+ return jose.calculateJwkThumbprint(jwk, "sha256");
38
+ }
39
+ async function proofFor(key, options) {
40
+ const jose = await loadJose();
41
+ const htu = (() => {
42
+ try {
43
+ const url = new URL(options.url);
44
+ url.search = "";
45
+ url.hash = "";
46
+ return url.toString();
47
+ } catch {
48
+ return options.url;
49
+ }
50
+ })();
51
+ const claims = {
52
+ htm: options.method.toUpperCase(),
53
+ htu,
54
+ jti: crypto.randomUUID()
55
+ };
56
+ if (options.accessToken) {
57
+ const digest = await crypto.subtle.digest(
58
+ "SHA-256",
59
+ new TextEncoder().encode(options.accessToken)
60
+ );
61
+ let binary = "";
62
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
63
+ claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
64
+ }
65
+ if (options.nonce) claims.nonce = options.nonce;
66
+ return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
67
+ }
68
+ function nonceFrom(response) {
69
+ return response.headers.get("dpop-nonce");
70
+ }
71
+ function wantsNonce(error, response) {
72
+ const code = typeof error === "string" ? error : error?.error;
73
+ return code === "use_dpop_nonce" || !!response && !!nonceFrom(response);
74
+ }
75
+ function boundKeyOf(claims) {
76
+ const cnf = claims.cnf;
77
+ return typeof cnf?.jkt === "string" ? cnf.jkt : null;
78
+ }
79
+ export {
80
+ boundKeyOf,
81
+ createDpopKey,
82
+ nonceFrom,
83
+ proofFor,
84
+ thumbprintOf,
85
+ wantsNonce
86
+ };
@@ -0,0 +1,366 @@
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 __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/env-config.ts
21
+ var env_config_exports = {};
22
+ __export(env_config_exports, {
23
+ authFromEnv: () => authFromEnv
24
+ });
25
+ module.exports = __toCommonJS(env_config_exports);
26
+
27
+ // src/roles.ts
28
+ var rolesFrom = {
29
+ /** Keycloak, realm roles. Where it puts them unless a client mapper is configured. */
30
+ keycloakRealm: (claims) => {
31
+ const realm = claims.realm_access;
32
+ return realm?.roles ?? [];
33
+ },
34
+ /** Keycloak, roles scoped to one client rather than the realm. */
35
+ keycloakClient: (clientId) => (claims) => {
36
+ const all = claims.resource_access;
37
+ return all?.[clientId]?.roles ?? [];
38
+ },
39
+ /** Okta, and anything else using a groups claim. Needs a groups claim on the token. */
40
+ groups: (claims) => Array.isArray(claims.groups) ? claims.groups : [],
41
+ /** Cognito, which namespaces the claim. */
42
+ cognito: (claims) => {
43
+ const groups = claims["cognito:groups"];
44
+ return Array.isArray(groups) ? groups : [];
45
+ },
46
+ /**
47
+ * Auth0, which requires a namespaced custom claim added by an action.
48
+ *
49
+ * `rolesFrom.namespaced('https://adafri.com/roles')`. Auth0 silently drops a custom claim
50
+ * that is not a URI, so an empty list here usually means the namespace, not the action.
51
+ */
52
+ namespaced: (claim) => (claims) => {
53
+ const value = claims[claim];
54
+ return Array.isArray(value) ? value : [];
55
+ },
56
+ /** The `scope` claim read as a role list, for a provider issuing no roles at all. */
57
+ scope: (claims) => typeof claims.scope === "string" ? claims.scope.split(" ") : []
58
+ };
59
+
60
+ // src/presets.ts
61
+ var trim = (url) => url.replace(/\/+$/, "");
62
+ var presets = {
63
+ /**
64
+ * Any OIDC provider publishing a discovery document.
65
+ *
66
+ * The agnostic path, and the one to reach for first. A provider not listed below almost
67
+ * certainly works through this.
68
+ */
69
+ oidc(options) {
70
+ return {
71
+ label: options.issuer,
72
+ client: { issuer: trim(options.issuer), scopes: options.scopes },
73
+ roles: options.roles ?? rolesFrom.scope,
74
+ verifier: (clientId) => ({
75
+ issuer: trim(options.issuer),
76
+ audience: options.audience ?? clientId,
77
+ ...options.audience === false ? { authorizedParty: clientId } : {}
78
+ }),
79
+ constraints: []
80
+ };
81
+ },
82
+ /**
83
+ * A plain OAuth2 server with no discovery document.
84
+ *
85
+ * Every endpoint is given, because there is nothing to ask. Token verification needs an
86
+ * issuer regardless, since a verifier will not accept a token without one.
87
+ */
88
+ oauth2(options) {
89
+ return {
90
+ label: options.issuer,
91
+ client: { endpoints: options.endpoints, scopes: options.scopes },
92
+ roles: options.roles ?? rolesFrom.scope,
93
+ verifier: (clientId) => ({
94
+ issuer: trim(options.issuer),
95
+ audience: options.audience ?? clientId,
96
+ ...options.audience === false ? { authorizedParty: clientId } : {}
97
+ }),
98
+ constraints: ["Publishes no discovery document, so every endpoint is configured by hand."]
99
+ };
100
+ },
101
+ /** Keycloak. A realm is its own issuer. */
102
+ keycloak(options) {
103
+ const issuer = `${trim(options.baseUrl)}/realms/${options.realm}`;
104
+ return {
105
+ label: "Keycloak",
106
+ client: {
107
+ issuer,
108
+ scopes: options.scopes ?? ["openid", "profile", "email"]
109
+ },
110
+ roles: rolesFrom.keycloakRealm,
111
+ // No `aud` on an access token until an audience mapper is configured, and the client is
112
+ // named in `azp` instead. So the check moves to `authorizedParty` rather than being
113
+ // dropped: `audience: false` alone would accept a token minted for any client of the
114
+ // realm, which is the check being skipped.
115
+ verifier: (clientId) => ({ issuer, audience: false, authorizedParty: clientId }),
116
+ constraints: [
117
+ "Puts no `aud` on an access token until a dedicated audience mapper is configured. Configure one and switch the verifier to `audience: clientId`.",
118
+ "Revokes the whole session when an authorization code is replayed, so a replay test has to run last."
119
+ ]
120
+ };
121
+ },
122
+ /**
123
+ * Auth0.
124
+ *
125
+ * `audience` is the field that decides what kind of token comes back. Without it Auth0
126
+ * issues an opaque token for its own userinfo endpoint and the JWT checks have nothing to
127
+ * work with.
128
+ */
129
+ auth0(options) {
130
+ const issuer = `https://${options.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "")}/`;
131
+ return {
132
+ label: "Auth0",
133
+ client: {
134
+ issuer,
135
+ scopes: options.scopes ?? ["openid", "profile", "email", "offline_access"],
136
+ ...options.audience ? { extraParams: { audience: options.audience } } : {}
137
+ },
138
+ roles: options.rolesClaim ? rolesFrom.namespaced(options.rolesClaim) : rolesFrom.scope,
139
+ verifier: (clientId) => ({
140
+ issuer,
141
+ // The API identifier is the audience of an access token. Without one the token is
142
+ // opaque and there is nothing for a verifier to check.
143
+ audience: options.audience ?? false,
144
+ ...options.audience ? {} : { authorizedParty: clientId }
145
+ }),
146
+ constraints: [
147
+ "Issues an opaque access token unless `audience` names an API, so token verification needs it.",
148
+ "A refresh token needs three things together: `offline_access` in the scopes, an audience whose API has Allow Offline Access on, and Refresh Token Rotation enabled on the application. Any one missing and the scope is ignored silently.",
149
+ "Has no roles claim of its own. An action must add a namespaced one, and Auth0 drops a custom claim that is not a URI.",
150
+ "The issuer carries a trailing slash. A verifier pinned without it rejects every token."
151
+ ]
152
+ };
153
+ },
154
+ /**
155
+ * Okta.
156
+ *
157
+ * Two authorization servers that are not interchangeable: a custom one per application,
158
+ * and the org itself for Okta's own management API.
159
+ */
160
+ okta(options) {
161
+ const host = `https://${options.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "")}`;
162
+ const server = options.authorizationServer === void 0 ? "default" : options.authorizationServer;
163
+ const issuer = server === null ? host : `${host}/oauth2/${server}`;
164
+ return {
165
+ label: "Okta",
166
+ client: {
167
+ issuer,
168
+ scopes: options.scopes ?? ["openid", "profile", "email", "offline_access"],
169
+ dpop: options.dpop ?? true
170
+ },
171
+ roles: rolesFrom.groups,
172
+ verifier: (clientId) => ({
173
+ issuer,
174
+ // An access token from a custom server is minted for that server's audience, named in
175
+ // the console and `api://default` by default. It does not carry the client id.
176
+ audience: false,
177
+ authorizedParty: clientId
178
+ }),
179
+ constraints: [
180
+ "Two authorization servers: /oauth2/<name> for an application, the bare domain for the management API. They are not interchangeable.",
181
+ "Turns DPoP on by default for a new application, and an integrator org will not let you untick it.",
182
+ "Binds the refresh token to the DPoP key as well as the access token, so a refresh must be proved with the key that obtained it.",
183
+ "A public SPA cannot introspect at all. An API Services client with `private_key_jwt` has to ask instead.",
184
+ "Client credentials against the org server must use `private_key_jwt`, whatever the application is set to."
185
+ ]
186
+ };
187
+ },
188
+ /**
189
+ * Google.
190
+ *
191
+ * Refuses a public client outright, so server mode is the only option: the token endpoint
192
+ * checks for a client secret before it looks at PKCE.
193
+ */
194
+ google(options = {}) {
195
+ return {
196
+ label: "Google",
197
+ client: {
198
+ issuer: "https://accounts.google.com",
199
+ scopes: options.scopes ?? ["openid", "profile", "email"],
200
+ ...options.hostedDomain ? { extraParams: { hd: options.hostedDomain } } : {}
201
+ },
202
+ roles: rolesFrom.scope,
203
+ verifier: (clientId) => ({
204
+ // Names itself two ways, and a service pinned to one rejects tokens carrying the
205
+ // other. Discovery uses the first.
206
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
207
+ audience: clientId
208
+ }),
209
+ constraints: [
210
+ "Refuses a public client: the token endpoint reports `client_secret is missing` before checking PKCE, so browser mode cannot work.",
211
+ "Publishes its issuer both with and without a scheme, so a verifier needs both spellings.",
212
+ "Issues a refresh token only on the first consent unless `prompt=consent` and `access_type=offline` are sent."
213
+ ]
214
+ };
215
+ },
216
+ /**
217
+ * Dex.
218
+ *
219
+ * Sends no CORS headers, on discovery or the token endpoint, so a browser cannot talk to it
220
+ * at all. Server mode only, and giving endpoints explicitly does not help: the token request
221
+ * is blocked too.
222
+ */
223
+ dex(options) {
224
+ const issuer = trim(options.baseUrl);
225
+ return {
226
+ label: "Dex",
227
+ client: {
228
+ issuer,
229
+ scopes: options.scopes ?? ["openid", "profile", "email", "offline_access"]
230
+ },
231
+ roles: rolesFrom.groups,
232
+ verifier: (clientId) => ({ issuer, audience: clientId }),
233
+ constraints: [
234
+ "Sends no CORS headers on discovery or the token endpoint, so browser mode cannot work. Naming the endpoints explicitly only moves the failure to the token request.",
235
+ "Has no admin interface. Users are static entries in its config file, and a password is a bcrypt hash.",
236
+ "Publishes no revocation endpoint."
237
+ ]
238
+ };
239
+ }
240
+ };
241
+
242
+ // src/env-config.ts
243
+ var SECRETISH = /(SECRET|PASSWORD|PRIVATE_KEY|CREDENTIALS?)($|_)/;
244
+ var scopeList = (value) => value ? value.split(/[\s,]+/).filter(Boolean) : void 0;
245
+ var flag = (value) => value === void 0 ? void 0 : /^(true|1|on|yes)$/i.test(value.trim());
246
+ function authFromEnv(source, options = {}) {
247
+ const clientPrefix = options.clientPrefix ?? "VITE_";
248
+ const specific = `${clientPrefix}${(options.prefix ?? "AUTH").toUpperCase()}`;
249
+ const general = `${clientPrefix}AUTH`;
250
+ const read = (suffix) => {
251
+ const found = source[`${specific}_${suffix}`] ?? source[`${general}_${suffix}`];
252
+ return found === void 0 || found === "" ? void 0 : found;
253
+ };
254
+ if (clientPrefix) {
255
+ const exposed = Object.keys(source).filter(
256
+ (name2) => name2.startsWith(clientPrefix) && SECRETISH.test(name2.slice(clientPrefix.length))
257
+ );
258
+ if (exposed.length) {
259
+ throw new Error(
260
+ `[fluixi/oauth2] ${exposed.join(", ")} ${exposed.length > 1 ? "are" : "is"} prefixed \`${clientPrefix}\`, which inlines the value into the browser bundle. Drop the prefix and read it on the server: \`credentialsFromEnv\` looks for the unprefixed name.`
261
+ );
262
+ }
263
+ }
264
+ const name = options.provider ?? read("PROVIDER") ?? "oidc";
265
+ if (!(name in presets)) {
266
+ throw new Error(
267
+ `[fluixi/oauth2] ${specific}_PROVIDER is "${name}", which is not a preset. Use one of ${Object.keys(presets).join(", ")}, or \`oidc\` with an issuer for anything else.`
268
+ );
269
+ }
270
+ const clientId = read("CLIENT_ID");
271
+ if (!clientId) {
272
+ throw new Error(
273
+ `[fluixi/oauth2] no client id. Set ${specific}_CLIENT_ID (or ${general}_CLIENT_ID).`
274
+ );
275
+ }
276
+ const scopes = scopeList(read("SCOPES"));
277
+ const audience = read("AUDIENCE");
278
+ const rolesClaim = read("ROLES_CLAIM");
279
+ const require2 = (suffix) => {
280
+ const found = read(suffix);
281
+ if (!found) {
282
+ throw new Error(
283
+ `[fluixi/oauth2] the ${name} preset needs ${specific}_${suffix}, which is not set.`
284
+ );
285
+ }
286
+ return found;
287
+ };
288
+ let provider;
289
+ switch (name) {
290
+ case "keycloak":
291
+ provider = presets.keycloak({
292
+ baseUrl: require2("BASE_URL"),
293
+ realm: require2("REALM"),
294
+ scopes
295
+ });
296
+ break;
297
+ case "auth0":
298
+ provider = presets.auth0({
299
+ domain: require2("DOMAIN"),
300
+ audience,
301
+ scopes,
302
+ rolesClaim
303
+ });
304
+ break;
305
+ case "okta": {
306
+ const server = read("AUTH_SERVER");
307
+ provider = presets.okta({
308
+ domain: require2("DOMAIN"),
309
+ // `org` names the org server, which the preset takes as null.
310
+ authorizationServer: server === "org" ? null : server,
311
+ scopes,
312
+ dpop: flag(read("DPOP"))
313
+ });
314
+ break;
315
+ }
316
+ case "google":
317
+ provider = presets.google({ scopes, hostedDomain: read("HOSTED_DOMAIN") });
318
+ break;
319
+ case "dex":
320
+ provider = presets.dex({ baseUrl: require2("BASE_URL"), scopes });
321
+ break;
322
+ case "oauth2":
323
+ provider = presets.oauth2({
324
+ issuer: require2("ISSUER"),
325
+ endpoints: {
326
+ authorization: require2("AUTHORIZATION_ENDPOINT"),
327
+ token: require2("TOKEN_ENDPOINT"),
328
+ userinfo: read("USERINFO_ENDPOINT"),
329
+ revocation: read("REVOCATION_ENDPOINT"),
330
+ introspection: read("INTROSPECTION_ENDPOINT")
331
+ },
332
+ scopes,
333
+ audience: audience ?? (flag(read("NO_AUDIENCE")) ? false : void 0),
334
+ roles: options.roles
335
+ });
336
+ break;
337
+ default:
338
+ provider = presets.oidc({
339
+ issuer: require2("ISSUER"),
340
+ scopes,
341
+ // A provider putting no `aud` on an access token. Keycloak without a mapper is the
342
+ // common case, and the preset moves the check to `azp` rather than dropping it.
343
+ audience: audience ?? (flag(read("NO_AUDIENCE")) ? false : void 0),
344
+ roles: options.roles
345
+ });
346
+ }
347
+ const dpop = flag(read("DPOP"));
348
+ if (dpop !== void 0) provider = { ...provider, client: { ...provider.client, dpop } };
349
+ if (options.roles) provider = { ...provider, roles: options.roles };
350
+ const tokens = options.tokens ?? (read("TOKENS") ?? "browser");
351
+ if (tokens !== "browser" && tokens !== "server") {
352
+ throw new Error(
353
+ `[fluixi/oauth2] ${specific}_TOKENS is "${tokens}". It is \`browser\` or \`server\`.`
354
+ );
355
+ }
356
+ const redirectUri = read("REDIRECT_URI");
357
+ return {
358
+ provider,
359
+ providerName: name,
360
+ clientId,
361
+ tokens,
362
+ redirectUri,
363
+ scopes,
364
+ client: { ...provider.client, clientId, tokens, ...redirectUri ? { redirectUri } : {} }
365
+ };
366
+ }