@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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spotify OAuth2 driver.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Oauth2Driver } from "../Oauth2Driver.js";
|
|
6
|
+
import type { TransitUser } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export class SpotifyDriver extends Oauth2Driver {
|
|
9
|
+
protected readonly provider = "Spotify";
|
|
10
|
+
protected readonly authorizeUrl = "https://accounts.spotify.com/authorize";
|
|
11
|
+
protected readonly accessTokenUrl = "https://accounts.spotify.com/api/token";
|
|
12
|
+
protected readonly userInfoUrl = "https://api.spotify.com/v1/me";
|
|
13
|
+
protected readonly defaultScopes = ["user-read-email"] as const;
|
|
14
|
+
/** Spotify wants the client credentials as HTTP Basic on the token request. */
|
|
15
|
+
protected override readonly tokenAuth = "basic" as const;
|
|
16
|
+
|
|
17
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
18
|
+
return {
|
|
19
|
+
id: String(raw.id ?? ""),
|
|
20
|
+
email: String(raw.email ?? ""),
|
|
21
|
+
name: String(raw.display_name ?? ""),
|
|
22
|
+
nickName:
|
|
23
|
+
typeof raw.display_name === "string" ? raw.display_name : undefined,
|
|
24
|
+
avatarUrl: firstImage(raw),
|
|
25
|
+
// Spotify says nothing about the address it returns.
|
|
26
|
+
emailVerificationState: "unsupported",
|
|
27
|
+
raw,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Spotify returns a list of images; the first is the one to show. */
|
|
33
|
+
function firstImage(raw: Record<string, unknown>): string | undefined {
|
|
34
|
+
const images = raw.images;
|
|
35
|
+
if (!Array.isArray(images) || images.length === 0) return undefined;
|
|
36
|
+
const url = Reflect.get(Object(images[0]), "url");
|
|
37
|
+
return typeof url === "string" && url !== "" ? url : undefined;
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X (Twitter) OAuth1 driver.
|
|
3
|
+
*
|
|
4
|
+
* The older of X's two flows. `TwitterXDriver` is the OAuth2 one, and is what
|
|
5
|
+
* a new application wants; this one exists for an application already built
|
|
6
|
+
* on OAuth1 tokens, and it is also the only flow that returns the address
|
|
7
|
+
* through `verify_credentials`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Oauth1Driver } from "../Oauth1Driver.js";
|
|
11
|
+
import type { TransitUser } from "../types.js";
|
|
12
|
+
|
|
13
|
+
export class TwitterDriver extends Oauth1Driver {
|
|
14
|
+
protected readonly provider = "X";
|
|
15
|
+
protected readonly requestTokenUrl =
|
|
16
|
+
"https://api.twitter.com/oauth/request_token";
|
|
17
|
+
/** `authenticate`, not `authorize`: a returning user is not asked twice. */
|
|
18
|
+
protected readonly authorizeUrl =
|
|
19
|
+
"https://api.twitter.com/oauth/authenticate";
|
|
20
|
+
protected readonly accessTokenUrl =
|
|
21
|
+
"https://api.twitter.com/oauth/access_token";
|
|
22
|
+
protected readonly userInfoUrl =
|
|
23
|
+
"https://api.twitter.com/1.1/account/verify_credentials.json";
|
|
24
|
+
|
|
25
|
+
protected override userInfoParams(): Record<string, string> {
|
|
26
|
+
// The address is omitted unless asked for, and only reaches an
|
|
27
|
+
// application whose X app was granted it.
|
|
28
|
+
return { include_email: "true" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
32
|
+
return {
|
|
33
|
+
// `id_str`, never `id`: the numeric form loses precision past 2^53.
|
|
34
|
+
id: String(raw.id_str ?? ""),
|
|
35
|
+
email: String(raw.email ?? ""),
|
|
36
|
+
name: String(raw.name ?? raw.screen_name ?? ""),
|
|
37
|
+
nickName:
|
|
38
|
+
typeof raw.screen_name === "string" ? raw.screen_name : undefined,
|
|
39
|
+
avatarUrl: fullSizeAvatar(raw),
|
|
40
|
+
// X vouches for nothing about the address it returns.
|
|
41
|
+
emailVerificationState: "unsupported",
|
|
42
|
+
raw,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The profile carries the 48px thumbnail. The full-size original is the same
|
|
49
|
+
* URL with the `_normal` suffix removed.
|
|
50
|
+
*/
|
|
51
|
+
function fullSizeAvatar(raw: Record<string, unknown>): string | undefined {
|
|
52
|
+
const url = raw.profile_image_url_https;
|
|
53
|
+
if (typeof url !== "string" || url === "") return undefined;
|
|
54
|
+
return url.replace(/_normal(\.[a-z]+)$/i, "$1");
|
|
55
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X (Twitter) OAuth2 driver.
|
|
3
|
+
*
|
|
4
|
+
* X requires PKCE, so this driver refuses to build a redirect without a code
|
|
5
|
+
* verifier. Mint one with `createCodeVerifier()`, store it beside the state,
|
|
6
|
+
* and hand it back to `callback()`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Oauth2Driver } from "../Oauth2Driver.js";
|
|
10
|
+
import type { TransitUser } from "../types.js";
|
|
11
|
+
|
|
12
|
+
export class TwitterXDriver extends Oauth2Driver {
|
|
13
|
+
protected readonly provider = "X";
|
|
14
|
+
protected readonly authorizeUrl = "https://x.com/i/oauth2/authorize";
|
|
15
|
+
protected readonly accessTokenUrl = "https://api.x.com/2/oauth2/token";
|
|
16
|
+
protected readonly userInfoUrl = "https://api.x.com/2/users/me";
|
|
17
|
+
protected readonly defaultScopes = [
|
|
18
|
+
"tweet.read",
|
|
19
|
+
"users.read",
|
|
20
|
+
"users.email",
|
|
21
|
+
] as const;
|
|
22
|
+
protected override readonly requiresPkce = true;
|
|
23
|
+
/** X accepts the client credentials only as HTTP Basic. */
|
|
24
|
+
protected override readonly tokenAuth = "basic" as const;
|
|
25
|
+
|
|
26
|
+
protected override userInfoParams(): Record<string, string> {
|
|
27
|
+
// Neither the picture nor the address is returned unless asked for.
|
|
28
|
+
return { "user.fields": "profile_image_url,confirmed_email" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
protected override mapUser(raw: Record<string, unknown>): TransitUser {
|
|
32
|
+
// The profile arrives wrapped: `{ data: { ... } }`.
|
|
33
|
+
const data =
|
|
34
|
+
typeof raw.data === "object" && raw.data !== null
|
|
35
|
+
? (raw.data as Record<string, unknown>)
|
|
36
|
+
: raw;
|
|
37
|
+
return {
|
|
38
|
+
id: String(data.id ?? ""),
|
|
39
|
+
email: String(data.confirmed_email ?? ""),
|
|
40
|
+
name: String(data.name ?? data.username ?? ""),
|
|
41
|
+
nickName: typeof data.username === "string" ? data.username : undefined,
|
|
42
|
+
avatarUrl:
|
|
43
|
+
typeof data.profile_image_url === "string" &&
|
|
44
|
+
data.profile_image_url !== ""
|
|
45
|
+
? data.profile_image_url
|
|
46
|
+
: undefined,
|
|
47
|
+
// The field is named `confirmed_email`, but X publishes no separate
|
|
48
|
+
// verification flag, so nothing here can be claimed.
|
|
49
|
+
emailVerificationState: "unsupported",
|
|
50
|
+
raw,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/dsig.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XML Signature verification.
|
|
3
|
+
*
|
|
4
|
+
* The danger in XML-DSig is not the cryptography — it is that a signature can
|
|
5
|
+
* be perfectly valid over one part of a document while the application reads
|
|
6
|
+
* another. That is XML Signature Wrapping, and it has broken SAML
|
|
7
|
+
* implementations repeatedly. The mitigation is structural, not a check:
|
|
8
|
+
*
|
|
9
|
+
* **This returns the element the signature actually covers, and the caller
|
|
10
|
+
* must read that object. Never search the document again afterwards.**
|
|
11
|
+
*
|
|
12
|
+
* Everything else here exists to make that guarantee hold:
|
|
13
|
+
*
|
|
14
|
+
* - Exactly one reference. Several is not a shape a signed assertion needs,
|
|
15
|
+
* and it multiplies what "the signed element" could mean.
|
|
16
|
+
* - The reference must resolve to exactly ONE element. A second element
|
|
17
|
+
* carrying the same ID is the classic wrapping payload.
|
|
18
|
+
* - The signature must sit inside the element it signs. A signature that
|
|
19
|
+
* references a sibling is describing a document this refuses to reason
|
|
20
|
+
* about.
|
|
21
|
+
* - Only the enveloped-signature and exclusive-c14n transforms are accepted.
|
|
22
|
+
* XPath and XSLT transforms are a way to make the digest cover something
|
|
23
|
+
* other than the element, and XSLT is executable.
|
|
24
|
+
* - SHA-1 is refused. Collisions against it are practical, and a signature
|
|
25
|
+
* over attacker-influenced XML is exactly where that matters.
|
|
26
|
+
* - The key comes from the caller, from the provider's metadata. The
|
|
27
|
+
* certificate embedded in the document is checked against it, never
|
|
28
|
+
* trusted on its own: verifying a document against a key it carries is
|
|
29
|
+
* verifying it against itself.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
createHash,
|
|
34
|
+
type KeyObject,
|
|
35
|
+
verify as verifySignature,
|
|
36
|
+
X509Certificate,
|
|
37
|
+
} from "node:crypto";
|
|
38
|
+
import { canonicalize } from "./c14n.js";
|
|
39
|
+
import {
|
|
40
|
+
childrenNamed,
|
|
41
|
+
textOf,
|
|
42
|
+
walk,
|
|
43
|
+
type XmlElement,
|
|
44
|
+
type XmlNode,
|
|
45
|
+
} from "./xml.js";
|
|
46
|
+
|
|
47
|
+
export const DSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
|
|
48
|
+
const EXC_C14N = "http://www.w3.org/2001/10/xml-exc-c14n#";
|
|
49
|
+
const EXC_C14N_COMMENTS = "http://www.w3.org/2001/10/xml-exc-c14n#WithComments";
|
|
50
|
+
const ENVELOPED = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
|
|
51
|
+
const EC_NS = "http://www.w3.org/2001/10/xml-exc-c14n#";
|
|
52
|
+
|
|
53
|
+
/** Digest algorithms, by their URI. SHA-1 is absent on purpose. */
|
|
54
|
+
const DIGESTS: Record<string, string> = {
|
|
55
|
+
"http://www.w3.org/2001/04/xmlenc#sha256": "sha256",
|
|
56
|
+
"http://www.w3.org/2001/04/xmldsig-more#sha384": "sha384",
|
|
57
|
+
"http://www.w3.org/2001/04/xmlenc#sha512": "sha512",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Signature algorithms, by their URI. SHA-1 is absent on purpose. */
|
|
61
|
+
const SIGNATURES: Record<string, { hash: string; kind: "rsa" | "ec" }> = {
|
|
62
|
+
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256": {
|
|
63
|
+
hash: "sha256",
|
|
64
|
+
kind: "rsa",
|
|
65
|
+
},
|
|
66
|
+
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha384": {
|
|
67
|
+
hash: "sha384",
|
|
68
|
+
kind: "rsa",
|
|
69
|
+
},
|
|
70
|
+
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha512": {
|
|
71
|
+
hash: "sha512",
|
|
72
|
+
kind: "rsa",
|
|
73
|
+
},
|
|
74
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256": {
|
|
75
|
+
hash: "sha256",
|
|
76
|
+
kind: "ec",
|
|
77
|
+
},
|
|
78
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384": {
|
|
79
|
+
hash: "sha384",
|
|
80
|
+
kind: "ec",
|
|
81
|
+
},
|
|
82
|
+
"http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512": {
|
|
83
|
+
hash: "sha512",
|
|
84
|
+
kind: "ec",
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export class SignatureError extends Error {
|
|
89
|
+
constructor(message: string) {
|
|
90
|
+
super(`[transit] ${message}`);
|
|
91
|
+
this.name = "SignatureError";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface VerifyOptions {
|
|
96
|
+
/**
|
|
97
|
+
* The provider's signing certificates, from its metadata — PEM, or the
|
|
98
|
+
* bare base64 an `<X509Certificate>` element carries. Several are accepted
|
|
99
|
+
* so a rotation can be prepared before it happens.
|
|
100
|
+
*/
|
|
101
|
+
certificates: string[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verify one signature under `root`, and answer with the element it covers.
|
|
106
|
+
*
|
|
107
|
+
* Read the returned element. Reading anything found by searching the document
|
|
108
|
+
* afterwards throws the guarantee away.
|
|
109
|
+
*/
|
|
110
|
+
export function verifyXmlSignature(
|
|
111
|
+
root: XmlElement,
|
|
112
|
+
options: VerifyOptions,
|
|
113
|
+
): XmlElement {
|
|
114
|
+
const signatures = [...walk(root)].filter(
|
|
115
|
+
(element) =>
|
|
116
|
+
element.namespaceUri === DSIG_NS && element.local === "Signature",
|
|
117
|
+
);
|
|
118
|
+
if (signatures.length === 0) {
|
|
119
|
+
throw new SignatureError("the document carries no signature");
|
|
120
|
+
}
|
|
121
|
+
if (signatures.length > 1) {
|
|
122
|
+
// Which one covers what a reader will use is exactly the ambiguity
|
|
123
|
+
// wrapping attacks live in.
|
|
124
|
+
throw new SignatureError(
|
|
125
|
+
`the document carries ${signatures.length} signatures, and this verifies documents with one`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const signature = signatures[0] as XmlElement;
|
|
129
|
+
|
|
130
|
+
const signedInfo = only(signature, "SignedInfo");
|
|
131
|
+
const method = only(signedInfo, "SignatureMethod").attributes.find(
|
|
132
|
+
(a) => a.local === "Algorithm",
|
|
133
|
+
)?.value;
|
|
134
|
+
const spec = method === undefined ? undefined : SIGNATURES[method];
|
|
135
|
+
if (spec === undefined) {
|
|
136
|
+
throw new SignatureError(
|
|
137
|
+
`the signature uses '${method ?? "no algorithm"}', which this refuses. SHA-1 and the symmetric methods are not accepted.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const canonicalizationMethod = only(
|
|
142
|
+
signedInfo,
|
|
143
|
+
"CanonicalizationMethod",
|
|
144
|
+
).attributes.find((a) => a.local === "Algorithm")?.value;
|
|
145
|
+
if (
|
|
146
|
+
canonicalizationMethod !== EXC_C14N &&
|
|
147
|
+
canonicalizationMethod !== EXC_C14N_COMMENTS
|
|
148
|
+
) {
|
|
149
|
+
throw new SignatureError(
|
|
150
|
+
`SignedInfo is canonicalized with '${canonicalizationMethod ?? "nothing"}', and this verifies exclusive canonicalization`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The signature is over SignedInfo, canonicalized. Everything the reference
|
|
155
|
+
// claims is only trustworthy once this holds.
|
|
156
|
+
const signedBytes = Buffer.from(
|
|
157
|
+
canonicalize(signedInfo, {
|
|
158
|
+
withComments: canonicalizationMethod === EXC_C14N_COMMENTS,
|
|
159
|
+
}),
|
|
160
|
+
"utf8",
|
|
161
|
+
);
|
|
162
|
+
const signatureValue = Buffer.from(
|
|
163
|
+
textOf(only(signature, "SignatureValue")).replace(/\s+/g, ""),
|
|
164
|
+
"base64",
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const key = resolveKey(signature, options.certificates);
|
|
168
|
+
const holds = verifySignature(
|
|
169
|
+
spec.hash,
|
|
170
|
+
signedBytes,
|
|
171
|
+
spec.kind === "ec" ? { key, dsaEncoding: "ieee-p1363" } : key,
|
|
172
|
+
signatureValue,
|
|
173
|
+
);
|
|
174
|
+
if (!holds) {
|
|
175
|
+
throw new SignatureError("the signature does not verify");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return verifyReference(root, signature, signedInfo);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Check the digest, and answer with the element it was computed over.
|
|
183
|
+
*
|
|
184
|
+
* The element is resolved once, here, and handed back. That is the whole
|
|
185
|
+
* defence: a caller that uses this object cannot be reading a different part of
|
|
186
|
+
* the document than the one the signature covers.
|
|
187
|
+
*/
|
|
188
|
+
function verifyReference(
|
|
189
|
+
root: XmlElement,
|
|
190
|
+
signature: XmlElement,
|
|
191
|
+
signedInfo: XmlElement,
|
|
192
|
+
): XmlElement {
|
|
193
|
+
const references = childrenNamed(signedInfo, DSIG_NS, "Reference");
|
|
194
|
+
if (references.length !== 1) {
|
|
195
|
+
throw new SignatureError(
|
|
196
|
+
`SignedInfo carries ${references.length} references, and this verifies one`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const reference = references[0] as XmlElement;
|
|
200
|
+
|
|
201
|
+
const uri = reference.attributes.find((a) => a.local === "URI")?.value;
|
|
202
|
+
if (uri === undefined || !uri.startsWith("#") || uri.length < 2) {
|
|
203
|
+
throw new SignatureError(
|
|
204
|
+
`the reference points at '${uri ?? "nothing"}', and this verifies a same-document '#id' reference`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const id = uri.slice(1);
|
|
208
|
+
|
|
209
|
+
const matches = [...walk(root)].filter((element) =>
|
|
210
|
+
element.attributes.some(
|
|
211
|
+
(a) => a.namespaceUri === "" && isIdAttribute(a.local) && a.value === id,
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
if (matches.length === 0) {
|
|
215
|
+
throw new SignatureError(
|
|
216
|
+
`the reference names '${id}', which is not in the document`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (matches.length > 1) {
|
|
220
|
+
// A second element carrying the signed element's ID is the classic
|
|
221
|
+
// wrapping payload: the signature stays valid, and a reader that looks
|
|
222
|
+
// the ID up finds the attacker's copy.
|
|
223
|
+
throw new SignatureError(
|
|
224
|
+
`${matches.length} elements carry the id '${id}', which is how a signature gets pointed at the wrong one`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const signed = matches[0] as XmlElement;
|
|
228
|
+
|
|
229
|
+
if (!contains(signed, signature)) {
|
|
230
|
+
throw new SignatureError(
|
|
231
|
+
"the signature sits outside the element it references, which this refuses to reason about",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { inclusivePrefixes, withComments } = readTransforms(
|
|
236
|
+
reference,
|
|
237
|
+
signature,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const digestMethod = only(reference, "DigestMethod").attributes.find(
|
|
241
|
+
(a) => a.local === "Algorithm",
|
|
242
|
+
)?.value;
|
|
243
|
+
const hash = digestMethod === undefined ? undefined : DIGESTS[digestMethod];
|
|
244
|
+
if (hash === undefined) {
|
|
245
|
+
throw new SignatureError(
|
|
246
|
+
`the digest uses '${digestMethod ?? "no algorithm"}', which this refuses. SHA-1 is not accepted.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The enveloped-signature transform: the element cannot carry a digest of
|
|
251
|
+
// itself, so the signature is left out of what is hashed.
|
|
252
|
+
const omit: ReadonlySet<XmlNode> = new Set<XmlNode>([signature]);
|
|
253
|
+
const digest = createHash(hash)
|
|
254
|
+
.update(
|
|
255
|
+
canonicalize(signed, { inclusivePrefixes, withComments, omit }),
|
|
256
|
+
"utf8",
|
|
257
|
+
)
|
|
258
|
+
.digest("base64");
|
|
259
|
+
|
|
260
|
+
const expected = textOf(only(reference, "DigestValue")).replace(/\s+/g, "");
|
|
261
|
+
if (digest !== expected) {
|
|
262
|
+
throw new SignatureError(
|
|
263
|
+
"the signed element does not match its digest — it was changed after signing",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return signed;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Which transforms the reference asks for, refusing the dangerous ones. */
|
|
271
|
+
function readTransforms(
|
|
272
|
+
reference: XmlElement,
|
|
273
|
+
signature: XmlElement,
|
|
274
|
+
): { inclusivePrefixes: string[]; withComments: boolean } {
|
|
275
|
+
const container = childrenNamed(reference, DSIG_NS, "Transforms")[0];
|
|
276
|
+
const transforms = container
|
|
277
|
+
? childrenNamed(container, DSIG_NS, "Transform")
|
|
278
|
+
: [];
|
|
279
|
+
|
|
280
|
+
let sawEnveloped = false;
|
|
281
|
+
let inclusivePrefixes: string[] = [];
|
|
282
|
+
let withComments = false;
|
|
283
|
+
|
|
284
|
+
for (const transform of transforms) {
|
|
285
|
+
const algorithm = transform.attributes.find(
|
|
286
|
+
(a) => a.local === "Algorithm",
|
|
287
|
+
)?.value;
|
|
288
|
+
if (algorithm === ENVELOPED) {
|
|
289
|
+
sawEnveloped = true;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (algorithm === EXC_C14N || algorithm === EXC_C14N_COMMENTS) {
|
|
293
|
+
withComments = algorithm === EXC_C14N_COMMENTS;
|
|
294
|
+
const list = childrenNamed(transform, EC_NS, "InclusiveNamespaces")[0];
|
|
295
|
+
const prefixes = list?.attributes.find(
|
|
296
|
+
(a) => a.local === "PrefixList",
|
|
297
|
+
)?.value;
|
|
298
|
+
if (prefixes) {
|
|
299
|
+
inclusivePrefixes = prefixes.split(/\s+/).filter(Boolean);
|
|
300
|
+
}
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
// XPath and XSLT can make the digest cover something other than the
|
|
304
|
+
// element, and one of them is executable.
|
|
305
|
+
throw new SignatureError(
|
|
306
|
+
`the reference asks for the transform '${algorithm ?? "(none)"}', which this refuses`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!sawEnveloped && contains(signature.parent ?? signature, signature)) {
|
|
311
|
+
// A signature inside the element it signs has to be excluded from the
|
|
312
|
+
// digest, and the transform is how that is declared.
|
|
313
|
+
throw new SignatureError(
|
|
314
|
+
"the signature is inside the element it signs and does not declare the enveloped-signature transform",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return { inclusivePrefixes, withComments };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The key to verify with — the caller's, from the provider's metadata.
|
|
322
|
+
*
|
|
323
|
+
* When the document carries a certificate it must be one of them. That check
|
|
324
|
+
* gives a clear error instead of an opaque "does not verify" when a provider
|
|
325
|
+
* has rotated and the metadata has not been updated.
|
|
326
|
+
*/
|
|
327
|
+
function resolveKey(signature: XmlElement, certificates: string[]): KeyObject {
|
|
328
|
+
if (certificates.length === 0) {
|
|
329
|
+
throw new SignatureError(
|
|
330
|
+
"no certificate was supplied — the key has to come from the provider's metadata, never from the document itself",
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const known = certificates.map(toCertificate);
|
|
334
|
+
const embedded = embeddedCertificate(signature);
|
|
335
|
+
if (embedded !== undefined) {
|
|
336
|
+
const match = known.find((cert) => cert.raw.equals(embedded.raw));
|
|
337
|
+
if (!match) {
|
|
338
|
+
throw new SignatureError(
|
|
339
|
+
"the document is signed with a certificate the provider's metadata does not list",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return match.publicKey;
|
|
343
|
+
}
|
|
344
|
+
// No certificate in the document: the metadata is all there is, which is
|
|
345
|
+
// the safer of the two cases.
|
|
346
|
+
return (known[0] as X509Certificate).publicKey;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function embeddedCertificate(
|
|
350
|
+
signature: XmlElement,
|
|
351
|
+
): X509Certificate | undefined {
|
|
352
|
+
for (const element of walk(signature)) {
|
|
353
|
+
if (
|
|
354
|
+
element.namespaceUri === DSIG_NS &&
|
|
355
|
+
element.local === "X509Certificate"
|
|
356
|
+
) {
|
|
357
|
+
return toCertificate(textOf(element));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function toCertificate(value: string): X509Certificate {
|
|
364
|
+
const trimmed = value.trim();
|
|
365
|
+
const pem = trimmed.includes("BEGIN CERTIFICATE")
|
|
366
|
+
? trimmed
|
|
367
|
+
: `-----BEGIN CERTIFICATE-----\n${trimmed.replace(/\s+/g, "").replace(/(.{64})/g, "$1\n")}\n-----END CERTIFICATE-----`;
|
|
368
|
+
try {
|
|
369
|
+
return new X509Certificate(pem);
|
|
370
|
+
} catch {
|
|
371
|
+
throw new SignatureError("a certificate could not be read");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Exactly one child named `local`, or a clear refusal. */
|
|
376
|
+
function only(parent: XmlElement, local: string): XmlElement {
|
|
377
|
+
const found = childrenNamed(parent, DSIG_NS, local);
|
|
378
|
+
if (found.length !== 1) {
|
|
379
|
+
throw new SignatureError(
|
|
380
|
+
`expected one <${local}> under <${parent.local}>, and there are ${found.length}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return found[0] as XmlElement;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Whether `descendant` sits inside `ancestor`, or is it. */
|
|
387
|
+
function contains(ancestor: XmlElement, descendant: XmlElement): boolean {
|
|
388
|
+
for (
|
|
389
|
+
let node: XmlElement | undefined = descendant;
|
|
390
|
+
node !== undefined;
|
|
391
|
+
node = node.parent
|
|
392
|
+
) {
|
|
393
|
+
if (node === ancestor) return true;
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* SAML identifies signed elements with `ID`; other dialects use `Id` or `id`.
|
|
400
|
+
* All three are accepted, and a document mixing them is what the
|
|
401
|
+
* duplicate check above catches.
|
|
402
|
+
*/
|
|
403
|
+
function isIdAttribute(local: string): boolean {
|
|
404
|
+
return local === "ID" || local === "Id" || local === "id";
|
|
405
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @c9up/transit — federated sign-in for the Ream framework.
|
|
3
|
+
*
|
|
4
|
+
* Everything that lets a person prove who they are through an authority the
|
|
5
|
+
* application does not own. Today that is OAuth1 and OAuth2, and the providers
|
|
6
|
+
* that speak them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { type AppleKey, appleClientSecret, parseAppleUser } from "./apple.js";
|
|
10
|
+
export { type CanonicalizeOptions, canonicalize } from "./c14n.js";
|
|
11
|
+
export type {
|
|
12
|
+
TransitConfig,
|
|
13
|
+
TransitDriverFactory,
|
|
14
|
+
TransitProviderEntry,
|
|
15
|
+
} from "./config.js";
|
|
16
|
+
export { defineConfig, ldap, oidc, saml, socials } from "./config.js";
|
|
17
|
+
export { type AppleConfig, AppleDriver } from "./drivers/AppleDriver.js";
|
|
18
|
+
export { DiscordDriver } from "./drivers/DiscordDriver.js";
|
|
19
|
+
export { FacebookDriver } from "./drivers/FacebookDriver.js";
|
|
20
|
+
export { GitHubDriver } from "./drivers/GitHubDriver.js";
|
|
21
|
+
export { GoogleDriver } from "./drivers/GoogleDriver.js";
|
|
22
|
+
export { type LdapConfig, LdapDirectory } from "./drivers/LdapDirectory.js";
|
|
23
|
+
export { LinkedInDriver } from "./drivers/LinkedInDriver.js";
|
|
24
|
+
export { LinkedInOpenidConnectDriver } from "./drivers/LinkedInOpenidConnectDriver.js";
|
|
25
|
+
export { type OidcConfig, OidcDriver } from "./drivers/OidcDriver.js";
|
|
26
|
+
export { type SamlConfig, SamlDriver } from "./drivers/SamlDriver.js";
|
|
27
|
+
export { SpotifyDriver } from "./drivers/SpotifyDriver.js";
|
|
28
|
+
export { TwitterDriver } from "./drivers/TwitterDriver.js";
|
|
29
|
+
export { TwitterXDriver } from "./drivers/TwitterXDriver.js";
|
|
30
|
+
export type { IdTokenClaims, Jwk, SupportedAlg } from "./jwt.js";
|
|
31
|
+
export {
|
|
32
|
+
LdapConnection,
|
|
33
|
+
type LdapEntry,
|
|
34
|
+
LdapError,
|
|
35
|
+
type LdapFilter,
|
|
36
|
+
type LdapOptions,
|
|
37
|
+
} from "./ldap.js";
|
|
38
|
+
export { Oauth1Driver } from "./Oauth1Driver.js";
|
|
39
|
+
export { createCodeVerifier, Oauth2Driver } from "./Oauth2Driver.js";
|
|
40
|
+
export type { OidcMetadata, RemoteOptions } from "./oidc.js";
|
|
41
|
+
export {
|
|
42
|
+
type AssertionReplayStore,
|
|
43
|
+
MemoryAssertionReplayStore,
|
|
44
|
+
RedisAssertionReplayStore,
|
|
45
|
+
type ReplayRedisClient,
|
|
46
|
+
type ReplayRedisResolver,
|
|
47
|
+
replayStores,
|
|
48
|
+
} from "./replay.js";
|
|
49
|
+
export {
|
|
50
|
+
assertionInside,
|
|
51
|
+
assertResponseSucceeded,
|
|
52
|
+
SAML_ASSERTION_NS,
|
|
53
|
+
SAML_PROTOCOL_NS,
|
|
54
|
+
SamlError,
|
|
55
|
+
type SamlExpectations,
|
|
56
|
+
type SamlIdentity,
|
|
57
|
+
validateAssertion,
|
|
58
|
+
} from "./saml.js";
|
|
59
|
+
export { TransitManager } from "./TransitManager.js";
|
|
60
|
+
export type { TransitAppContext } from "./TransitProvider.js";
|
|
61
|
+
export { default as TransitProvider } from "./TransitProvider.js";
|
|
62
|
+
export type {
|
|
63
|
+
DirectoryDriver,
|
|
64
|
+
EmailVerificationState,
|
|
65
|
+
OAuthConfig,
|
|
66
|
+
OAuthToken,
|
|
67
|
+
RedirectRequest,
|
|
68
|
+
TransitDriver,
|
|
69
|
+
TransitEntry,
|
|
70
|
+
TransitUser,
|
|
71
|
+
} from "./types.js";
|
|
72
|
+
export { assertOAuthState, isDirectory } from "./types.js";
|
|
73
|
+
export {
|
|
74
|
+
childrenNamed,
|
|
75
|
+
findByAttribute,
|
|
76
|
+
parseXml,
|
|
77
|
+
resolvePrefix,
|
|
78
|
+
textOf,
|
|
79
|
+
walk,
|
|
80
|
+
XML_NS,
|
|
81
|
+
type XmlAttribute,
|
|
82
|
+
type XmlElement,
|
|
83
|
+
type XmlNamespace,
|
|
84
|
+
type XmlNode,
|
|
85
|
+
} from "./xml.js";
|