@orthacms/identity-provider-oidc 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
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.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @orthacms/identity-provider-oidc
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/identity-provider-oidc
7
+ ```
@@ -0,0 +1,5 @@
1
+ export { createOidcProvider } from './lib/oidc-provider';
2
+ export type { OidcProviderConfig, OidcEndpoints } from './lib/config';
3
+ export { createAuth0Provider, createEntraProvider, createGoogleProvider, createKeycloakProvider, createOktaProvider } from './lib/presets';
4
+ export type { PresetConfig } from './lib/presets';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,YAAY,EACR,kBAAkB,EAClB,aAAa,EAChB,MAAM,cAAc,CAAC;AACtB,OAAO,EACH,mBAAmB,EACnB,mBAAmB,EACnB,oBAAoB,EACpB,sBAAsB,EACtB,kBAAkB,EACrB,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createOktaProvider = exports.createKeycloakProvider = exports.createGoogleProvider = exports.createEntraProvider = exports.createAuth0Provider = exports.createOidcProvider = void 0;
4
+ var oidc_provider_1 = require("./lib/oidc-provider");
5
+ Object.defineProperty(exports, "createOidcProvider", { enumerable: true, get: function () { return oidc_provider_1.createOidcProvider; } });
6
+ var presets_1 = require("./lib/presets");
7
+ Object.defineProperty(exports, "createAuth0Provider", { enumerable: true, get: function () { return presets_1.createAuth0Provider; } });
8
+ Object.defineProperty(exports, "createEntraProvider", { enumerable: true, get: function () { return presets_1.createEntraProvider; } });
9
+ Object.defineProperty(exports, "createGoogleProvider", { enumerable: true, get: function () { return presets_1.createGoogleProvider; } });
10
+ Object.defineProperty(exports, "createKeycloakProvider", { enumerable: true, get: function () { return presets_1.createKeycloakProvider; } });
11
+ Object.defineProperty(exports, "createOktaProvider", { enumerable: true, get: function () { return presets_1.createOktaProvider; } });
@@ -0,0 +1,16 @@
1
+ import { type SsoProfile } from '@orthacms/identity-domain';
2
+ import type { ResolvedOidcConfig } from './config';
3
+ /** An identity token's verified payload, as far as this adapter reads it. */
4
+ export type IdTokenClaims = Record<string, unknown>;
5
+ /**
6
+ * Turns verified claims into the normalised profile the CMS resolves accounts
7
+ * with.
8
+ *
9
+ * Every decision here is about being **faithful** rather than convenient: the
10
+ * subject is `sub` and only `sub`, the email is whichever configured claim is
11
+ * present first, and `email_verified` is reported exactly as the provider sent
12
+ * it. The one place an operator can influence the answer is
13
+ * `emailVerifiedWhenAbsent`, and only when the claim is missing entirely.
14
+ */
15
+ export declare function toProfile(claims: IdTokenClaims, config: ResolvedOidcConfig): SsoProfile;
16
+ //# sourceMappingURL=claims.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../../src/lib/claims.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAClF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAEnD,6EAA6E;AAC7E,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEpD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CACrB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,kBAAkB,GAC3B,UAAU,CA2BZ"}
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toProfile = toProfile;
4
+ const identity_domain_1 = require("@orthacms/identity-domain");
5
+ /**
6
+ * Turns verified claims into the normalised profile the CMS resolves accounts
7
+ * with.
8
+ *
9
+ * Every decision here is about being **faithful** rather than convenient: the
10
+ * subject is `sub` and only `sub`, the email is whichever configured claim is
11
+ * present first, and `email_verified` is reported exactly as the provider sent
12
+ * it. The one place an operator can influence the answer is
13
+ * `emailVerifiedWhenAbsent`, and only when the claim is missing entirely.
14
+ */
15
+ function toProfile(claims, config) {
16
+ const subject = claims['sub'];
17
+ if (typeof subject !== 'string' || subject === '') {
18
+ throw new identity_domain_1.SsoVerificationError('the identity token carries no `sub`, so there is nothing stable to key a link on');
19
+ }
20
+ const email = firstString(claims, config.emailClaims);
21
+ if (!email) {
22
+ throw new identity_domain_1.SsoVerificationError(`the identity token carries no address in ${config.emailClaims.join(', ')} — request the "email" scope, or point emailClaims at the claim this provider uses`);
23
+ }
24
+ return {
25
+ subject,
26
+ email,
27
+ emailVerified: readEmailVerified(claims, config),
28
+ name: readName(claims),
29
+ ...(config.groupsClaim
30
+ ? { groups: readGroups(claims[config.groupsClaim]) }
31
+ : {}),
32
+ sessionId: typeof claims['sid'] === 'string' ? claims['sid'] : null
33
+ };
34
+ }
35
+ /**
36
+ * Whether the provider vouches for the address.
37
+ *
38
+ * Three cases, and the middle one is the whole point:
39
+ *
40
+ * - the claim is present and boolean → that value, always, including `false`;
41
+ * - the claim is present as the string `"true"`/`"false"` → some providers send
42
+ * it that way, and reading `"false"` as truthy would be the worst possible
43
+ * parsing bug to have here;
44
+ * - the claim is absent → `emailVerifiedWhenAbsent`, which defaults to `false`
45
+ * and can only be turned on by an operator asserting that their directory is
46
+ * authoritative.
47
+ */
48
+ function readEmailVerified(claims, config) {
49
+ const raw = claims['email_verified'];
50
+ if (typeof raw === 'boolean') {
51
+ return raw;
52
+ }
53
+ if (raw === 'true') {
54
+ return true;
55
+ }
56
+ if (raw === 'false') {
57
+ return false;
58
+ }
59
+ return config.emailVerifiedWhenAbsent;
60
+ }
61
+ /** A display name from the usual claims, or `null`. */
62
+ function readName(claims) {
63
+ const name = claims['name'];
64
+ if (typeof name === 'string' && name.trim()) {
65
+ return name.trim();
66
+ }
67
+ const given = claims['given_name'];
68
+ const family = claims['family_name'];
69
+ const joined = [given, family]
70
+ .filter((part) => typeof part === 'string' && !!part.trim())
71
+ .join(' ')
72
+ .trim();
73
+ return joined || null;
74
+ }
75
+ /**
76
+ * Group claims, normalised to a string array.
77
+ *
78
+ * Providers disagree on the shape: an array (the common case), a single string
79
+ * (one group), or a space-separated string (a few). Anything else is dropped
80
+ * rather than coerced — a group list nobody can read is safer empty than
81
+ * guessed at, because the next phase maps these to roles.
82
+ */
83
+ function readGroups(raw) {
84
+ if (Array.isArray(raw)) {
85
+ return raw.filter((item) => typeof item === 'string');
86
+ }
87
+ if (typeof raw === 'string' && raw.trim()) {
88
+ return raw.trim().split(/\s+/);
89
+ }
90
+ return [];
91
+ }
92
+ /** The first configured claim that holds a non-empty string, lower-cased. */
93
+ function firstString(claims, names) {
94
+ for (const name of names) {
95
+ const value = claims[name];
96
+ if (typeof value === 'string' && value.trim()) {
97
+ return value.trim().toLowerCase();
98
+ }
99
+ }
100
+ return null;
101
+ }
@@ -0,0 +1,130 @@
1
+ /** The endpoints an adapter needs, when discovery is unavailable or overridden. */
2
+ export interface OidcEndpoints {
3
+ /** Where the browser is sent to authorize. */
4
+ authorization: string;
5
+ /** Where the authorization code is exchanged. */
6
+ token: string;
7
+ /** Where the signing keys are published. */
8
+ jwks: string;
9
+ /** RP-initiated logout, when the provider offers one. */
10
+ endSession?: string;
11
+ }
12
+ /** How one OIDC identity provider is reached and read. */
13
+ export interface OidcProviderConfig {
14
+ /**
15
+ * The issuer, exactly as it appears in the `iss` claim — e.g.
16
+ * `https://accounts.google.com`. Discovery is fetched from
17
+ * `<issuer>/.well-known/openid-configuration`, and every identity token is
18
+ * verified against this value.
19
+ *
20
+ * It is compared byte for byte, because that is what the check is worth:
21
+ * an issuer that "looks right" is exactly what a malicious token supplies.
22
+ */
23
+ issuer: string;
24
+ /** The client id registered with the provider. */
25
+ clientId: string;
26
+ /**
27
+ * The client secret, when the provider issued one.
28
+ *
29
+ * Optional because PKCE makes a public client viable, and some
30
+ * deployments prefer one. When present it is sent with HTTP Basic
31
+ * (`client_secret_basic`), which the spec prefers and every provider here
32
+ * accepts.
33
+ */
34
+ clientSecret?: string;
35
+ /** Button text on the sign-in page. Defaults to the issuer's host. */
36
+ label?: string;
37
+ /**
38
+ * Scopes to request. Defaults to `openid profile email` — `openid` is
39
+ * mandatory, and without `email` there is nothing to match an account on.
40
+ */
41
+ scopes?: readonly string[];
42
+ /**
43
+ * Extra authorization parameters, verbatim. This is where a provider's own
44
+ * knobs go: Google's `hd` to pin a hosted domain, `prompt=select_account`,
45
+ * Okta's `idp`. Reserved protocol parameters cannot be overridden here.
46
+ */
47
+ authorizationParams?: Readonly<Record<string, string>>;
48
+ /**
49
+ * Endpoints to use instead of discovery. Supply all three to skip the
50
+ * discovery request entirely — useful for a provider behind a network
51
+ * whose discovery document is not reachable from the CMS.
52
+ */
53
+ endpoints?: OidcEndpoints;
54
+ /**
55
+ * The claim carrying group membership, when the deployment maps groups to
56
+ * roles. Unset means groups are not read at all — the CMS should not
57
+ * collect a claim nobody asked it to use.
58
+ */
59
+ groupsClaim?: string;
60
+ /**
61
+ * The claim to read the email from, in order of preference. Defaults to
62
+ * `email`, then `preferred_username`, then `upn` — the three spellings
63
+ * that cover the shipped presets.
64
+ */
65
+ emailClaims?: readonly string[];
66
+ /**
67
+ * Whether to treat an address as verified when the provider sends **no**
68
+ * `email_verified` claim at all.
69
+ *
70
+ * Defaults to `false`, and the default is the safe one: `emailVerified` is
71
+ * the only gate on a first sign-in claiming an existing account, so
72
+ * inventing a `true` would turn "sign in with your work account" into "sign
73
+ * in with any account that types the right address".
74
+ *
75
+ * Some providers — Microsoft Entra ID most notably — simply never emit the
76
+ * claim. Setting this is an operator asserting that *this* directory is
77
+ * authoritative for the addresses it reports. That assertion may be
78
+ * perfectly true; it is just not something an adapter may make on the
79
+ * operator's behalf. A provider that sends `email_verified: false` is
80
+ * always taken at its word, whatever this is set to.
81
+ */
82
+ emailVerifiedWhenAbsent?: boolean;
83
+ /**
84
+ * How long a discovery document is reused, in milliseconds. Defaults to one
85
+ * hour. Signing keys are cached separately and refetched on an unknown
86
+ * `kid`, so this only bounds how stale an *endpoint* can be.
87
+ */
88
+ discoveryCacheMs?: number;
89
+ /**
90
+ * Accepted clock skew when checking `exp` and `iat`, in seconds. Defaults
91
+ * to 60.
92
+ *
93
+ * Small on purpose. Some tolerance is necessary — two machines are never
94
+ * exactly in step — but a generous one silently extends the life of every
95
+ * token the provider issues, which is the opposite of what these claims are
96
+ * for.
97
+ */
98
+ clockToleranceSeconds?: number;
99
+ /**
100
+ * The `fetch` used for discovery and the token exchange. Injected so a test
101
+ * can drive the adapter with no network; production leaves it unset and
102
+ * gets the platform's.
103
+ */
104
+ fetch?: typeof globalThis.fetch;
105
+ }
106
+ /** Defaults applied once, so no code path has to remember them. */
107
+ export interface ResolvedOidcConfig extends OidcProviderConfig {
108
+ label: string;
109
+ scopes: readonly string[];
110
+ emailClaims: readonly string[];
111
+ emailVerifiedWhenAbsent: boolean;
112
+ discoveryCacheMs: number;
113
+ clockToleranceSeconds: number;
114
+ fetch: typeof globalThis.fetch;
115
+ }
116
+ /** The scopes requested when a deployment names none. */
117
+ export declare const DEFAULT_SCOPES: readonly ["openid", "profile", "email"];
118
+ /** The claims an email is read from, in order, when a deployment names none. */
119
+ export declare const DEFAULT_EMAIL_CLAIMS: readonly ["email", "preferred_username", "upn"];
120
+ /**
121
+ * Applies the defaults and rejects a configuration that cannot work.
122
+ *
123
+ * Eager, at construction, like `CopilotPlugin`'s option check: an issuer that
124
+ * is not a URL or a missing client id would otherwise surface as a failed
125
+ * sign-in — and every failed SSO sign-in looks identical to every other, by
126
+ * design, so the one error a person sees would say nothing about the typo that
127
+ * caused it.
128
+ */
129
+ export declare function resolveOidcConfig(config: OidcProviderConfig): ResolvedOidcConfig;
130
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/lib/config.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,MAAM,WAAW,aAAa;IAC1B,8CAA8C;IAC9C,aAAa,EAAE,MAAM,CAAC;IACtB,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,0DAA0D;AAC1D,MAAM,WAAW,kBAAkB;IAC/B;;;;;;;;OAQG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACvD;;;;OAIG;IACH,SAAS,CAAC,EAAE,aAAa,CAAC;IAC1B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAChC;;;;;;;;;;;;;;;OAeG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;;;;;;OAQG;IACH,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACnC;AAED,mEAAmE;AACnE,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,uBAAuB,EAAE,OAAO,CAAC;IACjC,gBAAgB,EAAE,MAAM,CAAC;IACzB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CAClC;AAED,yDAAyD;AACzD,eAAO,MAAM,cAAc,yCAA0C,CAAC;AAEtE,gFAAgF;AAChF,eAAO,MAAM,oBAAoB,iDAIvB,CAAC;AAEX;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC7B,MAAM,EAAE,kBAAkB,GAC3B,kBAAkB,CAoCpB"}
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_EMAIL_CLAIMS = exports.DEFAULT_SCOPES = void 0;
4
+ exports.resolveOidcConfig = resolveOidcConfig;
5
+ /** The scopes requested when a deployment names none. */
6
+ exports.DEFAULT_SCOPES = ['openid', 'profile', 'email'];
7
+ /** The claims an email is read from, in order, when a deployment names none. */
8
+ exports.DEFAULT_EMAIL_CLAIMS = [
9
+ 'email',
10
+ 'preferred_username',
11
+ 'upn'
12
+ ];
13
+ /**
14
+ * Applies the defaults and rejects a configuration that cannot work.
15
+ *
16
+ * Eager, at construction, like `CopilotPlugin`'s option check: an issuer that
17
+ * is not a URL or a missing client id would otherwise surface as a failed
18
+ * sign-in — and every failed SSO sign-in looks identical to every other, by
19
+ * design, so the one error a person sees would say nothing about the typo that
20
+ * caused it.
21
+ */
22
+ function resolveOidcConfig(config) {
23
+ let issuerUrl;
24
+ try {
25
+ issuerUrl = new URL(config.issuer);
26
+ }
27
+ catch {
28
+ throw new Error(`createOidcProvider needs an absolute issuer URL; got "${config.issuer}".`);
29
+ }
30
+ if (issuerUrl.protocol !== 'https:' && issuerUrl.hostname !== 'localhost') {
31
+ throw new Error(`createOidcProvider refuses the non-HTTPS issuer "${config.issuer}": identity tokens and the client secret would cross the network in clear text. Only localhost is exempt, for development.`);
32
+ }
33
+ if (!config.clientId.trim()) {
34
+ throw new Error('createOidcProvider needs a clientId — it is how the provider knows which application is asking.');
35
+ }
36
+ const scopes = config.scopes ?? exports.DEFAULT_SCOPES;
37
+ if (!scopes.includes('openid')) {
38
+ throw new Error(`createOidcProvider needs the "openid" scope — without it the provider returns no identity token, and there is nothing to verify. Got: ${scopes.join(' ')}.`);
39
+ }
40
+ return {
41
+ ...config,
42
+ label: config.label ?? issuerUrl.hostname,
43
+ scopes,
44
+ emailClaims: config.emailClaims ?? exports.DEFAULT_EMAIL_CLAIMS,
45
+ emailVerifiedWhenAbsent: config.emailVerifiedWhenAbsent ?? false,
46
+ discoveryCacheMs: config.discoveryCacheMs ?? 3_600_000,
47
+ clockToleranceSeconds: config.clockToleranceSeconds ?? 60,
48
+ fetch: config.fetch ?? globalThis.fetch
49
+ };
50
+ }
@@ -0,0 +1,47 @@
1
+ import type { OidcEndpoints, ResolvedOidcConfig } from './config';
2
+ /**
3
+ * Resolves a provider's endpoints, from configuration or from discovery, and
4
+ * caches the answer.
5
+ *
6
+ * **Single-flight.** The in-flight promise is shared, so ten people signing in
7
+ * during the same second produce one discovery request rather than ten. Without
8
+ * it, the first traffic after a restart is the moment the CMS is least polite
9
+ * to the identity provider — which is also the moment an operator is most
10
+ * likely to be watching.
11
+ *
12
+ * The cache holds only the *successful* answer. A failed discovery is not
13
+ * cached, because caching it would extend a transient outage into a fixed
14
+ * window during which every sign-in fails for a reason that has already gone
15
+ * away.
16
+ */
17
+ export declare class EndpointResolver {
18
+ private readonly config;
19
+ private cached;
20
+ private inFlight;
21
+ constructor(config: ResolvedOidcConfig);
22
+ /**
23
+ * The endpoints already in hand — configured, or from a discovery document
24
+ * fetched earlier — without fetching one.
25
+ *
26
+ * For the synchronous parts of the port. `logoutUrl` is declared sync
27
+ * because a caller building a redirect has nowhere to await, so it can only
28
+ * answer from what is already known; before the first sign-in that is
29
+ * nothing, and `null` is the correct answer then.
30
+ */
31
+ cachedEndpoints(): OidcEndpoints | null;
32
+ /** The endpoints, fetching and caching a discovery document if needed. */
33
+ endpoints(): Promise<OidcEndpoints>;
34
+ private discover;
35
+ }
36
+ /**
37
+ * The well-known discovery URL for an issuer.
38
+ *
39
+ * The path is appended to the issuer's own path rather than replacing it —
40
+ * `https://login.example.com/realms/acme` discovers at
41
+ * `…/realms/acme/.well-known/openid-configuration`, which is what Keycloak,
42
+ * Auth0's custom domains and every multi-tenant provider actually serve.
43
+ * Treating the issuer as an origin is the classic way to make this work against
44
+ * Google and fail against everything else.
45
+ */
46
+ export declare function discoveryUrl(issuer: string): string;
47
+ //# sourceMappingURL=discovery.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../src/lib/discovery.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAWlE;;;;;;;;;;;;;;GAcG;AACH,qBAAa,gBAAgB;IAKb,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJnC,OAAO,CAAC,MAAM,CACL;IACT,OAAO,CAAC,QAAQ,CAAuC;gBAE1B,MAAM,EAAE,kBAAkB;IAEvD;;;;;;;;OAQG;IACH,eAAe,IAAI,aAAa,GAAG,IAAI;IAUvC,0EAA0E;IACpE,SAAS,IAAI,OAAO,CAAC,aAAa,CAAC;YAyB3B,QAAQ;CA+CzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEnD"}
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EndpointResolver = void 0;
4
+ exports.discoveryUrl = discoveryUrl;
5
+ const identity_domain_1 = require("@orthacms/identity-domain");
6
+ /**
7
+ * Resolves a provider's endpoints, from configuration or from discovery, and
8
+ * caches the answer.
9
+ *
10
+ * **Single-flight.** The in-flight promise is shared, so ten people signing in
11
+ * during the same second produce one discovery request rather than ten. Without
12
+ * it, the first traffic after a restart is the moment the CMS is least polite
13
+ * to the identity provider — which is also the moment an operator is most
14
+ * likely to be watching.
15
+ *
16
+ * The cache holds only the *successful* answer. A failed discovery is not
17
+ * cached, because caching it would extend a transient outage into a fixed
18
+ * window during which every sign-in fails for a reason that has already gone
19
+ * away.
20
+ */
21
+ class EndpointResolver {
22
+ config;
23
+ cached = null;
24
+ inFlight = null;
25
+ constructor(config) {
26
+ this.config = config;
27
+ }
28
+ /**
29
+ * The endpoints already in hand — configured, or from a discovery document
30
+ * fetched earlier — without fetching one.
31
+ *
32
+ * For the synchronous parts of the port. `logoutUrl` is declared sync
33
+ * because a caller building a redirect has nowhere to await, so it can only
34
+ * answer from what is already known; before the first sign-in that is
35
+ * nothing, and `null` is the correct answer then.
36
+ */
37
+ cachedEndpoints() {
38
+ if (this.config.endpoints) {
39
+ return this.config.endpoints;
40
+ }
41
+ if (this.cached && this.cached.expiresAt > Date.now()) {
42
+ return this.cached.endpoints;
43
+ }
44
+ return null;
45
+ }
46
+ /** The endpoints, fetching and caching a discovery document if needed. */
47
+ async endpoints() {
48
+ if (this.config.endpoints) {
49
+ return this.config.endpoints;
50
+ }
51
+ if (this.cached && this.cached.expiresAt > Date.now()) {
52
+ return this.cached.endpoints;
53
+ }
54
+ if (this.inFlight) {
55
+ return this.inFlight;
56
+ }
57
+ this.inFlight = this.discover()
58
+ .then((endpoints) => {
59
+ this.cached = {
60
+ endpoints,
61
+ expiresAt: Date.now() + this.config.discoveryCacheMs
62
+ };
63
+ return endpoints;
64
+ })
65
+ .finally(() => {
66
+ this.inFlight = null;
67
+ });
68
+ return this.inFlight;
69
+ }
70
+ async discover() {
71
+ const url = discoveryUrl(this.config.issuer);
72
+ let response;
73
+ try {
74
+ response = await this.config.fetch(url, {
75
+ headers: { accept: 'application/json' }
76
+ });
77
+ }
78
+ catch (error) {
79
+ throw new identity_domain_1.SsoVerificationError(`the provider's discovery document at ${url} could not be fetched (${error instanceof Error ? error.message : String(error)})`);
80
+ }
81
+ if (!response.ok) {
82
+ throw new identity_domain_1.SsoVerificationError(`the provider's discovery document at ${url} answered ${response.status}`);
83
+ }
84
+ const document = (await response.json());
85
+ // The issuer is checked here as well as on the token, because a
86
+ // discovery document that names a different issuer means this adapter
87
+ // is pointed at the wrong place — and the resulting failure would
88
+ // otherwise appear one step later, as an unexplained token rejection.
89
+ if (document.issuer !== this.config.issuer) {
90
+ throw new identity_domain_1.SsoVerificationError(`the discovery document at ${url} names issuer "${String(document.issuer)}", not the configured "${this.config.issuer}"`);
91
+ }
92
+ return {
93
+ authorization: requireUrl(document.authorization_endpoint, 'authorization_endpoint', url),
94
+ token: requireUrl(document.token_endpoint, 'token_endpoint', url),
95
+ jwks: requireUrl(document.jwks_uri, 'jwks_uri', url),
96
+ ...(typeof document.end_session_endpoint === 'string'
97
+ ? { endSession: document.end_session_endpoint }
98
+ : {})
99
+ };
100
+ }
101
+ }
102
+ exports.EndpointResolver = EndpointResolver;
103
+ /**
104
+ * The well-known discovery URL for an issuer.
105
+ *
106
+ * The path is appended to the issuer's own path rather than replacing it —
107
+ * `https://login.example.com/realms/acme` discovers at
108
+ * `…/realms/acme/.well-known/openid-configuration`, which is what Keycloak,
109
+ * Auth0's custom domains and every multi-tenant provider actually serve.
110
+ * Treating the issuer as an origin is the classic way to make this work against
111
+ * Google and fail against everything else.
112
+ */
113
+ function discoveryUrl(issuer) {
114
+ return `${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
115
+ }
116
+ /** Reads a required absolute URL out of a discovery document. */
117
+ function requireUrl(value, field, source) {
118
+ if (typeof value !== 'string' || !value) {
119
+ throw new identity_domain_1.SsoVerificationError(`the discovery document at ${source} has no usable ${field}`);
120
+ }
121
+ return value;
122
+ }
@@ -0,0 +1,22 @@
1
+ import { type SsoProvider } from '@orthacms/identity-domain';
2
+ import { type OidcProviderConfig } from './config';
3
+ /**
4
+ * A generic OpenID Connect adapter: authorization code flow, PKCE, and identity
5
+ * tokens verified against the provider's published keys.
6
+ *
7
+ * One adapter covers most of the market — Okta, Auth0, Keycloak, Google, Entra
8
+ * ID, Authentik, Zitadel, JumpCloud, Ping and GitLab all speak this. That is
9
+ * why SSO diverges from the copilot's package-per-vendor shape: the copilot
10
+ * splits because the *SDKs* differ, and here the wire does not. The named
11
+ * vendors are presets over this, in `presets.ts`.
12
+ *
13
+ * **`jose` does the cryptography.** `createRemoteJWKSet` caches the provider's
14
+ * keys, refetches on an unknown `kid` (which is how key rotation is survived)
15
+ * and rate-limits that refetch (which is what stops a stream of junk tokens
16
+ * from turning this CMS into a load generator aimed at someone else's identity
17
+ * provider). Hand-rolling JWT and JWKS validation is not where to demonstrate
18
+ * independence — ADR-0012 permits the dependency here for exactly this reason,
19
+ * and forbids it in `identity-domain` and `identity-server`.
20
+ */
21
+ export declare function createOidcProvider(config: OidcProviderConfig): SsoProvider;
22
+ //# sourceMappingURL=oidc-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oidc-provider.d.ts","sourceRoot":"","sources":["../../src/lib/oidc-provider.ts"],"names":[],"mappings":"AAOA,OAAO,EAQH,KAAK,WAAW,EAEnB,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAAqB,KAAK,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAuCtE;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAC9B,MAAM,EAAE,kBAAkB,GAC3B,WAAW,CA0Rb"}
@@ -0,0 +1,260 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createOidcProvider = createOidcProvider;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const jose_1 = require("jose");
6
+ const identity_domain_1 = require("@orthacms/identity-domain");
7
+ const claims_1 = require("./claims");
8
+ const config_1 = require("./config");
9
+ const discovery_1 = require("./discovery");
10
+ /**
11
+ * Protocol parameters an operator's `authorizationParams` may not overwrite.
12
+ *
13
+ * Every one of them is either a security control the core owns (`state`,
14
+ * `nonce`, the PKCE pair) or the thing that decides what flow is running
15
+ * (`response_type`, `redirect_uri`). A config typo that silently replaced one
16
+ * would not fail — it would produce a sign-in that works and is not protected.
17
+ */
18
+ const RESERVED_PARAMS = new Set([
19
+ 'response_type',
20
+ 'client_id',
21
+ 'redirect_uri',
22
+ 'scope',
23
+ 'state',
24
+ 'nonce',
25
+ 'code_challenge',
26
+ 'code_challenge_method'
27
+ ]);
28
+ /**
29
+ * The claim that marks a token as a back-channel logout notification.
30
+ *
31
+ * Checked because everything *else* about a logout token matches an identity
32
+ * token — same issuer, same audience, same signing key. Without this check,
33
+ * anyone holding a stolen identity token could sign its owner out at will.
34
+ */
35
+ const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
36
+ /**
37
+ * A generic OpenID Connect adapter: authorization code flow, PKCE, and identity
38
+ * tokens verified against the provider's published keys.
39
+ *
40
+ * One adapter covers most of the market — Okta, Auth0, Keycloak, Google, Entra
41
+ * ID, Authentik, Zitadel, JumpCloud, Ping and GitLab all speak this. That is
42
+ * why SSO diverges from the copilot's package-per-vendor shape: the copilot
43
+ * splits because the *SDKs* differ, and here the wire does not. The named
44
+ * vendors are presets over this, in `presets.ts`.
45
+ *
46
+ * **`jose` does the cryptography.** `createRemoteJWKSet` caches the provider's
47
+ * keys, refetches on an unknown `kid` (which is how key rotation is survived)
48
+ * and rate-limits that refetch (which is what stops a stream of junk tokens
49
+ * from turning this CMS into a load generator aimed at someone else's identity
50
+ * provider). Hand-rolling JWT and JWKS validation is not where to demonstrate
51
+ * independence — ADR-0012 permits the dependency here for exactly this reason,
52
+ * and forbids it in `identity-domain` and `identity-server`.
53
+ */
54
+ function createOidcProvider(config) {
55
+ const resolved = (0, config_1.resolveOidcConfig)(config);
56
+ const endpoints = new discovery_1.EndpointResolver(resolved);
57
+ const descriptor = Object.freeze({
58
+ kind: 'oidc',
59
+ label: resolved.label,
60
+ callbackMethod: 'GET'
61
+ });
62
+ /**
63
+ * The key set, built once and reused.
64
+ *
65
+ * Lazy, because the JWKS URL may come from discovery, and a provider that
66
+ * is unreachable at boot must not stop the CMS from starting — SSO is one
67
+ * way in, not the only one.
68
+ */
69
+ let keys = null;
70
+ const keySet = async () => {
71
+ if (!keys) {
72
+ const { jwks } = await endpoints.endpoints();
73
+ keys = (0, jose_1.createRemoteJWKSet)(new URL(jwks), {
74
+ // A `kid` this set has not seen triggers a refetch, which is
75
+ // how a rotated signing key is picked up without a restart…
76
+ cacheMaxAge: 600_000,
77
+ // …and this is the floor between two such refetches, so a
78
+ // stream of tokens bearing invented `kid`s cannot turn into a
79
+ // stream of requests aimed at the provider.
80
+ cooldownDuration: 30_000,
81
+ // The same `fetch` the rest of the adapter uses. Without this,
82
+ // key fetching would quietly bypass a configured HTTP proxy —
83
+ // and a test's stubbed transport — while discovery and the
84
+ // token exchange honoured it, which is the kind of split that
85
+ // works everywhere except the one deployment that needed it.
86
+ // jose types this hook loosely on purpose; see its own note.
87
+ [jose_1.customFetch]: resolved.fetch
88
+ });
89
+ }
90
+ return keys;
91
+ };
92
+ return {
93
+ descriptor: () => descriptor,
94
+ async authorize(request) {
95
+ const { authorization } = await endpoints.endpoints();
96
+ const url = new URL(authorization);
97
+ for (const [key, value] of Object.entries(resolved.authorizationParams ?? {})) {
98
+ if (RESERVED_PARAMS.has(key)) {
99
+ throw new Error(`The OIDC provider "${resolved.label}" tries to set the reserved authorization parameter "${key}". That parameter is either a security control the CMS owns (state, nonce, PKCE) or the one that decides which flow runs — overriding it would produce a sign-in that appears to work and is not protected.`);
100
+ }
101
+ url.searchParams.set(key, value);
102
+ }
103
+ url.searchParams.set('response_type', 'code');
104
+ url.searchParams.set('client_id', resolved.clientId);
105
+ url.searchParams.set('redirect_uri', request.redirectUri);
106
+ url.searchParams.set('scope', [...new Set([...resolved.scopes, ...(request.scopes ?? [])])].join(' '));
107
+ url.searchParams.set('state', request.state);
108
+ url.searchParams.set('nonce', request.nonce);
109
+ // The challenge, never the verifier: the browser carries this URL,
110
+ // and the verifier is the half that must not travel with it.
111
+ url.searchParams.set('code_challenge', challengeFor(request.codeVerifier));
112
+ url.searchParams.set('code_challenge_method', 'S256');
113
+ return { url: url.toString() };
114
+ },
115
+ async complete(callback) {
116
+ const { params } = callback;
117
+ // A provider's own refusal comes back as a parameter, not an HTTP
118
+ // error, so it has to be read before anything else is attempted.
119
+ if (typeof params['error'] === 'string' && params['error']) {
120
+ throw new identity_domain_1.SsoVerificationError(`the provider answered error=${params['error']}${params['error_description']
121
+ ? ` (${params['error_description']})`
122
+ : ''}`);
123
+ }
124
+ const code = params['code'];
125
+ if (!code) {
126
+ throw new identity_domain_1.SsoVerificationError('the response carried no authorization code');
127
+ }
128
+ const idToken = await exchange(code, callback);
129
+ const claims = await verify(idToken, callback);
130
+ return (0, claims_1.toProfile)(claims, resolved);
131
+ },
132
+ logoutUrl(request) {
133
+ // Synchronous by contract, so this answers only from a discovery
134
+ // document already in hand. Before the first sign-in there is none,
135
+ // and `null` is the correct answer then: the CMS session ends
136
+ // either way, and the provider's simply does not.
137
+ const endSession = endpoints.cachedEndpoints()?.endSession;
138
+ if (!endSession) {
139
+ return null;
140
+ }
141
+ const url = new URL(endSession);
142
+ url.searchParams.set('client_id', resolved.clientId);
143
+ url.searchParams.set('post_logout_redirect_uri', request.returnTo);
144
+ return url.toString();
145
+ },
146
+ async verifyLogoutToken(token) {
147
+ let claims;
148
+ try {
149
+ const verified = await (0, jose_1.jwtVerify)(token, await keySet(), {
150
+ issuer: resolved.issuer,
151
+ audience: resolved.clientId,
152
+ clockTolerance: resolved.clockToleranceSeconds,
153
+ // The spec gives logout tokens their own `typ`. Providers
154
+ // are inconsistent about sending it, so it is not required
155
+ // — the `events` claim below is the check that actually
156
+ // separates a logout token from an identity one.
157
+ typ: undefined
158
+ });
159
+ claims = verified.payload;
160
+ }
161
+ catch (error) {
162
+ throw new identity_domain_1.SsoVerificationError(`the logout token did not verify (${error instanceof Error ? error.message : String(error)})`);
163
+ }
164
+ // Without this, a **stolen identity token** would be accepted here:
165
+ // same issuer, same audience, same signature, and it names a `sub`.
166
+ // Anyone who obtained one could then sign that person out at will.
167
+ // The `events` claim is what says "this token is a logout
168
+ // notification and nothing else".
169
+ const events = claims['events'];
170
+ if (typeof events !== 'object' ||
171
+ events === null ||
172
+ !(BACKCHANNEL_LOGOUT_EVENT in events)) {
173
+ throw new identity_domain_1.SsoVerificationError('the token carries no back-channel logout event, so it is not a logout token');
174
+ }
175
+ // The spec forbids a `nonce` on a logout token, precisely because
176
+ // its presence means somebody handed us an identity token.
177
+ if ('nonce' in claims) {
178
+ throw new identity_domain_1.SsoVerificationError('the logout token carries a nonce, which only an identity token has');
179
+ }
180
+ const sessionId = typeof claims['sid'] === 'string' ? claims['sid'] : null;
181
+ const subject = typeof claims['sub'] === 'string' ? claims['sub'] : null;
182
+ if (!sessionId && !subject) {
183
+ throw new identity_domain_1.SsoVerificationError('the logout token names neither a session nor a subject');
184
+ }
185
+ return { sessionId, subject };
186
+ }
187
+ };
188
+ /** Spends the authorization code and the PKCE verifier for a token. */
189
+ async function exchange(code, callback) {
190
+ const { token } = await endpoints.endpoints();
191
+ const body = new URLSearchParams({
192
+ grant_type: 'authorization_code',
193
+ code,
194
+ // Sent again, and it must match the authorization request byte for
195
+ // byte: most providers bind the code to it, and a mismatch comes
196
+ // back as a flat `invalid_grant` with nothing pointing at the cause.
197
+ redirect_uri: callback.redirectUri,
198
+ code_verifier: callback.codeVerifier,
199
+ client_id: resolved.clientId
200
+ });
201
+ const headers = {
202
+ 'content-type': 'application/x-www-form-urlencoded',
203
+ accept: 'application/json'
204
+ };
205
+ if (resolved.clientSecret) {
206
+ // `client_secret_basic`: the spec prefers it, every shipped preset
207
+ // accepts it, and it keeps the secret out of a body that
208
+ // intermediaries are more likely to log.
209
+ headers['authorization'] = `Basic ${Buffer.from(`${encodeURIComponent(resolved.clientId)}:${encodeURIComponent(resolved.clientSecret)}`).toString('base64')}`;
210
+ }
211
+ let response;
212
+ try {
213
+ response = await resolved.fetch(token, {
214
+ method: 'POST',
215
+ headers,
216
+ body: body.toString()
217
+ });
218
+ }
219
+ catch (error) {
220
+ throw new identity_domain_1.SsoVerificationError(`the token exchange could not reach ${token} (${error instanceof Error ? error.message : String(error)})`);
221
+ }
222
+ const payload = (await response
223
+ .json()
224
+ .catch(() => ({})));
225
+ if (!response.ok) {
226
+ throw new identity_domain_1.SsoVerificationError(`the token exchange answered ${response.status}${payload.error ? ` (${String(payload.error)})` : ''}`);
227
+ }
228
+ if (typeof payload.id_token !== 'string' || !payload.id_token) {
229
+ throw new identity_domain_1.SsoVerificationError('the token response carried no identity token, so there is nothing to verify');
230
+ }
231
+ return payload.id_token;
232
+ }
233
+ /** Verifies signature, issuer, audience, expiry — and the nonce. */
234
+ async function verify(idToken, callback) {
235
+ let claims;
236
+ try {
237
+ const verified = await (0, jose_1.jwtVerify)(idToken, await keySet(), {
238
+ issuer: resolved.issuer,
239
+ audience: resolved.clientId,
240
+ clockTolerance: resolved.clockToleranceSeconds
241
+ });
242
+ claims = verified.payload;
243
+ }
244
+ catch (error) {
245
+ throw new identity_domain_1.SsoVerificationError(`the identity token did not verify (${error instanceof Error ? error.message : String(error)})`);
246
+ }
247
+ // Checked here rather than left to `jwtVerify`, because it is not a
248
+ // property of the token — it is the link between this token and the
249
+ // attempt the browser started. Without it a token captured from another
250
+ // attempt, still validly signed and unexpired, would be accepted.
251
+ if (claims['nonce'] !== callback.nonce) {
252
+ throw new identity_domain_1.SsoVerificationError('the identity token carries a nonce from a different attempt');
253
+ }
254
+ return claims;
255
+ }
256
+ }
257
+ /** The PKCE `S256` challenge for a verifier. */
258
+ function challengeFor(codeVerifier) {
259
+ return (0, node_crypto_1.createHash)('sha256').update(codeVerifier).digest('base64url');
260
+ }
@@ -0,0 +1,70 @@
1
+ import type { SsoProvider } from '@orthacms/identity-domain';
2
+ import type { OidcProviderConfig } from './config';
3
+ /** What every preset needs; the rest of {@link OidcProviderConfig} still applies. */
4
+ export type PresetConfig = Omit<OidcProviderConfig, 'issuer'> & Partial<Pick<OidcProviderConfig, 'issuer'>>;
5
+ /**
6
+ * Google Workspace.
7
+ *
8
+ * Emits `email` and `email_verified` properly, so nothing here has to be
9
+ * asserted on the operator's behalf.
10
+ *
11
+ * `hostedDomain` sets Google's `hd` parameter, which asks Google to show only
12
+ * accounts in that domain. Treat it as a convenience, **not** a security
13
+ * control: it shapes the account chooser, and the CMS's own rules — a verified
14
+ * address matching an existing active account — are what actually decide who
15
+ * gets in.
16
+ */
17
+ export declare function createGoogleProvider(config: PresetConfig & {
18
+ hostedDomain?: string;
19
+ }): SsoProvider;
20
+ /**
21
+ * Microsoft Entra ID (formerly Azure AD).
22
+ *
23
+ * `tenantId` builds the issuer. Use the directory's own GUID rather than
24
+ * `common` or `organizations`: those multi-tenant issuers accept accounts from
25
+ * **any** Microsoft directory, which is almost never what a CMS wants, and the
26
+ * issuer check that would normally catch a foreign token cannot help when the
27
+ * issuer is deliberately everyone's.
28
+ *
29
+ * **Entra does not emit `email_verified`.** A first sign-in therefore cannot
30
+ * claim an existing account until an operator sets `emailVerifiedWhenAbsent:
31
+ * true`, asserting that this directory is authoritative for the addresses it
32
+ * reports. That assertion is usually true for a corporate tenant; it is simply
33
+ * not one an adapter may make on the operator's behalf. Entra also often
34
+ * reports the address in `preferred_username` rather than `email`, which the
35
+ * default claim order already covers.
36
+ */
37
+ export declare function createEntraProvider(config: PresetConfig & {
38
+ tenantId: string;
39
+ }): SsoProvider;
40
+ /**
41
+ * Okta.
42
+ *
43
+ * `domain` is the org's host (`acme.okta.com`), optionally with a custom
44
+ * authorization server (`authorizationServerId`, e.g. `default`). Okta's
45
+ * default org server issues at the bare domain; a custom one issues at
46
+ * `/oauth2/<id>`, and the two are different issuers — pointing at the wrong one
47
+ * fails at discovery rather than mysteriously later, which is why the issuer is
48
+ * built here rather than typed by hand.
49
+ */
50
+ export declare function createOktaProvider(config: PresetConfig & {
51
+ domain: string;
52
+ authorizationServerId?: string;
53
+ }): SsoProvider;
54
+ /** Auth0. `domain` is the tenant host (`acme.eu.auth0.com` or a custom one). */
55
+ export declare function createAuth0Provider(config: PresetConfig & {
56
+ domain: string;
57
+ }): SsoProvider;
58
+ /**
59
+ * Keycloak. `baseUrl` is the server root, `realm` the realm name.
60
+ *
61
+ * Groups are not in the token by default — a client scope has to map them. Pass
62
+ * `groupsClaim: 'groups'` once that mapper exists, and not before: a claim that
63
+ * is never sent reads as "this person is in no groups", which a future
64
+ * role-mapping handler would quietly act on.
65
+ */
66
+ export declare function createKeycloakProvider(config: PresetConfig & {
67
+ baseUrl: string;
68
+ realm: string;
69
+ }): SsoProvider;
70
+ //# sourceMappingURL=presets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presets.d.ts","sourceRoot":"","sources":["../../src/lib/presets.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAE7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAEnD,qFAAqF;AACrF,MAAM,MAAM,YAAY,GAAG,IAAI,CAAC,kBAAkB,EAAE,QAAQ,CAAC,GACzD,OAAO,CAAC,IAAI,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC,CAAC;AAEhD;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAChC,MAAM,EAAE,YAAY,GAAG;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GACjD,WAAW,CAeb;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAC/B,MAAM,EAAE,YAAY,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC5C,WAAW,CAOb;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAC9B,MAAM,EAAE,YAAY,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,qBAAqB,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1E,WAAW,CAUb;AAED,gFAAgF;AAChF,wBAAgB,mBAAmB,CAC/B,MAAM,EAAE,YAAY,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1C,WAAW,CAWb;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAClC,MAAM,EAAE,YAAY,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC1D,WAAW,CAOb"}
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createGoogleProvider = createGoogleProvider;
4
+ exports.createEntraProvider = createEntraProvider;
5
+ exports.createOktaProvider = createOktaProvider;
6
+ exports.createAuth0Provider = createAuth0Provider;
7
+ exports.createKeycloakProvider = createKeycloakProvider;
8
+ const oidc_provider_1 = require("./oidc-provider");
9
+ /**
10
+ * Google Workspace.
11
+ *
12
+ * Emits `email` and `email_verified` properly, so nothing here has to be
13
+ * asserted on the operator's behalf.
14
+ *
15
+ * `hostedDomain` sets Google's `hd` parameter, which asks Google to show only
16
+ * accounts in that domain. Treat it as a convenience, **not** a security
17
+ * control: it shapes the account chooser, and the CMS's own rules — a verified
18
+ * address matching an existing active account — are what actually decide who
19
+ * gets in.
20
+ */
21
+ function createGoogleProvider(config) {
22
+ const { hostedDomain, ...rest } = config;
23
+ return (0, oidc_provider_1.createOidcProvider)({
24
+ issuer: 'https://accounts.google.com',
25
+ label: 'Google',
26
+ ...rest,
27
+ ...(hostedDomain
28
+ ? {
29
+ authorizationParams: {
30
+ hd: hostedDomain,
31
+ ...config.authorizationParams
32
+ }
33
+ }
34
+ : {})
35
+ });
36
+ }
37
+ /**
38
+ * Microsoft Entra ID (formerly Azure AD).
39
+ *
40
+ * `tenantId` builds the issuer. Use the directory's own GUID rather than
41
+ * `common` or `organizations`: those multi-tenant issuers accept accounts from
42
+ * **any** Microsoft directory, which is almost never what a CMS wants, and the
43
+ * issuer check that would normally catch a foreign token cannot help when the
44
+ * issuer is deliberately everyone's.
45
+ *
46
+ * **Entra does not emit `email_verified`.** A first sign-in therefore cannot
47
+ * claim an existing account until an operator sets `emailVerifiedWhenAbsent:
48
+ * true`, asserting that this directory is authoritative for the addresses it
49
+ * reports. That assertion is usually true for a corporate tenant; it is simply
50
+ * not one an adapter may make on the operator's behalf. Entra also often
51
+ * reports the address in `preferred_username` rather than `email`, which the
52
+ * default claim order already covers.
53
+ */
54
+ function createEntraProvider(config) {
55
+ const { tenantId, ...rest } = config;
56
+ return (0, oidc_provider_1.createOidcProvider)({
57
+ issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`,
58
+ label: 'Microsoft',
59
+ ...rest
60
+ });
61
+ }
62
+ /**
63
+ * Okta.
64
+ *
65
+ * `domain` is the org's host (`acme.okta.com`), optionally with a custom
66
+ * authorization server (`authorizationServerId`, e.g. `default`). Okta's
67
+ * default org server issues at the bare domain; a custom one issues at
68
+ * `/oauth2/<id>`, and the two are different issuers — pointing at the wrong one
69
+ * fails at discovery rather than mysteriously later, which is why the issuer is
70
+ * built here rather than typed by hand.
71
+ */
72
+ function createOktaProvider(config) {
73
+ const { domain, authorizationServerId, ...rest } = config;
74
+ const host = domain.startsWith('http') ? domain : `https://${domain}`;
75
+ return (0, oidc_provider_1.createOidcProvider)({
76
+ issuer: authorizationServerId
77
+ ? `${host}/oauth2/${authorizationServerId}`
78
+ : host,
79
+ label: 'Okta',
80
+ ...rest
81
+ });
82
+ }
83
+ /** Auth0. `domain` is the tenant host (`acme.eu.auth0.com` or a custom one). */
84
+ function createAuth0Provider(config) {
85
+ const { domain, ...rest } = config;
86
+ const host = domain.startsWith('http') ? domain : `https://${domain}`;
87
+ return (0, oidc_provider_1.createOidcProvider)({
88
+ // Auth0 issues with a trailing slash, and the `iss` claim is compared
89
+ // byte for byte. Omitting it is the single most common way to get a
90
+ // working discovery document and a token that will not verify.
91
+ issuer: `${host.replace(/\/+$/, '')}/`,
92
+ label: 'Auth0',
93
+ ...rest
94
+ });
95
+ }
96
+ /**
97
+ * Keycloak. `baseUrl` is the server root, `realm` the realm name.
98
+ *
99
+ * Groups are not in the token by default — a client scope has to map them. Pass
100
+ * `groupsClaim: 'groups'` once that mapper exists, and not before: a claim that
101
+ * is never sent reads as "this person is in no groups", which a future
102
+ * role-mapping handler would quietly act on.
103
+ */
104
+ function createKeycloakProvider(config) {
105
+ const { baseUrl, realm, ...rest } = config;
106
+ return (0, oidc_provider_1.createOidcProvider)({
107
+ issuer: `${baseUrl.replace(/\/+$/, '')}/realms/${realm}`,
108
+ label: 'Keycloak',
109
+ ...rest
110
+ });
111
+ }
@@ -0,0 +1,68 @@
1
+ import { generateKeyPair } from 'jose';
2
+ import type { SsoAuthorizeRequest, SsoCallback } from '@orthacms/identity-domain';
3
+ export declare const ISSUER = "https://idp.test";
4
+ export declare const CLIENT_ID = "ortha-cms";
5
+ export declare const REDIRECT_URI = "https://cms.test/api/auth/sso/idp/callback";
6
+ /** The one-attempt secrets a core would have minted. */
7
+ export declare const CORE_SECRETS: SsoAuthorizeRequest;
8
+ /** What one scripted provider answers with. */
9
+ export interface StubOptions {
10
+ /** Claims to merge into (or delete from) the identity token. */
11
+ claims?: Record<string, unknown>;
12
+ /** Sign with a key the published JWKS does not contain. */
13
+ signWithForeignKey?: boolean;
14
+ /** Answer the token endpoint with this status instead of 200. */
15
+ tokenStatus?: number;
16
+ /** Answer the token endpoint with this body instead of an id_token. */
17
+ tokenBody?: Record<string, unknown>;
18
+ /** Answer discovery with these fields merged in. */
19
+ discovery?: Record<string, unknown>;
20
+ /** Fail the discovery request with this status. */
21
+ discoveryStatus?: number;
22
+ }
23
+ /** A stubbed identity provider: discovery, JWKS and a token endpoint. */
24
+ export interface StubIdp {
25
+ /** Drop-in for `fetch`. */
26
+ fetch: typeof globalThis.fetch;
27
+ /**
28
+ * The key the stub signs with, for tokens a test mints itself.
29
+ *
30
+ * Typed from `generateKeyPair`'s own return rather than as `CryptoKey`:
31
+ * that name comes from the DOM lib, which these Node-targeted packages do
32
+ * not include.
33
+ */
34
+ privateKey: Awaited<ReturnType<typeof generateKeyPair>>['privateKey'];
35
+ /** How many requests reached each endpoint. */
36
+ calls: {
37
+ discovery: number;
38
+ jwks: number;
39
+ token: number;
40
+ };
41
+ /** The last form body the token endpoint received. */
42
+ lastTokenBody: URLSearchParams | null;
43
+ /** The last `Authorization` header the token endpoint received. */
44
+ lastTokenAuth: string | null;
45
+ }
46
+ /**
47
+ * Builds a stubbed OpenID provider that signs **real** identity tokens.
48
+ *
49
+ * Signing for real is what makes the tampering scenario mean something: a
50
+ * flipped byte fails because `jose` verifies a signature, not because a flag
51
+ * says so. It is also the only way to check that the adapter rejects a token
52
+ * signed by a key the provider does not publish, which is the failure a
53
+ * signature check exists to catch.
54
+ */
55
+ export declare function stubIdp(options?: StubOptions): Promise<StubIdp>;
56
+ /**
57
+ * Signs a back-channel logout token with the stub's key.
58
+ *
59
+ * A real signature, like the identity tokens: the checks under test are "did
60
+ * this verify" and "is this actually a logout token", and a hand-built string
61
+ * could only exercise the second.
62
+ */
63
+ export declare function signLogoutToken(idp: StubIdp, claims?: Record<string, unknown>): Promise<string>;
64
+ /** The event claim that marks a token as a logout notification. */
65
+ export declare const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
66
+ /** The callback the core would build from a provider's redirect back. */
67
+ export declare function callbackWith(overrides?: Record<string, string>): SsoCallback;
68
+ //# sourceMappingURL=test-support.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-support.d.ts","sourceRoot":"","sources":["../../src/lib/test-support.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,eAAe,EAAqB,MAAM,MAAM,CAAC;AAWrE,OAAO,KAAK,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAElF,eAAO,MAAM,MAAM,qBAAqB,CAAC;AACzC,eAAO,MAAM,SAAS,cAAc,CAAC;AACrC,eAAO,MAAM,YAAY,+CACuB,CAAC;AAEjD,wDAAwD;AACxD,eAAO,MAAM,YAAY,EAAE,mBAK1B,CAAC;AAEF,+CAA+C;AAC/C,MAAM,WAAW,WAAW;IACxB,gEAAgE;IAChE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,2DAA2D;IAC3D,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,mDAAmD;IACnD,eAAe,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,yEAAyE;AACzE,MAAM,WAAW,OAAO;IACpB,2BAA2B;IAC3B,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAC/B;;;;;;OAMG;IACH,UAAU,EAAE,OAAO,CAAC,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;IACtE,+CAA+C;IAC/C,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1D,sDAAsD;IACtD,aAAa,EAAE,eAAe,GAAG,IAAI,CAAC;IACtC,mEAAmE;IACnE,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED;;;;;;;;GAQG;AACH,wBAAsB,OAAO,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,OAAO,CAAC,CA+EzE;AAUD;;;;;;GAMG;AACH,wBAAsB,eAAe,CACjC,GAAG,EAAE,OAAO,EACZ,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACrC,OAAO,CAAC,MAAM,CAAC,CAmBjB;AAED,mEAAmE;AACnE,eAAO,MAAM,wBAAwB,uDACmB,CAAC;AAEzD,yEAAyE;AACzE,wBAAgB,YAAY,CACxB,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GACvC,WAAW,CAYb"}
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BACKCHANNEL_LOGOUT_EVENT = exports.CORE_SECRETS = exports.REDIRECT_URI = exports.CLIENT_ID = exports.ISSUER = void 0;
4
+ exports.stubIdp = stubIdp;
5
+ exports.signLogoutToken = signLogoutToken;
6
+ exports.callbackWith = callbackWith;
7
+ const jose_1 = require("jose");
8
+ exports.ISSUER = 'https://idp.test';
9
+ exports.CLIENT_ID = 'ortha-cms';
10
+ exports.REDIRECT_URI = 'https://cms.test/api/auth/sso/idp/callback';
11
+ /** The one-attempt secrets a core would have minted. */
12
+ exports.CORE_SECRETS = {
13
+ redirectUri: exports.REDIRECT_URI,
14
+ state: 'state-2f6a1c9d',
15
+ nonce: 'nonce-8b0e47aa',
16
+ codeVerifier: 'verifier-4c1d55e0f39b2a7681ce'
17
+ };
18
+ /**
19
+ * Builds a stubbed OpenID provider that signs **real** identity tokens.
20
+ *
21
+ * Signing for real is what makes the tampering scenario mean something: a
22
+ * flipped byte fails because `jose` verifies a signature, not because a flag
23
+ * says so. It is also the only way to check that the adapter rejects a token
24
+ * signed by a key the provider does not publish, which is the failure a
25
+ * signature check exists to catch.
26
+ */
27
+ async function stubIdp(options = {}) {
28
+ const { privateKey, publicKey } = await (0, jose_1.generateKeyPair)('RS256', {
29
+ extractable: true
30
+ });
31
+ const foreign = await (0, jose_1.generateKeyPair)('RS256', { extractable: true });
32
+ const publicJwk = {
33
+ ...(await (0, jose_1.exportJWK)(publicKey)),
34
+ kid: 'test-key',
35
+ alg: 'RS256',
36
+ use: 'sig'
37
+ };
38
+ const state = {
39
+ fetch: (() => Promise.reject(new Error('unset'))),
40
+ privateKey,
41
+ calls: { discovery: 0, jwks: 0, token: 0 },
42
+ lastTokenBody: null,
43
+ lastTokenAuth: null
44
+ };
45
+ const claims = {
46
+ sub: 'idp-subject-1',
47
+ email: 'ada@example.com',
48
+ email_verified: true,
49
+ name: 'Ada Lovelace',
50
+ nonce: exports.CORE_SECRETS.nonce,
51
+ ...options.claims
52
+ };
53
+ for (const [key, value] of Object.entries(options.claims ?? {})) {
54
+ if (value === undefined) {
55
+ delete claims[key];
56
+ }
57
+ }
58
+ const idToken = await new jose_1.SignJWT(claims)
59
+ .setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
60
+ .setIssuer(exports.ISSUER)
61
+ .setAudience(exports.CLIENT_ID)
62
+ .setIssuedAt()
63
+ .setExpirationTime('5m')
64
+ .sign(options.signWithForeignKey ? foreign.privateKey : privateKey);
65
+ state.fetch = (async (input, init) => {
66
+ const url = typeof input === 'string' ? input : input.toString();
67
+ if (url.includes('.well-known/openid-configuration')) {
68
+ state.calls.discovery += 1;
69
+ if (options.discoveryStatus) {
70
+ return json({}, options.discoveryStatus);
71
+ }
72
+ return json({
73
+ issuer: exports.ISSUER,
74
+ authorization_endpoint: `${exports.ISSUER}/authorize`,
75
+ token_endpoint: `${exports.ISSUER}/token`,
76
+ jwks_uri: `${exports.ISSUER}/jwks`,
77
+ end_session_endpoint: `${exports.ISSUER}/logout`,
78
+ ...options.discovery
79
+ });
80
+ }
81
+ if (url.endsWith('/jwks')) {
82
+ state.calls.jwks += 1;
83
+ return json({ keys: [publicJwk] });
84
+ }
85
+ if (url.endsWith('/token')) {
86
+ state.calls.token += 1;
87
+ state.lastTokenBody = new URLSearchParams(String(init?.body ?? ''));
88
+ state.lastTokenAuth =
89
+ (init?.headers ?? {})['authorization'] ?? null;
90
+ if (options.tokenStatus && options.tokenStatus !== 200) {
91
+ return json(options.tokenBody ?? {}, options.tokenStatus);
92
+ }
93
+ return json(options.tokenBody ?? { id_token: idToken });
94
+ }
95
+ throw new Error(`stub IdP got an unexpected request: ${url}`);
96
+ });
97
+ return state;
98
+ }
99
+ /** A JSON `Response`, as the stub's endpoints answer with. */
100
+ function json(body, status = 200) {
101
+ return new Response(JSON.stringify(body), {
102
+ status,
103
+ headers: { 'content-type': 'application/json' }
104
+ });
105
+ }
106
+ /**
107
+ * Signs a back-channel logout token with the stub's key.
108
+ *
109
+ * A real signature, like the identity tokens: the checks under test are "did
110
+ * this verify" and "is this actually a logout token", and a hand-built string
111
+ * could only exercise the second.
112
+ */
113
+ async function signLogoutToken(idp, claims = {}) {
114
+ const payload = {
115
+ events: { [exports.BACKCHANNEL_LOGOUT_EVENT]: {} },
116
+ sid: 'provider-session-1',
117
+ sub: 'idp-subject-1',
118
+ ...claims
119
+ };
120
+ for (const [key, value] of Object.entries(claims)) {
121
+ if (value === undefined) {
122
+ delete payload[key];
123
+ }
124
+ }
125
+ return new jose_1.SignJWT(payload)
126
+ .setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
127
+ .setIssuer(exports.ISSUER)
128
+ .setAudience(exports.CLIENT_ID)
129
+ .setIssuedAt()
130
+ .setExpirationTime('5m')
131
+ .sign(idp.privateKey);
132
+ }
133
+ /** The event claim that marks a token as a logout notification. */
134
+ exports.BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
135
+ /** The callback the core would build from a provider's redirect back. */
136
+ function callbackWith(overrides = {}) {
137
+ return {
138
+ params: {
139
+ code: 'authorization-code-1',
140
+ state: exports.CORE_SECRETS.state,
141
+ ...overrides
142
+ },
143
+ state: exports.CORE_SECRETS.state,
144
+ nonce: exports.CORE_SECRETS.nonce,
145
+ codeVerifier: exports.CORE_SECRETS.codeVerifier,
146
+ redirectUri: exports.REDIRECT_URI
147
+ };
148
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@orthacms/identity-provider-oidc",
3
+ "version": "0.4.0",
4
+ "description": "@orthacms/identity-provider-oidc — part of Ortha CMS.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/identity/provider-oidc",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ortha-source/ortha-cms.git",
10
+ "directory": "packages/identity/provider-oidc"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ortha-source/ortha-cms/issues"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@orthacms/identity-domain": "^0.4.0",
29
+ "jose": "^6.2.0",
30
+ "tslib": "^2.3.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }