@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.
- package/LICENSE +21 -0
- package/dist/api-guard.cjs +185 -0
- package/dist/api-guard.d.ts +94 -0
- package/dist/api-guard.d.ts.map +1 -0
- package/dist/api-guard.js +127 -0
- package/dist/api-guard.mjs +141 -0
- package/dist/browser-FDUOEUM2.mjs +3544 -0
- package/dist/browser-ONMHX3Z3.mjs +3545 -0
- package/dist/browser.cjs +735 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +448 -0
- package/dist/browser.mjs +704 -0
- package/dist/callback.cjs +125 -0
- package/dist/callback.d.ts +113 -0
- package/dist/callback.d.ts.map +1 -0
- package/dist/callback.js +183 -0
- package/dist/callback.mjs +106 -0
- package/dist/chunk-25WE6UWI.mjs +10 -0
- package/dist/chunk-4NSOGPLM.mjs +15 -0
- package/dist/chunk-7P6ASYW6.mjs +9 -0
- package/dist/chunk-OGSFNTKA.mjs +13 -0
- package/dist/client-auth.cjs +115 -0
- package/dist/client-auth.d.ts +43 -0
- package/dist/client-auth.d.ts.map +1 -0
- package/dist/client-auth.js +112 -0
- package/dist/client-auth.mjs +84 -0
- package/dist/discovery.cjs +86 -0
- package/dist/discovery.d.ts +15 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +51 -0
- package/dist/discovery.mjs +63 -0
- package/dist/dpop-LVPWWADA.mjs +80 -0
- package/dist/dpop-NS7NKOWM.mjs +79 -0
- package/dist/dpop-store.cjs +152 -0
- package/dist/dpop-store.d.ts +37 -0
- package/dist/dpop-store.d.ts.map +1 -0
- package/dist/dpop-store.js +119 -0
- package/dist/dpop-store.mjs +121 -0
- package/dist/dpop.cjs +117 -0
- package/dist/dpop.d.ts +72 -0
- package/dist/dpop.d.ts.map +1 -0
- package/dist/dpop.js +114 -0
- package/dist/dpop.mjs +86 -0
- package/dist/env-config.cjs +366 -0
- package/dist/env-config.d.ts +103 -0
- package/dist/env-config.d.ts.map +1 -0
- package/dist/env-config.js +184 -0
- package/dist/env-config.mjs +343 -0
- package/dist/env-credentials.cjs +82 -0
- package/dist/env-credentials.d.ts +68 -0
- package/dist/env-credentials.d.ts.map +1 -0
- package/dist/env-credentials.js +76 -0
- package/dist/env-credentials.mjs +51 -0
- package/dist/index.cjs +940 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +101 -0
- package/dist/index.mjs +909 -0
- package/dist/pkce.cjs +87 -0
- package/dist/pkce.d.ts +58 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +112 -0
- package/dist/pkce.mjs +66 -0
- package/dist/presets.cjs +240 -0
- package/dist/presets.d.ts +140 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +182 -0
- package/dist/presets.mjs +217 -0
- package/dist/return-to.cjs +46 -0
- package/dist/return-to.d.ts +49 -0
- package/dist/return-to.d.ts.map +1 -0
- package/dist/return-to.js +71 -0
- package/dist/return-to.mjs +25 -0
- package/dist/roles.cjs +56 -0
- package/dist/roles.d.ts +35 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +37 -0
- package/dist/roles.mjs +35 -0
- package/dist/server-client.cjs +134 -0
- package/dist/server-client.d.ts +12 -0
- package/dist/server-client.d.ts.map +1 -0
- package/dist/server-client.js +107 -0
- package/dist/server-client.mjs +111 -0
- package/dist/server.cjs +470 -0
- package/dist/server.d.ts +89 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +360 -0
- package/dist/server.mjs +441 -0
- package/dist/service-client.cjs +244 -0
- package/dist/service-client.d.ts +89 -0
- package/dist/service-client.d.ts.map +1 -0
- package/dist/service-client.js +112 -0
- package/dist/service-client.mjs +213 -0
- package/dist/service.cjs +903 -0
- package/dist/service.d.ts +96 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +129 -0
- package/dist/service.mjs +874 -0
- package/dist/tokens.cjs +29 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +21 -0
- package/dist/tokens.mjs +8 -0
- package/dist/tsconfig.lib.tsbuildinfo +1 -0
- package/dist/types.cjs +33 -0
- package/dist/types.d.ts +239 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.mjs +12 -0
- package/dist/verify.cjs +724 -0
- package/dist/verify.d.ts +205 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +371 -0
- package/dist/verify.mjs +590 -0
- package/package.json +121 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth configuration from environment variables, with names the framework agrees on.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces with opposite rules, and mixing them is how a secret ships:
|
|
5
|
+
*
|
|
6
|
+
* - the **client** half is public by nature. An issuer, a client id, where the tokens live, a redirect URI:
|
|
7
|
+
* all of it reaches the browser whatever you do, because a public client sends them to the
|
|
8
|
+
* provider in a URL. Prefixed `VITE_`, which is what makes Vite inline them.
|
|
9
|
+
* - the **server** half is a client secret, a signing key, a cookie secret. Never prefixed.
|
|
10
|
+
* `credentialsFromEnv` reads those, and `@fluixi/start`'s env guard fails a build that
|
|
11
|
+
* names one `VITE_`.
|
|
12
|
+
*
|
|
13
|
+
* This module is the client half. It takes the record rather than reading `import.meta.env`
|
|
14
|
+
* itself, and that is deliberate. Vite replaces `import.meta.env` where it processes the
|
|
15
|
+
* source, so writing it in app code is reliable in a way that a published package reading it
|
|
16
|
+
* is not. The cost is one word at the call site:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* export const auth = authFromEnv(import.meta.env);
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The same function then works against `process.env`, a worker binding, Deno, or a fixture in
|
|
23
|
+
* a test, none of which involve a bundler at all.
|
|
24
|
+
*/
|
|
25
|
+
import { type PresetName, type ProviderPreset } from './presets.js';
|
|
26
|
+
import type { TokenClaims } from './verify.js';
|
|
27
|
+
export interface AuthEnvOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Prefix for the variable names, after the client prefix. Default `AUTH`.
|
|
30
|
+
*
|
|
31
|
+
* `VITE_AUTH_ISSUER`, `VITE_AUTH_CLIENT_ID`, and so on. Pass a provider name for an app
|
|
32
|
+
* offering several: `prefix: 'OKTA'` reads `VITE_OKTA_ISSUER`, falling back to
|
|
33
|
+
* `VITE_AUTH_ISSUER` for anything it does not set.
|
|
34
|
+
*/
|
|
35
|
+
prefix?: string;
|
|
36
|
+
/**
|
|
37
|
+
* The client prefix. Default `VITE_`, which is Vite's.
|
|
38
|
+
*
|
|
39
|
+
* Pass `''` when reading `process.env` on a server, where nothing is prefixed.
|
|
40
|
+
*/
|
|
41
|
+
clientPrefix?: string;
|
|
42
|
+
/** Overrides `<PREFIX>_PROVIDER`, for an app that knows which provider it is. */
|
|
43
|
+
provider?: PresetName;
|
|
44
|
+
/** Overrides `<PREFIX>_TOKENS`. */
|
|
45
|
+
tokens?: 'browser' | 'server';
|
|
46
|
+
/** Where this provider puts roles, when the preset's default is not right. */
|
|
47
|
+
roles?: (claims: TokenClaims) => string[];
|
|
48
|
+
}
|
|
49
|
+
/** What the environment said, assembled. */
|
|
50
|
+
export interface AuthEnv {
|
|
51
|
+
/** The preset, built from whichever variables were present. */
|
|
52
|
+
provider: ProviderPreset;
|
|
53
|
+
/** Which preset was chosen, for a message or a log. */
|
|
54
|
+
providerName: PresetName;
|
|
55
|
+
clientId: string;
|
|
56
|
+
/** Where the tokens live. `browser` unless told otherwise. */
|
|
57
|
+
tokens: 'browser' | 'server';
|
|
58
|
+
/** The redirect URI, or undefined when the app builds it from its own base URL. */
|
|
59
|
+
redirectUri?: string;
|
|
60
|
+
/** Scopes, when the environment overrode the preset's. */
|
|
61
|
+
scopes?: string[];
|
|
62
|
+
/** Everything needed by `createBrowserClient`, ready to spread. */
|
|
63
|
+
client: ProviderPreset['client'] & {
|
|
64
|
+
clientId: string;
|
|
65
|
+
tokens: 'browser' | 'server';
|
|
66
|
+
redirectUri?: string;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read auth configuration from an environment record.
|
|
71
|
+
*
|
|
72
|
+
* Variables, with `VITE_AUTH` as the default prefix. Everything is optional except an issuer
|
|
73
|
+
* (or the provider-specific field that builds one) and a client id.
|
|
74
|
+
*
|
|
75
|
+
* | variable | meaning |
|
|
76
|
+
* | --- | --- |
|
|
77
|
+
* | `_PROVIDER` | `oidc` (default), `keycloak`, `auth0`, `okta`, `google`, `dex`, `oauth2` |
|
|
78
|
+
* | `_ISSUER` | the issuer, for `oidc` and `oauth2` |
|
|
79
|
+
* | `_CLIENT_ID` | this client |
|
|
80
|
+
* | `_TOKENS` | `browser` (default) or `server` |
|
|
81
|
+
* | `_REDIRECT_URI` | where the provider sends the browser back |
|
|
82
|
+
* | `_SCOPES` | space or comma separated, overriding the preset's |
|
|
83
|
+
* | `_AUDIENCE` | an API identifier. Auth0 needs it for a JWT access token |
|
|
84
|
+
* | `_DPOP` | `true` or `false`, overriding the preset |
|
|
85
|
+
* | `_DOMAIN` | the tenant, for `auth0` and `okta` |
|
|
86
|
+
* | `_BASE_URL` | for `keycloak` and `dex` |
|
|
87
|
+
* | `_REALM` | for `keycloak` |
|
|
88
|
+
* | `_AUTH_SERVER` | for `okta`, default `default`, `org` for the org server |
|
|
89
|
+
* | `_ROLES_CLAIM` | the namespaced claim an Auth0 action adds |
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* // src/config/auth.ts
|
|
94
|
+
* import { authFromEnv } from '@fluixi/oauth2/env';
|
|
95
|
+
* export const auth = authFromEnv(import.meta.env);
|
|
96
|
+
*
|
|
97
|
+
* // src/services/auth/auth.ts
|
|
98
|
+
* const client = createBrowserClient({ ...auth.client, redirectUri: `${env.appUrl}/callback` });
|
|
99
|
+
* const verify = createTokenVerifier(auth.provider.verifier(auth.clientId));
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export declare function authFromEnv(source: Record<string, string | undefined>, options?: AuthEnvOptions): AuthEnv;
|
|
103
|
+
//# sourceMappingURL=env-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env-config.d.ts","sourceRoot":"","sources":["../src/env-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAW,KAAK,UAAU,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAC7E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAK/C,MAAM,WAAW,cAAc;IAC7B;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iFAAiF;IACjF,QAAQ,CAAC,EAAE,UAAU,CAAC;IACtB,mCAAmC;IACnC,MAAM,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC9B,8EAA8E;IAC9E,KAAK,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,MAAM,EAAE,CAAC;CAC3C;AAED,4CAA4C;AAC5C,MAAM,WAAW,OAAO;IACtB,+DAA+D;IAC/D,QAAQ,EAAE,cAAc,CAAC;IACzB,uDAAuD;IACvD,YAAY,EAAE,UAAU,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,8DAA8D;IAC9D,MAAM,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC7B,mFAAmF;IACnF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,mEAAmE;IACnE,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,GAAG;QACjC,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,SAAS,GAAG,QAAQ,CAAC;QAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAC1C,OAAO,GAAE,cAAmB,GAC3B,OAAO,CA4IT"}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth configuration from environment variables, with names the framework agrees on.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces with opposite rules, and mixing them is how a secret ships:
|
|
5
|
+
*
|
|
6
|
+
* - the **client** half is public by nature. An issuer, a client id, where the tokens live, a redirect URI:
|
|
7
|
+
* all of it reaches the browser whatever you do, because a public client sends them to the
|
|
8
|
+
* provider in a URL. Prefixed `VITE_`, which is what makes Vite inline them.
|
|
9
|
+
* - the **server** half is a client secret, a signing key, a cookie secret. Never prefixed.
|
|
10
|
+
* `credentialsFromEnv` reads those, and `@fluixi/start`'s env guard fails a build that
|
|
11
|
+
* names one `VITE_`.
|
|
12
|
+
*
|
|
13
|
+
* This module is the client half. It takes the record rather than reading `import.meta.env`
|
|
14
|
+
* itself, and that is deliberate. Vite replaces `import.meta.env` where it processes the
|
|
15
|
+
* source, so writing it in app code is reliable in a way that a published package reading it
|
|
16
|
+
* is not. The cost is one word at the call site:
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* export const auth = authFromEnv(import.meta.env);
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The same function then works against `process.env`, a worker binding, Deno, or a fixture in
|
|
23
|
+
* a test, none of which involve a bundler at all.
|
|
24
|
+
*/
|
|
25
|
+
import { presets } from './presets.js';
|
|
26
|
+
/** Names that mean a secret, which must never arrive through a client-prefixed variable. */
|
|
27
|
+
const SECRETISH = /(SECRET|PASSWORD|PRIVATE_KEY|CREDENTIALS?)($|_)/;
|
|
28
|
+
/** Splits a scope list written with spaces or commas. */
|
|
29
|
+
const scopeList = (value) => value ? value.split(/[\s,]+/).filter(Boolean) : undefined;
|
|
30
|
+
/** `true`, `1` and `on` all mean yes. Anything else present means no. */
|
|
31
|
+
const flag = (value) => value === undefined ? undefined : /^(true|1|on|yes)$/i.test(value.trim());
|
|
32
|
+
/**
|
|
33
|
+
* Read auth configuration from an environment record.
|
|
34
|
+
*
|
|
35
|
+
* Variables, with `VITE_AUTH` as the default prefix. Everything is optional except an issuer
|
|
36
|
+
* (or the provider-specific field that builds one) and a client id.
|
|
37
|
+
*
|
|
38
|
+
* | variable | meaning |
|
|
39
|
+
* | --- | --- |
|
|
40
|
+
* | `_PROVIDER` | `oidc` (default), `keycloak`, `auth0`, `okta`, `google`, `dex`, `oauth2` |
|
|
41
|
+
* | `_ISSUER` | the issuer, for `oidc` and `oauth2` |
|
|
42
|
+
* | `_CLIENT_ID` | this client |
|
|
43
|
+
* | `_TOKENS` | `browser` (default) or `server` |
|
|
44
|
+
* | `_REDIRECT_URI` | where the provider sends the browser back |
|
|
45
|
+
* | `_SCOPES` | space or comma separated, overriding the preset's |
|
|
46
|
+
* | `_AUDIENCE` | an API identifier. Auth0 needs it for a JWT access token |
|
|
47
|
+
* | `_DPOP` | `true` or `false`, overriding the preset |
|
|
48
|
+
* | `_DOMAIN` | the tenant, for `auth0` and `okta` |
|
|
49
|
+
* | `_BASE_URL` | for `keycloak` and `dex` |
|
|
50
|
+
* | `_REALM` | for `keycloak` |
|
|
51
|
+
* | `_AUTH_SERVER` | for `okta`, default `default`, `org` for the org server |
|
|
52
|
+
* | `_ROLES_CLAIM` | the namespaced claim an Auth0 action adds |
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* // src/config/auth.ts
|
|
57
|
+
* import { authFromEnv } from '@fluixi/oauth2/env';
|
|
58
|
+
* export const auth = authFromEnv(import.meta.env);
|
|
59
|
+
*
|
|
60
|
+
* // src/services/auth/auth.ts
|
|
61
|
+
* const client = createBrowserClient({ ...auth.client, redirectUri: `${env.appUrl}/callback` });
|
|
62
|
+
* const verify = createTokenVerifier(auth.provider.verifier(auth.clientId));
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function authFromEnv(source, options = {}) {
|
|
66
|
+
const clientPrefix = options.clientPrefix ?? 'VITE_';
|
|
67
|
+
const specific = `${clientPrefix}${(options.prefix ?? 'AUTH').toUpperCase()}`;
|
|
68
|
+
const general = `${clientPrefix}AUTH`;
|
|
69
|
+
/** The specific prefix wins, so one app can carry several providers. */
|
|
70
|
+
const read = (suffix) => {
|
|
71
|
+
const found = source[`${specific}_${suffix}`] ?? source[`${general}_${suffix}`];
|
|
72
|
+
return found === undefined || found === '' ? undefined : found;
|
|
73
|
+
};
|
|
74
|
+
// A secret arriving through a client-prefixed variable is published, whether anything reads
|
|
75
|
+
// it or not. `@fluixi/start`'s guard fails the build; this catches the case where the build
|
|
76
|
+
// guard is not in play, and says the same thing.
|
|
77
|
+
if (clientPrefix) {
|
|
78
|
+
const exposed = Object.keys(source).filter((name) => name.startsWith(clientPrefix) && SECRETISH.test(name.slice(clientPrefix.length)));
|
|
79
|
+
if (exposed.length) {
|
|
80
|
+
throw new Error(`[fluixi/oauth2] ${exposed.join(', ')} ${exposed.length > 1 ? 'are' : 'is'} prefixed \`${clientPrefix}\`, which inlines the value into the browser bundle. ` +
|
|
81
|
+
'Drop the prefix and read it on the server: `credentialsFromEnv` looks for the unprefixed name.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const name = (options.provider ?? read('PROVIDER') ?? 'oidc');
|
|
85
|
+
if (!(name in presets)) {
|
|
86
|
+
throw new Error(`[fluixi/oauth2] ${specific}_PROVIDER is "${name}", which is not a preset. ` +
|
|
87
|
+
`Use one of ${Object.keys(presets).join(', ')}, or \`oidc\` with an issuer for anything else.`);
|
|
88
|
+
}
|
|
89
|
+
const clientId = read('CLIENT_ID');
|
|
90
|
+
if (!clientId) {
|
|
91
|
+
throw new Error(`[fluixi/oauth2] no client id. Set ${specific}_CLIENT_ID (or ${general}_CLIENT_ID).`);
|
|
92
|
+
}
|
|
93
|
+
const scopes = scopeList(read('SCOPES'));
|
|
94
|
+
const audience = read('AUDIENCE');
|
|
95
|
+
const rolesClaim = read('ROLES_CLAIM');
|
|
96
|
+
/** Names the variable a preset needs, so a missing one says which to set. */
|
|
97
|
+
const require = (suffix) => {
|
|
98
|
+
const found = read(suffix);
|
|
99
|
+
if (!found) {
|
|
100
|
+
throw new Error(`[fluixi/oauth2] the ${name} preset needs ${specific}_${suffix}, which is not set.`);
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
};
|
|
104
|
+
let provider;
|
|
105
|
+
switch (name) {
|
|
106
|
+
case 'keycloak':
|
|
107
|
+
provider = presets.keycloak({
|
|
108
|
+
baseUrl: require('BASE_URL'),
|
|
109
|
+
realm: require('REALM'),
|
|
110
|
+
scopes,
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
case 'auth0':
|
|
114
|
+
provider = presets.auth0({
|
|
115
|
+
domain: require('DOMAIN'),
|
|
116
|
+
audience,
|
|
117
|
+
scopes,
|
|
118
|
+
rolesClaim,
|
|
119
|
+
});
|
|
120
|
+
break;
|
|
121
|
+
case 'okta': {
|
|
122
|
+
const server = read('AUTH_SERVER');
|
|
123
|
+
provider = presets.okta({
|
|
124
|
+
domain: require('DOMAIN'),
|
|
125
|
+
// `org` names the org server, which the preset takes as null.
|
|
126
|
+
authorizationServer: server === 'org' ? null : server,
|
|
127
|
+
scopes,
|
|
128
|
+
dpop: flag(read('DPOP')),
|
|
129
|
+
});
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'google':
|
|
133
|
+
provider = presets.google({ scopes, hostedDomain: read('HOSTED_DOMAIN') });
|
|
134
|
+
break;
|
|
135
|
+
case 'dex':
|
|
136
|
+
provider = presets.dex({ baseUrl: require('BASE_URL'), scopes });
|
|
137
|
+
break;
|
|
138
|
+
case 'oauth2':
|
|
139
|
+
provider = presets.oauth2({
|
|
140
|
+
issuer: require('ISSUER'),
|
|
141
|
+
endpoints: {
|
|
142
|
+
authorization: require('AUTHORIZATION_ENDPOINT'),
|
|
143
|
+
token: require('TOKEN_ENDPOINT'),
|
|
144
|
+
userinfo: read('USERINFO_ENDPOINT'),
|
|
145
|
+
revocation: read('REVOCATION_ENDPOINT'),
|
|
146
|
+
introspection: read('INTROSPECTION_ENDPOINT'),
|
|
147
|
+
},
|
|
148
|
+
scopes,
|
|
149
|
+
audience: audience ?? (flag(read('NO_AUDIENCE')) ? false : undefined),
|
|
150
|
+
roles: options.roles,
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
default:
|
|
154
|
+
provider = presets.oidc({
|
|
155
|
+
issuer: require('ISSUER'),
|
|
156
|
+
scopes,
|
|
157
|
+
// A provider putting no `aud` on an access token. Keycloak without a mapper is the
|
|
158
|
+
// common case, and the preset moves the check to `azp` rather than dropping it.
|
|
159
|
+
audience: audience ?? (flag(read('NO_AUDIENCE')) ? false : undefined),
|
|
160
|
+
roles: options.roles,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
// An explicit override beats the preset, which is how an app turns DPoP off for a provider
|
|
164
|
+
// that has it on by default.
|
|
165
|
+
const dpop = flag(read('DPOP'));
|
|
166
|
+
if (dpop !== undefined)
|
|
167
|
+
provider = { ...provider, client: { ...provider.client, dpop } };
|
|
168
|
+
if (options.roles)
|
|
169
|
+
provider = { ...provider, roles: options.roles };
|
|
170
|
+
const tokens = options.tokens ?? (read('TOKENS') ?? 'browser');
|
|
171
|
+
if (tokens !== 'browser' && tokens !== 'server') {
|
|
172
|
+
throw new Error(`[fluixi/oauth2] ${specific}_TOKENS is "${tokens}". It is \`browser\` or \`server\`.`);
|
|
173
|
+
}
|
|
174
|
+
const redirectUri = read('REDIRECT_URI');
|
|
175
|
+
return {
|
|
176
|
+
provider,
|
|
177
|
+
providerName: name,
|
|
178
|
+
clientId,
|
|
179
|
+
tokens,
|
|
180
|
+
redirectUri,
|
|
181
|
+
scopes,
|
|
182
|
+
client: { ...provider.client, clientId, tokens, ...(redirectUri ? { redirectUri } : {}) },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// src/roles.ts
|
|
2
|
+
var rolesFrom = {
|
|
3
|
+
/** Keycloak, realm roles. Where it puts them unless a client mapper is configured. */
|
|
4
|
+
keycloakRealm: (claims) => {
|
|
5
|
+
const realm = claims.realm_access;
|
|
6
|
+
return realm?.roles ?? [];
|
|
7
|
+
},
|
|
8
|
+
/** Keycloak, roles scoped to one client rather than the realm. */
|
|
9
|
+
keycloakClient: (clientId) => (claims) => {
|
|
10
|
+
const all = claims.resource_access;
|
|
11
|
+
return all?.[clientId]?.roles ?? [];
|
|
12
|
+
},
|
|
13
|
+
/** Okta, and anything else using a groups claim. Needs a groups claim on the token. */
|
|
14
|
+
groups: (claims) => Array.isArray(claims.groups) ? claims.groups : [],
|
|
15
|
+
/** Cognito, which namespaces the claim. */
|
|
16
|
+
cognito: (claims) => {
|
|
17
|
+
const groups = claims["cognito:groups"];
|
|
18
|
+
return Array.isArray(groups) ? groups : [];
|
|
19
|
+
},
|
|
20
|
+
/**
|
|
21
|
+
* Auth0, which requires a namespaced custom claim added by an action.
|
|
22
|
+
*
|
|
23
|
+
* `rolesFrom.namespaced('https://adafri.com/roles')`. Auth0 silently drops a custom claim
|
|
24
|
+
* that is not a URI, so an empty list here usually means the namespace, not the action.
|
|
25
|
+
*/
|
|
26
|
+
namespaced: (claim) => (claims) => {
|
|
27
|
+
const value = claims[claim];
|
|
28
|
+
return Array.isArray(value) ? value : [];
|
|
29
|
+
},
|
|
30
|
+
/** The `scope` claim read as a role list, for a provider issuing no roles at all. */
|
|
31
|
+
scope: (claims) => typeof claims.scope === "string" ? claims.scope.split(" ") : []
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// src/presets.ts
|
|
35
|
+
var trim = (url) => url.replace(/\/+$/, "");
|
|
36
|
+
var presets = {
|
|
37
|
+
/**
|
|
38
|
+
* Any OIDC provider publishing a discovery document.
|
|
39
|
+
*
|
|
40
|
+
* The agnostic path, and the one to reach for first. A provider not listed below almost
|
|
41
|
+
* certainly works through this.
|
|
42
|
+
*/
|
|
43
|
+
oidc(options) {
|
|
44
|
+
return {
|
|
45
|
+
label: options.issuer,
|
|
46
|
+
client: { issuer: trim(options.issuer), scopes: options.scopes },
|
|
47
|
+
roles: options.roles ?? rolesFrom.scope,
|
|
48
|
+
verifier: (clientId) => ({
|
|
49
|
+
issuer: trim(options.issuer),
|
|
50
|
+
audience: options.audience ?? clientId,
|
|
51
|
+
...options.audience === false ? { authorizedParty: clientId } : {}
|
|
52
|
+
}),
|
|
53
|
+
constraints: []
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
/**
|
|
57
|
+
* A plain OAuth2 server with no discovery document.
|
|
58
|
+
*
|
|
59
|
+
* Every endpoint is given, because there is nothing to ask. Token verification needs an
|
|
60
|
+
* issuer regardless, since a verifier will not accept a token without one.
|
|
61
|
+
*/
|
|
62
|
+
oauth2(options) {
|
|
63
|
+
return {
|
|
64
|
+
label: options.issuer,
|
|
65
|
+
client: { endpoints: options.endpoints, scopes: options.scopes },
|
|
66
|
+
roles: options.roles ?? rolesFrom.scope,
|
|
67
|
+
verifier: (clientId) => ({
|
|
68
|
+
issuer: trim(options.issuer),
|
|
69
|
+
audience: options.audience ?? clientId,
|
|
70
|
+
...options.audience === false ? { authorizedParty: clientId } : {}
|
|
71
|
+
}),
|
|
72
|
+
constraints: ["Publishes no discovery document, so every endpoint is configured by hand."]
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
/** Keycloak. A realm is its own issuer. */
|
|
76
|
+
keycloak(options) {
|
|
77
|
+
const issuer = `${trim(options.baseUrl)}/realms/${options.realm}`;
|
|
78
|
+
return {
|
|
79
|
+
label: "Keycloak",
|
|
80
|
+
client: {
|
|
81
|
+
issuer,
|
|
82
|
+
scopes: options.scopes ?? ["openid", "profile", "email"]
|
|
83
|
+
},
|
|
84
|
+
roles: rolesFrom.keycloakRealm,
|
|
85
|
+
// No `aud` on an access token until an audience mapper is configured, and the client is
|
|
86
|
+
// named in `azp` instead. So the check moves to `authorizedParty` rather than being
|
|
87
|
+
// dropped: `audience: false` alone would accept a token minted for any client of the
|
|
88
|
+
// realm, which is the check being skipped.
|
|
89
|
+
verifier: (clientId) => ({ issuer, audience: false, authorizedParty: clientId }),
|
|
90
|
+
constraints: [
|
|
91
|
+
"Puts no `aud` on an access token until a dedicated audience mapper is configured. Configure one and switch the verifier to `audience: clientId`.",
|
|
92
|
+
"Revokes the whole session when an authorization code is replayed, so a replay test has to run last."
|
|
93
|
+
]
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
/**
|
|
97
|
+
* Auth0.
|
|
98
|
+
*
|
|
99
|
+
* `audience` is the field that decides what kind of token comes back. Without it Auth0
|
|
100
|
+
* issues an opaque token for its own userinfo endpoint and the JWT checks have nothing to
|
|
101
|
+
* work with.
|
|
102
|
+
*/
|
|
103
|
+
auth0(options) {
|
|
104
|
+
const issuer = `https://${options.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "")}/`;
|
|
105
|
+
return {
|
|
106
|
+
label: "Auth0",
|
|
107
|
+
client: {
|
|
108
|
+
issuer,
|
|
109
|
+
scopes: options.scopes ?? ["openid", "profile", "email", "offline_access"],
|
|
110
|
+
...options.audience ? { extraParams: { audience: options.audience } } : {}
|
|
111
|
+
},
|
|
112
|
+
roles: options.rolesClaim ? rolesFrom.namespaced(options.rolesClaim) : rolesFrom.scope,
|
|
113
|
+
verifier: (clientId) => ({
|
|
114
|
+
issuer,
|
|
115
|
+
// The API identifier is the audience of an access token. Without one the token is
|
|
116
|
+
// opaque and there is nothing for a verifier to check.
|
|
117
|
+
audience: options.audience ?? false,
|
|
118
|
+
...options.audience ? {} : { authorizedParty: clientId }
|
|
119
|
+
}),
|
|
120
|
+
constraints: [
|
|
121
|
+
"Issues an opaque access token unless `audience` names an API, so token verification needs it.",
|
|
122
|
+
"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.",
|
|
123
|
+
"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.",
|
|
124
|
+
"The issuer carries a trailing slash. A verifier pinned without it rejects every token."
|
|
125
|
+
]
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
/**
|
|
129
|
+
* Okta.
|
|
130
|
+
*
|
|
131
|
+
* Two authorization servers that are not interchangeable: a custom one per application,
|
|
132
|
+
* and the org itself for Okta's own management API.
|
|
133
|
+
*/
|
|
134
|
+
okta(options) {
|
|
135
|
+
const host = `https://${options.domain.replace(/^https?:\/\//, "").replace(/\/+$/, "")}`;
|
|
136
|
+
const server = options.authorizationServer === void 0 ? "default" : options.authorizationServer;
|
|
137
|
+
const issuer = server === null ? host : `${host}/oauth2/${server}`;
|
|
138
|
+
return {
|
|
139
|
+
label: "Okta",
|
|
140
|
+
client: {
|
|
141
|
+
issuer,
|
|
142
|
+
scopes: options.scopes ?? ["openid", "profile", "email", "offline_access"],
|
|
143
|
+
dpop: options.dpop ?? true
|
|
144
|
+
},
|
|
145
|
+
roles: rolesFrom.groups,
|
|
146
|
+
verifier: (clientId) => ({
|
|
147
|
+
issuer,
|
|
148
|
+
// An access token from a custom server is minted for that server's audience, named in
|
|
149
|
+
// the console and `api://default` by default. It does not carry the client id.
|
|
150
|
+
audience: false,
|
|
151
|
+
authorizedParty: clientId
|
|
152
|
+
}),
|
|
153
|
+
constraints: [
|
|
154
|
+
"Two authorization servers: /oauth2/<name> for an application, the bare domain for the management API. They are not interchangeable.",
|
|
155
|
+
"Turns DPoP on by default for a new application, and an integrator org will not let you untick it.",
|
|
156
|
+
"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.",
|
|
157
|
+
"A public SPA cannot introspect at all. An API Services client with `private_key_jwt` has to ask instead.",
|
|
158
|
+
"Client credentials against the org server must use `private_key_jwt`, whatever the application is set to."
|
|
159
|
+
]
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
/**
|
|
163
|
+
* Google.
|
|
164
|
+
*
|
|
165
|
+
* Refuses a public client outright, so server mode is the only option: the token endpoint
|
|
166
|
+
* checks for a client secret before it looks at PKCE.
|
|
167
|
+
*/
|
|
168
|
+
google(options = {}) {
|
|
169
|
+
return {
|
|
170
|
+
label: "Google",
|
|
171
|
+
client: {
|
|
172
|
+
issuer: "https://accounts.google.com",
|
|
173
|
+
scopes: options.scopes ?? ["openid", "profile", "email"],
|
|
174
|
+
...options.hostedDomain ? { extraParams: { hd: options.hostedDomain } } : {}
|
|
175
|
+
},
|
|
176
|
+
roles: rolesFrom.scope,
|
|
177
|
+
verifier: (clientId) => ({
|
|
178
|
+
// Names itself two ways, and a service pinned to one rejects tokens carrying the
|
|
179
|
+
// other. Discovery uses the first.
|
|
180
|
+
issuer: ["https://accounts.google.com", "accounts.google.com"],
|
|
181
|
+
audience: clientId
|
|
182
|
+
}),
|
|
183
|
+
constraints: [
|
|
184
|
+
"Refuses a public client: the token endpoint reports `client_secret is missing` before checking PKCE, so browser mode cannot work.",
|
|
185
|
+
"Publishes its issuer both with and without a scheme, so a verifier needs both spellings.",
|
|
186
|
+
"Issues a refresh token only on the first consent unless `prompt=consent` and `access_type=offline` are sent."
|
|
187
|
+
]
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
/**
|
|
191
|
+
* Dex.
|
|
192
|
+
*
|
|
193
|
+
* Sends no CORS headers, on discovery or the token endpoint, so a browser cannot talk to it
|
|
194
|
+
* at all. Server mode only, and giving endpoints explicitly does not help: the token request
|
|
195
|
+
* is blocked too.
|
|
196
|
+
*/
|
|
197
|
+
dex(options) {
|
|
198
|
+
const issuer = trim(options.baseUrl);
|
|
199
|
+
return {
|
|
200
|
+
label: "Dex",
|
|
201
|
+
client: {
|
|
202
|
+
issuer,
|
|
203
|
+
scopes: options.scopes ?? ["openid", "profile", "email", "offline_access"]
|
|
204
|
+
},
|
|
205
|
+
roles: rolesFrom.groups,
|
|
206
|
+
verifier: (clientId) => ({ issuer, audience: clientId }),
|
|
207
|
+
constraints: [
|
|
208
|
+
"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.",
|
|
209
|
+
"Has no admin interface. Users are static entries in its config file, and a password is a bcrypt hash.",
|
|
210
|
+
"Publishes no revocation endpoint."
|
|
211
|
+
]
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// src/env-config.ts
|
|
217
|
+
var SECRETISH = /(SECRET|PASSWORD|PRIVATE_KEY|CREDENTIALS?)($|_)/;
|
|
218
|
+
var scopeList = (value) => value ? value.split(/[\s,]+/).filter(Boolean) : void 0;
|
|
219
|
+
var flag = (value) => value === void 0 ? void 0 : /^(true|1|on|yes)$/i.test(value.trim());
|
|
220
|
+
function authFromEnv(source, options = {}) {
|
|
221
|
+
const clientPrefix = options.clientPrefix ?? "VITE_";
|
|
222
|
+
const specific = `${clientPrefix}${(options.prefix ?? "AUTH").toUpperCase()}`;
|
|
223
|
+
const general = `${clientPrefix}AUTH`;
|
|
224
|
+
const read = (suffix) => {
|
|
225
|
+
const found = source[`${specific}_${suffix}`] ?? source[`${general}_${suffix}`];
|
|
226
|
+
return found === void 0 || found === "" ? void 0 : found;
|
|
227
|
+
};
|
|
228
|
+
if (clientPrefix) {
|
|
229
|
+
const exposed = Object.keys(source).filter(
|
|
230
|
+
(name2) => name2.startsWith(clientPrefix) && SECRETISH.test(name2.slice(clientPrefix.length))
|
|
231
|
+
);
|
|
232
|
+
if (exposed.length) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`[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.`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const name = options.provider ?? read("PROVIDER") ?? "oidc";
|
|
239
|
+
if (!(name in presets)) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`[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.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
const clientId = read("CLIENT_ID");
|
|
245
|
+
if (!clientId) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`[fluixi/oauth2] no client id. Set ${specific}_CLIENT_ID (or ${general}_CLIENT_ID).`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const scopes = scopeList(read("SCOPES"));
|
|
251
|
+
const audience = read("AUDIENCE");
|
|
252
|
+
const rolesClaim = read("ROLES_CLAIM");
|
|
253
|
+
const require2 = (suffix) => {
|
|
254
|
+
const found = read(suffix);
|
|
255
|
+
if (!found) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`[fluixi/oauth2] the ${name} preset needs ${specific}_${suffix}, which is not set.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return found;
|
|
261
|
+
};
|
|
262
|
+
let provider;
|
|
263
|
+
switch (name) {
|
|
264
|
+
case "keycloak":
|
|
265
|
+
provider = presets.keycloak({
|
|
266
|
+
baseUrl: require2("BASE_URL"),
|
|
267
|
+
realm: require2("REALM"),
|
|
268
|
+
scopes
|
|
269
|
+
});
|
|
270
|
+
break;
|
|
271
|
+
case "auth0":
|
|
272
|
+
provider = presets.auth0({
|
|
273
|
+
domain: require2("DOMAIN"),
|
|
274
|
+
audience,
|
|
275
|
+
scopes,
|
|
276
|
+
rolesClaim
|
|
277
|
+
});
|
|
278
|
+
break;
|
|
279
|
+
case "okta": {
|
|
280
|
+
const server = read("AUTH_SERVER");
|
|
281
|
+
provider = presets.okta({
|
|
282
|
+
domain: require2("DOMAIN"),
|
|
283
|
+
// `org` names the org server, which the preset takes as null.
|
|
284
|
+
authorizationServer: server === "org" ? null : server,
|
|
285
|
+
scopes,
|
|
286
|
+
dpop: flag(read("DPOP"))
|
|
287
|
+
});
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case "google":
|
|
291
|
+
provider = presets.google({ scopes, hostedDomain: read("HOSTED_DOMAIN") });
|
|
292
|
+
break;
|
|
293
|
+
case "dex":
|
|
294
|
+
provider = presets.dex({ baseUrl: require2("BASE_URL"), scopes });
|
|
295
|
+
break;
|
|
296
|
+
case "oauth2":
|
|
297
|
+
provider = presets.oauth2({
|
|
298
|
+
issuer: require2("ISSUER"),
|
|
299
|
+
endpoints: {
|
|
300
|
+
authorization: require2("AUTHORIZATION_ENDPOINT"),
|
|
301
|
+
token: require2("TOKEN_ENDPOINT"),
|
|
302
|
+
userinfo: read("USERINFO_ENDPOINT"),
|
|
303
|
+
revocation: read("REVOCATION_ENDPOINT"),
|
|
304
|
+
introspection: read("INTROSPECTION_ENDPOINT")
|
|
305
|
+
},
|
|
306
|
+
scopes,
|
|
307
|
+
audience: audience ?? (flag(read("NO_AUDIENCE")) ? false : void 0),
|
|
308
|
+
roles: options.roles
|
|
309
|
+
});
|
|
310
|
+
break;
|
|
311
|
+
default:
|
|
312
|
+
provider = presets.oidc({
|
|
313
|
+
issuer: require2("ISSUER"),
|
|
314
|
+
scopes,
|
|
315
|
+
// A provider putting no `aud` on an access token. Keycloak without a mapper is the
|
|
316
|
+
// common case, and the preset moves the check to `azp` rather than dropping it.
|
|
317
|
+
audience: audience ?? (flag(read("NO_AUDIENCE")) ? false : void 0),
|
|
318
|
+
roles: options.roles
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const dpop = flag(read("DPOP"));
|
|
322
|
+
if (dpop !== void 0) provider = { ...provider, client: { ...provider.client, dpop } };
|
|
323
|
+
if (options.roles) provider = { ...provider, roles: options.roles };
|
|
324
|
+
const tokens = options.tokens ?? (read("TOKENS") ?? "browser");
|
|
325
|
+
if (tokens !== "browser" && tokens !== "server") {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`[fluixi/oauth2] ${specific}_TOKENS is "${tokens}". It is \`browser\` or \`server\`.`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const redirectUri = read("REDIRECT_URI");
|
|
331
|
+
return {
|
|
332
|
+
provider,
|
|
333
|
+
providerName: name,
|
|
334
|
+
clientId,
|
|
335
|
+
tokens,
|
|
336
|
+
redirectUri,
|
|
337
|
+
scopes,
|
|
338
|
+
client: { ...provider.client, clientId, tokens, ...redirectUri ? { redirectUri } : {} }
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
export {
|
|
342
|
+
authFromEnv
|
|
343
|
+
};
|