@c9up/transit 0.1.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 +191 -0
- package/dist/Oauth1Driver.d.ts +68 -0
- package/dist/Oauth1Driver.d.ts.map +1 -0
- package/dist/Oauth1Driver.js +148 -0
- package/dist/Oauth1Driver.js.map +1 -0
- package/dist/Oauth2Driver.d.ts +89 -0
- package/dist/Oauth2Driver.d.ts.map +1 -0
- package/dist/Oauth2Driver.js +196 -0
- package/dist/Oauth2Driver.js.map +1 -0
- package/dist/TransitManager.d.ts +59 -0
- package/dist/TransitManager.d.ts.map +1 -0
- package/dist/TransitManager.js +100 -0
- package/dist/TransitManager.js.map +1 -0
- package/dist/TransitProvider.d.ts +26 -0
- package/dist/TransitProvider.d.ts.map +1 -0
- package/dist/TransitProvider.js +32 -0
- package/dist/TransitProvider.js.map +1 -0
- package/dist/apple.d.ts +45 -0
- package/dist/apple.d.ts.map +1 -0
- package/dist/apple.js +80 -0
- package/dist/apple.js.map +1 -0
- package/dist/ber.d.ts +50 -0
- package/dist/ber.d.ts.map +1 -0
- package/dist/ber.js +156 -0
- package/dist/ber.js.map +1 -0
- package/dist/c14n.d.ts +38 -0
- package/dist/c14n.d.ts.map +1 -0
- package/dist/c14n.js +129 -0
- package/dist/c14n.js.map +1 -0
- package/dist/config.d.ts +115 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +146 -0
- package/dist/config.js.map +1 -0
- package/dist/configure.d.ts +18 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +33 -0
- package/dist/configure.js.map +1 -0
- package/dist/drivers/AppleDriver.d.ts +36 -0
- package/dist/drivers/AppleDriver.d.ts.map +1 -0
- package/dist/drivers/AppleDriver.js +58 -0
- package/dist/drivers/AppleDriver.js.map +1 -0
- package/dist/drivers/DiscordDriver.d.ts +15 -0
- package/dist/drivers/DiscordDriver.d.ts.map +1 -0
- package/dist/drivers/DiscordDriver.js +45 -0
- package/dist/drivers/DiscordDriver.js.map +1 -0
- package/dist/drivers/FacebookDriver.d.ts +15 -0
- package/dist/drivers/FacebookDriver.d.ts.map +1 -0
- package/dist/drivers/FacebookDriver.js +47 -0
- package/dist/drivers/FacebookDriver.js.map +1 -0
- package/dist/drivers/GitHubDriver.d.ts +33 -0
- package/dist/drivers/GitHubDriver.d.ts.map +1 -0
- package/dist/drivers/GitHubDriver.js +72 -0
- package/dist/drivers/GitHubDriver.js.map +1 -0
- package/dist/drivers/GoogleDriver.d.ts +16 -0
- package/dist/drivers/GoogleDriver.d.ts.map +1 -0
- package/dist/drivers/GoogleDriver.js +31 -0
- package/dist/drivers/GoogleDriver.js.map +1 -0
- package/dist/drivers/LdapDirectory.d.ts +51 -0
- package/dist/drivers/LdapDirectory.d.ts.map +1 -0
- package/dist/drivers/LdapDirectory.js +127 -0
- package/dist/drivers/LdapDirectory.js.map +1 -0
- package/dist/drivers/LinkedInDriver.d.ts +29 -0
- package/dist/drivers/LinkedInDriver.d.ts.map +1 -0
- package/dist/drivers/LinkedInDriver.js +97 -0
- package/dist/drivers/LinkedInDriver.js.map +1 -0
- package/dist/drivers/LinkedInOpenidConnectDriver.d.ts +17 -0
- package/dist/drivers/LinkedInOpenidConnectDriver.d.ts.map +1 -0
- package/dist/drivers/LinkedInOpenidConnectDriver.js +27 -0
- package/dist/drivers/LinkedInOpenidConnectDriver.js.map +1 -0
- package/dist/drivers/OidcDriver.d.ts +64 -0
- package/dist/drivers/OidcDriver.d.ts.map +1 -0
- package/dist/drivers/OidcDriver.js +259 -0
- package/dist/drivers/OidcDriver.js.map +1 -0
- package/dist/drivers/SamlDriver.d.ts +66 -0
- package/dist/drivers/SamlDriver.d.ts.map +1 -0
- package/dist/drivers/SamlDriver.js +228 -0
- package/dist/drivers/SamlDriver.js.map +1 -0
- package/dist/drivers/SpotifyDriver.d.ts +16 -0
- package/dist/drivers/SpotifyDriver.d.ts.map +1 -0
- package/dist/drivers/SpotifyDriver.js +34 -0
- package/dist/drivers/SpotifyDriver.js.map +1 -0
- package/dist/drivers/TwitterDriver.d.ts +21 -0
- package/dist/drivers/TwitterDriver.d.ts.map +1 -0
- package/dist/drivers/TwitterDriver.js +46 -0
- package/dist/drivers/TwitterDriver.js.map +1 -0
- package/dist/drivers/TwitterXDriver.d.ts +22 -0
- package/dist/drivers/TwitterXDriver.d.ts.map +1 -0
- package/dist/drivers/TwitterXDriver.js +47 -0
- package/dist/drivers/TwitterXDriver.js.map +1 -0
- package/dist/dsig.d.ts +51 -0
- package/dist/dsig.d.ts.map +1 -0
- package/dist/dsig.js +273 -0
- package/dist/dsig.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +135 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +167 -0
- package/dist/jwt.js.map +1 -0
- package/dist/ldap.d.ts +82 -0
- package/dist/ldap.d.ts.map +1 -0
- package/dist/ldap.js +271 -0
- package/dist/ldap.js.map +1 -0
- package/dist/nodeEnv.d.ts +17 -0
- package/dist/nodeEnv.d.ts.map +1 -0
- package/dist/nodeEnv.js +33 -0
- package/dist/nodeEnv.js.map +1 -0
- package/dist/oauth1.d.ts +68 -0
- package/dist/oauth1.d.ts.map +1 -0
- package/dist/oauth1.js +106 -0
- package/dist/oauth1.js.map +1 -0
- package/dist/oidc.d.ts +61 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +151 -0
- package/dist/oidc.js.map +1 -0
- package/dist/quasar.d.ts +14 -0
- package/dist/quasar.d.ts.map +1 -0
- package/dist/quasar.js +42 -0
- package/dist/quasar.js.map +1 -0
- package/dist/replay.d.ts +70 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +82 -0
- package/dist/replay.js.map +1 -0
- package/dist/saml.d.ts +68 -0
- package/dist/saml.d.ts.map +1 -0
- package/dist/saml.js +181 -0
- package/dist/saml.js.map +1 -0
- package/dist/services/main.d.ts +22 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +39 -0
- package/dist/services/main.js.map +1 -0
- package/dist/testing/FakeTransit.d.ts +82 -0
- package/dist/testing/FakeTransit.d.ts.map +1 -0
- package/dist/testing/FakeTransit.js +160 -0
- package/dist/testing/FakeTransit.js.map +1 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +27 -0
- package/dist/types.js.map +1 -0
- package/dist/xml.d.ts +72 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +353 -0
- package/dist/xml.js.map +1 -0
- package/package.json +73 -0
- package/src/Oauth1Driver.ts +216 -0
- package/src/Oauth2Driver.ts +271 -0
- package/src/TransitManager.ts +145 -0
- package/src/TransitProvider.ts +51 -0
- package/src/apple.ts +101 -0
- package/src/ber.ts +175 -0
- package/src/c14n.ts +187 -0
- package/src/config.ts +160 -0
- package/src/configure.ts +47 -0
- package/src/drivers/AppleDriver.ts +69 -0
- package/src/drivers/DiscordDriver.ts +50 -0
- package/src/drivers/FacebookDriver.ts +53 -0
- package/src/drivers/GitHubDriver.ts +91 -0
- package/src/drivers/GoogleDriver.ts +38 -0
- package/src/drivers/LdapDirectory.ts +185 -0
- package/src/drivers/LinkedInDriver.ts +105 -0
- package/src/drivers/LinkedInOpenidConnectDriver.ts +33 -0
- package/src/drivers/OidcDriver.ts +379 -0
- package/src/drivers/SamlDriver.ts +326 -0
- package/src/drivers/SpotifyDriver.ts +38 -0
- package/src/drivers/TwitterDriver.ts +55 -0
- package/src/drivers/TwitterXDriver.ts +53 -0
- package/src/dsig.ts +405 -0
- package/src/index.ts +85 -0
- package/src/jwt.ts +263 -0
- package/src/ldap.ts +374 -0
- package/src/nodeEnv.ts +31 -0
- package/src/oauth1.ts +144 -0
- package/src/oidc.ts +208 -0
- package/src/quasar.ts +64 -0
- package/src/replay.ts +136 -0
- package/src/saml.ts +309 -0
- package/src/services/main.ts +47 -0
- package/src/testing/FakeTransit.ts +233 -0
- package/src/types.ts +150 -0
- package/src/xml.ts +438 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic OpenID Connect.
|
|
3
|
+
*
|
|
4
|
+
* One driver for every provider that conforms: Keycloak, Auth0, Okta,
|
|
5
|
+
* Entra ID, Authentik, Zitadel, Ping, Google. It is given an issuer and reads
|
|
6
|
+
* the rest — endpoints, keys, supported algorithms — from what the provider
|
|
7
|
+
* publishes about itself.
|
|
8
|
+
*
|
|
9
|
+
* What separates this from a plain OAuth2 driver is that the answer is a
|
|
10
|
+
* SIGNED STATEMENT about who the user is, not just a token to go ask with.
|
|
11
|
+
* That statement is only worth anything once it has been verified, which is
|
|
12
|
+
* what `jwt.ts` does and what most of this class exists to feed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
assertIdTokenClaims,
|
|
18
|
+
decodeJws,
|
|
19
|
+
type IdTokenClaims,
|
|
20
|
+
isSupportedAlg,
|
|
21
|
+
type SupportedAlg,
|
|
22
|
+
verifyJwsSignature,
|
|
23
|
+
} from "../jwt.js";
|
|
24
|
+
import { challengeFor, createCodeVerifier } from "../Oauth2Driver.js";
|
|
25
|
+
import {
|
|
26
|
+
JwksCache,
|
|
27
|
+
OidcDiscovery,
|
|
28
|
+
type OidcMetadata,
|
|
29
|
+
type RemoteOptions,
|
|
30
|
+
} from "../oidc.js";
|
|
31
|
+
import type {
|
|
32
|
+
OAuthToken,
|
|
33
|
+
RedirectRequest,
|
|
34
|
+
TransitDriver,
|
|
35
|
+
TransitUser,
|
|
36
|
+
} from "../types.js";
|
|
37
|
+
import { assertOAuthState } from "../types.js";
|
|
38
|
+
|
|
39
|
+
export interface OidcConfig {
|
|
40
|
+
/** The provider's issuer URL, exactly as it appears in its tokens. */
|
|
41
|
+
issuer: string;
|
|
42
|
+
clientId: string;
|
|
43
|
+
/**
|
|
44
|
+
* The client secret, or something that mints one. Apple has no static
|
|
45
|
+
* secret — it expects a short-lived JWT signed with a private key — so this
|
|
46
|
+
* is resolved at each token request rather than read once.
|
|
47
|
+
*/
|
|
48
|
+
clientSecret: string | (() => string | Promise<string>);
|
|
49
|
+
callbackUrl: string;
|
|
50
|
+
/** Default `["openid", "profile", "email"]`. `openid` is added if omitted. */
|
|
51
|
+
scopes?: string[];
|
|
52
|
+
/** Extra parameters on the authorize URL — `prompt`, `login_hint`, `acr_values`. */
|
|
53
|
+
authorizeParams?: Record<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Also call the userinfo endpoint, when the provider advertises one.
|
|
56
|
+
* Default `true`: the id_token often carries only the subject.
|
|
57
|
+
*/
|
|
58
|
+
userinfo?: boolean;
|
|
59
|
+
/** Tolerance for clock drift against the provider, in seconds. Default 60. */
|
|
60
|
+
leewaySeconds?: number;
|
|
61
|
+
/** Cache tuning for the discovery document and the signing keys. */
|
|
62
|
+
cache?: RemoteOptions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `secret` carries two values here — the PKCE verifier and the nonce — and the
|
|
67
|
+
* caller only has to store it and hand it back. Joined by a dot, which the
|
|
68
|
+
* base64url alphabet does not contain, so neither half can forge a boundary.
|
|
69
|
+
*/
|
|
70
|
+
const SECRET_SEPARATOR = ".";
|
|
71
|
+
|
|
72
|
+
export class OidcDriver implements TransitDriver {
|
|
73
|
+
readonly #config: OidcConfig;
|
|
74
|
+
readonly #discovery: OidcDiscovery;
|
|
75
|
+
readonly #jwks: JwksCache;
|
|
76
|
+
|
|
77
|
+
constructor(config: OidcConfig) {
|
|
78
|
+
this.#config = config;
|
|
79
|
+
this.#discovery = new OidcDiscovery(config.issuer, config.cache);
|
|
80
|
+
this.#jwks = new JwksCache(config.cache);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The endpoints are only known once the provider has been asked, so there
|
|
85
|
+
* is nothing to build offline.
|
|
86
|
+
*/
|
|
87
|
+
redirectUrl(): string {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"[transit] an OpenID Connect provider publishes its endpoints, so the redirect is fetched. Use begin() instead of redirect().",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async begin(state: string = randomUUID()): Promise<RedirectRequest> {
|
|
94
|
+
const metadata = await this.#discovery.metadata();
|
|
95
|
+
const verifier = createCodeVerifier();
|
|
96
|
+
const nonce = randomUUID();
|
|
97
|
+
|
|
98
|
+
const params = new URLSearchParams({
|
|
99
|
+
client_id: this.#config.clientId,
|
|
100
|
+
redirect_uri: this.#config.callbackUrl,
|
|
101
|
+
response_type: "code",
|
|
102
|
+
scope: this.#scopes().join(" "),
|
|
103
|
+
state,
|
|
104
|
+
nonce,
|
|
105
|
+
...this.#config.authorizeParams,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// PKCE is not optional for a public client and costs nothing for a
|
|
109
|
+
// confidential one, so it is sent unless the provider says outright that
|
|
110
|
+
// it does not take S256.
|
|
111
|
+
const pkce =
|
|
112
|
+
metadata.code_challenge_methods_supported?.includes("S256") !== false;
|
|
113
|
+
if (pkce) {
|
|
114
|
+
params.set("code_challenge", challengeFor(verifier));
|
|
115
|
+
params.set("code_challenge_method", "S256");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Whether PKCE was used travels in `secret`. Sending a verifier for a
|
|
119
|
+
// challenge that was never sent is at best noise, and a strict provider
|
|
120
|
+
// refuses the exchange over it.
|
|
121
|
+
return {
|
|
122
|
+
url: `${metadata.authorization_endpoint}?${params}`,
|
|
123
|
+
state,
|
|
124
|
+
secret: `${pkce ? verifier : ""}${SECRET_SEPARATOR}${nonce}`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async callback(
|
|
129
|
+
code: string,
|
|
130
|
+
state?: string,
|
|
131
|
+
expectedState?: string,
|
|
132
|
+
secret?: string,
|
|
133
|
+
): Promise<{ user: TransitUser; token: OAuthToken }> {
|
|
134
|
+
assertOAuthState(state, expectedState);
|
|
135
|
+
const { verifier, nonce } = splitSecret(secret);
|
|
136
|
+
|
|
137
|
+
const metadata = await this.#discovery.metadata();
|
|
138
|
+
const tokens = await this.#exchange(metadata, code, verifier);
|
|
139
|
+
|
|
140
|
+
const idToken = tokens.id_token;
|
|
141
|
+
if (typeof idToken !== "string" || idToken === "") {
|
|
142
|
+
throw new Error(
|
|
143
|
+
"[transit] the provider returned no id_token — check that the 'openid' scope is granted",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const claims = await this.#verifyIdToken(metadata, idToken, nonce);
|
|
147
|
+
|
|
148
|
+
const accessToken = String(tokens.access_token ?? "");
|
|
149
|
+
const profile = await this.#profile(metadata, accessToken, claims);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
user: this.mapUser(profile),
|
|
153
|
+
token: {
|
|
154
|
+
accessToken,
|
|
155
|
+
...(typeof tokens.refresh_token === "string"
|
|
156
|
+
? { refreshToken: tokens.refresh_token }
|
|
157
|
+
: {}),
|
|
158
|
+
...(typeof tokens.expires_in === "number"
|
|
159
|
+
? { expiresIn: tokens.expires_in }
|
|
160
|
+
: {}),
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Read a profile behind a token already held, through the userinfo endpoint. */
|
|
166
|
+
async userFromToken(accessToken: string): Promise<TransitUser> {
|
|
167
|
+
const metadata = await this.#discovery.metadata();
|
|
168
|
+
if (!metadata.userinfo_endpoint) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`[transit] '${this.#config.issuer}' publishes no userinfo endpoint, so a profile cannot be read from a token alone.`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return this.mapUser(
|
|
174
|
+
await this.#userinfo(metadata.userinfo_endpoint, accessToken),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Turn the claims into the shape every driver answers with. A provider that
|
|
180
|
+
* spells a standard claim its own way overrides this.
|
|
181
|
+
*/
|
|
182
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
183
|
+
return mapOidcUser(raw);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The secret for one token request. */
|
|
187
|
+
async #clientSecret(): Promise<string> {
|
|
188
|
+
const secret = this.#config.clientSecret;
|
|
189
|
+
return typeof secret === "function" ? await secret() : secret;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#scopes(): string[] {
|
|
193
|
+
const scopes = [...(this.#config.scopes ?? ["openid", "profile", "email"])];
|
|
194
|
+
// Without `openid` the provider runs a plain OAuth2 flow and returns no
|
|
195
|
+
// id_token, which fails later with a much less obvious message.
|
|
196
|
+
if (!scopes.includes("openid")) scopes.unshift("openid");
|
|
197
|
+
return scopes;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async #exchange(
|
|
201
|
+
metadata: OidcMetadata,
|
|
202
|
+
code: string,
|
|
203
|
+
verifier: string,
|
|
204
|
+
): Promise<Record<string, unknown>> {
|
|
205
|
+
const body = new URLSearchParams({
|
|
206
|
+
grant_type: "authorization_code",
|
|
207
|
+
code,
|
|
208
|
+
redirect_uri: this.#config.callbackUrl,
|
|
209
|
+
client_id: this.#config.clientId,
|
|
210
|
+
client_secret: await this.#clientSecret(),
|
|
211
|
+
// Only when a challenge was actually sent.
|
|
212
|
+
...(verifier === "" ? {} : { code_verifier: verifier }),
|
|
213
|
+
});
|
|
214
|
+
const response = await fetch(metadata.token_endpoint, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: {
|
|
217
|
+
Accept: "application/json",
|
|
218
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
219
|
+
},
|
|
220
|
+
body,
|
|
221
|
+
});
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`[transit] OpenID Connect token exchange failed (HTTP ${response.status})`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return (await response.json()) as Record<string, unknown>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The signature first, then every claim. The algorithm comes from what the
|
|
232
|
+
* provider declared it signs with, intersected with what can be verified —
|
|
233
|
+
* never from the token's own header.
|
|
234
|
+
*/
|
|
235
|
+
async #verifyIdToken(
|
|
236
|
+
metadata: OidcMetadata,
|
|
237
|
+
idToken: string,
|
|
238
|
+
nonce: string,
|
|
239
|
+
): Promise<IdTokenClaims> {
|
|
240
|
+
const parts = decodeJws(idToken);
|
|
241
|
+
const alg = this.#algorithmFor(metadata, parts.header.alg);
|
|
242
|
+
const kid =
|
|
243
|
+
typeof parts.header.kid === "string" ? parts.header.kid : undefined;
|
|
244
|
+
|
|
245
|
+
const key = await this.#jwks.key(metadata.jwks_uri, kid);
|
|
246
|
+
if (!verifyJwsSignature(parts, key, alg)) {
|
|
247
|
+
throw new Error("[transit] id_token signature does not verify");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return assertIdTokenClaims(parts.payload, {
|
|
251
|
+
issuer: metadata.issuer,
|
|
252
|
+
audience: this.#config.clientId,
|
|
253
|
+
nonce,
|
|
254
|
+
leewaySeconds: this.#config.leewaySeconds,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Which algorithm to verify with.
|
|
260
|
+
*
|
|
261
|
+
* The header's value is used only to CHOOSE among what the provider says it
|
|
262
|
+
* signs with; it can never introduce one that was not declared, and it can
|
|
263
|
+
* never select a family this refuses.
|
|
264
|
+
*/
|
|
265
|
+
#algorithmFor(metadata: OidcMetadata, headerAlg: unknown): SupportedAlg {
|
|
266
|
+
// Every conforming provider supports RS256 and publishes the list; the
|
|
267
|
+
// narrow reading when it does not is that RS256 is all there is.
|
|
268
|
+
const declared = metadata.id_token_signing_alg_values_supported ?? [
|
|
269
|
+
"RS256",
|
|
270
|
+
];
|
|
271
|
+
const allowed = declared.filter(isSupportedAlg);
|
|
272
|
+
if (allowed.length === 0) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`[transit] '${this.#config.issuer}' signs id_tokens only with algorithms Transit refuses (${declared.join(", ")}). Asymmetric signatures are required.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (!isSupportedAlg(headerAlg)) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`[transit] id_token announces '${String(headerAlg)}', which Transit does not verify — only asymmetric signatures are accepted`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (!allowed.includes(headerAlg)) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`[transit] id_token announces '${headerAlg}', which '${this.#config.issuer}' did not declare it signs with`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// The header only ever SELECTS from the set computed above. It cannot
|
|
288
|
+
// introduce an algorithm, which is what keeps `none` and the
|
|
289
|
+
// shared-secret families away from the verifier entirely.
|
|
290
|
+
return headerAlg;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The claims, plus userinfo when there is one. The subject must be the same
|
|
295
|
+
* in both: a userinfo response describing another user is a substituted
|
|
296
|
+
* token, not a richer profile.
|
|
297
|
+
*/
|
|
298
|
+
async #profile(
|
|
299
|
+
metadata: OidcMetadata,
|
|
300
|
+
accessToken: string,
|
|
301
|
+
claims: IdTokenClaims,
|
|
302
|
+
): Promise<Record<string, unknown>> {
|
|
303
|
+
if (
|
|
304
|
+
this.#config.userinfo === false ||
|
|
305
|
+
!metadata.userinfo_endpoint ||
|
|
306
|
+
accessToken === ""
|
|
307
|
+
) {
|
|
308
|
+
return claims as unknown as Record<string, unknown>;
|
|
309
|
+
}
|
|
310
|
+
const info = await this.#userinfo(metadata.userinfo_endpoint, accessToken);
|
|
311
|
+
if (info.sub !== claims.sub) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
"[transit] the userinfo response describes a different subject than the id_token",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return { ...(claims as unknown as Record<string, unknown>), ...info };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async #userinfo(
|
|
320
|
+
endpoint: string,
|
|
321
|
+
accessToken: string,
|
|
322
|
+
): Promise<Record<string, unknown>> {
|
|
323
|
+
const response = await fetch(endpoint, {
|
|
324
|
+
headers: {
|
|
325
|
+
Authorization: `Bearer ${accessToken}`,
|
|
326
|
+
Accept: "application/json",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`[transit] OpenID Connect userinfo request failed (HTTP ${response.status})`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return (await response.json()) as Record<string, unknown>;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Standard OpenID Connect claims, in the shape every driver answers with. */
|
|
339
|
+
export function mapOidcUser(raw: Record<string, unknown>): TransitUser {
|
|
340
|
+
const given = str(raw, "given_name");
|
|
341
|
+
const family = str(raw, "family_name");
|
|
342
|
+
const email = str(raw, "email");
|
|
343
|
+
return {
|
|
344
|
+
id: String(raw.sub ?? ""),
|
|
345
|
+
email: email ?? "",
|
|
346
|
+
name: str(raw, "name") ?? [given, family].filter(Boolean).join(" "),
|
|
347
|
+
nickName: str(raw, "preferred_username") ?? given,
|
|
348
|
+
avatarUrl: str(raw, "picture"),
|
|
349
|
+
emailVerificationState:
|
|
350
|
+
email === undefined
|
|
351
|
+
? "unsupported"
|
|
352
|
+
: raw.email_verified === true
|
|
353
|
+
? "verified"
|
|
354
|
+
: "unverified",
|
|
355
|
+
raw,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function str(raw: Record<string, unknown>, key: string): string | undefined {
|
|
360
|
+
const value = raw[key];
|
|
361
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Split the value `begin()` asked the caller to keep. */
|
|
365
|
+
function splitSecret(secret: string | undefined): {
|
|
366
|
+
verifier: string;
|
|
367
|
+
nonce: string;
|
|
368
|
+
} {
|
|
369
|
+
const index = secret?.indexOf(SECRET_SEPARATOR) ?? -1;
|
|
370
|
+
// An empty verifier is legitimate — it says PKCE was not used. A missing
|
|
371
|
+
// separator, or a missing nonce, is a value that did not come from begin().
|
|
372
|
+
const nonce = secret === undefined ? "" : secret.slice(index + 1);
|
|
373
|
+
if (secret === undefined || index === -1 || nonce === "") {
|
|
374
|
+
throw new Error(
|
|
375
|
+
"[transit] OpenID Connect needs the value begin() returned as `secret` — store it with the state and pass it back here.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return { verifier: secret.slice(0, index), nonce };
|
|
379
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SAML 2.0 Web Browser SSO.
|
|
3
|
+
*
|
|
4
|
+
* The pieces are the four layers below this one; what this adds is the walk
|
|
5
|
+
* between them, and the two bindings a browser sign-in uses: the request
|
|
6
|
+
* leaves deflated in a query string, and the response comes back base64 in a
|
|
7
|
+
* form POST.
|
|
8
|
+
*
|
|
9
|
+
* The order here is the security. The signature is checked FIRST, and
|
|
10
|
+
* everything read afterwards comes out of the element it covers — the status
|
|
11
|
+
* being the one exception, and a deliberate one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { deflateRawSync } from "node:zlib";
|
|
16
|
+
import { verifyXmlSignature } from "../dsig.js";
|
|
17
|
+
import { inProduction } from "../nodeEnv.js";
|
|
18
|
+
import {
|
|
19
|
+
type AssertionReplayStore,
|
|
20
|
+
MemoryAssertionReplayStore,
|
|
21
|
+
} from "../replay.js";
|
|
22
|
+
import {
|
|
23
|
+
assertionInside,
|
|
24
|
+
assertResponseSucceeded,
|
|
25
|
+
SamlError,
|
|
26
|
+
type SamlIdentity,
|
|
27
|
+
validateAssertion,
|
|
28
|
+
} from "../saml.js";
|
|
29
|
+
import type {
|
|
30
|
+
OAuthToken,
|
|
31
|
+
RedirectRequest,
|
|
32
|
+
TransitDriver,
|
|
33
|
+
TransitUser,
|
|
34
|
+
} from "../types.js";
|
|
35
|
+
import { assertOAuthState } from "../types.js";
|
|
36
|
+
import { parseXml } from "../xml.js";
|
|
37
|
+
|
|
38
|
+
const PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol";
|
|
39
|
+
const ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion";
|
|
40
|
+
const HTTP_POST = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
|
41
|
+
const EMAIL_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The attribute names providers actually use for the same three things. A
|
|
45
|
+
* config names its own when its provider invents another.
|
|
46
|
+
*/
|
|
47
|
+
const EMAIL_CLAIMS = [
|
|
48
|
+
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
|
49
|
+
"urn:oid:0.9.2342.19200300.100.1.3",
|
|
50
|
+
"email",
|
|
51
|
+
"mail",
|
|
52
|
+
"emailAddress",
|
|
53
|
+
];
|
|
54
|
+
const NAME_CLAIMS = [
|
|
55
|
+
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
|
|
56
|
+
"urn:oid:2.16.840.1.113730.3.1.241",
|
|
57
|
+
"displayName",
|
|
58
|
+
"cn",
|
|
59
|
+
"name",
|
|
60
|
+
];
|
|
61
|
+
const NICK_CLAIMS = [
|
|
62
|
+
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
|
63
|
+
"urn:oid:0.9.2342.19200300.100.1.1",
|
|
64
|
+
"uid",
|
|
65
|
+
"givenName",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export interface SamlConfig {
|
|
69
|
+
/** This application's entity id, as the provider knows it. */
|
|
70
|
+
entityId: string;
|
|
71
|
+
/** Where the provider posts its response — this application's ACS URL. */
|
|
72
|
+
callbackUrl: string;
|
|
73
|
+
/** The provider's entity id, exactly as its metadata declares it. */
|
|
74
|
+
issuer: string;
|
|
75
|
+
/** The provider's sign-on endpoint, for the redirect binding. */
|
|
76
|
+
signOnUrl: string;
|
|
77
|
+
/**
|
|
78
|
+
* The provider's signing certificates, from its metadata. Several are
|
|
79
|
+
* accepted so a rotation can be prepared before it happens.
|
|
80
|
+
*/
|
|
81
|
+
certificates: string[];
|
|
82
|
+
/** The name identifier format to ask for. */
|
|
83
|
+
nameIdFormat?: string;
|
|
84
|
+
/** Tolerance for clock drift against the provider. Default 60 seconds. */
|
|
85
|
+
clockSkewSeconds?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Where used assertions are remembered.
|
|
88
|
+
*
|
|
89
|
+
* Defaults to this process's memory, which accepts the same assertion once
|
|
90
|
+
* per replica — pass `replayStores.redis(…)` as soon as there are two.
|
|
91
|
+
*/
|
|
92
|
+
replayStore?: AssertionReplayStore | (() => AssertionReplayStore);
|
|
93
|
+
/** The attribute names this provider uses, when they are not the usual ones. */
|
|
94
|
+
claims?: { email?: string; name?: string; nickName?: string };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Refuse to fall into the memory replay store by DEFAULT in production.
|
|
99
|
+
*
|
|
100
|
+
* A store that bounds replay per process is real protection on one replica and
|
|
101
|
+
* none at all on two, and nothing here can tell which is deployed. Defaulting
|
|
102
|
+
* quietly means the protection disappears exactly when a service is scaled —
|
|
103
|
+
* the moment nobody is looking at this file.
|
|
104
|
+
*
|
|
105
|
+
* Outside production the default stands: a single dev process is the case it
|
|
106
|
+
* is correct for.
|
|
107
|
+
*/
|
|
108
|
+
function refuseImplicitMemoryReplayStoreInProduction(): void {
|
|
109
|
+
if (!inProduction()) return;
|
|
110
|
+
throw new SamlError(
|
|
111
|
+
"SAML replay protection has no store configured, and the in-process default only bounds replay within ONE process — behind a second replica the same assertion is accepted again on each.\n" +
|
|
112
|
+
" Set `replayStore: replayStores.redis({ connection })` to share the record across replicas,\n" +
|
|
113
|
+
" or `replayStores.memory()` to state that a single process is intended.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class SamlDriver implements TransitDriver {
|
|
118
|
+
readonly #config: SamlConfig;
|
|
119
|
+
#replay: AssertionReplayStore | undefined;
|
|
120
|
+
|
|
121
|
+
constructor(config: SamlConfig) {
|
|
122
|
+
if (config.certificates.length === 0) {
|
|
123
|
+
// Without a certificate there is nothing to verify against, and a
|
|
124
|
+
// driver that discovered that at the first sign-in would have let the
|
|
125
|
+
// application boot looking healthy.
|
|
126
|
+
throw new SamlError(
|
|
127
|
+
`no signing certificate for '${config.issuer}' — take them from the provider's metadata`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (config.replayStore === undefined) {
|
|
131
|
+
// Decided here, not on the first sign-in: a driver that discovered
|
|
132
|
+
// this mid-request would have let the application boot looking
|
|
133
|
+
// healthy, and reported it to whoever happened to sign in first.
|
|
134
|
+
refuseImplicitMemoryReplayStoreInProduction();
|
|
135
|
+
}
|
|
136
|
+
this.#config = config;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A SAML request carries its own id, and that id is what the response has
|
|
141
|
+
* to answer. There is nothing to build without minting one.
|
|
142
|
+
*/
|
|
143
|
+
redirectUrl(): string {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"[transit] a SAML sign-in carries a request id the response must answer. Use begin() instead of redirect().",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async begin(state: string = randomUUID()): Promise<RedirectRequest> {
|
|
150
|
+
// A SAML id is an XML name: it may not start with a digit, and a bare
|
|
151
|
+
// UUID often does.
|
|
152
|
+
const requestId = `_${randomUUID()}`;
|
|
153
|
+
const request = this.#authnRequest(requestId);
|
|
154
|
+
|
|
155
|
+
// The redirect binding carries the request deflated — raw, with no zlib
|
|
156
|
+
// header, which is what the specification asks for.
|
|
157
|
+
const deflated = deflateRawSync(Buffer.from(request, "utf8")).toString(
|
|
158
|
+
"base64",
|
|
159
|
+
);
|
|
160
|
+
const params = new URLSearchParams({
|
|
161
|
+
SAMLRequest: deflated,
|
|
162
|
+
RelayState: state,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
url: `${this.#config.signOnUrl}${this.#config.signOnUrl.includes("?") ? "&" : "?"}${params}`,
|
|
167
|
+
state,
|
|
168
|
+
// The response is checked against this id; storing it is not optional.
|
|
169
|
+
secret: requestId,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `code` is the base64 `SAMLResponse` from the form post, `state` the
|
|
175
|
+
* `RelayState` that came back, `expectedState` the one sent, and `secret`
|
|
176
|
+
* the request id `begin()` minted.
|
|
177
|
+
*/
|
|
178
|
+
async callback(
|
|
179
|
+
code: string,
|
|
180
|
+
state?: string,
|
|
181
|
+
expectedState?: string,
|
|
182
|
+
secret?: string,
|
|
183
|
+
): Promise<{ user: TransitUser; token: OAuthToken }> {
|
|
184
|
+
assertOAuthState(state, expectedState);
|
|
185
|
+
if (!secret) {
|
|
186
|
+
throw new SamlError(
|
|
187
|
+
"the request id from begin() is missing — store it with the state and pass it back, or any assertion can be posted here",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const document = parseXml(decode(code));
|
|
192
|
+
if (
|
|
193
|
+
document.namespaceUri !== PROTOCOL_NS ||
|
|
194
|
+
document.local !== "Response"
|
|
195
|
+
) {
|
|
196
|
+
throw new SamlError(
|
|
197
|
+
`expected a <Response>, and the document is <${document.qname}>`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Read from the unsigned document exactly once, and only this: a refusal
|
|
202
|
+
// arrives unsigned when the assertion is what carries the signature, and
|
|
203
|
+
// it exists to be reported rather than trusted. Flipping it the other
|
|
204
|
+
// way cannot produce an assertion that verifies.
|
|
205
|
+
assertResponseSucceeded(document);
|
|
206
|
+
|
|
207
|
+
// From here on, only what the signature covers.
|
|
208
|
+
const signed = verifyXmlSignature(document, {
|
|
209
|
+
certificates: this.#config.certificates,
|
|
210
|
+
});
|
|
211
|
+
const assertion = assertionInside(signed);
|
|
212
|
+
|
|
213
|
+
const identity = validateAssertion(assertion, {
|
|
214
|
+
issuer: this.#config.issuer,
|
|
215
|
+
audience: this.#config.entityId,
|
|
216
|
+
recipient: this.#config.callbackUrl,
|
|
217
|
+
inResponseTo: secret,
|
|
218
|
+
clockSkewSeconds: this.#config.clockSkewSeconds,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const fresh = await this.#replayStore().remember(
|
|
222
|
+
identity.assertionId,
|
|
223
|
+
identity.notOnOrAfter,
|
|
224
|
+
);
|
|
225
|
+
if (!fresh) {
|
|
226
|
+
// Everything above passes on a captured response posted a second
|
|
227
|
+
// time. This is what does not.
|
|
228
|
+
throw new SamlError(
|
|
229
|
+
"this assertion has already been used — a response cannot sign somebody in twice",
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
user: this.#mapUser(identity),
|
|
235
|
+
// SAML hands over a statement, not a token to go asking with. The
|
|
236
|
+
// session index is what a single logout would name.
|
|
237
|
+
token: { accessToken: identity.sessionIndex ?? identity.assertionId },
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#replayStore(): AssertionReplayStore {
|
|
242
|
+
if (!this.#replay) {
|
|
243
|
+
const configured = this.#config.replayStore;
|
|
244
|
+
this.#replay =
|
|
245
|
+
typeof configured === "function"
|
|
246
|
+
? configured()
|
|
247
|
+
: (configured ?? new MemoryAssertionReplayStore());
|
|
248
|
+
}
|
|
249
|
+
return this.#replay;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#authnRequest(id: string): string {
|
|
253
|
+
const format = this.#config.nameIdFormat;
|
|
254
|
+
return (
|
|
255
|
+
`<samlp:AuthnRequest xmlns:samlp="${PROTOCOL_NS}"` +
|
|
256
|
+
` ID="${escapeXml(id)}" Version="2.0"` +
|
|
257
|
+
` IssueInstant="${new Date().toISOString()}"` +
|
|
258
|
+
` Destination="${escapeXml(this.#config.signOnUrl)}"` +
|
|
259
|
+
` AssertionConsumerServiceURL="${escapeXml(this.#config.callbackUrl)}"` +
|
|
260
|
+
` ProtocolBinding="${HTTP_POST}">` +
|
|
261
|
+
`<saml:Issuer xmlns:saml="${ASSERTION_NS}">${escapeXml(this.#config.entityId)}</saml:Issuer>` +
|
|
262
|
+
(format
|
|
263
|
+
? `<samlp:NameIDPolicy Format="${escapeXml(format)}" AllowCreate="true"/>`
|
|
264
|
+
: "") +
|
|
265
|
+
"</samlp:AuthnRequest>"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#mapUser(identity: SamlIdentity): TransitUser {
|
|
270
|
+
const claims = this.#config.claims ?? {};
|
|
271
|
+
const email =
|
|
272
|
+
pick(identity, claims.email, EMAIL_CLAIMS) ??
|
|
273
|
+
(identity.nameIdFormat === EMAIL_FORMAT ? identity.nameId : undefined);
|
|
274
|
+
const name = pick(identity, claims.name, NAME_CLAIMS);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
// The NameID is what the provider calls this person, and the only
|
|
278
|
+
// value guaranteed to be there.
|
|
279
|
+
id: identity.nameId,
|
|
280
|
+
email: email ?? "",
|
|
281
|
+
name: name ?? identity.nameId,
|
|
282
|
+
nickName: pick(identity, claims.nickName, NICK_CLAIMS),
|
|
283
|
+
// A provider that asserts an address has vouched for it: that is what
|
|
284
|
+
// its directory is.
|
|
285
|
+
emailVerificationState: email === undefined ? "unsupported" : "verified",
|
|
286
|
+
raw: {
|
|
287
|
+
nameId: identity.nameId,
|
|
288
|
+
nameIdFormat: identity.nameIdFormat,
|
|
289
|
+
sessionIndex: identity.sessionIndex,
|
|
290
|
+
attributes: identity.attributes,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The first value among the configured name, then the usual ones. */
|
|
297
|
+
function pick(
|
|
298
|
+
identity: SamlIdentity,
|
|
299
|
+
configured: string | undefined,
|
|
300
|
+
candidates: string[],
|
|
301
|
+
): string | undefined {
|
|
302
|
+
const names = configured ? [configured] : candidates;
|
|
303
|
+
for (const name of names) {
|
|
304
|
+
const value = identity.attributes[name]?.[0];
|
|
305
|
+
if (value !== undefined && value !== "") return value;
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function decode(value: string): string {
|
|
311
|
+
const text = Buffer.from(value.replace(/\s+/g, ""), "base64").toString(
|
|
312
|
+
"utf8",
|
|
313
|
+
);
|
|
314
|
+
if (!text.includes("<")) {
|
|
315
|
+
throw new SamlError("the SAMLResponse is not base64-encoded XML");
|
|
316
|
+
}
|
|
317
|
+
return text;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function escapeXml(value: string): string {
|
|
321
|
+
return value
|
|
322
|
+
.replace(/&/g, "&")
|
|
323
|
+
.replace(/</g, "<")
|
|
324
|
+
.replace(/>/g, ">")
|
|
325
|
+
.replace(/"/g, """);
|
|
326
|
+
}
|