@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
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
|
+
};
|