@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/oauth1.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * OAuth 1.0a request signing (RFC 5849), HMAC-SHA1.
3
+ *
4
+ * OAuth1 authenticates each request by signing it, rather than by carrying a
5
+ * bearer token, which is why it needs this at all and OAuth2 does not. The
6
+ * signature covers the method, the URL and every parameter — so getting the
7
+ * base string wrong is the failure mode, and it fails as an opaque 401 from
8
+ * the provider.
9
+ *
10
+ * Everything here is pure: no network, no clock, no randomness. The nonce and
11
+ * timestamp are passed in, which is also what makes the result testable
12
+ * against the vector in the specification.
13
+ */
14
+
15
+ import { createHmac, randomBytes } from "node:crypto";
16
+
17
+ /** One parameter. Duplicates of a key are allowed, as the spec permits. */
18
+ export type Param = readonly [key: string, value: string];
19
+
20
+ export interface SignatureInput {
21
+ method: string;
22
+ /** Full URL. Its query string is folded into the signed parameters. */
23
+ url: string;
24
+ /** Parameters beyond the URL query and the `oauth_*` set — a form body. */
25
+ params?: readonly Param[];
26
+ /** The `oauth_*` parameters, without `oauth_signature`. */
27
+ oauth: readonly Param[];
28
+ consumerSecret: string;
29
+ /** Absent on the very first call, when no token exists yet. */
30
+ tokenSecret?: string;
31
+ }
32
+
33
+ /**
34
+ * Percent-encoding as the specification defines it: unreserved characters
35
+ * stay, everything else is uppercase `%XX`.
36
+ *
37
+ * `encodeURIComponent` is close but leaves `!'()*` alone, and a signature
38
+ * computed with those unescaped does not match the one the provider computes.
39
+ */
40
+ export function percentEncode(value: string): string {
41
+ return encodeURIComponent(value).replace(
42
+ /[!'()*]/g,
43
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
44
+ );
45
+ }
46
+
47
+ /** A fresh nonce. Only its uniqueness matters. */
48
+ export function nonce(): string {
49
+ return randomBytes(16).toString("hex");
50
+ }
51
+
52
+ /**
53
+ * The signature base string — the exact bytes both sides hash.
54
+ *
55
+ * Query and body parameters are decoded, then re-encoded, then sorted by
56
+ * encoded key and encoded value. That round trip is deliberate: two requests
57
+ * differing only in how they spelled an escape have to produce the same
58
+ * string, or the signature depends on the spelling.
59
+ */
60
+ export function signatureBaseString(input: SignatureInput): string {
61
+ const url = new URL(input.url);
62
+ const query: Param[] = [...url.searchParams.entries()].map(
63
+ ([key, value]) => [key, value] as Param,
64
+ );
65
+
66
+ const encoded = [...query, ...(input.params ?? []), ...input.oauth]
67
+ .map(([key, value]): Param => [percentEncode(key), percentEncode(value)])
68
+ .sort((a, b) => (a[0] === b[0] ? compare(a[1], b[1]) : compare(a[0], b[0])))
69
+ .map(([key, value]) => `${key}=${value}`)
70
+ .join("&");
71
+
72
+ // The base URL carries no query, no fragment, and no default port.
73
+ url.search = "";
74
+ url.hash = "";
75
+ const base = url.toString().replace(/\/$/, url.pathname === "/" ? "/" : "");
76
+
77
+ return [
78
+ input.method.toUpperCase(),
79
+ percentEncode(base),
80
+ percentEncode(encoded),
81
+ ].join("&");
82
+ }
83
+
84
+ /** The signature itself, base64 of HMAC-SHA1 over the base string. */
85
+ export function sign(input: SignatureInput): string {
86
+ const key = `${percentEncode(input.consumerSecret)}&${percentEncode(
87
+ input.tokenSecret ?? "",
88
+ )}`;
89
+ return createHmac("sha1", key)
90
+ .update(signatureBaseString(input))
91
+ .digest("base64");
92
+ }
93
+
94
+ /**
95
+ * The `Authorization: OAuth …` header value, signature included.
96
+ *
97
+ * Only the `oauth_*` parameters go in the header; the query and body ones are
98
+ * signed but stay where they were.
99
+ */
100
+ export function authorizationHeader(input: SignatureInput): string {
101
+ const signed: Param[] = [
102
+ ...input.oauth,
103
+ ["oauth_signature", sign(input)] as Param,
104
+ ];
105
+ const parts = signed
106
+ .map(([key, value]) => `${percentEncode(key)}="${percentEncode(value)}"`)
107
+ .join(", ");
108
+ return `OAuth ${parts}`;
109
+ }
110
+
111
+ /** The `oauth_*` parameters every signed request carries. */
112
+ export function baseOauthParams(options: {
113
+ consumerKey: string;
114
+ token?: string;
115
+ timestamp: number;
116
+ nonce: string;
117
+ }): Param[] {
118
+ const params: Param[] = [
119
+ ["oauth_consumer_key", options.consumerKey],
120
+ ["oauth_nonce", options.nonce],
121
+ ["oauth_signature_method", "HMAC-SHA1"],
122
+ ["oauth_timestamp", String(options.timestamp)],
123
+ ["oauth_version", "1.0"],
124
+ ];
125
+ if (options.token) params.push(["oauth_token", options.token]);
126
+ return params;
127
+ }
128
+
129
+ /**
130
+ * Parse a `key=value&…` response body. The token endpoints answer in this
131
+ * form rather than JSON.
132
+ */
133
+ export function parseFormBody(body: string): Record<string, string> {
134
+ const out: Record<string, string> = {};
135
+ for (const [key, value] of new URLSearchParams(body).entries()) {
136
+ out[key] = value;
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** Byte-order comparison, which is what the specification asks for. */
142
+ function compare(a: string, b: string): number {
143
+ return a < b ? -1 : a > b ? 1 : 0;
144
+ }
package/src/oidc.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The two documents an OpenID Connect provider publishes about itself: where
3
+ * its endpoints are, and which keys it signs with.
4
+ *
5
+ * Both are cached, and both are refetched on a rule rather than on demand.
6
+ * Keys rotate, so a cache that never refreshes locks an application out at
7
+ * the worst moment; a cache that refreshes whenever a token names an unknown
8
+ * key hands anyone a way to make this application hammer the provider. The
9
+ * middle is a rate limit.
10
+ */
11
+
12
+ import type { Jwk } from "./jwt.js";
13
+
14
+ /** The slice of the discovery document this reads. */
15
+ export interface OidcMetadata {
16
+ issuer: string;
17
+ authorization_endpoint: string;
18
+ token_endpoint: string;
19
+ jwks_uri: string;
20
+ userinfo_endpoint?: string;
21
+ id_token_signing_alg_values_supported?: string[];
22
+ code_challenge_methods_supported?: string[];
23
+ }
24
+
25
+ export interface RemoteOptions {
26
+ /** How long a fetched document is trusted, in milliseconds. Default 1 hour. */
27
+ ttlMs?: number;
28
+ /** Shortest interval between two key refetches. Default 5 minutes. */
29
+ minRefetchMs?: number;
30
+ /** Injected in tests. */
31
+ now?: () => number;
32
+ }
33
+
34
+ const DEFAULT_TTL_MS = 3_600_000;
35
+ const DEFAULT_MIN_REFETCH_MS = 300_000;
36
+
37
+ /**
38
+ * Reads and caches `/.well-known/openid-configuration`.
39
+ *
40
+ * The document's own `issuer` is checked against the configured one. They are
41
+ * allowed to differ in the specification's letter about as much as a passport
42
+ * is allowed to name someone else: a mismatch means the URL that was asked is
43
+ * not the authority that answered.
44
+ */
45
+ export class OidcDiscovery {
46
+ readonly #issuer: string;
47
+ readonly #ttlMs: number;
48
+ readonly #now: () => number;
49
+ #cached: { metadata: OidcMetadata; fetchedAt: number } | undefined;
50
+ #inFlight: Promise<OidcMetadata> | undefined;
51
+
52
+ constructor(issuer: string, options: RemoteOptions = {}) {
53
+ // A trailing slash changes the well-known URL and, on some providers,
54
+ // the issuer claim it must be compared against.
55
+ this.#issuer = issuer.replace(/\/+$/, "");
56
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
57
+ this.#now = options.now ?? Date.now;
58
+ }
59
+
60
+ get issuer(): string {
61
+ return this.#issuer;
62
+ }
63
+
64
+ get url(): string {
65
+ return `${this.#issuer}/.well-known/openid-configuration`;
66
+ }
67
+
68
+ async metadata(): Promise<OidcMetadata> {
69
+ const cached = this.#cached;
70
+ if (cached && this.#now() - cached.fetchedAt < this.#ttlMs) {
71
+ return cached.metadata;
72
+ }
73
+ // One fetch at a time: a burst of sign-ins on a cold cache would
74
+ // otherwise open one request per sign-in.
75
+ this.#inFlight ??= this.#fetch().finally(() => {
76
+ this.#inFlight = undefined;
77
+ });
78
+ return this.#inFlight;
79
+ }
80
+
81
+ async #fetch(): Promise<OidcMetadata> {
82
+ const response = await fetch(this.url, {
83
+ headers: { Accept: "application/json" },
84
+ });
85
+ if (!response.ok) {
86
+ throw new Error(
87
+ `[transit] OpenID Connect discovery failed for '${this.#issuer}' (HTTP ${response.status})`,
88
+ );
89
+ }
90
+ const body = (await response.json()) as Partial<OidcMetadata>;
91
+ const metadata = assertMetadata(body, this.#issuer);
92
+ this.#cached = { metadata, fetchedAt: this.#now() };
93
+ return metadata;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Reads and caches a provider's signing keys, and knows when it is allowed to
99
+ * look again.
100
+ */
101
+ export class JwksCache {
102
+ readonly #minRefetchMs: number;
103
+ readonly #now: () => number;
104
+ #keys: Jwk[] = [];
105
+ #fetchedAt = 0;
106
+ #inFlight: Promise<Jwk[]> | undefined;
107
+
108
+ constructor(options: RemoteOptions = {}) {
109
+ this.#minRefetchMs = options.minRefetchMs ?? DEFAULT_MIN_REFETCH_MS;
110
+ this.#now = options.now ?? Date.now;
111
+ }
112
+
113
+ /**
114
+ * The key a token names.
115
+ *
116
+ * A `kid` that is not held triggers at most one refetch per interval — that
117
+ * is how a rotated key is picked up, and how a stream of tokens naming
118
+ * invented keys stays cheap.
119
+ */
120
+ async key(uri: string, kid: string | undefined): Promise<Jwk> {
121
+ const found = this.#find(kid);
122
+ if (found) return found;
123
+
124
+ if (
125
+ this.#keys.length > 0 &&
126
+ this.#now() - this.#fetchedAt < this.#minRefetchMs
127
+ ) {
128
+ throw new Error(
129
+ `[transit] id_token names a signing key ('${kid ?? "no kid"}') the provider did not publish`,
130
+ );
131
+ }
132
+
133
+ await this.#fetch(uri);
134
+ const refreshed = this.#find(kid);
135
+ if (!refreshed) {
136
+ throw new Error(
137
+ `[transit] id_token names a signing key ('${kid ?? "no kid"}') the provider did not publish`,
138
+ );
139
+ }
140
+ return refreshed;
141
+ }
142
+
143
+ #find(kid: string | undefined): Jwk | undefined {
144
+ const usable = this.#keys.filter(
145
+ (jwk) => jwk.use === undefined || jwk.use === "sig",
146
+ );
147
+ if (kid !== undefined) return usable.find((jwk) => jwk.kid === kid);
148
+ // No `kid` is only unambiguous when the provider publishes one key.
149
+ return usable.length === 1 ? usable[0] : undefined;
150
+ }
151
+
152
+ async #fetch(uri: string): Promise<Jwk[]> {
153
+ this.#inFlight ??= this.#fetchOnce(uri).finally(() => {
154
+ this.#inFlight = undefined;
155
+ });
156
+ return this.#inFlight;
157
+ }
158
+
159
+ async #fetchOnce(uri: string): Promise<Jwk[]> {
160
+ const response = await fetch(uri, {
161
+ headers: { Accept: "application/json" },
162
+ });
163
+ if (!response.ok) {
164
+ throw new Error(
165
+ `[transit] fetching the provider's signing keys failed (HTTP ${response.status})`,
166
+ );
167
+ }
168
+ const body = (await response.json()) as { keys?: unknown };
169
+ if (!Array.isArray(body.keys)) {
170
+ throw new Error("[transit] the provider's JWKS has no `keys` array");
171
+ }
172
+ this.#keys = body.keys.filter(isJwk);
173
+ this.#fetchedAt = this.#now();
174
+ return this.#keys;
175
+ }
176
+ }
177
+
178
+ function isJwk(value: unknown): value is Jwk {
179
+ return (
180
+ typeof value === "object" &&
181
+ value !== null &&
182
+ typeof Reflect.get(value, "kty") === "string"
183
+ );
184
+ }
185
+
186
+ function assertMetadata(
187
+ body: Partial<OidcMetadata>,
188
+ expectedIssuer: string,
189
+ ): OidcMetadata {
190
+ for (const field of [
191
+ "issuer",
192
+ "authorization_endpoint",
193
+ "token_endpoint",
194
+ "jwks_uri",
195
+ ] as const) {
196
+ if (typeof body[field] !== "string" || body[field] === "") {
197
+ throw new Error(
198
+ `[transit] the discovery document of '${expectedIssuer}' has no ${field}`,
199
+ );
200
+ }
201
+ }
202
+ if (body.issuer !== expectedIssuer) {
203
+ throw new Error(
204
+ `[transit] discovery answered for issuer '${body.issuer}', not '${expectedIssuer}' — the URL asked is not the authority that replied`,
205
+ );
206
+ }
207
+ return body as OidcMetadata;
208
+ }
package/src/quasar.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Resolving a Redis connection by name, from `@c9up/quasar`.
3
+ *
4
+ * Transit does not depend on quasar: it is an optional peer, and this module
5
+ * never imports it statically — the specifier is built at runtime so the
6
+ * TypeScript build stays free of it too.
7
+ */
8
+
9
+ interface ConnectionSource {
10
+ connection(name?: string): unknown;
11
+ }
12
+
13
+ function isConnectionSource(value: unknown): value is ConnectionSource {
14
+ return (
15
+ typeof value === "object" &&
16
+ value !== null &&
17
+ typeof Reflect.get(value, "connection") === "function"
18
+ );
19
+ }
20
+
21
+ /**
22
+ * The named connection, checked for the commands the caller needs before it is
23
+ * handed over — a connection missing one would fail on the first sign-in, far
24
+ * from the cause.
25
+ */
26
+ export async function quasarConnection<T>(
27
+ name: string | undefined,
28
+ required: readonly string[],
29
+ what: string,
30
+ ): Promise<T> {
31
+ const specifier = "@c9up/quasar/services/main";
32
+ let loaded: unknown;
33
+ try {
34
+ loaded = await import(/* @vite-ignore */ specifier);
35
+ } catch (cause) {
36
+ throw new Error(
37
+ `[transit] naming a Redis connection needs @c9up/quasar, which is not installed.\n pnpm add @c9up/quasar`,
38
+ { cause },
39
+ );
40
+ }
41
+
42
+ const manager = isConnectionSource(loaded)
43
+ ? loaded
44
+ : Reflect.get(Object(loaded), "default");
45
+ if (!isConnectionSource(manager)) {
46
+ throw new Error(
47
+ "[transit] @c9up/quasar/services/main did not expose a connection() manager",
48
+ );
49
+ }
50
+
51
+ const connection = manager.connection(name);
52
+ const missing = required.filter(
53
+ (command) =>
54
+ typeof connection !== "object" ||
55
+ connection === null ||
56
+ typeof Reflect.get(connection, command) !== "function",
57
+ );
58
+ if (missing.length > 0) {
59
+ throw new Error(
60
+ `[transit] the quasar connection${name ? ` '${name}'` : ""} is missing ${missing.join(", ")}, which ${what} issues`,
61
+ );
62
+ }
63
+ return connection as T;
64
+ }
package/src/replay.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Refusing an assertion that has already been used.
3
+ *
4
+ * A SAML assertion is a bearer token: whoever holds it is who it says they
5
+ * are, until it expires. A signature does not change that, and neither does
6
+ * any condition on it — a response captured once and posted twice is valid
7
+ * both times. Remembering the id until the assertion expires is what makes the
8
+ * second time fail.
9
+ *
10
+ * The window is short, so what is stored stays small: an id is forgotten as
11
+ * soon as the assertion it names could no longer have been accepted anyway.
12
+ */
13
+
14
+ import { quasarConnection } from "./quasar.js";
15
+
16
+ export interface AssertionReplayStore {
17
+ /**
18
+ * Remember `assertionId` until `expiresAt` (epoch seconds).
19
+ *
20
+ * Answers `true` when this is the first time, `false` when the assertion
21
+ * has already been used. Implementations must decide that atomically:
22
+ * checking and then storing lets two concurrent posts of the same response
23
+ * both win.
24
+ */
25
+ remember(assertionId: string, expiresAt: number): Promise<boolean>;
26
+ }
27
+
28
+ /**
29
+ * Remembers in this process's memory.
30
+ *
31
+ * Correct while the application runs in one process, and only then: two
32
+ * replicas each keep their own table, so the same assertion is accepted once
33
+ * per replica. Reach for the Redis store the moment a second one exists.
34
+ */
35
+ export class MemoryAssertionReplayStore implements AssertionReplayStore {
36
+ readonly #seen = new Map<string, number>();
37
+
38
+ async remember(assertionId: string, expiresAt: number): Promise<boolean> {
39
+ const now = Math.floor(Date.now() / 1000);
40
+ // An opportunistic sweep, bounded by the map's own size: the window is
41
+ // minutes, so nothing accumulates.
42
+ for (const [id, expiry] of this.#seen) {
43
+ if (expiry <= now) this.#seen.delete(id);
44
+ }
45
+ if (this.#seen.has(assertionId)) return false;
46
+ this.#seen.set(assertionId, expiresAt);
47
+ return true;
48
+ }
49
+ }
50
+
51
+ /** The commands the Redis store issues. */
52
+ export interface ReplayRedisClient {
53
+ /** `SET key value EX <ttl> NX` — answers null when the key already exists. */
54
+ set(key: string, value: string, ...args: unknown[]): Promise<unknown>;
55
+ }
56
+
57
+ export type ReplayRedisResolver =
58
+ | ReplayRedisClient
59
+ | (() => ReplayRedisClient | Promise<ReplayRedisClient>);
60
+
61
+ export interface RedisReplayOptions {
62
+ /** Key prefix. Default `"transit:saml:assertion:"`. */
63
+ prefix?: string;
64
+ }
65
+
66
+ /**
67
+ * Remembers in Redis, so every replica refuses the assertion the first one
68
+ * accepted.
69
+ *
70
+ * `SET … NX` decides in one round trip, atomically on the server, which is
71
+ * what stops two concurrent posts of the same response from both winning.
72
+ */
73
+ export class RedisAssertionReplayStore implements AssertionReplayStore {
74
+ readonly #source: ReplayRedisResolver;
75
+ readonly #prefix: string;
76
+ #resolved: Promise<ReplayRedisClient> | undefined;
77
+
78
+ constructor(client: ReplayRedisResolver, options: RedisReplayOptions = {}) {
79
+ this.#source = client;
80
+ this.#prefix = options.prefix ?? "transit:saml:assertion:";
81
+ }
82
+
83
+ async remember(assertionId: string, expiresAt: number): Promise<boolean> {
84
+ const client = await this.#client();
85
+ // The key outlives the assertion by a minute, so an assertion accepted
86
+ // at the edge of its window cannot be replayed just after it.
87
+ const ttl = Math.max(
88
+ 1,
89
+ Math.ceil(expiresAt - Math.floor(Date.now() / 1000)) + 60,
90
+ );
91
+ const stored = await client.set(
92
+ `${this.#prefix}${assertionId}`,
93
+ "1",
94
+ "EX",
95
+ ttl,
96
+ "NX",
97
+ );
98
+ return stored !== null && stored !== undefined;
99
+ }
100
+
101
+ #client(): Promise<ReplayRedisClient> {
102
+ this.#resolved ??= Promise.resolve(
103
+ typeof this.#source === "function" ? this.#source() : this.#source,
104
+ );
105
+ return this.#resolved;
106
+ }
107
+ }
108
+
109
+ /** The replay stores a config names. */
110
+ export const replayStores = {
111
+ /** In this process's memory. Bounds nothing across replicas. */
112
+ memory(): () => AssertionReplayStore {
113
+ return () => new MemoryAssertionReplayStore();
114
+ },
115
+
116
+ /**
117
+ * Redis. `connection` takes a client, a function answering one, or the NAME
118
+ * of a `@c9up/quasar` connection.
119
+ */
120
+ redis(options: {
121
+ connection: ReplayRedisResolver | string;
122
+ prefix?: string;
123
+ }): () => AssertionReplayStore {
124
+ const client: ReplayRedisResolver =
125
+ typeof options.connection === "string"
126
+ ? () =>
127
+ quasarConnection<ReplayRedisClient>(
128
+ options.connection as string,
129
+ ["set"],
130
+ "the assertion replay store",
131
+ )
132
+ : options.connection;
133
+ return () =>
134
+ new RedisAssertionReplayStore(client, { prefix: options.prefix });
135
+ },
136
+ };