@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 +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/lib/claims.d.ts +16 -0
- package/dist/lib/claims.d.ts.map +1 -0
- package/dist/lib/claims.js +101 -0
- package/dist/lib/config.d.ts +130 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +50 -0
- package/dist/lib/discovery.d.ts +47 -0
- package/dist/lib/discovery.d.ts.map +1 -0
- package/dist/lib/discovery.js +122 -0
- package/dist/lib/oidc-provider.d.ts +22 -0
- package/dist/lib/oidc-provider.d.ts.map +1 -0
- package/dist/lib/oidc-provider.js +260 -0
- package/dist/lib/presets.d.ts +70 -0
- package/dist/lib/presets.d.ts.map +1 -0
- package/dist/lib/presets.js +111 -0
- package/dist/lib/test-support.d.ts +68 -0
- package/dist/lib/test-support.d.ts.map +1 -0
- package/dist/lib/test-support.js +148 -0
- package/package.json +35 -0
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
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|