@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/configure.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ream configure @c9up/transit` — wire federated sign-in in one command.
|
|
3
|
+
*
|
|
4
|
+
* The provider alone is not enough: it reads `config/transit.ts`, and a package
|
|
5
|
+
* registered without one falls back to a default that is rarely the one an
|
|
6
|
+
* application wants. Writing both together is what makes `ream add` mean
|
|
7
|
+
* installed AND working.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface Codemods {
|
|
11
|
+
addProvider(importPath: string): Promise<void>;
|
|
12
|
+
addEnvVars(vars: Record<string, string>): Promise<void>;
|
|
13
|
+
writeFile(
|
|
14
|
+
filePath: string,
|
|
15
|
+
content: string,
|
|
16
|
+
options?: { force?: boolean },
|
|
17
|
+
): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function configure(codemods: Codemods): Promise<void> {
|
|
21
|
+
// The config below reads these, so they are declared here. Writing the file
|
|
22
|
+
// without them leaves an application whose config asks the environment for
|
|
23
|
+
// something nothing ever put there.
|
|
24
|
+
await codemods.addEnvVars({
|
|
25
|
+
GOOGLE_CLIENT_ID: "",
|
|
26
|
+
GOOGLE_CLIENT_SECRET: "",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await codemods.addProvider("@c9up/transit/provider");
|
|
30
|
+
await codemods.writeFile(
|
|
31
|
+
"config/transit.ts",
|
|
32
|
+
`import { defineConfig, socials } from '@c9up/transit'
|
|
33
|
+
import env from '#start/env'
|
|
34
|
+
|
|
35
|
+
export default defineConfig({
|
|
36
|
+
// The key is yours: it is what \`transit.begin(name)\` asks for.
|
|
37
|
+
google: socials.google({
|
|
38
|
+
clientId: env.get('GOOGLE_CLIENT_ID', ''),
|
|
39
|
+
clientSecret: env.get('GOOGLE_CLIENT_SECRET', ''),
|
|
40
|
+
callbackUrl: 'http://localhost:3333/auth/google/callback',
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
// An OpenID Connect provider needs only its issuer:
|
|
44
|
+
// work: oidc({ issuer: env.get('OIDC_ISSUER'), clientId, clientSecret, callbackUrl }),
|
|
45
|
+
})`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign in with Apple.
|
|
3
|
+
*
|
|
4
|
+
* Apple speaks OpenID Connect, so most of this is the generic driver. Three
|
|
5
|
+
* things differ, and each is a place implementations get caught:
|
|
6
|
+
*
|
|
7
|
+
* - There is no client secret. Apple takes a short-lived JWT signed with the
|
|
8
|
+
* `.p8` private key from the developer account.
|
|
9
|
+
* - The callback arrives as a **POST**, not a redirect with a query string,
|
|
10
|
+
* because asking for a name or an address forces `response_mode=form_post`.
|
|
11
|
+
* - The name is sent **once**, in that first POST, and never again.
|
|
12
|
+
*
|
|
13
|
+
* Apple publishes no userinfo endpoint: everything there is comes from the
|
|
14
|
+
* id_token.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type AppleKey, appleClientSecret } from "../apple.js";
|
|
18
|
+
import type { TransitUser } from "../types.js";
|
|
19
|
+
import { OidcDriver } from "./OidcDriver.js";
|
|
20
|
+
|
|
21
|
+
export interface AppleConfig extends AppleKey {
|
|
22
|
+
callbackUrl: string;
|
|
23
|
+
/** Default `["name", "email"]`. */
|
|
24
|
+
scopes?: string[];
|
|
25
|
+
/** Extra parameters on the authorize URL. */
|
|
26
|
+
authorizeParams?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class AppleDriver extends OidcDriver {
|
|
30
|
+
constructor(config: AppleConfig) {
|
|
31
|
+
super({
|
|
32
|
+
issuer: "https://appleid.apple.com",
|
|
33
|
+
clientId: config.clientId,
|
|
34
|
+
clientSecret: () => appleClientSecret(config),
|
|
35
|
+
callbackUrl: config.callbackUrl,
|
|
36
|
+
scopes: config.scopes ?? ["name", "email"],
|
|
37
|
+
authorizeParams: {
|
|
38
|
+
// Apple refuses to send a name or an address back on a redirect,
|
|
39
|
+
// so the callback route has to accept a POST.
|
|
40
|
+
response_mode: "form_post",
|
|
41
|
+
...config.authorizeParams,
|
|
42
|
+
},
|
|
43
|
+
// Apple has no userinfo endpoint; asking would be one failed request
|
|
44
|
+
// per sign-in.
|
|
45
|
+
userinfo: false,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Apple sends `email_verified` and `is_private_email` as the strings
|
|
51
|
+
* `"true"` / `"false"`. Read as a boolean, a verified address reads as
|
|
52
|
+
* unverified — and an application that gates account linking on that would
|
|
53
|
+
* refuse every Apple sign-in.
|
|
54
|
+
*/
|
|
55
|
+
protected override mapUser(raw: Record<string, unknown>): TransitUser {
|
|
56
|
+
const user = super.mapUser(raw);
|
|
57
|
+
if (user.email === "") return user;
|
|
58
|
+
return {
|
|
59
|
+
...user,
|
|
60
|
+
emailVerificationState: appleFlag(raw.email_verified)
|
|
61
|
+
? "verified"
|
|
62
|
+
: "unverified",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function appleFlag(value: unknown): boolean {
|
|
68
|
+
return value === true || value === "true";
|
|
69
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord OAuth2 driver.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Oauth2Driver } from "../Oauth2Driver.js";
|
|
6
|
+
import type { TransitUser } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export class DiscordDriver extends Oauth2Driver {
|
|
9
|
+
protected readonly provider = "Discord";
|
|
10
|
+
protected readonly authorizeUrl = "https://discord.com/oauth2/authorize";
|
|
11
|
+
protected readonly accessTokenUrl = "https://discord.com/api/oauth2/token";
|
|
12
|
+
protected readonly userInfoUrl = "https://discord.com/api/users/@me";
|
|
13
|
+
/** `email` is what makes the address present at all; `identify` is the rest. */
|
|
14
|
+
protected readonly defaultScopes = ["identify", "email"] as const;
|
|
15
|
+
|
|
16
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
17
|
+
const id = String(raw.id ?? "");
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
email: String(raw.email ?? ""),
|
|
21
|
+
name: String(raw.global_name ?? raw.username ?? ""),
|
|
22
|
+
nickName: typeof raw.username === "string" ? raw.username : undefined,
|
|
23
|
+
avatarUrl: avatarUrl(id, raw),
|
|
24
|
+
// Discord reports it only when the `email` scope was granted.
|
|
25
|
+
emailVerificationState:
|
|
26
|
+
"verified" in raw
|
|
27
|
+
? raw.verified === true
|
|
28
|
+
? "verified"
|
|
29
|
+
: "unverified"
|
|
30
|
+
: "unsupported",
|
|
31
|
+
raw,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Discord serves avatars from a CDN path built from the id and the hash, and
|
|
38
|
+
* animated ones only as `.gif`. An account with no avatar has a default one
|
|
39
|
+
* derived from its id.
|
|
40
|
+
*/
|
|
41
|
+
function avatarUrl(id: string, raw: Record<string, unknown>): string {
|
|
42
|
+
const hash = raw.avatar;
|
|
43
|
+
if (typeof hash !== "string" || hash === "") {
|
|
44
|
+
// Post-migration accounts (no discriminator) index the defaults by id.
|
|
45
|
+
const index = (BigInt(id || "0") >> 22n) % 6n;
|
|
46
|
+
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
|
47
|
+
}
|
|
48
|
+
const extension = hash.startsWith("a_") ? "gif" : "png";
|
|
49
|
+
return `https://cdn.discordapp.com/avatars/${id}/${hash}.${extension}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Facebook OAuth2 driver.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Oauth2Driver } from "../Oauth2Driver.js";
|
|
6
|
+
import type { TransitUser } from "../types.js";
|
|
7
|
+
|
|
8
|
+
/** The Graph API returns nothing but the id unless the fields are asked for. */
|
|
9
|
+
const USER_FIELDS = [
|
|
10
|
+
"name",
|
|
11
|
+
"first_name",
|
|
12
|
+
"last_name",
|
|
13
|
+
"link",
|
|
14
|
+
"email",
|
|
15
|
+
"picture.width(400).height(400)",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export class FacebookDriver extends Oauth2Driver {
|
|
19
|
+
protected readonly provider = "Facebook";
|
|
20
|
+
protected readonly authorizeUrl =
|
|
21
|
+
"https://www.facebook.com/v21.0/dialog/oauth";
|
|
22
|
+
protected readonly accessTokenUrl =
|
|
23
|
+
"https://graph.facebook.com/v21.0/oauth/access_token";
|
|
24
|
+
protected readonly userInfoUrl = "https://graph.facebook.com/v21.0/me";
|
|
25
|
+
protected readonly defaultScopes = ["email"] as const;
|
|
26
|
+
|
|
27
|
+
protected override userInfoParams(): Record<string, string> {
|
|
28
|
+
return { fields: USER_FIELDS.join(",") };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
32
|
+
return {
|
|
33
|
+
id: String(raw.id ?? ""),
|
|
34
|
+
// The address is absent unless the user granted the `email` scope.
|
|
35
|
+
email: String(raw.email ?? ""),
|
|
36
|
+
name: String(raw.name ?? ""),
|
|
37
|
+
nickName: typeof raw.name === "string" ? raw.name : undefined,
|
|
38
|
+
avatarUrl: pictureUrl(raw),
|
|
39
|
+
emailVerificationState: "unsupported",
|
|
40
|
+
raw,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The picture arrives nested as `picture.data.url`. */
|
|
46
|
+
function pictureUrl(raw: Record<string, unknown>): string | undefined {
|
|
47
|
+
const picture = raw.picture;
|
|
48
|
+
if (typeof picture !== "object" || picture === null) return undefined;
|
|
49
|
+
const data = Reflect.get(picture, "data");
|
|
50
|
+
if (typeof data !== "object" || data === null) return undefined;
|
|
51
|
+
const url = Reflect.get(data, "url");
|
|
52
|
+
return typeof url === "string" && url !== "" ? url : undefined;
|
|
53
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth2 driver.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Oauth2Driver, str } from "../Oauth2Driver.js";
|
|
6
|
+
import type { TransitUser } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const EMAILS_URL = "https://api.github.com/user/emails";
|
|
9
|
+
|
|
10
|
+
/** One entry of `/user/emails`. */
|
|
11
|
+
interface GitHubEmail {
|
|
12
|
+
email: string;
|
|
13
|
+
primary: boolean;
|
|
14
|
+
verified: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class GitHubDriver extends Oauth2Driver {
|
|
18
|
+
protected readonly provider = "GitHub";
|
|
19
|
+
protected readonly authorizeUrl = "https://github.com/login/oauth/authorize";
|
|
20
|
+
protected readonly accessTokenUrl =
|
|
21
|
+
"https://github.com/login/oauth/access_token";
|
|
22
|
+
protected readonly userInfoUrl = "https://api.github.com/user";
|
|
23
|
+
protected readonly defaultScopes = ["read:user", "user:email"] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An account whose address is private returns `email: null` from `/user`,
|
|
27
|
+
* which is most accounts. The address then lives behind `/user/emails`,
|
|
28
|
+
* which also says whether GitHub verified it.
|
|
29
|
+
*/
|
|
30
|
+
protected override async fetchUser(
|
|
31
|
+
accessToken: string,
|
|
32
|
+
): Promise<TransitUser> {
|
|
33
|
+
const raw = await this.get(this.userInfoUrl, accessToken);
|
|
34
|
+
const user = this.mapUser(raw);
|
|
35
|
+
if (user.email !== "") return user;
|
|
36
|
+
|
|
37
|
+
const found = await this.fetchEmail(accessToken);
|
|
38
|
+
if (found === undefined) return user;
|
|
39
|
+
return {
|
|
40
|
+
...user,
|
|
41
|
+
email: found.email,
|
|
42
|
+
emailVerificationState: found.verified ? "verified" : "unverified",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The verified primary address, or the best available one. Returns
|
|
48
|
+
* `undefined` when the token was not granted `user:email` — a sign-in
|
|
49
|
+
* without an address is still a sign-in.
|
|
50
|
+
*/
|
|
51
|
+
async fetchEmail(accessToken: string): Promise<GitHubEmail | undefined> {
|
|
52
|
+
let entries: unknown[];
|
|
53
|
+
try {
|
|
54
|
+
entries = await this.getList(EMAILS_URL, accessToken);
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const emails = entries.filter(isGitHubEmail);
|
|
59
|
+
// Primary first, then verified: an unverified primary is still the
|
|
60
|
+
// address the account is known by.
|
|
61
|
+
return (
|
|
62
|
+
emails.find((entry) => entry.primary && entry.verified) ??
|
|
63
|
+
emails.find((entry) => entry.verified) ??
|
|
64
|
+
emails.find((entry) => entry.primary) ??
|
|
65
|
+
emails[0]
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
70
|
+
return {
|
|
71
|
+
id: String(raw.id ?? ""),
|
|
72
|
+
email: String(raw.email ?? ""),
|
|
73
|
+
// An account with no display name still has a login.
|
|
74
|
+
name: String(raw.name ?? raw.login ?? ""),
|
|
75
|
+
nickName: str(raw, "login"),
|
|
76
|
+
avatarUrl: str(raw, "avatar_url"),
|
|
77
|
+
// `/user` says nothing about the address; `fetchUser` refines this
|
|
78
|
+
// when it has to read `/user/emails`.
|
|
79
|
+
emailVerificationState: "unsupported",
|
|
80
|
+
raw,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isGitHubEmail(value: unknown): value is GitHubEmail {
|
|
86
|
+
return (
|
|
87
|
+
typeof value === "object" &&
|
|
88
|
+
value !== null &&
|
|
89
|
+
typeof Reflect.get(value, "email") === "string"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth2 driver.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Oauth2Driver, str } from "../Oauth2Driver.js";
|
|
6
|
+
import type { TransitUser } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export class GoogleDriver extends Oauth2Driver {
|
|
9
|
+
protected readonly provider = "Google";
|
|
10
|
+
protected readonly authorizeUrl =
|
|
11
|
+
"https://accounts.google.com/o/oauth2/v2/auth";
|
|
12
|
+
protected readonly accessTokenUrl = "https://oauth2.googleapis.com/token";
|
|
13
|
+
protected readonly userInfoUrl =
|
|
14
|
+
"https://www.googleapis.com/oauth2/v3/userinfo";
|
|
15
|
+
protected readonly defaultScopes = ["openid", "email", "profile"] as const;
|
|
16
|
+
|
|
17
|
+
/** Asking for offline access is what makes Google issue a refresh token. */
|
|
18
|
+
protected override authorizeParams(): Record<string, string> {
|
|
19
|
+
return { access_type: "offline" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
23
|
+
return {
|
|
24
|
+
// The OpenID Connect endpoint calls the subject `sub`; the older one
|
|
25
|
+
// called it `id`, and both are the same value.
|
|
26
|
+
id: String(raw.sub ?? raw.id ?? ""),
|
|
27
|
+
email: String(raw.email ?? ""),
|
|
28
|
+
name: String(raw.name ?? ""),
|
|
29
|
+
nickName: str(raw, "given_name") ?? str(raw, "name"),
|
|
30
|
+
avatarUrl: str(raw, "picture"),
|
|
31
|
+
emailVerificationState:
|
|
32
|
+
raw.email_verified === true || raw.verified_email === true
|
|
33
|
+
? "verified"
|
|
34
|
+
: "unverified",
|
|
35
|
+
raw,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An LDAP or Active Directory directory.
|
|
3
|
+
*
|
|
4
|
+
* Nothing is redirected: the application already holds the credentials, and the
|
|
5
|
+
* directory is the authority that says whether they are right. Verifying them
|
|
6
|
+
* means binding AS that person — a bind that succeeds is the answer, and there
|
|
7
|
+
* is no other one.
|
|
8
|
+
*
|
|
9
|
+
* The flow is two connections on purpose. The first finds the person's DN,
|
|
10
|
+
* bound as whatever the application is allowed to search with. The second binds
|
|
11
|
+
* as the person, and is closed immediately: a connection carries the identity
|
|
12
|
+
* of whatever last bound on it, so nothing else may run on that one.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
LdapConnection,
|
|
17
|
+
type LdapEntry,
|
|
18
|
+
LdapError,
|
|
19
|
+
type LdapFilter,
|
|
20
|
+
} from "../ldap.js";
|
|
21
|
+
import type { DirectoryDriver, TransitUser } from "../types.js";
|
|
22
|
+
|
|
23
|
+
export interface LdapConfig {
|
|
24
|
+
/** `ldaps://directory.acme.test:636`. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** Where to search from — `dc=acme,dc=test`. */
|
|
27
|
+
baseDn: string;
|
|
28
|
+
/**
|
|
29
|
+
* The attribute a person types. `uid` on OpenLDAP,
|
|
30
|
+
* `sAMAccountName` or `userPrincipalName` on Active Directory.
|
|
31
|
+
*/
|
|
32
|
+
loginAttribute: string;
|
|
33
|
+
/** The account the search runs as. Omitted means an anonymous search. */
|
|
34
|
+
bindDn?: string;
|
|
35
|
+
bindPassword?: string;
|
|
36
|
+
/** Extra conditions on the search, ANDed with the login attribute. */
|
|
37
|
+
filter?: LdapFilter;
|
|
38
|
+
/** Attributes to read. Defaults to the ones the mapping below uses. */
|
|
39
|
+
attributes?: string[];
|
|
40
|
+
/** The attribute names this directory uses, when they are not the usual ones. */
|
|
41
|
+
claims?: { email?: string; name?: string; nickName?: string };
|
|
42
|
+
/** Allow a plaintext connection — see the note on `LdapOptions`. */
|
|
43
|
+
allowInsecure?: boolean;
|
|
44
|
+
/** Refuse a server certificate that does not check out. Default true. */
|
|
45
|
+
rejectUnauthorized?: boolean;
|
|
46
|
+
/** How long to wait for the directory, in milliseconds. Default 10 000. */
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const EMAIL_ATTRS = ["mail", "userPrincipalName", "email"];
|
|
51
|
+
const NAME_ATTRS = ["displayName", "cn", "name"];
|
|
52
|
+
const NICK_ATTRS = ["uid", "sAMAccountName", "givenName"];
|
|
53
|
+
|
|
54
|
+
export class LdapDirectory implements DirectoryDriver {
|
|
55
|
+
readonly #config: LdapConfig;
|
|
56
|
+
|
|
57
|
+
constructor(config: LdapConfig) {
|
|
58
|
+
this.#config = config;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async authenticate(username: string, password: string): Promise<TransitUser> {
|
|
62
|
+
// Refused here as well as in the bind: a form that submits an empty
|
|
63
|
+
// password must not reach the directory, which would answer success to
|
|
64
|
+
// an anonymous bind.
|
|
65
|
+
if (password === "") {
|
|
66
|
+
throw new LdapError(
|
|
67
|
+
"a password is required — an empty one is an anonymous bind, which the directory would accept",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (username === "") {
|
|
71
|
+
throw new LdapError("a username is required");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entry = await this.#find(username);
|
|
75
|
+
await this.#bindAs(entry.dn, password);
|
|
76
|
+
return this.#mapUser(entry);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Find the person, bound as whatever may search. */
|
|
80
|
+
async #find(username: string): Promise<LdapEntry> {
|
|
81
|
+
const connection = this.#open();
|
|
82
|
+
await connection.connect();
|
|
83
|
+
try {
|
|
84
|
+
if (this.#config.bindDn) {
|
|
85
|
+
await connection.bind(
|
|
86
|
+
this.#config.bindDn,
|
|
87
|
+
this.#config.bindPassword ?? "",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The username is a VALUE in a structure, never text in a filter, so
|
|
92
|
+
// a login of `*)(uid=admin` is a login containing those characters.
|
|
93
|
+
const login: LdapFilter = {
|
|
94
|
+
equals: [this.#config.loginAttribute, username],
|
|
95
|
+
};
|
|
96
|
+
const filter: LdapFilter = this.#config.filter
|
|
97
|
+
? { and: [this.#config.filter, login] }
|
|
98
|
+
: login;
|
|
99
|
+
|
|
100
|
+
const entries = await connection.search({
|
|
101
|
+
base: this.#config.baseDn,
|
|
102
|
+
filter,
|
|
103
|
+
attributes: this.#config.attributes ?? [
|
|
104
|
+
...EMAIL_ATTRS,
|
|
105
|
+
...NAME_ATTRS,
|
|
106
|
+
...NICK_ATTRS,
|
|
107
|
+
],
|
|
108
|
+
// Two, so an ambiguous login is seen rather than silently
|
|
109
|
+
// resolved to whichever the directory returned first.
|
|
110
|
+
sizeLimit: 2,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (entries.length === 0) {
|
|
114
|
+
// The same message as a wrong password: which of the two it was
|
|
115
|
+
// is not something a sign-in form should disclose.
|
|
116
|
+
throw new LdapError(
|
|
117
|
+
"the directory refused the bind: invalid credentials",
|
|
118
|
+
49,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (entries.length > 1) {
|
|
122
|
+
throw new LdapError(
|
|
123
|
+
`'${username}' matches ${entries.length} entries, and this signs in one person`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return entries[0] as LdapEntry;
|
|
127
|
+
} finally {
|
|
128
|
+
await connection.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Bind as the person. A bind that succeeds IS the verification. */
|
|
133
|
+
async #bindAs(dn: string, password: string): Promise<void> {
|
|
134
|
+
const connection = this.#open();
|
|
135
|
+
await connection.connect();
|
|
136
|
+
try {
|
|
137
|
+
await connection.bind(dn, password);
|
|
138
|
+
} finally {
|
|
139
|
+
// The connection now carries this person's identity; it is closed
|
|
140
|
+
// rather than reused.
|
|
141
|
+
await connection.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#open(): LdapConnection {
|
|
146
|
+
return new LdapConnection({
|
|
147
|
+
url: this.#config.url,
|
|
148
|
+
allowInsecure: this.#config.allowInsecure,
|
|
149
|
+
rejectUnauthorized: this.#config.rejectUnauthorized,
|
|
150
|
+
timeoutMs: this.#config.timeoutMs,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#mapUser(entry: LdapEntry): TransitUser {
|
|
155
|
+
const claims = this.#config.claims ?? {};
|
|
156
|
+
const email = pick(entry, claims.email, EMAIL_ATTRS);
|
|
157
|
+
const name = pick(entry, claims.name, NAME_ATTRS);
|
|
158
|
+
const nickName = pick(entry, claims.nickName, NICK_ATTRS);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
// The DN is what the directory calls this entry, and the only value
|
|
162
|
+
// guaranteed to be unique and stable.
|
|
163
|
+
id: entry.dn,
|
|
164
|
+
email: email ?? "",
|
|
165
|
+
name: name ?? nickName ?? entry.dn,
|
|
166
|
+
nickName,
|
|
167
|
+
// A directory that holds an address has vouched for it: that is what
|
|
168
|
+
// a directory is.
|
|
169
|
+
emailVerificationState: email === undefined ? "unsupported" : "verified",
|
|
170
|
+
raw: { dn: entry.dn, attributes: entry.attributes },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pick(
|
|
176
|
+
entry: LdapEntry,
|
|
177
|
+
configured: string | undefined,
|
|
178
|
+
candidates: string[],
|
|
179
|
+
): string | undefined {
|
|
180
|
+
for (const name of configured ? [configured] : candidates) {
|
|
181
|
+
const value = entry.attributes[name]?.[0];
|
|
182
|
+
if (value !== undefined && value !== "") return value;
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn OAuth2 driver — the member API.
|
|
3
|
+
*
|
|
4
|
+
* For an application whose LinkedIn app holds `r_liteprofile` /
|
|
5
|
+
* `r_emailaddress`. It needs two calls: the profile carries no address, and
|
|
6
|
+
* the address endpoint carries no profile.
|
|
7
|
+
*
|
|
8
|
+
* LinkedIn issues new applications OpenID Connect instead — that is
|
|
9
|
+
* `LinkedInOpenidConnectDriver`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Oauth2Driver } from "../Oauth2Driver.js";
|
|
13
|
+
import type { TransitUser } from "../types.js";
|
|
14
|
+
|
|
15
|
+
const EMAIL_URL = "https://api.linkedin.com/v2/emailAddress";
|
|
16
|
+
const PROFILE_PROJECTION =
|
|
17
|
+
"(id,localizedFirstName,localizedLastName,vanityName,profilePicture(displayImage~:playableStreams))";
|
|
18
|
+
|
|
19
|
+
export class LinkedInDriver extends Oauth2Driver {
|
|
20
|
+
protected readonly provider = "LinkedIn";
|
|
21
|
+
protected readonly authorizeUrl =
|
|
22
|
+
"https://www.linkedin.com/oauth/v2/authorization";
|
|
23
|
+
protected readonly accessTokenUrl =
|
|
24
|
+
"https://www.linkedin.com/oauth/v2/accessToken";
|
|
25
|
+
protected readonly userInfoUrl = "https://api.linkedin.com/v2/me";
|
|
26
|
+
protected readonly defaultScopes = [
|
|
27
|
+
"r_liteprofile",
|
|
28
|
+
"r_emailaddress",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
protected override userInfoParams(): Record<string, string> {
|
|
32
|
+
return { projection: PROFILE_PROJECTION };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
protected override async fetchUser(
|
|
36
|
+
accessToken: string,
|
|
37
|
+
): Promise<TransitUser> {
|
|
38
|
+
const raw = await this.get(this.userInfoUrl, accessToken, {
|
|
39
|
+
...this.userInfoParams(),
|
|
40
|
+
});
|
|
41
|
+
const email = await this.fetchEmail(accessToken);
|
|
42
|
+
return { ...this.mapUser(raw), email };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The address lives behind its own endpoint, and only with the
|
|
47
|
+
* `r_emailaddress` scope — without it the response has no elements, which is
|
|
48
|
+
* reported here rather than surfacing as an empty address downstream.
|
|
49
|
+
*/
|
|
50
|
+
async fetchEmail(accessToken: string): Promise<string> {
|
|
51
|
+
const body = await this.get(EMAIL_URL, accessToken, {
|
|
52
|
+
q: "members",
|
|
53
|
+
projection: "(elements*(primary,type,handle~))",
|
|
54
|
+
});
|
|
55
|
+
const elements = Array.isArray(body.elements) ? body.elements : [];
|
|
56
|
+
for (const element of elements) {
|
|
57
|
+
if (typeof element !== "object" || element === null) continue;
|
|
58
|
+
if (Reflect.get(element, "type") !== "EMAIL") continue;
|
|
59
|
+
const handle = Reflect.get(element, "handle~");
|
|
60
|
+
if (typeof handle !== "object" || handle === null) continue;
|
|
61
|
+
const address = Reflect.get(handle, "emailAddress");
|
|
62
|
+
if (typeof address === "string" && address !== "") return address;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(
|
|
65
|
+
"LinkedIn returned no email address — the application needs the 'r_emailaddress' scope.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
70
|
+
const first = String(raw.localizedFirstName ?? "");
|
|
71
|
+
const last = String(raw.localizedLastName ?? "");
|
|
72
|
+
return {
|
|
73
|
+
id: String(raw.id ?? ""),
|
|
74
|
+
email: "",
|
|
75
|
+
name: [first, last].filter(Boolean).join(" "),
|
|
76
|
+
nickName:
|
|
77
|
+
typeof raw.vanityName === "string" && raw.vanityName !== ""
|
|
78
|
+
? raw.vanityName
|
|
79
|
+
: [first, last].filter(Boolean).join(" "),
|
|
80
|
+
avatarUrl: pictureUrl(raw),
|
|
81
|
+
// The member API vouches for nothing about the address.
|
|
82
|
+
emailVerificationState: "unsupported",
|
|
83
|
+
raw,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The picture arrives as a list of renditions, largest last. Nothing about
|
|
90
|
+
* the shape is guaranteed, so every step is checked.
|
|
91
|
+
*/
|
|
92
|
+
function pictureUrl(raw: Record<string, unknown>): string | undefined {
|
|
93
|
+
const picture = raw.profilePicture;
|
|
94
|
+
if (typeof picture !== "object" || picture === null) return undefined;
|
|
95
|
+
const display = Reflect.get(picture, "displayImage~");
|
|
96
|
+
if (typeof display !== "object" || display === null) return undefined;
|
|
97
|
+
const elements = Reflect.get(display, "elements");
|
|
98
|
+
if (!Array.isArray(elements) || elements.length === 0) return undefined;
|
|
99
|
+
const identifiers = Reflect.get(Object(elements[0]), "identifiers");
|
|
100
|
+
if (!Array.isArray(identifiers) || identifiers.length === 0) return undefined;
|
|
101
|
+
const identifier = Reflect.get(Object(identifiers[0]), "identifier");
|
|
102
|
+
return typeof identifier === "string" && identifier !== ""
|
|
103
|
+
? identifier
|
|
104
|
+
: undefined;
|
|
105
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn OAuth2 driver — OpenID Connect.
|
|
3
|
+
*
|
|
4
|
+
* This is the flow LinkedIn issues new applications: one `userinfo` call,
|
|
5
|
+
* standard claims. The member API is `LinkedInDriver`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Oauth2Driver, str } from "../Oauth2Driver.js";
|
|
9
|
+
import type { TransitUser } from "../types.js";
|
|
10
|
+
|
|
11
|
+
export class LinkedInOpenidConnectDriver extends Oauth2Driver {
|
|
12
|
+
protected readonly provider = "LinkedIn";
|
|
13
|
+
protected readonly authorizeUrl =
|
|
14
|
+
"https://www.linkedin.com/oauth/v2/authorization";
|
|
15
|
+
protected readonly accessTokenUrl =
|
|
16
|
+
"https://www.linkedin.com/oauth/v2/accessToken";
|
|
17
|
+
protected readonly userInfoUrl = "https://api.linkedin.com/v2/userinfo";
|
|
18
|
+
protected readonly defaultScopes = ["openid", "profile", "email"] as const;
|
|
19
|
+
|
|
20
|
+
protected mapUser(raw: Record<string, unknown>): TransitUser {
|
|
21
|
+
return {
|
|
22
|
+
// `sub` is the stable subject identifier; `id` does not exist here.
|
|
23
|
+
id: String(raw.sub ?? ""),
|
|
24
|
+
email: String(raw.email ?? ""),
|
|
25
|
+
name: String(raw.name ?? ""),
|
|
26
|
+
nickName: str(raw, "given_name"),
|
|
27
|
+
avatarUrl: str(raw, "picture"),
|
|
28
|
+
emailVerificationState:
|
|
29
|
+
raw.email_verified === true ? "verified" : "unverified",
|
|
30
|
+
raw,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|