@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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/Oauth1Driver.d.ts +68 -0
  4. package/dist/Oauth1Driver.d.ts.map +1 -0
  5. package/dist/Oauth1Driver.js +148 -0
  6. package/dist/Oauth1Driver.js.map +1 -0
  7. package/dist/Oauth2Driver.d.ts +89 -0
  8. package/dist/Oauth2Driver.d.ts.map +1 -0
  9. package/dist/Oauth2Driver.js +196 -0
  10. package/dist/Oauth2Driver.js.map +1 -0
  11. package/dist/TransitManager.d.ts +59 -0
  12. package/dist/TransitManager.d.ts.map +1 -0
  13. package/dist/TransitManager.js +100 -0
  14. package/dist/TransitManager.js.map +1 -0
  15. package/dist/TransitProvider.d.ts +26 -0
  16. package/dist/TransitProvider.d.ts.map +1 -0
  17. package/dist/TransitProvider.js +32 -0
  18. package/dist/TransitProvider.js.map +1 -0
  19. package/dist/apple.d.ts +45 -0
  20. package/dist/apple.d.ts.map +1 -0
  21. package/dist/apple.js +80 -0
  22. package/dist/apple.js.map +1 -0
  23. package/dist/ber.d.ts +50 -0
  24. package/dist/ber.d.ts.map +1 -0
  25. package/dist/ber.js +156 -0
  26. package/dist/ber.js.map +1 -0
  27. package/dist/c14n.d.ts +38 -0
  28. package/dist/c14n.d.ts.map +1 -0
  29. package/dist/c14n.js +129 -0
  30. package/dist/c14n.js.map +1 -0
  31. package/dist/config.d.ts +115 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +146 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/configure.d.ts +18 -0
  36. package/dist/configure.d.ts.map +1 -0
  37. package/dist/configure.js +33 -0
  38. package/dist/configure.js.map +1 -0
  39. package/dist/drivers/AppleDriver.d.ts +36 -0
  40. package/dist/drivers/AppleDriver.d.ts.map +1 -0
  41. package/dist/drivers/AppleDriver.js +58 -0
  42. package/dist/drivers/AppleDriver.js.map +1 -0
  43. package/dist/drivers/DiscordDriver.d.ts +15 -0
  44. package/dist/drivers/DiscordDriver.d.ts.map +1 -0
  45. package/dist/drivers/DiscordDriver.js +45 -0
  46. package/dist/drivers/DiscordDriver.js.map +1 -0
  47. package/dist/drivers/FacebookDriver.d.ts +15 -0
  48. package/dist/drivers/FacebookDriver.d.ts.map +1 -0
  49. package/dist/drivers/FacebookDriver.js +47 -0
  50. package/dist/drivers/FacebookDriver.js.map +1 -0
  51. package/dist/drivers/GitHubDriver.d.ts +33 -0
  52. package/dist/drivers/GitHubDriver.d.ts.map +1 -0
  53. package/dist/drivers/GitHubDriver.js +72 -0
  54. package/dist/drivers/GitHubDriver.js.map +1 -0
  55. package/dist/drivers/GoogleDriver.d.ts +16 -0
  56. package/dist/drivers/GoogleDriver.d.ts.map +1 -0
  57. package/dist/drivers/GoogleDriver.js +31 -0
  58. package/dist/drivers/GoogleDriver.js.map +1 -0
  59. package/dist/drivers/LdapDirectory.d.ts +51 -0
  60. package/dist/drivers/LdapDirectory.d.ts.map +1 -0
  61. package/dist/drivers/LdapDirectory.js +127 -0
  62. package/dist/drivers/LdapDirectory.js.map +1 -0
  63. package/dist/drivers/LinkedInDriver.d.ts +29 -0
  64. package/dist/drivers/LinkedInDriver.d.ts.map +1 -0
  65. package/dist/drivers/LinkedInDriver.js +97 -0
  66. package/dist/drivers/LinkedInDriver.js.map +1 -0
  67. package/dist/drivers/LinkedInOpenidConnectDriver.d.ts +17 -0
  68. package/dist/drivers/LinkedInOpenidConnectDriver.d.ts.map +1 -0
  69. package/dist/drivers/LinkedInOpenidConnectDriver.js +27 -0
  70. package/dist/drivers/LinkedInOpenidConnectDriver.js.map +1 -0
  71. package/dist/drivers/OidcDriver.d.ts +64 -0
  72. package/dist/drivers/OidcDriver.d.ts.map +1 -0
  73. package/dist/drivers/OidcDriver.js +259 -0
  74. package/dist/drivers/OidcDriver.js.map +1 -0
  75. package/dist/drivers/SamlDriver.d.ts +66 -0
  76. package/dist/drivers/SamlDriver.d.ts.map +1 -0
  77. package/dist/drivers/SamlDriver.js +228 -0
  78. package/dist/drivers/SamlDriver.js.map +1 -0
  79. package/dist/drivers/SpotifyDriver.d.ts +16 -0
  80. package/dist/drivers/SpotifyDriver.d.ts.map +1 -0
  81. package/dist/drivers/SpotifyDriver.js +34 -0
  82. package/dist/drivers/SpotifyDriver.js.map +1 -0
  83. package/dist/drivers/TwitterDriver.d.ts +21 -0
  84. package/dist/drivers/TwitterDriver.d.ts.map +1 -0
  85. package/dist/drivers/TwitterDriver.js +46 -0
  86. package/dist/drivers/TwitterDriver.js.map +1 -0
  87. package/dist/drivers/TwitterXDriver.d.ts +22 -0
  88. package/dist/drivers/TwitterXDriver.d.ts.map +1 -0
  89. package/dist/drivers/TwitterXDriver.js +47 -0
  90. package/dist/drivers/TwitterXDriver.js.map +1 -0
  91. package/dist/dsig.d.ts +51 -0
  92. package/dist/dsig.d.ts.map +1 -0
  93. package/dist/dsig.js +273 -0
  94. package/dist/dsig.js.map +1 -0
  95. package/dist/index.d.ts +38 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +33 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/jwt.d.ts +135 -0
  100. package/dist/jwt.d.ts.map +1 -0
  101. package/dist/jwt.js +167 -0
  102. package/dist/jwt.js.map +1 -0
  103. package/dist/ldap.d.ts +82 -0
  104. package/dist/ldap.d.ts.map +1 -0
  105. package/dist/ldap.js +271 -0
  106. package/dist/ldap.js.map +1 -0
  107. package/dist/nodeEnv.d.ts +17 -0
  108. package/dist/nodeEnv.d.ts.map +1 -0
  109. package/dist/nodeEnv.js +33 -0
  110. package/dist/nodeEnv.js.map +1 -0
  111. package/dist/oauth1.d.ts +68 -0
  112. package/dist/oauth1.d.ts.map +1 -0
  113. package/dist/oauth1.js +106 -0
  114. package/dist/oauth1.js.map +1 -0
  115. package/dist/oidc.d.ts +61 -0
  116. package/dist/oidc.d.ts.map +1 -0
  117. package/dist/oidc.js +151 -0
  118. package/dist/oidc.js.map +1 -0
  119. package/dist/quasar.d.ts +14 -0
  120. package/dist/quasar.d.ts.map +1 -0
  121. package/dist/quasar.js +42 -0
  122. package/dist/quasar.js.map +1 -0
  123. package/dist/replay.d.ts +70 -0
  124. package/dist/replay.d.ts.map +1 -0
  125. package/dist/replay.js +82 -0
  126. package/dist/replay.js.map +1 -0
  127. package/dist/saml.d.ts +68 -0
  128. package/dist/saml.d.ts.map +1 -0
  129. package/dist/saml.js +181 -0
  130. package/dist/saml.js.map +1 -0
  131. package/dist/services/main.d.ts +22 -0
  132. package/dist/services/main.d.ts.map +1 -0
  133. package/dist/services/main.js +39 -0
  134. package/dist/services/main.js.map +1 -0
  135. package/dist/testing/FakeTransit.d.ts +82 -0
  136. package/dist/testing/FakeTransit.d.ts.map +1 -0
  137. package/dist/testing/FakeTransit.js +160 -0
  138. package/dist/testing/FakeTransit.js.map +1 -0
  139. package/dist/types.d.ts +118 -0
  140. package/dist/types.d.ts.map +1 -0
  141. package/dist/types.js +27 -0
  142. package/dist/types.js.map +1 -0
  143. package/dist/xml.d.ts +72 -0
  144. package/dist/xml.d.ts.map +1 -0
  145. package/dist/xml.js +353 -0
  146. package/dist/xml.js.map +1 -0
  147. package/package.json +73 -0
  148. package/src/Oauth1Driver.ts +216 -0
  149. package/src/Oauth2Driver.ts +271 -0
  150. package/src/TransitManager.ts +145 -0
  151. package/src/TransitProvider.ts +51 -0
  152. package/src/apple.ts +101 -0
  153. package/src/ber.ts +175 -0
  154. package/src/c14n.ts +187 -0
  155. package/src/config.ts +160 -0
  156. package/src/configure.ts +47 -0
  157. package/src/drivers/AppleDriver.ts +69 -0
  158. package/src/drivers/DiscordDriver.ts +50 -0
  159. package/src/drivers/FacebookDriver.ts +53 -0
  160. package/src/drivers/GitHubDriver.ts +91 -0
  161. package/src/drivers/GoogleDriver.ts +38 -0
  162. package/src/drivers/LdapDirectory.ts +185 -0
  163. package/src/drivers/LinkedInDriver.ts +105 -0
  164. package/src/drivers/LinkedInOpenidConnectDriver.ts +33 -0
  165. package/src/drivers/OidcDriver.ts +379 -0
  166. package/src/drivers/SamlDriver.ts +326 -0
  167. package/src/drivers/SpotifyDriver.ts +38 -0
  168. package/src/drivers/TwitterDriver.ts +55 -0
  169. package/src/drivers/TwitterXDriver.ts +53 -0
  170. package/src/dsig.ts +405 -0
  171. package/src/index.ts +85 -0
  172. package/src/jwt.ts +263 -0
  173. package/src/ldap.ts +374 -0
  174. package/src/nodeEnv.ts +31 -0
  175. package/src/oauth1.ts +144 -0
  176. package/src/oidc.ts +208 -0
  177. package/src/quasar.ts +64 -0
  178. package/src/replay.ts +136 -0
  179. package/src/saml.ts +309 -0
  180. package/src/services/main.ts +47 -0
  181. package/src/testing/FakeTransit.ts +233 -0
  182. package/src/types.ts +150 -0
  183. package/src/xml.ts +438 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Wires `config/transit.ts` into the container.
3
+ *
4
+ * Transit does not import `@c9up/ream`: the slice of the host it needs is
5
+ * duck-typed below, which is what keeps the package publishable on its own and
6
+ * usable from a host that is not Ream.
7
+ */
8
+
9
+ import type { TransitConfig } from "./config.js";
10
+ import { setTransit } from "./services/main.js";
11
+ import { TransitManager } from "./TransitManager.js";
12
+
13
+ interface TransitContainer {
14
+ singleton(token: unknown, factory: () => unknown): void;
15
+ resolve<T = unknown>(token: unknown): Promise<T>;
16
+ }
17
+
18
+ interface TransitConfigStore {
19
+ get<T = unknown>(key: string): T | undefined;
20
+ }
21
+
22
+ export interface TransitAppContext {
23
+ container: TransitContainer;
24
+ config: TransitConfigStore;
25
+ }
26
+
27
+ export default class TransitProvider {
28
+ constructor(protected app: TransitAppContext) {}
29
+
30
+ register(): void {
31
+ this.app.container.singleton(TransitManager, () => {
32
+ const config = this.app.config.get<TransitConfig>("transit") ?? {};
33
+ const manager = new TransitManager();
34
+ for (const [name, entry] of Object.entries(config)) {
35
+ manager.register(name, typeof entry === "function" ? entry() : entry);
36
+ }
37
+ return manager;
38
+ });
39
+ // String alias, so a consumer that cannot import Transit still resolves
40
+ // it — the same convention the other providers follow.
41
+ this.app.container.singleton("transit", () =>
42
+ this.app.container.resolve<TransitManager>(TransitManager),
43
+ );
44
+ }
45
+
46
+ async boot(): Promise<void> {
47
+ setTransit(
48
+ await this.app.container.resolve<TransitManager>(TransitManager),
49
+ );
50
+ }
51
+ }
package/src/apple.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The two things Sign in with Apple does differently from every other OpenID
3
+ * Connect provider.
4
+ *
5
+ * Apple has no client secret. It has a private key, and expects a short-lived
6
+ * JWT minted from it on every token request — which is why `clientSecret` is
7
+ * allowed to be a function.
8
+ *
9
+ * And it sends the user's name exactly ONCE, in the body of the first consent
10
+ * callback, never in a token and never again. An application that does not
11
+ * store it then has no way to ask for it later.
12
+ */
13
+
14
+ import { createPrivateKey, sign as signData } from "node:crypto";
15
+
16
+ export interface AppleKey {
17
+ /** The Services ID (web) or App ID this signs for. */
18
+ clientId: string;
19
+ /** The ten-character Team ID from the developer account. */
20
+ teamId: string;
21
+ /** The Key ID of the .p8 this signs with. */
22
+ keyId: string;
23
+ /** The contents of the .p8 file, PEM as Apple issues it. */
24
+ privateKey: string;
25
+ }
26
+
27
+ /** Apple rejects a secret valid for more than six months; minutes are plenty. */
28
+ const LIFETIME_SECONDS = 300;
29
+
30
+ /**
31
+ * Mint the JWT Apple takes in place of a client secret.
32
+ *
33
+ * Signed ES256 with the .p8, naming the team as issuer and the client as
34
+ * subject. Minted per request rather than cached: it costs one signature, and
35
+ * a long-lived one is a credential sitting in memory for no reason.
36
+ */
37
+ export function appleClientSecret(
38
+ key: AppleKey,
39
+ now: number = Math.floor(Date.now() / 1000),
40
+ ): string {
41
+ const header = { alg: "ES256", kid: key.keyId, typ: "JWT" };
42
+ const claims = {
43
+ iss: key.teamId,
44
+ iat: now,
45
+ exp: now + LIFETIME_SECONDS,
46
+ aud: "https://appleid.apple.com",
47
+ sub: key.clientId,
48
+ };
49
+
50
+ const signingInput = `${b64(header)}.${b64(claims)}`;
51
+ // JOSE wants R concatenated with S; node emits DER unless told, and Apple
52
+ // rejects DER.
53
+ const signature = signData("sha256", Buffer.from(signingInput, "ascii"), {
54
+ key: createPrivateKey(key.privateKey),
55
+ dsaEncoding: "ieee-p1363",
56
+ });
57
+ return `${signingInput}.${signature.toString("base64url")}`;
58
+ }
59
+
60
+ /**
61
+ * Read the `user` field of Apple's first consent callback.
62
+ *
63
+ * It arrives as JSON in the POST body, on that one request only. Store what
64
+ * comes out — the id_token never carries a name, and a second sign-in by the
65
+ * same person will not carry one either.
66
+ *
67
+ * Returns `undefined` for anything unreadable rather than throwing: a returning
68
+ * user simply has no `user` field, and that is the normal case.
69
+ */
70
+ export function parseAppleUser(
71
+ value: unknown,
72
+ ): { name?: string; email?: string } | undefined {
73
+ if (typeof value !== "string" || value === "") return undefined;
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(value);
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ if (typeof parsed !== "object" || parsed === null) return undefined;
81
+
82
+ const email = Reflect.get(parsed, "email");
83
+ const rawName = Reflect.get(parsed, "name");
84
+ const name =
85
+ typeof rawName === "object" && rawName !== null
86
+ ? [Reflect.get(rawName, "firstName"), Reflect.get(rawName, "lastName")]
87
+ .filter(
88
+ (part): part is string => typeof part === "string" && part !== "",
89
+ )
90
+ .join(" ")
91
+ : undefined;
92
+
93
+ const out: { name?: string; email?: string } = {};
94
+ if (name) out.name = name;
95
+ if (typeof email === "string" && email !== "") out.email = email;
96
+ return Object.keys(out).length > 0 ? out : undefined;
97
+ }
98
+
99
+ function b64(value: unknown): string {
100
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
101
+ }
package/src/ber.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The encoding LDAP speaks: BER, the subset of it LDAP actually uses.
3
+ *
4
+ * This exists so that nothing an application passes ever becomes part of a
5
+ * string that a server then parses. A search filter here is a structure, not
6
+ * text — its values are written as length-prefixed octets, so a username
7
+ * containing `)(uid=*` is a username containing those characters and cannot
8
+ * become syntax. LDAP injection is not escaped away here; it is unrepresentable.
9
+ */
10
+
11
+ /** Universal tags, and the two LDAP builds everything else from. */
12
+ export const BOOLEAN = 0x01;
13
+ export const INTEGER = 0x02;
14
+ export const OCTET_STRING = 0x04;
15
+ export const ENUMERATED = 0x0a;
16
+ export const SEQUENCE = 0x30;
17
+
18
+ /** `[APPLICATION n]`, constructed. */
19
+ export const application = (n: number): number => 0x60 | n;
20
+ /** `[APPLICATION n]`, primitive — which a couple of LDAP operations are. */
21
+ export const applicationPrimitive = (n: number): number => 0x40 | n;
22
+ /** `[n]` context-specific, constructed. */
23
+ export const context = (n: number): number => 0xa0 | n;
24
+ /** `[n]` context-specific, primitive. */
25
+ export const contextPrimitive = (n: number): number => 0x80 | n;
26
+
27
+ export class BerError extends Error {
28
+ constructor(message: string) {
29
+ super(`[transit] ${message}`);
30
+ this.name = "BerError";
31
+ }
32
+ }
33
+
34
+ /** One tag-length-value element. */
35
+ export function encode(tag: number, value: Buffer): Buffer {
36
+ return Buffer.concat([Buffer.from([tag]), encodeLength(value.length), value]);
37
+ }
38
+
39
+ export function encodeInteger(value: number, tag = INTEGER): Buffer {
40
+ if (!Number.isSafeInteger(value)) {
41
+ throw new BerError(`cannot encode ${value} as an integer`);
42
+ }
43
+ const bytes: number[] = [];
44
+ let remaining = value;
45
+ do {
46
+ bytes.unshift(remaining & 0xff);
47
+ remaining >>= 8;
48
+ } while (remaining !== 0 && remaining !== -1);
49
+ // A leading bit set on a positive number would read as negative.
50
+ if (value >= 0 && (bytes[0] as number) & 0x80) bytes.unshift(0);
51
+ if (value < 0 && !((bytes[0] as number) & 0x80)) bytes.unshift(0xff);
52
+ return encode(tag, Buffer.from(bytes));
53
+ }
54
+
55
+ export function encodeString(value: string, tag = OCTET_STRING): Buffer {
56
+ // The value is written as length-prefixed bytes, never interpolated. That
57
+ // is what makes injection impossible rather than merely escaped.
58
+ return encode(tag, Buffer.from(value, "utf8"));
59
+ }
60
+
61
+ export function encodeBoolean(value: boolean, tag = BOOLEAN): Buffer {
62
+ return encode(tag, Buffer.from([value ? 0xff : 0x00]));
63
+ }
64
+
65
+ export function encodeSequence(parts: Buffer[], tag = SEQUENCE): Buffer {
66
+ return encode(tag, Buffer.concat(parts));
67
+ }
68
+
69
+ /** A decoded element: its tag, its contents, and where it ended. */
70
+ export interface BerElement {
71
+ tag: number;
72
+ value: Buffer;
73
+ end: number;
74
+ }
75
+
76
+ /** Read one element at `offset`. */
77
+ export function decode(buffer: Buffer, offset = 0): BerElement {
78
+ if (offset >= buffer.length) {
79
+ throw new BerError("the message ends where an element was expected");
80
+ }
81
+ const tag = buffer[offset] as number;
82
+ const { length, at } = decodeLength(buffer, offset + 1);
83
+ const end = at + length;
84
+ if (end > buffer.length) {
85
+ throw new BerError(
86
+ `an element claims ${length} bytes and the message holds ${buffer.length - at}`,
87
+ );
88
+ }
89
+ return { tag, value: buffer.subarray(at, end), end };
90
+ }
91
+
92
+ /** Every element inside a constructed one. */
93
+ export function decodeSequence(value: Buffer): BerElement[] {
94
+ const out: BerElement[] = [];
95
+ let offset = 0;
96
+ while (offset < value.length) {
97
+ const element = decode(value, offset);
98
+ out.push(element);
99
+ offset = element.end;
100
+ }
101
+ return out;
102
+ }
103
+
104
+ export function decodeInteger(value: Buffer): number {
105
+ if (value.length === 0) throw new BerError("an integer has no bytes");
106
+ if (value.length > 6) {
107
+ // Beyond this a result code or message id is not something this reads.
108
+ throw new BerError(`an integer of ${value.length} bytes is out of range`);
109
+ }
110
+ let out = (value[0] as number) & 0x80 ? -1 : 0;
111
+ for (const byte of value) out = out * 256 + byte;
112
+ return out;
113
+ }
114
+
115
+ export function decodeString(value: Buffer): string {
116
+ return value.toString("utf8");
117
+ }
118
+
119
+ /**
120
+ * How many bytes a whole message occupies, or `undefined` while it is still
121
+ * arriving. LDAP runs over a stream, so a read can stop anywhere.
122
+ */
123
+ export function messageLength(buffer: Buffer): number | undefined {
124
+ if (buffer.length < 2) return undefined;
125
+ const first = buffer[1] as number;
126
+ if (first < 0x80) return 2 + first;
127
+ const count = first & 0x7f;
128
+ if (count === 0 || count > 4) {
129
+ throw new BerError("a message declares a length this refuses to read");
130
+ }
131
+ if (buffer.length < 2 + count) return undefined;
132
+ let length = 0;
133
+ for (let i = 0; i < count; i += 1) {
134
+ length = length * 256 + (buffer[2 + i] as number);
135
+ }
136
+ return 2 + count + length;
137
+ }
138
+
139
+ function encodeLength(length: number): Buffer {
140
+ if (length < 0x80) return Buffer.from([length]);
141
+ const bytes: number[] = [];
142
+ let remaining = length;
143
+ while (remaining > 0) {
144
+ bytes.unshift(remaining & 0xff);
145
+ remaining = Math.floor(remaining / 256);
146
+ }
147
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
148
+ }
149
+
150
+ function decodeLength(
151
+ buffer: Buffer,
152
+ offset: number,
153
+ ): { length: number; at: number } {
154
+ if (offset >= buffer.length) {
155
+ throw new BerError("an element has no length");
156
+ }
157
+ const first = buffer[offset] as number;
158
+ if (first < 0x80) return { length: first, at: offset + 1 };
159
+ const count = first & 0x7f;
160
+ if (count === 0) {
161
+ // Indefinite length is legal BER and not legal LDAP; accepting it would
162
+ // mean scanning for a terminator supplied by the other side.
163
+ throw new BerError(
164
+ "an element uses an indefinite length, which LDAP does not",
165
+ );
166
+ }
167
+ if (count > 4 || offset + 1 + count > buffer.length) {
168
+ throw new BerError("an element declares a length this refuses to read");
169
+ }
170
+ let length = 0;
171
+ for (let i = 0; i < count; i += 1) {
172
+ length = length * 256 + (buffer[offset + 1 + i] as number);
173
+ }
174
+ return { length, at: offset + 1 + count };
175
+ }
package/src/c14n.ts ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Exclusive XML Canonicalization (`xml-exc-c14n#`).
3
+ *
4
+ * A signature is over BYTES, and two parties only agree on which bytes if they
5
+ * agree on this algorithm exactly. Every rule below comes from the
6
+ * specification, and the awkward ones are the ones that matter:
7
+ *
8
+ * - Only namespaces an element **visibly utilizes** are written out, and only
9
+ * when an output ancestor has not already written the same prefix with the
10
+ * same value. That is what makes a signed fragment survive being moved
11
+ * between documents — and it is the whole reason exclusive exists.
12
+ * - An unprefixed attribute is in NO namespace, so it never makes the default
13
+ * namespace visibly utilized.
14
+ * - The `xml` prefix is bound by the XML specification and is never declared.
15
+ *
16
+ * Getting any of these wrong yields a canonical form that differs from the
17
+ * signer's by a few bytes, and every signature then fails — or, worse, one is
18
+ * accepted over a document that is not the one that was signed.
19
+ */
20
+
21
+ import {
22
+ resolvePrefix,
23
+ XML_NS,
24
+ type XmlAttribute,
25
+ type XmlElement,
26
+ type XmlNode,
27
+ } from "./xml.js";
28
+
29
+ export interface CanonicalizeOptions {
30
+ /**
31
+ * Prefixes to treat the inclusive way, from a `<InclusiveNamespaces
32
+ * PrefixList="…">` transform parameter.
33
+ */
34
+ inclusivePrefixes?: string[];
35
+ /** Comments are excluded unless the algorithm says otherwise. */
36
+ withComments?: boolean;
37
+ /**
38
+ * Nodes to leave out of the output — what the enveloped-signature transform
39
+ * needs, since an element cannot carry a digest of itself.
40
+ */
41
+ omit?: ReadonlySet<XmlNode>;
42
+ }
43
+
44
+ /** The canonical form of an element and everything under it. */
45
+ export function canonicalize(
46
+ element: XmlElement,
47
+ options: CanonicalizeOptions = {},
48
+ ): string {
49
+ const out: string[] = [];
50
+ writeElement(element, out, new Map(), options);
51
+ return out.join("");
52
+ }
53
+
54
+ /**
55
+ * The prefixes this element makes visible: its own, and those of its
56
+ * attributes. The default namespace counts only when the element itself has no
57
+ * prefix.
58
+ */
59
+ function visiblyUtilized(
60
+ element: XmlElement,
61
+ inclusivePrefixes: string[],
62
+ ): Set<string> {
63
+ const used = new Set<string>([element.prefix]);
64
+ for (const attribute of element.attributes) {
65
+ if (attribute.prefix !== "") used.add(attribute.prefix);
66
+ }
67
+ // The prefix list is handled inclusively: a listed prefix is rendered
68
+ // whenever it is in scope, utilized or not.
69
+ for (const prefix of inclusivePrefixes) {
70
+ if (resolvePrefix(element, prefix) !== undefined) used.add(prefix);
71
+ }
72
+ // Never declared — the specification binds it.
73
+ used.delete("xml");
74
+ return used;
75
+ }
76
+
77
+ function writeElement(
78
+ element: XmlElement,
79
+ out: string[],
80
+ rendered: Map<string, string>,
81
+ options: CanonicalizeOptions,
82
+ ): void {
83
+ const inclusive = options.inclusivePrefixes ?? [];
84
+ const utilized = [...visiblyUtilized(element, inclusive)].sort(compare);
85
+
86
+ const declarations: Array<[prefix: string, uri: string]> = [];
87
+ const childRendered = new Map(rendered);
88
+
89
+ for (const prefix of utilized) {
90
+ const uri = resolvePrefix(element, prefix) ?? "";
91
+ if (prefix === "" && uri === "") {
92
+ // `xmlns=""` is written only to undo a default namespace an output
93
+ // ancestor put in scope. Writing it otherwise is noise the signer
94
+ // did not produce.
95
+ if ((rendered.get("") ?? "") !== "") {
96
+ declarations.push(["", ""]);
97
+ childRendered.set("", "");
98
+ }
99
+ continue;
100
+ }
101
+ if (rendered.get(prefix) !== uri) {
102
+ declarations.push([prefix, uri]);
103
+ childRendered.set(prefix, uri);
104
+ }
105
+ }
106
+
107
+ out.push("<", element.qname);
108
+ for (const [prefix, uri] of declarations) {
109
+ out.push(
110
+ " ",
111
+ prefix === "" ? "xmlns" : `xmlns:${prefix}`,
112
+ '="',
113
+ escapeAttribute(uri),
114
+ '"',
115
+ );
116
+ }
117
+ for (const attribute of [...element.attributes].sort(byNamespaceThenLocal)) {
118
+ out.push(" ", attribute.qname, '="', escapeAttribute(attribute.value), '"');
119
+ }
120
+ out.push(">");
121
+
122
+ for (const child of element.children) {
123
+ if (options.omit?.has(child)) continue;
124
+ writeNode(child, out, childRendered, options);
125
+ }
126
+
127
+ // Empty elements are written as a start/end pair, never self-closed.
128
+ out.push("</", element.qname, ">");
129
+ }
130
+
131
+ function writeNode(
132
+ node: XmlNode,
133
+ out: string[],
134
+ rendered: Map<string, string>,
135
+ options: CanonicalizeOptions,
136
+ ): void {
137
+ switch (node.type) {
138
+ case "element":
139
+ writeElement(node, out, rendered, options);
140
+ return;
141
+ case "text":
142
+ out.push(escapeText(node.value));
143
+ return;
144
+ case "comment":
145
+ if (options.withComments) out.push("<!--", node.value, "-->");
146
+ return;
147
+ case "instruction":
148
+ out.push(
149
+ "<?",
150
+ node.target,
151
+ node.data === "" ? "" : ` ${node.data}`,
152
+ "?>",
153
+ );
154
+ return;
155
+ }
156
+ }
157
+
158
+ /** Namespace URI first, then local name; an empty URI sorts least. */
159
+ function byNamespaceThenLocal(a: XmlAttribute, b: XmlAttribute): number {
160
+ const uri = compare(a.namespaceUri, b.namespaceUri);
161
+ return uri !== 0 ? uri : compare(a.local, b.local);
162
+ }
163
+
164
+ /** Byte order, which is what the specification means by lexicographic here. */
165
+ function compare(a: string, b: string): number {
166
+ return a < b ? -1 : a > b ? 1 : 0;
167
+ }
168
+
169
+ function escapeText(value: string): string {
170
+ return value
171
+ .replace(/&/g, "&amp;")
172
+ .replace(/</g, "&lt;")
173
+ .replace(/>/g, "&gt;")
174
+ .replace(/\r/g, "&#xD;");
175
+ }
176
+
177
+ function escapeAttribute(value: string): string {
178
+ return value
179
+ .replace(/&/g, "&amp;")
180
+ .replace(/</g, "&lt;")
181
+ .replace(/"/g, "&quot;")
182
+ .replace(/\t/g, "&#x9;")
183
+ .replace(/\n/g, "&#xA;")
184
+ .replace(/\r/g, "&#xD;");
185
+ }
186
+
187
+ export { XML_NS };
package/src/config.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Author-time config for `config/transit.ts`.
3
+ *
4
+ * import { defineConfig, socials } from '@c9up/transit'
5
+ *
6
+ * export default defineConfig({
7
+ * google: socials.google({
8
+ * clientId: env.get('GOOGLE_CLIENT_ID'),
9
+ * clientSecret: env.get('GOOGLE_CLIENT_SECRET'),
10
+ * callbackUrl: 'https://acme.test/auth/google/callback',
11
+ * }),
12
+ * })
13
+ *
14
+ * The keys are yours. Two entries may reach the same provider with different
15
+ * credentials — a `staff` sign-in and a `customers` one, both on Google — and
16
+ * `use(name)` asks for the key, not for the provider.
17
+ */
18
+
19
+ import { type AppleConfig, AppleDriver } from "./drivers/AppleDriver.js";
20
+ import { DiscordDriver } from "./drivers/DiscordDriver.js";
21
+ import { FacebookDriver } from "./drivers/FacebookDriver.js";
22
+ import { GitHubDriver } from "./drivers/GitHubDriver.js";
23
+ import { GoogleDriver } from "./drivers/GoogleDriver.js";
24
+ import { type LdapConfig, LdapDirectory } from "./drivers/LdapDirectory.js";
25
+ import { LinkedInDriver } from "./drivers/LinkedInDriver.js";
26
+ import { LinkedInOpenidConnectDriver } from "./drivers/LinkedInOpenidConnectDriver.js";
27
+ import { type OidcConfig, OidcDriver } from "./drivers/OidcDriver.js";
28
+ import { type SamlConfig, SamlDriver } from "./drivers/SamlDriver.js";
29
+ import { SpotifyDriver } from "./drivers/SpotifyDriver.js";
30
+ import { TwitterDriver } from "./drivers/TwitterDriver.js";
31
+ import { TwitterXDriver } from "./drivers/TwitterXDriver.js";
32
+ import type { OAuthConfig, TransitEntry } from "./types.js";
33
+
34
+ /** A provider, built when the manager is. */
35
+ export type TransitDriverFactory = () => TransitEntry;
36
+
37
+ /** One declared provider: the driver itself, or a factory answering one. */
38
+ export type TransitProviderEntry = TransitEntry | TransitDriverFactory;
39
+
40
+ export type TransitConfig = Record<string, TransitProviderEntry>;
41
+
42
+ export function defineConfig<T extends TransitConfig>(config: T): T {
43
+ return config;
44
+ }
45
+
46
+ /**
47
+ * The social providers Transit speaks to.
48
+ *
49
+ * Factories are lazy, so a config naming a provider this environment never
50
+ * selects costs nothing to declare.
51
+ */
52
+ /**
53
+ * Any provider that speaks OpenID Connect, from its issuer alone — Keycloak,
54
+ * Auth0, Okta, Entra ID, Authentik, Zitadel, and the rest.
55
+ *
56
+ * work: oidc({
57
+ * issuer: 'https://id.acme.com',
58
+ * clientId: env.get('OIDC_CLIENT_ID'),
59
+ * clientSecret: env.get('OIDC_CLIENT_SECRET'),
60
+ * callbackUrl: 'https://acme.test/auth/work/callback',
61
+ * })
62
+ *
63
+ * The endpoints, the signing keys and the algorithms come from what the
64
+ * provider publishes, so there is nothing else to configure and nothing to
65
+ * update when it rotates a key.
66
+ */
67
+ export function oidc(config: OidcConfig): TransitDriverFactory {
68
+ return () => new OidcDriver(config);
69
+ }
70
+
71
+ /**
72
+ * A SAML 2.0 identity provider — the enterprise directories that speak it:
73
+ * Okta, Entra ID, ADFS, OneLogin, Shibboleth.
74
+ *
75
+ * corp: saml({
76
+ * entityId: 'https://acme.test/saml',
77
+ * callbackUrl: 'https://acme.test/saml/acs',
78
+ * issuer: 'https://idp.acme.test/metadata',
79
+ * signOnUrl: 'https://idp.acme.test/sso',
80
+ * certificates: [env.get('IDP_CERTIFICATE')],
81
+ * })
82
+ *
83
+ * The certificates come from the provider's metadata and nowhere else: a
84
+ * response is never verified against a key it carries itself.
85
+ */
86
+ export function saml(config: SamlConfig): TransitDriverFactory {
87
+ return () => new SamlDriver(config);
88
+ }
89
+
90
+ /**
91
+ * An LDAP or Active Directory directory.
92
+ *
93
+ * staff: ldap({
94
+ * url: 'ldaps://directory.acme.test',
95
+ * baseDn: 'dc=acme,dc=test',
96
+ * loginAttribute: 'uid',
97
+ * bindDn: 'cn=reader,dc=acme,dc=test',
98
+ * bindPassword: env.get('LDAP_PASSWORD'),
99
+ * })
100
+ *
101
+ * Reached with `transit.authenticate(name, username, password)`: nothing is
102
+ * redirected, so there is no `begin()` for it.
103
+ */
104
+ export function ldap(config: LdapConfig): TransitDriverFactory {
105
+ return () => new LdapDirectory(config);
106
+ }
107
+
108
+ export const socials = {
109
+ /**
110
+ * Sign in with Apple. Required on iOS as soon as another social sign-in is
111
+ * offered.
112
+ *
113
+ * It takes a key rather than a secret — the Services ID, the Team ID, the
114
+ * Key ID and the contents of the `.p8` — and its callback arrives as a
115
+ * POST, because asking for a name forces `response_mode=form_post`. The
116
+ * name comes once, in that POST: read it with `parseAppleUser` and store
117
+ * it, because Apple never sends it again.
118
+ */
119
+ apple(config: AppleConfig): TransitDriverFactory {
120
+ return () => new AppleDriver(config);
121
+ },
122
+ discord(config: OAuthConfig): TransitDriverFactory {
123
+ return () => new DiscordDriver(config);
124
+ },
125
+ facebook(config: OAuthConfig): TransitDriverFactory {
126
+ return () => new FacebookDriver(config);
127
+ },
128
+ github(config: OAuthConfig): TransitDriverFactory {
129
+ return () => new GitHubDriver(config);
130
+ },
131
+ google(config: OAuthConfig): TransitDriverFactory {
132
+ return () => new GoogleDriver(config);
133
+ },
134
+ /**
135
+ * LinkedIn through the member API, for an application whose LinkedIn app
136
+ * holds `r_liteprofile` / `r_emailaddress`.
137
+ */
138
+ linkedin(config: OAuthConfig): TransitDriverFactory {
139
+ return () => new LinkedInDriver(config);
140
+ },
141
+ /** LinkedIn through OpenID Connect — what a new application is issued. */
142
+ linkedinOpenidConnect(config: OAuthConfig): TransitDriverFactory {
143
+ return () => new LinkedInOpenidConnectDriver(config);
144
+ },
145
+ spotify(config: OAuthConfig): TransitDriverFactory {
146
+ return () => new SpotifyDriver(config);
147
+ },
148
+ /**
149
+ * X through OAuth1 — the older flow, and the one whose profile call returns
150
+ * the address. Its redirect needs a request token from X, so it is reached
151
+ * through `begin()` rather than `redirect()`.
152
+ */
153
+ twitter(config: OAuthConfig): TransitDriverFactory {
154
+ return () => new TwitterDriver(config);
155
+ },
156
+ /** X through OAuth2. It requires PKCE, which `begin()` handles. */
157
+ twitterX(config: OAuthConfig): TransitDriverFactory {
158
+ return () => new TwitterXDriver(config);
159
+ },
160
+ };