@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
package/src/saml.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Reading a SAML response, once its signature has been verified.
3
+ *
4
+ * A valid signature says the identity provider wrote this. It says nothing
5
+ * about whether the statement was meant for THIS application, at THIS moment,
6
+ * in answer to THIS request — and a response that is genuine but not any of
7
+ * those is exactly what a replay or a redirect to another service provider
8
+ * looks like. Every condition below is one of those questions.
9
+ *
10
+ * Everything here works on the element the signature covers. Nothing searches
11
+ * the document again: that is the guarantee `verifyXmlSignature` hands over,
12
+ * and re-finding an element would throw it away.
13
+ */
14
+
15
+ import { childrenNamed, textOf, walk, type XmlElement } from "./xml.js";
16
+
17
+ export const SAML_PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol";
18
+ export const SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion";
19
+ const STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
20
+ const BEARER = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
21
+
22
+ export class SamlError extends Error {
23
+ constructor(message: string) {
24
+ super(`[transit] ${message}`);
25
+ this.name = "SamlError";
26
+ }
27
+ }
28
+
29
+ /** What a validated assertion says about the person. */
30
+ export interface SamlIdentity {
31
+ /** The `NameID`, which is what the provider calls this person. */
32
+ nameId: string;
33
+ nameIdFormat: string | undefined;
34
+ /** Every attribute the assertion carried, values kept as a list. */
35
+ attributes: Record<string, string[]>;
36
+ /** The assertion's own id, for the replay check. */
37
+ assertionId: string;
38
+ /** When the assertion stops being usable, epoch seconds. */
39
+ notOnOrAfter: number;
40
+ /** The provider's session, when it named one. */
41
+ sessionIndex: string | undefined;
42
+ }
43
+
44
+ export interface SamlExpectations {
45
+ /** The provider's entity id, as its metadata declares it. */
46
+ issuer: string;
47
+ /** This application's entity id, which the assertion must be addressed to. */
48
+ audience: string;
49
+ /** The assertion consumer URL the response was meant to land on. */
50
+ recipient: string;
51
+ /**
52
+ * The id of the request this answers. Required for a sign-in this
53
+ * application started: without it, an assertion obtained elsewhere can be
54
+ * posted here.
55
+ */
56
+ inResponseTo?: string;
57
+ /** Tolerance for clock drift against the provider. Default 60 seconds. */
58
+ clockSkewSeconds?: number;
59
+ /** Injected in tests; seconds since the epoch. */
60
+ now?: number;
61
+ }
62
+
63
+ /**
64
+ * Check the `Status` of a response, and say what the provider refused.
65
+ *
66
+ * A failed status is the provider declining — an unknown user, a cancelled
67
+ * consent — and it arrives signed just like a success.
68
+ */
69
+ export function assertResponseSucceeded(response: XmlElement): void {
70
+ const status = childrenNamed(response, SAML_PROTOCOL_NS, "Status")[0];
71
+ const code = status
72
+ ? childrenNamed(status, SAML_PROTOCOL_NS, "StatusCode")[0]
73
+ : undefined;
74
+ const value = code?.attributes.find((a) => a.local === "Value")?.value;
75
+ if (value === STATUS_SUCCESS) return;
76
+
77
+ const message = status
78
+ ? childrenNamed(status, SAML_PROTOCOL_NS, "StatusMessage")[0]
79
+ : undefined;
80
+ throw new SamlError(
81
+ `the provider refused the sign-in: ${value ?? "no status"}${
82
+ message ? ` — ${textOf(message).trim()}` : ""
83
+ }`,
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Validate an assertion and read what it says.
89
+ *
90
+ * `assertion` must be the element the signature covers, or an element inside
91
+ * it — never one looked up in the document afterwards.
92
+ */
93
+ export function validateAssertion(
94
+ assertion: XmlElement,
95
+ expected: SamlExpectations,
96
+ ): SamlIdentity {
97
+ if (
98
+ assertion.namespaceUri !== SAML_ASSERTION_NS ||
99
+ assertion.local !== "Assertion"
100
+ ) {
101
+ throw new SamlError(
102
+ `expected a signed <Assertion>, and the signature covers <${assertion.qname}>`,
103
+ );
104
+ }
105
+
106
+ const skew = expected.clockSkewSeconds ?? 60;
107
+ const now = expected.now ?? Math.floor(Date.now() / 1000);
108
+
109
+ const issuer = childrenNamed(assertion, SAML_ASSERTION_NS, "Issuer")[0];
110
+ const issuerValue = issuer ? textOf(issuer).trim() : undefined;
111
+ if (issuerValue !== expected.issuer) {
112
+ throw new SamlError(
113
+ `the assertion is issued by '${issuerValue ?? "nobody"}', not by '${expected.issuer}'`,
114
+ );
115
+ }
116
+
117
+ const assertionId = assertion.attributes.find((a) => a.local === "ID")?.value;
118
+ if (!assertionId) {
119
+ throw new SamlError("the assertion carries no id, so it cannot be tracked");
120
+ }
121
+
122
+ const subject = one(assertion, "Subject");
123
+ const confirmation = childrenNamed(
124
+ subject,
125
+ SAML_ASSERTION_NS,
126
+ "SubjectConfirmation",
127
+ ).find(
128
+ (c) => c.attributes.find((a) => a.local === "Method")?.value === BEARER,
129
+ );
130
+ if (!confirmation) {
131
+ throw new SamlError(
132
+ "the assertion has no bearer subject confirmation, which is the only method a browser sign-in uses",
133
+ );
134
+ }
135
+ const data = childrenNamed(
136
+ confirmation,
137
+ SAML_ASSERTION_NS,
138
+ "SubjectConfirmationData",
139
+ )[0];
140
+ if (!data) {
141
+ throw new SamlError("the bearer confirmation carries no data to check");
142
+ }
143
+
144
+ const recipient = data.attributes.find((a) => a.local === "Recipient")?.value;
145
+ if (recipient !== expected.recipient) {
146
+ // A response addressed to another service provider is a genuine
147
+ // assertion pointed at the wrong door.
148
+ throw new SamlError(
149
+ `the assertion was sent to '${recipient ?? "nowhere"}', and this application answers at '${expected.recipient}'`,
150
+ );
151
+ }
152
+
153
+ const answered = data.attributes.find(
154
+ (a) => a.local === "InResponseTo",
155
+ )?.value;
156
+ if (expected.inResponseTo !== undefined) {
157
+ if (answered !== expected.inResponseTo) {
158
+ // Without this, an assertion obtained anywhere else can be posted
159
+ // here and accepted.
160
+ throw new SamlError(
161
+ "the assertion answers a different request than the one this sign-in made",
162
+ );
163
+ }
164
+ } else if (answered !== undefined) {
165
+ throw new SamlError(
166
+ "the assertion answers a request this application did not make",
167
+ );
168
+ }
169
+
170
+ const confirmationExpiry = epoch(
171
+ data.attributes.find((a) => a.local === "NotOnOrAfter")?.value,
172
+ );
173
+ if (confirmationExpiry === undefined) {
174
+ throw new SamlError("the bearer confirmation never expires, which it must");
175
+ }
176
+ if (now - skew >= confirmationExpiry) {
177
+ throw new SamlError("the assertion is no longer usable");
178
+ }
179
+
180
+ const conditions = one(assertion, "Conditions");
181
+ const notBefore = epoch(
182
+ conditions.attributes.find((a) => a.local === "NotBefore")?.value,
183
+ );
184
+ const notOnOrAfter = epoch(
185
+ conditions.attributes.find((a) => a.local === "NotOnOrAfter")?.value,
186
+ );
187
+ if (notBefore !== undefined && now + skew < notBefore) {
188
+ throw new SamlError("the assertion is not valid yet");
189
+ }
190
+ if (notOnOrAfter === undefined) {
191
+ throw new SamlError("the assertion has no expiry, which it must");
192
+ }
193
+ if (now - skew >= notOnOrAfter) {
194
+ throw new SamlError("the assertion has expired");
195
+ }
196
+
197
+ const audiences = childrenNamed(
198
+ conditions,
199
+ SAML_ASSERTION_NS,
200
+ "AudienceRestriction",
201
+ ).flatMap((restriction) =>
202
+ childrenNamed(restriction, SAML_ASSERTION_NS, "Audience").map((a) =>
203
+ textOf(a).trim(),
204
+ ),
205
+ );
206
+ if (audiences.length === 0) {
207
+ throw new SamlError(
208
+ "the assertion restricts no audience, so it does not say who it is for",
209
+ );
210
+ }
211
+ if (!audiences.includes(expected.audience)) {
212
+ throw new SamlError(
213
+ `the assertion is for ${audiences.map((a) => `'${a}'`).join(", ")}, and this application is '${expected.audience}'`,
214
+ );
215
+ }
216
+
217
+ const nameIdElement = childrenNamed(subject, SAML_ASSERTION_NS, "NameID")[0];
218
+ if (!nameIdElement) {
219
+ throw new SamlError("the assertion names nobody");
220
+ }
221
+
222
+ const authn = childrenNamed(
223
+ assertion,
224
+ SAML_ASSERTION_NS,
225
+ "AuthnStatement",
226
+ )[0];
227
+
228
+ return {
229
+ nameId: textOf(nameIdElement).trim(),
230
+ nameIdFormat: nameIdElement.attributes.find((a) => a.local === "Format")
231
+ ?.value,
232
+ attributes: readAttributes(assertion),
233
+ assertionId,
234
+ notOnOrAfter,
235
+ sessionIndex: authn?.attributes.find((a) => a.local === "SessionIndex")
236
+ ?.value,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Every `<Attribute>` of the assertion, values kept as a list because SAML
242
+ * attributes are multi-valued and collapsing them loses group memberships.
243
+ */
244
+ function readAttributes(assertion: XmlElement): Record<string, string[]> {
245
+ const out: Record<string, string[]> = {};
246
+ for (const statement of childrenNamed(
247
+ assertion,
248
+ SAML_ASSERTION_NS,
249
+ "AttributeStatement",
250
+ )) {
251
+ for (const attribute of childrenNamed(
252
+ statement,
253
+ SAML_ASSERTION_NS,
254
+ "Attribute",
255
+ )) {
256
+ const name =
257
+ attribute.attributes.find((a) => a.local === "Name")?.value ??
258
+ attribute.attributes.find((a) => a.local === "FriendlyName")?.value;
259
+ if (!name) continue;
260
+ const values = childrenNamed(
261
+ attribute,
262
+ SAML_ASSERTION_NS,
263
+ "AttributeValue",
264
+ ).map((value) => textOf(value).trim());
265
+ const existing = out[name] ?? [];
266
+ existing.push(...values);
267
+ out[name] = existing;
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /** The assertion inside a signed response, when the response is what was signed. */
274
+ export function assertionInside(signed: XmlElement): XmlElement {
275
+ if (
276
+ signed.local === "Assertion" &&
277
+ signed.namespaceUri === SAML_ASSERTION_NS
278
+ ) {
279
+ return signed;
280
+ }
281
+ const assertions = [...walk(signed)].filter(
282
+ (element) =>
283
+ element.namespaceUri === SAML_ASSERTION_NS &&
284
+ element.local === "Assertion",
285
+ );
286
+ if (assertions.length !== 1) {
287
+ throw new SamlError(
288
+ `the signed element contains ${assertions.length} assertions, and this reads one`,
289
+ );
290
+ }
291
+ return assertions[0] as XmlElement;
292
+ }
293
+
294
+ function one(parent: XmlElement, local: string): XmlElement {
295
+ const found = childrenNamed(parent, SAML_ASSERTION_NS, local);
296
+ if (found.length !== 1) {
297
+ throw new SamlError(
298
+ `expected one <${local}> in the assertion, and there are ${found.length}`,
299
+ );
300
+ }
301
+ return found[0] as XmlElement;
302
+ }
303
+
304
+ /** A SAML instant, in seconds. Anything unparseable is treated as absent. */
305
+ function epoch(value: string | undefined): number | undefined {
306
+ if (value === undefined) return undefined;
307
+ const parsed = Date.parse(value);
308
+ return Number.isNaN(parsed) ? undefined : Math.floor(parsed / 1000);
309
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Container service accessor — `import transit from '@c9up/transit/services/main'`.
3
+ *
4
+ * Populated by `TransitProvider.boot()`. Reading it before the provider has
5
+ * booted throws rather than answering with an empty manager, because a manager
6
+ * with no providers fails on the first sign-in instead of at startup.
7
+ */
8
+
9
+ import type { TransitManager } from "../TransitManager.js";
10
+
11
+ let instance: TransitManager | undefined;
12
+
13
+ /** @internal Called by the provider once the manager exists. */
14
+ export function setTransit(manager: TransitManager): void {
15
+ instance = manager;
16
+ }
17
+
18
+ /** @internal Test helper — forget the manager between cases. */
19
+ export function clearTransit(): void {
20
+ instance = undefined;
21
+ }
22
+
23
+ function resolve(): TransitManager {
24
+ if (!instance) {
25
+ throw new Error(
26
+ "[transit] accessed before initialization — register TransitProvider, or call setTransit() yourself.",
27
+ );
28
+ }
29
+ return instance;
30
+ }
31
+
32
+ /**
33
+ * A proxy so the import can be held before the provider boots.
34
+ *
35
+ * Symbols and `then` answer undefined: a module namespace is probed for
36
+ * `then` when it is imported, and a proxy that threw there would crash the
37
+ * import itself.
38
+ */
39
+ const transit = new Proxy({} as TransitManager, {
40
+ get(_target, property) {
41
+ if (typeof property === "symbol" || property === "then") return undefined;
42
+ const value = Reflect.get(resolve(), property);
43
+ return typeof value === "function" ? value.bind(resolve()) : value;
44
+ },
45
+ }) as TransitManager;
46
+
47
+ export default transit;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * FakeTransit — a test double for `TransitManager`.
3
+ *
4
+ * A sign-in cannot be exercised against a real provider in a test, so this
5
+ * stands in for one: it hands back a redirect, and answers the callback with
6
+ * whatever user the test declared.
7
+ *
8
+ * It is deliberately NOT lenient. The state round trip and the value `begin()`
9
+ * asked to keep are enforced here exactly as the real drivers enforce them,
10
+ * because a fake that lets a forgetful controller pass is a fake that teaches
11
+ * applications to ship one. A test that fails here would have been a session
12
+ * fixation in production.
13
+ *
14
+ * import { FakeTransit } from '@c9up/transit/testing'
15
+ *
16
+ * const transit = new FakeTransit()
17
+ * transit.willReturn('google', { email: 'ada@acme.test' })
18
+ * container.singleton(TransitManager, () => transit)
19
+ *
20
+ * // ... the code under test runs its sign-in
21
+ * transit.assertSignedIn('google')
22
+ */
23
+
24
+ import { randomUUID } from "node:crypto";
25
+ import { TransitManager } from "../TransitManager.js";
26
+ import type { OAuthToken, RedirectRequest, TransitUser } from "../types.js";
27
+ import { assertOAuthState } from "../types.js";
28
+
29
+ /** What a test declares a provider will answer with. */
30
+ export interface FakeIdentity extends Partial<TransitUser> {
31
+ email?: string;
32
+ }
33
+
34
+ export interface BegunSignIn {
35
+ name: string;
36
+ state: string;
37
+ secret: string;
38
+ }
39
+
40
+ export interface CompletedSignIn {
41
+ name: string;
42
+ user: TransitUser;
43
+ }
44
+
45
+ const DEFAULT_TOKEN: OAuthToken = { accessToken: "fake-access-token" };
46
+
47
+ export class FakeTransit extends TransitManager {
48
+ readonly #identities = new Map<string, TransitUser>();
49
+ readonly #tokens = new Map<string, OAuthToken>();
50
+ readonly #credentials = new Map<
51
+ string,
52
+ { username: string; password: string }
53
+ >();
54
+ /** Every `begin()` this saw, oldest first. */
55
+ readonly begun: BegunSignIn[] = [];
56
+ /** Every sign-in that completed, oldest first. */
57
+ readonly signedIn: CompletedSignIn[] = [];
58
+
59
+ /**
60
+ * Declare who comes back from `name`. Anything left out is filled in, so a
61
+ * test states only what it is about.
62
+ */
63
+ /**
64
+ * Declare the credentials a directory accepts under `name`. Without this,
65
+ * `authenticate` accepts any pair — which is what a test about roles wants,
66
+ * and a test about a wrong password does not.
67
+ */
68
+ willAccept(name: string, username: string, password: string): this {
69
+ this.#credentials.set(name, { username, password });
70
+ return this;
71
+ }
72
+
73
+ willReturn(
74
+ name: string,
75
+ identity: FakeIdentity = {},
76
+ token?: OAuthToken,
77
+ ): this {
78
+ this.#identities.set(name, {
79
+ id: identity.id ?? `fake-${name}-user`,
80
+ email: identity.email ?? `${name}@example.test`,
81
+ name: identity.name ?? "Fake User",
82
+ nickName: identity.nickName,
83
+ avatarUrl: identity.avatarUrl,
84
+ emailVerificationState: identity.emailVerificationState ?? "verified",
85
+ raw: identity.raw ?? {},
86
+ });
87
+ if (token) this.#tokens.set(name, token);
88
+ return this;
89
+ }
90
+
91
+ override async begin(
92
+ name: string,
93
+ state: string = randomUUID(),
94
+ ): Promise<RedirectRequest> {
95
+ const secret = randomUUID();
96
+ this.begun.push({ name, state, secret });
97
+ return {
98
+ url: `https://transit.test/${encodeURIComponent(name)}?state=${encodeURIComponent(state)}`,
99
+ state,
100
+ secret,
101
+ };
102
+ }
103
+
104
+ override redirect(name: string, state?: string): string {
105
+ this.begun.push({ name, state: state ?? "", secret: "" });
106
+ return `https://transit.test/${encodeURIComponent(name)}?state=${encodeURIComponent(state ?? "")}`;
107
+ }
108
+
109
+ override async callback(
110
+ name: string,
111
+ _code: string,
112
+ state?: string,
113
+ expectedState?: string,
114
+ secret?: string,
115
+ ): Promise<{ user: TransitUser; token: OAuthToken }> {
116
+ // The same refusal the real drivers make. A controller that forgets to
117
+ // store the state, or to hand it back, fails in the test rather than in
118
+ // production.
119
+ assertOAuthState(state, expectedState);
120
+
121
+ const started = this.begun.find((entry) => entry.name === name);
122
+ if (started?.secret && secret !== started.secret) {
123
+ throw new Error(
124
+ `[transit] the callback for '${name}' did not carry the value begin() returned as \`secret\` — store it with the state.`,
125
+ );
126
+ }
127
+
128
+ const user = this.#identities.get(name);
129
+ if (!user) {
130
+ throw new Error(
131
+ `[transit] nothing declared for '${name}'. Call willReturn('${name}', …) before the sign-in runs.`,
132
+ );
133
+ }
134
+ this.signedIn.push({ name, user });
135
+ return { user, token: this.#tokens.get(name) ?? DEFAULT_TOKEN };
136
+ }
137
+
138
+ /**
139
+ * A directory sign-in, doubled.
140
+ *
141
+ * The empty-password refusal is repeated here for the same reason the state
142
+ * check is: a bind with no password is an anonymous bind the directory
143
+ * accepts, and a fake that let it through would teach an application to
144
+ * submit one.
145
+ */
146
+ override async authenticate(
147
+ name: string,
148
+ username: string,
149
+ password: string,
150
+ ): Promise<TransitUser> {
151
+ if (password === "") {
152
+ throw new Error(
153
+ "[transit] a password is required — an empty one is an anonymous bind, which the directory would accept",
154
+ );
155
+ }
156
+ if (username === "") {
157
+ throw new Error("[transit] a username is required");
158
+ }
159
+ const user = this.#identities.get(name);
160
+ if (!user) {
161
+ throw new Error(
162
+ `[transit] nothing declared for '${name}'. Call willReturn('${name}', …) before the sign-in runs.`,
163
+ );
164
+ }
165
+ const credentials = this.#credentials.get(name);
166
+ if (credentials && credentials.password !== password) {
167
+ throw new Error(
168
+ "[transit] the directory refused the bind: invalid credentials",
169
+ );
170
+ }
171
+ if (credentials && credentials.username !== username) {
172
+ throw new Error(
173
+ "[transit] the directory refused the bind: invalid credentials",
174
+ );
175
+ }
176
+ this.signedIn.push({ name, user });
177
+ return user;
178
+ }
179
+
180
+ override async userFromToken(name: string): Promise<TransitUser> {
181
+ const user = this.#identities.get(name);
182
+ if (!user) {
183
+ throw new Error(
184
+ `[transit] nothing declared for '${name}'. Call willReturn('${name}', …) first.`,
185
+ );
186
+ }
187
+ return user;
188
+ }
189
+
190
+ /** A sign-in through `name` was started. */
191
+ assertBegan(name: string): void {
192
+ if (!this.begun.some((entry) => entry.name === name)) {
193
+ throw new Error(
194
+ `Expected a sign-in to begin through '${name}'. ${this.#saw(this.begun.map((e) => e.name))}`,
195
+ );
196
+ }
197
+ }
198
+
199
+ /** A sign-in through `name` completed. */
200
+ assertSignedIn(name: string): void {
201
+ if (!this.signedIn.some((entry) => entry.name === name)) {
202
+ throw new Error(
203
+ `Expected a sign-in to complete through '${name}'. ${this.#saw(this.signedIn.map((e) => e.name))}`,
204
+ );
205
+ }
206
+ }
207
+
208
+ /** Nobody was signed in — what a rejected callback should leave behind. */
209
+ assertNobodySignedIn(): void {
210
+ if (this.signedIn.length > 0) {
211
+ throw new Error(
212
+ `Expected nobody to be signed in, and ${this.signedIn.length} was: ${this.signedIn.map((e) => e.name).join(", ")}`,
213
+ );
214
+ }
215
+ }
216
+
217
+ /** The user handed back by the last completed sign-in. */
218
+ lastUser(): TransitUser | undefined {
219
+ return this.signedIn.at(-1)?.user;
220
+ }
221
+
222
+ /** Forget everything recorded, keeping what was declared. */
223
+ reset(): void {
224
+ this.begun.length = 0;
225
+ this.signedIn.length = 0;
226
+ }
227
+
228
+ #saw(names: string[]): string {
229
+ return names.length === 0
230
+ ? "Nothing was recorded."
231
+ : `Recorded: ${names.join(", ")}.`;
232
+ }
233
+ }