@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/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@c9up/transit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Transit — federated sign-in for the Ream framework: SAML 2.0, LDAP, OpenID Connect, OAuth1, OAuth2",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./config": {
|
|
15
|
+
"types": "./dist/config.d.ts",
|
|
16
|
+
"import": "./dist/config.js"
|
|
17
|
+
},
|
|
18
|
+
"./provider": {
|
|
19
|
+
"types": "./dist/TransitProvider.d.ts",
|
|
20
|
+
"import": "./dist/TransitProvider.js"
|
|
21
|
+
},
|
|
22
|
+
"./configure": {
|
|
23
|
+
"types": "./dist/configure.d.ts",
|
|
24
|
+
"import": "./dist/configure.js"
|
|
25
|
+
},
|
|
26
|
+
"./services/main": {
|
|
27
|
+
"types": "./dist/services/main.d.ts",
|
|
28
|
+
"import": "./dist/services/main.js"
|
|
29
|
+
},
|
|
30
|
+
"./testing": {
|
|
31
|
+
"types": "./dist/testing/FakeTransit.d.ts",
|
|
32
|
+
"import": "./dist/testing/FakeTransit.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.4.10",
|
|
37
|
+
"@types/node": "^22.19.15",
|
|
38
|
+
"@vitest/coverage-v8": "^4.1.5",
|
|
39
|
+
"typescript": "^6.0.2",
|
|
40
|
+
"vitest": "^4.1.2"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@c9up/quasar": "^0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@c9up/quasar": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22.0.0"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"src",
|
|
55
|
+
"dist",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
},
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "git+https://github.com/C9up/transit.git"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"build": "tsc -p tsconfig.build.json",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"lint": "biome check src/",
|
|
70
|
+
"test:coverage": "vitest run --coverage",
|
|
71
|
+
"typecheck": "tsc --noEmit"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth 1.0a three-legged flow.
|
|
3
|
+
*
|
|
4
|
+
* A different protocol from OAuth2, not a variation on it. Two things follow
|
|
5
|
+
* from that, and both shape this class:
|
|
6
|
+
*
|
|
7
|
+
* - The redirect cannot be built offline. The application first asks the
|
|
8
|
+
* provider for a request token, and only then knows where to send the
|
|
9
|
+
* user — so `redirectUrl()` throws here and `begin()` is the way in.
|
|
10
|
+
* - There is no bearer token. Every call is SIGNED, request token included,
|
|
11
|
+
* which is what `oauth1.ts` exists for.
|
|
12
|
+
*
|
|
13
|
+
* The CSRF check is the same idea as OAuth2's `state` under another name: the
|
|
14
|
+
* provider hands back the request token it was given, and it has to match the
|
|
15
|
+
* one this application issued. So it maps onto `callback(code, state,
|
|
16
|
+
* expectedState, secret)` without inventing a second shape — the verifier is
|
|
17
|
+
* the code, the returned token is the state, and the request-token secret is
|
|
18
|
+
* the secret kept from `begin()`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
authorizationHeader,
|
|
23
|
+
baseOauthParams,
|
|
24
|
+
nonce,
|
|
25
|
+
type Param,
|
|
26
|
+
parseFormBody,
|
|
27
|
+
} from "./oauth1.js";
|
|
28
|
+
import type {
|
|
29
|
+
OAuthConfig,
|
|
30
|
+
OAuthToken,
|
|
31
|
+
RedirectRequest,
|
|
32
|
+
TransitDriver,
|
|
33
|
+
TransitUser,
|
|
34
|
+
} from "./types.js";
|
|
35
|
+
import { assertOAuthState } from "./types.js";
|
|
36
|
+
|
|
37
|
+
export abstract class Oauth1Driver implements TransitDriver {
|
|
38
|
+
/** Named in errors, so a failure says which provider refused. */
|
|
39
|
+
protected abstract readonly provider: string;
|
|
40
|
+
/** Where a request token is obtained, before anything else can happen. */
|
|
41
|
+
protected abstract readonly requestTokenUrl: string;
|
|
42
|
+
/** Where the user approves. */
|
|
43
|
+
protected abstract readonly authorizeUrl: string;
|
|
44
|
+
/** Where the request token is traded for an access token. */
|
|
45
|
+
protected abstract readonly accessTokenUrl: string;
|
|
46
|
+
/** Where the profile is read. */
|
|
47
|
+
protected abstract readonly userInfoUrl: string;
|
|
48
|
+
|
|
49
|
+
constructor(protected readonly config: OAuthConfig) {}
|
|
50
|
+
|
|
51
|
+
/** Extra query parameters this provider wants on the profile request. */
|
|
52
|
+
protected userInfoParams(): Record<string, string> {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Turn this provider's profile payload into the shape Warden hands back. */
|
|
57
|
+
protected abstract mapUser(raw: Record<string, unknown>): TransitUser;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* OAuth1 has no offline redirect: the URL carries a request token that only
|
|
61
|
+
* the provider can issue. Saying so is more useful than returning a URL
|
|
62
|
+
* that would be refused.
|
|
63
|
+
*/
|
|
64
|
+
redirectUrl(): string {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[transit] ${this.provider} uses OAuth1, whose redirect needs a request token from the provider. Use begin() instead of redirect().`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Ask for a request token, and answer with where to send the user plus the
|
|
72
|
+
* two values to keep: the token, which the callback has to match, and its
|
|
73
|
+
* secret, without which the exchange cannot be signed.
|
|
74
|
+
*/
|
|
75
|
+
async begin(): Promise<RedirectRequest> {
|
|
76
|
+
const oauth = this.#oauth();
|
|
77
|
+
oauth.push(["oauth_callback", this.config.callbackUrl]);
|
|
78
|
+
|
|
79
|
+
const body = await this.#post(this.requestTokenUrl, oauth);
|
|
80
|
+
if (body.oauth_callback_confirmed !== "true") {
|
|
81
|
+
// The provider rejects a callback URL it does not have registered,
|
|
82
|
+
// and says so here rather than at the end of the flow.
|
|
83
|
+
throw new Error(
|
|
84
|
+
`${this.provider} did not confirm the callback URL — check that '${this.config.callbackUrl}' is registered on the application.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const token = body.oauth_token;
|
|
88
|
+
const secret = body.oauth_token_secret;
|
|
89
|
+
if (!token || !secret) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${this.provider} OAuth: no request token in the response`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
url: `${this.authorizeUrl}?oauth_token=${encodeURIComponent(token)}`,
|
|
97
|
+
state: token,
|
|
98
|
+
secret,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `code` is the `oauth_verifier`, `state` the `oauth_token` the provider
|
|
104
|
+
* sent back, `expectedState` the one issued at `begin()`, and `secret` its
|
|
105
|
+
* secret.
|
|
106
|
+
*/
|
|
107
|
+
async callback(
|
|
108
|
+
code: string,
|
|
109
|
+
state?: string,
|
|
110
|
+
expectedState?: string,
|
|
111
|
+
secret?: string,
|
|
112
|
+
): Promise<{ user: TransitUser; token: OAuthToken }> {
|
|
113
|
+
assertOAuthState(state, expectedState);
|
|
114
|
+
if (!secret) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`[transit] ${this.provider} needs the request-token secret from begin() — store it with the token and pass it back here.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const oauth = this.#oauth(expectedState);
|
|
121
|
+
oauth.push(["oauth_verifier", code]);
|
|
122
|
+
const body = await this.#post(this.accessTokenUrl, oauth, secret);
|
|
123
|
+
|
|
124
|
+
const accessToken = body.oauth_token;
|
|
125
|
+
const tokenSecret = body.oauth_token_secret;
|
|
126
|
+
if (!accessToken || !tokenSecret) {
|
|
127
|
+
throw new Error(`${this.provider} OAuth: no access token in response`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const user = await this.fetchUser(accessToken, tokenSecret);
|
|
131
|
+
return { user, token: { accessToken, tokenSecret } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read the profile behind a token already held. Unlike OAuth2 this needs
|
|
136
|
+
* the secret too — an OAuth1 token alone signs nothing.
|
|
137
|
+
*/
|
|
138
|
+
async userFromToken(
|
|
139
|
+
accessToken: string,
|
|
140
|
+
tokenSecret?: string,
|
|
141
|
+
): Promise<TransitUser> {
|
|
142
|
+
if (!tokenSecret) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`[transit] ${this.provider} needs the token secret as well: an OAuth1 access token cannot sign a request on its own.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return this.fetchUser(accessToken, tokenSecret);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Read the profile with a signed GET. */
|
|
151
|
+
protected async fetchUser(
|
|
152
|
+
accessToken: string,
|
|
153
|
+
tokenSecret: string,
|
|
154
|
+
): Promise<TransitUser> {
|
|
155
|
+
const params = this.userInfoParams();
|
|
156
|
+
const query = new URLSearchParams(params).toString();
|
|
157
|
+
const url = query ? `${this.userInfoUrl}?${query}` : this.userInfoUrl;
|
|
158
|
+
|
|
159
|
+
const response = await fetch(url, {
|
|
160
|
+
headers: {
|
|
161
|
+
Authorization: authorizationHeader({
|
|
162
|
+
method: "GET",
|
|
163
|
+
url,
|
|
164
|
+
oauth: this.#oauth(accessToken),
|
|
165
|
+
consumerSecret: this.config.clientSecret,
|
|
166
|
+
tokenSecret,
|
|
167
|
+
}),
|
|
168
|
+
Accept: "application/json",
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${this.provider} profile request failed (HTTP ${response.status})`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return this.mapUser((await response.json()) as Record<string, unknown>);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The `oauth_*` set for one request, stamped now. */
|
|
180
|
+
#oauth(token?: string): Param[] {
|
|
181
|
+
return baseOauthParams({
|
|
182
|
+
consumerKey: this.config.clientId,
|
|
183
|
+
token,
|
|
184
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
185
|
+
nonce: nonce(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A signed POST to one of the token endpoints, whose body is form-encoded. */
|
|
190
|
+
async #post(
|
|
191
|
+
url: string,
|
|
192
|
+
oauth: Param[],
|
|
193
|
+
tokenSecret?: string,
|
|
194
|
+
): Promise<Record<string, string>> {
|
|
195
|
+
const response = await fetch(url, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
Authorization: authorizationHeader({
|
|
199
|
+
method: "POST",
|
|
200
|
+
url,
|
|
201
|
+
oauth,
|
|
202
|
+
consumerSecret: this.config.clientSecret,
|
|
203
|
+
tokenSecret,
|
|
204
|
+
}),
|
|
205
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
if (!response.ok) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`${this.provider} OAuth token exchange failed (HTTP ${response.status})`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
// These endpoints answer `key=value&…`, not JSON.
|
|
214
|
+
return parseFormBody(await response.text());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The half of an OAuth2 sign-in every provider shares.
|
|
3
|
+
*
|
|
4
|
+
* Each provider differs in three places — where you send the user, where the
|
|
5
|
+
* code is exchanged, and what the profile payload is called — and agrees on
|
|
6
|
+
* everything else. This holds the everything else, so a driver is the three
|
|
7
|
+
* URLs, the default scopes, and the mapping from that provider's payload to
|
|
8
|
+
* {@link TransitUser}.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here is stateful: the `state` and, where a provider requires it, the
|
|
11
|
+
* PKCE verifier are passed in and handed back by the caller. Warden does not
|
|
12
|
+
* own your session, so it cannot store them for you — and that is also what
|
|
13
|
+
* lets one driver serve several concurrent sign-ins.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
17
|
+
import type {
|
|
18
|
+
OAuthConfig,
|
|
19
|
+
OAuthToken,
|
|
20
|
+
RedirectRequest,
|
|
21
|
+
TransitDriver,
|
|
22
|
+
TransitUser,
|
|
23
|
+
} from "./types.js";
|
|
24
|
+
import { assertOAuthState } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/** A provider's token response, before it is narrowed. */
|
|
27
|
+
type TokenResponse = Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mint a PKCE code verifier.
|
|
31
|
+
*
|
|
32
|
+
* Store it next to the `state` — the same place, with the same lifetime — and
|
|
33
|
+
* hand it back to `callback()`. A provider that requires PKCE binds the
|
|
34
|
+
* authorization code to this value, so a code intercepted on its way back is
|
|
35
|
+
* useless to whoever intercepted it.
|
|
36
|
+
*/
|
|
37
|
+
export function createCodeVerifier(): string {
|
|
38
|
+
// 32 bytes of entropy, base64url — 43 characters, the shortest length
|
|
39
|
+
// RFC 7636 allows, and the length every provider accepts.
|
|
40
|
+
return randomBytes(32).toString("base64url");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export abstract class Oauth2Driver implements TransitDriver {
|
|
44
|
+
/** Named in errors, so a failure says which provider refused. */
|
|
45
|
+
protected abstract readonly provider: string;
|
|
46
|
+
/** Where the user is sent to approve. */
|
|
47
|
+
protected abstract readonly authorizeUrl: string;
|
|
48
|
+
/** Where the authorization code is exchanged for a token. */
|
|
49
|
+
protected abstract readonly accessTokenUrl: string;
|
|
50
|
+
/** Where the profile is read. */
|
|
51
|
+
protected abstract readonly userInfoUrl: string;
|
|
52
|
+
/** Scopes requested when the config names none. */
|
|
53
|
+
protected abstract readonly defaultScopes: readonly string[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Providers that require PKCE. The redirect then refuses to build without
|
|
57
|
+
* a verifier rather than sending the user to a URL the provider rejects.
|
|
58
|
+
*/
|
|
59
|
+
protected readonly requiresPkce: boolean = false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send the client credentials as HTTP Basic on the token request instead of
|
|
63
|
+
* in the body. A handful of providers accept nothing else.
|
|
64
|
+
*/
|
|
65
|
+
protected readonly tokenAuth: "body" | "basic" = "body";
|
|
66
|
+
|
|
67
|
+
constructor(protected readonly config: OAuthConfig) {}
|
|
68
|
+
|
|
69
|
+
/** Extra parameters this provider wants on the authorize URL. */
|
|
70
|
+
protected authorizeParams(): Record<string, string> {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Extra query parameters this provider wants on the profile request. */
|
|
75
|
+
protected userInfoParams(): Record<string, string> {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Turn this provider's profile payload into the shape Warden hands back. */
|
|
80
|
+
protected abstract mapUser(raw: Record<string, unknown>): TransitUser;
|
|
81
|
+
|
|
82
|
+
scopes(): string[] {
|
|
83
|
+
return [...(this.config.scopes ?? this.defaultScopes)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
redirectUrl(state?: string, secret?: string): string {
|
|
87
|
+
const params = new URLSearchParams({
|
|
88
|
+
client_id: this.config.clientId,
|
|
89
|
+
redirect_uri: this.config.callbackUrl,
|
|
90
|
+
response_type: "code",
|
|
91
|
+
scope: this.scopes().join(" "),
|
|
92
|
+
...this.authorizeParams(),
|
|
93
|
+
...this.config.authorizeParams,
|
|
94
|
+
...(state ? { state } : {}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (this.requiresPkce) {
|
|
98
|
+
if (!secret) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`[transit] ${this.provider} requires PKCE: pass the code verifier from createCodeVerifier() to redirectUrl(), store it with the state, and hand it back to callback().`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
params.set("code_challenge", challengeFor(secret));
|
|
104
|
+
params.set("code_challenge_method", "S256");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return `${this.authorizeUrl}?${params}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Where to send the user, plus what has to be kept until they come back.
|
|
112
|
+
* The state is minted here when the caller has no reason to choose one,
|
|
113
|
+
* and a provider requiring PKCE gets its verifier without the caller
|
|
114
|
+
* having to know it needed one.
|
|
115
|
+
*/
|
|
116
|
+
async begin(state: string = randomUUID()): Promise<RedirectRequest> {
|
|
117
|
+
if (!this.requiresPkce) return { url: this.redirectUrl(state), state };
|
|
118
|
+
const secret = createCodeVerifier();
|
|
119
|
+
return { url: this.redirectUrl(state, secret), state, secret };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async callback(
|
|
123
|
+
code: string,
|
|
124
|
+
state?: string,
|
|
125
|
+
expectedState?: string,
|
|
126
|
+
secret?: string,
|
|
127
|
+
): Promise<{ user: TransitUser; token: OAuthToken }> {
|
|
128
|
+
assertOAuthState(state, expectedState);
|
|
129
|
+
if (this.requiresPkce && !secret) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`[transit] ${this.provider} requires PKCE: pass back the code verifier you stored at redirect time.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const tokens = await this.exchange(code, secret);
|
|
135
|
+
const accessToken = String(tokens.access_token);
|
|
136
|
+
const user = await this.fetchUser(accessToken);
|
|
137
|
+
return { user, token: readToken(tokens, accessToken) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read the profile behind a token you already hold, with no code to
|
|
142
|
+
* exchange — a refreshed token, or one a mobile client obtained itself.
|
|
143
|
+
*/
|
|
144
|
+
async userFromToken(accessToken: string): Promise<TransitUser> {
|
|
145
|
+
return this.fetchUser(accessToken);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Trade the authorization code for an access token. */
|
|
149
|
+
protected async exchange(
|
|
150
|
+
code: string,
|
|
151
|
+
secret?: string,
|
|
152
|
+
): Promise<TokenResponse> {
|
|
153
|
+
const body = new URLSearchParams({
|
|
154
|
+
code,
|
|
155
|
+
redirect_uri: this.config.callbackUrl,
|
|
156
|
+
grant_type: "authorization_code",
|
|
157
|
+
...(secret ? { code_verifier: secret } : {}),
|
|
158
|
+
});
|
|
159
|
+
const headers: Record<string, string> = {
|
|
160
|
+
Accept: "application/json",
|
|
161
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (this.tokenAuth === "basic") {
|
|
165
|
+
const credentials = Buffer.from(
|
|
166
|
+
`${this.config.clientId}:${this.config.clientSecret}`,
|
|
167
|
+
).toString("base64");
|
|
168
|
+
headers.Authorization = `Basic ${credentials}`;
|
|
169
|
+
// Sending them twice is what makes some providers reject the request
|
|
170
|
+
// outright, so the body carries them only in the other mode.
|
|
171
|
+
body.set("client_id", this.config.clientId);
|
|
172
|
+
} else {
|
|
173
|
+
body.set("client_id", this.config.clientId);
|
|
174
|
+
body.set("client_secret", this.config.clientSecret);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const response = await fetch(this.accessTokenUrl, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers,
|
|
180
|
+
body,
|
|
181
|
+
});
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`${this.provider} OAuth token exchange failed (HTTP ${response.status})`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const tokens = (await response.json()) as TokenResponse;
|
|
188
|
+
if (!tokens.access_token) {
|
|
189
|
+
throw new Error(`${this.provider} OAuth: no access_token in response`);
|
|
190
|
+
}
|
|
191
|
+
return tokens;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Read the profile. Providers needing a second call override this. */
|
|
195
|
+
protected async fetchUser(accessToken: string): Promise<TransitUser> {
|
|
196
|
+
const raw = await this.get(this.userInfoUrl, accessToken, {
|
|
197
|
+
...this.userInfoParams(),
|
|
198
|
+
});
|
|
199
|
+
return this.mapUser(raw);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** An authenticated GET returning a JSON array — some endpoints answer one. */
|
|
203
|
+
protected async getList(
|
|
204
|
+
url: string,
|
|
205
|
+
accessToken: string,
|
|
206
|
+
params: Record<string, string> = {},
|
|
207
|
+
): Promise<unknown[]> {
|
|
208
|
+
const body = await this.#requestJson(url, accessToken, params);
|
|
209
|
+
return Array.isArray(body) ? body : [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** An authenticated GET returning the decoded JSON body. */
|
|
213
|
+
protected async get(
|
|
214
|
+
url: string,
|
|
215
|
+
accessToken: string,
|
|
216
|
+
params: Record<string, string> = {},
|
|
217
|
+
): Promise<Record<string, unknown>> {
|
|
218
|
+
const body = await this.#requestJson(url, accessToken, params);
|
|
219
|
+
return typeof body === "object" && body !== null && !Array.isArray(body)
|
|
220
|
+
? (body as Record<string, unknown>)
|
|
221
|
+
: {};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The authenticated GET itself, before anything is assumed of the body. */
|
|
225
|
+
async #requestJson(
|
|
226
|
+
url: string,
|
|
227
|
+
accessToken: string,
|
|
228
|
+
params: Record<string, string> = {},
|
|
229
|
+
): Promise<unknown> {
|
|
230
|
+
const query = new URLSearchParams(params).toString();
|
|
231
|
+
const response = await fetch(query ? `${url}?${query}` : url, {
|
|
232
|
+
headers: {
|
|
233
|
+
Authorization: `Bearer ${accessToken}`,
|
|
234
|
+
Accept: "application/json",
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`${this.provider} profile request failed (HTTP ${response.status})`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return await response.json();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The S256 challenge a provider compares the verifier against. */
|
|
247
|
+
export function challengeFor(secret: string): string {
|
|
248
|
+
return createHash("sha256").update(secret).digest("base64url");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Narrow a provider's token payload to the fields Warden promises. */
|
|
252
|
+
function readToken(tokens: TokenResponse, accessToken: string): OAuthToken {
|
|
253
|
+
return {
|
|
254
|
+
accessToken,
|
|
255
|
+
...(typeof tokens.refresh_token === "string"
|
|
256
|
+
? { refreshToken: tokens.refresh_token }
|
|
257
|
+
: {}),
|
|
258
|
+
...(typeof tokens.expires_in === "number"
|
|
259
|
+
? { expiresIn: tokens.expires_in }
|
|
260
|
+
: {}),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Read a string field, or `undefined` when it is absent or not a string. */
|
|
265
|
+
export function str(
|
|
266
|
+
raw: Record<string, unknown>,
|
|
267
|
+
key: string,
|
|
268
|
+
): string | undefined {
|
|
269
|
+
const value = raw[key];
|
|
270
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
271
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The providers an application signs people in through, keyed by the names its
|
|
3
|
+
* config chose.
|
|
4
|
+
*
|
|
5
|
+
* const { url, state, secret } = await transit.begin('google')
|
|
6
|
+
* const { user } = await transit.callback('google', code, state, expected, secret)
|
|
7
|
+
*
|
|
8
|
+
* The key is the caller's: two entries may speak to the same provider with
|
|
9
|
+
* different credentials, and `use(name)` asks for the key, not the protocol.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
DirectoryDriver,
|
|
14
|
+
OAuthToken,
|
|
15
|
+
RedirectRequest,
|
|
16
|
+
TransitDriver,
|
|
17
|
+
TransitEntry,
|
|
18
|
+
TransitUser,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
import { isDirectory } from "./types.js";
|
|
21
|
+
|
|
22
|
+
export class TransitManager {
|
|
23
|
+
#drivers: Map<string, TransitEntry> = new Map();
|
|
24
|
+
|
|
25
|
+
/** The provider a browser is sent to, under `name`. */
|
|
26
|
+
use(name: string): TransitDriver {
|
|
27
|
+
const entry = this.#entry(name);
|
|
28
|
+
if (isDirectory(entry)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[transit] '${name}' is a directory: it verifies credentials rather than redirecting. Use authenticate('${name}', …).`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return entry;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The directory under `name`. */
|
|
37
|
+
directory(name: string): DirectoryDriver {
|
|
38
|
+
const entry = this.#entry(name);
|
|
39
|
+
if (!isDirectory(entry)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`[transit] '${name}' is a redirect provider, not a directory. Use begin('${name}').`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return entry;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Verify a login against the directory under `name`.
|
|
49
|
+
*
|
|
50
|
+
* No redirect and no state: the application already holds the credentials,
|
|
51
|
+
* and the directory is the authority that says whether they are right.
|
|
52
|
+
*/
|
|
53
|
+
async authenticate(
|
|
54
|
+
name: string,
|
|
55
|
+
username: string,
|
|
56
|
+
password: string,
|
|
57
|
+
): Promise<TransitUser> {
|
|
58
|
+
return this.directory(name).authenticate(username, password);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#entry(name: string): TransitEntry {
|
|
62
|
+
const driver = this.#drivers.get(name);
|
|
63
|
+
if (!driver)
|
|
64
|
+
throw new Error(
|
|
65
|
+
`OAuth driver '${name}' not registered. Available: ${[...this.#drivers.keys()].join(", ")}`,
|
|
66
|
+
);
|
|
67
|
+
return driver;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
register(name: string, driver: TransitEntry): void {
|
|
71
|
+
this.#drivers.set(name, driver);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where to send the user, plus what to keep until they come back.
|
|
76
|
+
*
|
|
77
|
+
* This is the path that works for every provider: it mints the state,
|
|
78
|
+
* fetches a request token where the protocol needs one, and returns the
|
|
79
|
+
* secret to store when there is one.
|
|
80
|
+
*/
|
|
81
|
+
async begin(name: string, state?: string): Promise<RedirectRequest> {
|
|
82
|
+
const driver = this.use(name);
|
|
83
|
+
if (typeof driver.begin !== "function") {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`[transit] The '${name}' driver does not support begin(); use redirect().`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return driver.begin(state);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Where to send the user, when the provider can say so offline. `secret`
|
|
93
|
+
* is only read by providers that require PKCE — mint one with
|
|
94
|
+
* `createCodeVerifier()` and store it beside the state.
|
|
95
|
+
*
|
|
96
|
+
* An OAuth1 provider cannot answer here; call {@link begin} instead.
|
|
97
|
+
*/
|
|
98
|
+
redirect(name: string, state?: string, secret?: string): string {
|
|
99
|
+
return this.use(name).redirectUrl(state, secret);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Handle the OAuth callback. Pass `state` (from the query string) and
|
|
104
|
+
* `expectedState` (from the session, stored at redirect time) for CSRF
|
|
105
|
+
* protection. Omitting `expectedState` logs a security warning.
|
|
106
|
+
*/
|
|
107
|
+
async callback(
|
|
108
|
+
name: string,
|
|
109
|
+
code: string,
|
|
110
|
+
state?: string,
|
|
111
|
+
expectedState?: string,
|
|
112
|
+
secret?: string,
|
|
113
|
+
): Promise<{ user: TransitUser; token: OAuthToken }> {
|
|
114
|
+
if (!expectedState) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`[transit] OAuth callback for '${name}' requires expectedState for CSRF protection. ` +
|
|
117
|
+
`Store the state from redirect() in the session and pass it here.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return this.use(name).callback(code, state, expectedState, secret);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Read the profile behind a token already held, through the named provider.
|
|
125
|
+
* Throws when that driver has no way to — rather than answering with a
|
|
126
|
+
* profile it did not fetch.
|
|
127
|
+
*/
|
|
128
|
+
async userFromToken(
|
|
129
|
+
name: string,
|
|
130
|
+
accessToken: string,
|
|
131
|
+
tokenSecret?: string,
|
|
132
|
+
): Promise<TransitUser> {
|
|
133
|
+
const driver = this.use(name);
|
|
134
|
+
if (typeof driver.userFromToken !== "function") {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`[transit] The '${name}' driver cannot read a profile from an existing token.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return driver.userFromToken(accessToken, tokenSecret);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get registeredDrivers(): string[] {
|
|
143
|
+
return [...this.#drivers.keys()];
|
|
144
|
+
}
|
|
145
|
+
}
|