@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires `config/transit.ts` into the container.
|
|
3
|
+
*
|
|
4
|
+
* Transit does not import `@c9up/ream`: the slice of the host it needs is
|
|
5
|
+
* duck-typed below, which is what keeps the package publishable on its own and
|
|
6
|
+
* usable from a host that is not Ream.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TransitConfig } from "./config.js";
|
|
10
|
+
import { setTransit } from "./services/main.js";
|
|
11
|
+
import { TransitManager } from "./TransitManager.js";
|
|
12
|
+
|
|
13
|
+
interface TransitContainer {
|
|
14
|
+
singleton(token: unknown, factory: () => unknown): void;
|
|
15
|
+
resolve<T = unknown>(token: unknown): Promise<T>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TransitConfigStore {
|
|
19
|
+
get<T = unknown>(key: string): T | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TransitAppContext {
|
|
23
|
+
container: TransitContainer;
|
|
24
|
+
config: TransitConfigStore;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default class TransitProvider {
|
|
28
|
+
constructor(protected app: TransitAppContext) {}
|
|
29
|
+
|
|
30
|
+
register(): void {
|
|
31
|
+
this.app.container.singleton(TransitManager, () => {
|
|
32
|
+
const config = this.app.config.get<TransitConfig>("transit") ?? {};
|
|
33
|
+
const manager = new TransitManager();
|
|
34
|
+
for (const [name, entry] of Object.entries(config)) {
|
|
35
|
+
manager.register(name, typeof entry === "function" ? entry() : entry);
|
|
36
|
+
}
|
|
37
|
+
return manager;
|
|
38
|
+
});
|
|
39
|
+
// String alias, so a consumer that cannot import Transit still resolves
|
|
40
|
+
// it — the same convention the other providers follow.
|
|
41
|
+
this.app.container.singleton("transit", () =>
|
|
42
|
+
this.app.container.resolve<TransitManager>(TransitManager),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async boot(): Promise<void> {
|
|
47
|
+
setTransit(
|
|
48
|
+
await this.app.container.resolve<TransitManager>(TransitManager),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/apple.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two things Sign in with Apple does differently from every other OpenID
|
|
3
|
+
* Connect provider.
|
|
4
|
+
*
|
|
5
|
+
* Apple has no client secret. It has a private key, and expects a short-lived
|
|
6
|
+
* JWT minted from it on every token request — which is why `clientSecret` is
|
|
7
|
+
* allowed to be a function.
|
|
8
|
+
*
|
|
9
|
+
* And it sends the user's name exactly ONCE, in the body of the first consent
|
|
10
|
+
* callback, never in a token and never again. An application that does not
|
|
11
|
+
* store it then has no way to ask for it later.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createPrivateKey, sign as signData } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export interface AppleKey {
|
|
17
|
+
/** The Services ID (web) or App ID this signs for. */
|
|
18
|
+
clientId: string;
|
|
19
|
+
/** The ten-character Team ID from the developer account. */
|
|
20
|
+
teamId: string;
|
|
21
|
+
/** The Key ID of the .p8 this signs with. */
|
|
22
|
+
keyId: string;
|
|
23
|
+
/** The contents of the .p8 file, PEM as Apple issues it. */
|
|
24
|
+
privateKey: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Apple rejects a secret valid for more than six months; minutes are plenty. */
|
|
28
|
+
const LIFETIME_SECONDS = 300;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mint the JWT Apple takes in place of a client secret.
|
|
32
|
+
*
|
|
33
|
+
* Signed ES256 with the .p8, naming the team as issuer and the client as
|
|
34
|
+
* subject. Minted per request rather than cached: it costs one signature, and
|
|
35
|
+
* a long-lived one is a credential sitting in memory for no reason.
|
|
36
|
+
*/
|
|
37
|
+
export function appleClientSecret(
|
|
38
|
+
key: AppleKey,
|
|
39
|
+
now: number = Math.floor(Date.now() / 1000),
|
|
40
|
+
): string {
|
|
41
|
+
const header = { alg: "ES256", kid: key.keyId, typ: "JWT" };
|
|
42
|
+
const claims = {
|
|
43
|
+
iss: key.teamId,
|
|
44
|
+
iat: now,
|
|
45
|
+
exp: now + LIFETIME_SECONDS,
|
|
46
|
+
aud: "https://appleid.apple.com",
|
|
47
|
+
sub: key.clientId,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const signingInput = `${b64(header)}.${b64(claims)}`;
|
|
51
|
+
// JOSE wants R concatenated with S; node emits DER unless told, and Apple
|
|
52
|
+
// rejects DER.
|
|
53
|
+
const signature = signData("sha256", Buffer.from(signingInput, "ascii"), {
|
|
54
|
+
key: createPrivateKey(key.privateKey),
|
|
55
|
+
dsaEncoding: "ieee-p1363",
|
|
56
|
+
});
|
|
57
|
+
return `${signingInput}.${signature.toString("base64url")}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the `user` field of Apple's first consent callback.
|
|
62
|
+
*
|
|
63
|
+
* It arrives as JSON in the POST body, on that one request only. Store what
|
|
64
|
+
* comes out — the id_token never carries a name, and a second sign-in by the
|
|
65
|
+
* same person will not carry one either.
|
|
66
|
+
*
|
|
67
|
+
* Returns `undefined` for anything unreadable rather than throwing: a returning
|
|
68
|
+
* user simply has no `user` field, and that is the normal case.
|
|
69
|
+
*/
|
|
70
|
+
export function parseAppleUser(
|
|
71
|
+
value: unknown,
|
|
72
|
+
): { name?: string; email?: string } | undefined {
|
|
73
|
+
if (typeof value !== "string" || value === "") return undefined;
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(value);
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
81
|
+
|
|
82
|
+
const email = Reflect.get(parsed, "email");
|
|
83
|
+
const rawName = Reflect.get(parsed, "name");
|
|
84
|
+
const name =
|
|
85
|
+
typeof rawName === "object" && rawName !== null
|
|
86
|
+
? [Reflect.get(rawName, "firstName"), Reflect.get(rawName, "lastName")]
|
|
87
|
+
.filter(
|
|
88
|
+
(part): part is string => typeof part === "string" && part !== "",
|
|
89
|
+
)
|
|
90
|
+
.join(" ")
|
|
91
|
+
: undefined;
|
|
92
|
+
|
|
93
|
+
const out: { name?: string; email?: string } = {};
|
|
94
|
+
if (name) out.name = name;
|
|
95
|
+
if (typeof email === "string" && email !== "") out.email = email;
|
|
96
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function b64(value: unknown): string {
|
|
100
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
101
|
+
}
|
package/src/ber.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The encoding LDAP speaks: BER, the subset of it LDAP actually uses.
|
|
3
|
+
*
|
|
4
|
+
* This exists so that nothing an application passes ever becomes part of a
|
|
5
|
+
* string that a server then parses. A search filter here is a structure, not
|
|
6
|
+
* text — its values are written as length-prefixed octets, so a username
|
|
7
|
+
* containing `)(uid=*` is a username containing those characters and cannot
|
|
8
|
+
* become syntax. LDAP injection is not escaped away here; it is unrepresentable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Universal tags, and the two LDAP builds everything else from. */
|
|
12
|
+
export const BOOLEAN = 0x01;
|
|
13
|
+
export const INTEGER = 0x02;
|
|
14
|
+
export const OCTET_STRING = 0x04;
|
|
15
|
+
export const ENUMERATED = 0x0a;
|
|
16
|
+
export const SEQUENCE = 0x30;
|
|
17
|
+
|
|
18
|
+
/** `[APPLICATION n]`, constructed. */
|
|
19
|
+
export const application = (n: number): number => 0x60 | n;
|
|
20
|
+
/** `[APPLICATION n]`, primitive — which a couple of LDAP operations are. */
|
|
21
|
+
export const applicationPrimitive = (n: number): number => 0x40 | n;
|
|
22
|
+
/** `[n]` context-specific, constructed. */
|
|
23
|
+
export const context = (n: number): number => 0xa0 | n;
|
|
24
|
+
/** `[n]` context-specific, primitive. */
|
|
25
|
+
export const contextPrimitive = (n: number): number => 0x80 | n;
|
|
26
|
+
|
|
27
|
+
export class BerError extends Error {
|
|
28
|
+
constructor(message: string) {
|
|
29
|
+
super(`[transit] ${message}`);
|
|
30
|
+
this.name = "BerError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One tag-length-value element. */
|
|
35
|
+
export function encode(tag: number, value: Buffer): Buffer {
|
|
36
|
+
return Buffer.concat([Buffer.from([tag]), encodeLength(value.length), value]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function encodeInteger(value: number, tag = INTEGER): Buffer {
|
|
40
|
+
if (!Number.isSafeInteger(value)) {
|
|
41
|
+
throw new BerError(`cannot encode ${value} as an integer`);
|
|
42
|
+
}
|
|
43
|
+
const bytes: number[] = [];
|
|
44
|
+
let remaining = value;
|
|
45
|
+
do {
|
|
46
|
+
bytes.unshift(remaining & 0xff);
|
|
47
|
+
remaining >>= 8;
|
|
48
|
+
} while (remaining !== 0 && remaining !== -1);
|
|
49
|
+
// A leading bit set on a positive number would read as negative.
|
|
50
|
+
if (value >= 0 && (bytes[0] as number) & 0x80) bytes.unshift(0);
|
|
51
|
+
if (value < 0 && !((bytes[0] as number) & 0x80)) bytes.unshift(0xff);
|
|
52
|
+
return encode(tag, Buffer.from(bytes));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function encodeString(value: string, tag = OCTET_STRING): Buffer {
|
|
56
|
+
// The value is written as length-prefixed bytes, never interpolated. That
|
|
57
|
+
// is what makes injection impossible rather than merely escaped.
|
|
58
|
+
return encode(tag, Buffer.from(value, "utf8"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function encodeBoolean(value: boolean, tag = BOOLEAN): Buffer {
|
|
62
|
+
return encode(tag, Buffer.from([value ? 0xff : 0x00]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function encodeSequence(parts: Buffer[], tag = SEQUENCE): Buffer {
|
|
66
|
+
return encode(tag, Buffer.concat(parts));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A decoded element: its tag, its contents, and where it ended. */
|
|
70
|
+
export interface BerElement {
|
|
71
|
+
tag: number;
|
|
72
|
+
value: Buffer;
|
|
73
|
+
end: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Read one element at `offset`. */
|
|
77
|
+
export function decode(buffer: Buffer, offset = 0): BerElement {
|
|
78
|
+
if (offset >= buffer.length) {
|
|
79
|
+
throw new BerError("the message ends where an element was expected");
|
|
80
|
+
}
|
|
81
|
+
const tag = buffer[offset] as number;
|
|
82
|
+
const { length, at } = decodeLength(buffer, offset + 1);
|
|
83
|
+
const end = at + length;
|
|
84
|
+
if (end > buffer.length) {
|
|
85
|
+
throw new BerError(
|
|
86
|
+
`an element claims ${length} bytes and the message holds ${buffer.length - at}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return { tag, value: buffer.subarray(at, end), end };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every element inside a constructed one. */
|
|
93
|
+
export function decodeSequence(value: Buffer): BerElement[] {
|
|
94
|
+
const out: BerElement[] = [];
|
|
95
|
+
let offset = 0;
|
|
96
|
+
while (offset < value.length) {
|
|
97
|
+
const element = decode(value, offset);
|
|
98
|
+
out.push(element);
|
|
99
|
+
offset = element.end;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function decodeInteger(value: Buffer): number {
|
|
105
|
+
if (value.length === 0) throw new BerError("an integer has no bytes");
|
|
106
|
+
if (value.length > 6) {
|
|
107
|
+
// Beyond this a result code or message id is not something this reads.
|
|
108
|
+
throw new BerError(`an integer of ${value.length} bytes is out of range`);
|
|
109
|
+
}
|
|
110
|
+
let out = (value[0] as number) & 0x80 ? -1 : 0;
|
|
111
|
+
for (const byte of value) out = out * 256 + byte;
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function decodeString(value: Buffer): string {
|
|
116
|
+
return value.toString("utf8");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* How many bytes a whole message occupies, or `undefined` while it is still
|
|
121
|
+
* arriving. LDAP runs over a stream, so a read can stop anywhere.
|
|
122
|
+
*/
|
|
123
|
+
export function messageLength(buffer: Buffer): number | undefined {
|
|
124
|
+
if (buffer.length < 2) return undefined;
|
|
125
|
+
const first = buffer[1] as number;
|
|
126
|
+
if (first < 0x80) return 2 + first;
|
|
127
|
+
const count = first & 0x7f;
|
|
128
|
+
if (count === 0 || count > 4) {
|
|
129
|
+
throw new BerError("a message declares a length this refuses to read");
|
|
130
|
+
}
|
|
131
|
+
if (buffer.length < 2 + count) return undefined;
|
|
132
|
+
let length = 0;
|
|
133
|
+
for (let i = 0; i < count; i += 1) {
|
|
134
|
+
length = length * 256 + (buffer[2 + i] as number);
|
|
135
|
+
}
|
|
136
|
+
return 2 + count + length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function encodeLength(length: number): Buffer {
|
|
140
|
+
if (length < 0x80) return Buffer.from([length]);
|
|
141
|
+
const bytes: number[] = [];
|
|
142
|
+
let remaining = length;
|
|
143
|
+
while (remaining > 0) {
|
|
144
|
+
bytes.unshift(remaining & 0xff);
|
|
145
|
+
remaining = Math.floor(remaining / 256);
|
|
146
|
+
}
|
|
147
|
+
return Buffer.from([0x80 | bytes.length, ...bytes]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function decodeLength(
|
|
151
|
+
buffer: Buffer,
|
|
152
|
+
offset: number,
|
|
153
|
+
): { length: number; at: number } {
|
|
154
|
+
if (offset >= buffer.length) {
|
|
155
|
+
throw new BerError("an element has no length");
|
|
156
|
+
}
|
|
157
|
+
const first = buffer[offset] as number;
|
|
158
|
+
if (first < 0x80) return { length: first, at: offset + 1 };
|
|
159
|
+
const count = first & 0x7f;
|
|
160
|
+
if (count === 0) {
|
|
161
|
+
// Indefinite length is legal BER and not legal LDAP; accepting it would
|
|
162
|
+
// mean scanning for a terminator supplied by the other side.
|
|
163
|
+
throw new BerError(
|
|
164
|
+
"an element uses an indefinite length, which LDAP does not",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (count > 4 || offset + 1 + count > buffer.length) {
|
|
168
|
+
throw new BerError("an element declares a length this refuses to read");
|
|
169
|
+
}
|
|
170
|
+
let length = 0;
|
|
171
|
+
for (let i = 0; i < count; i += 1) {
|
|
172
|
+
length = length * 256 + (buffer[offset + 1 + i] as number);
|
|
173
|
+
}
|
|
174
|
+
return { length, at: offset + 1 + count };
|
|
175
|
+
}
|
package/src/c14n.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exclusive XML Canonicalization (`xml-exc-c14n#`).
|
|
3
|
+
*
|
|
4
|
+
* A signature is over BYTES, and two parties only agree on which bytes if they
|
|
5
|
+
* agree on this algorithm exactly. Every rule below comes from the
|
|
6
|
+
* specification, and the awkward ones are the ones that matter:
|
|
7
|
+
*
|
|
8
|
+
* - Only namespaces an element **visibly utilizes** are written out, and only
|
|
9
|
+
* when an output ancestor has not already written the same prefix with the
|
|
10
|
+
* same value. That is what makes a signed fragment survive being moved
|
|
11
|
+
* between documents — and it is the whole reason exclusive exists.
|
|
12
|
+
* - An unprefixed attribute is in NO namespace, so it never makes the default
|
|
13
|
+
* namespace visibly utilized.
|
|
14
|
+
* - The `xml` prefix is bound by the XML specification and is never declared.
|
|
15
|
+
*
|
|
16
|
+
* Getting any of these wrong yields a canonical form that differs from the
|
|
17
|
+
* signer's by a few bytes, and every signature then fails — or, worse, one is
|
|
18
|
+
* accepted over a document that is not the one that was signed.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
resolvePrefix,
|
|
23
|
+
XML_NS,
|
|
24
|
+
type XmlAttribute,
|
|
25
|
+
type XmlElement,
|
|
26
|
+
type XmlNode,
|
|
27
|
+
} from "./xml.js";
|
|
28
|
+
|
|
29
|
+
export interface CanonicalizeOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Prefixes to treat the inclusive way, from a `<InclusiveNamespaces
|
|
32
|
+
* PrefixList="…">` transform parameter.
|
|
33
|
+
*/
|
|
34
|
+
inclusivePrefixes?: string[];
|
|
35
|
+
/** Comments are excluded unless the algorithm says otherwise. */
|
|
36
|
+
withComments?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Nodes to leave out of the output — what the enveloped-signature transform
|
|
39
|
+
* needs, since an element cannot carry a digest of itself.
|
|
40
|
+
*/
|
|
41
|
+
omit?: ReadonlySet<XmlNode>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The canonical form of an element and everything under it. */
|
|
45
|
+
export function canonicalize(
|
|
46
|
+
element: XmlElement,
|
|
47
|
+
options: CanonicalizeOptions = {},
|
|
48
|
+
): string {
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
writeElement(element, out, new Map(), options);
|
|
51
|
+
return out.join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The prefixes this element makes visible: its own, and those of its
|
|
56
|
+
* attributes. The default namespace counts only when the element itself has no
|
|
57
|
+
* prefix.
|
|
58
|
+
*/
|
|
59
|
+
function visiblyUtilized(
|
|
60
|
+
element: XmlElement,
|
|
61
|
+
inclusivePrefixes: string[],
|
|
62
|
+
): Set<string> {
|
|
63
|
+
const used = new Set<string>([element.prefix]);
|
|
64
|
+
for (const attribute of element.attributes) {
|
|
65
|
+
if (attribute.prefix !== "") used.add(attribute.prefix);
|
|
66
|
+
}
|
|
67
|
+
// The prefix list is handled inclusively: a listed prefix is rendered
|
|
68
|
+
// whenever it is in scope, utilized or not.
|
|
69
|
+
for (const prefix of inclusivePrefixes) {
|
|
70
|
+
if (resolvePrefix(element, prefix) !== undefined) used.add(prefix);
|
|
71
|
+
}
|
|
72
|
+
// Never declared — the specification binds it.
|
|
73
|
+
used.delete("xml");
|
|
74
|
+
return used;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writeElement(
|
|
78
|
+
element: XmlElement,
|
|
79
|
+
out: string[],
|
|
80
|
+
rendered: Map<string, string>,
|
|
81
|
+
options: CanonicalizeOptions,
|
|
82
|
+
): void {
|
|
83
|
+
const inclusive = options.inclusivePrefixes ?? [];
|
|
84
|
+
const utilized = [...visiblyUtilized(element, inclusive)].sort(compare);
|
|
85
|
+
|
|
86
|
+
const declarations: Array<[prefix: string, uri: string]> = [];
|
|
87
|
+
const childRendered = new Map(rendered);
|
|
88
|
+
|
|
89
|
+
for (const prefix of utilized) {
|
|
90
|
+
const uri = resolvePrefix(element, prefix) ?? "";
|
|
91
|
+
if (prefix === "" && uri === "") {
|
|
92
|
+
// `xmlns=""` is written only to undo a default namespace an output
|
|
93
|
+
// ancestor put in scope. Writing it otherwise is noise the signer
|
|
94
|
+
// did not produce.
|
|
95
|
+
if ((rendered.get("") ?? "") !== "") {
|
|
96
|
+
declarations.push(["", ""]);
|
|
97
|
+
childRendered.set("", "");
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (rendered.get(prefix) !== uri) {
|
|
102
|
+
declarations.push([prefix, uri]);
|
|
103
|
+
childRendered.set(prefix, uri);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
out.push("<", element.qname);
|
|
108
|
+
for (const [prefix, uri] of declarations) {
|
|
109
|
+
out.push(
|
|
110
|
+
" ",
|
|
111
|
+
prefix === "" ? "xmlns" : `xmlns:${prefix}`,
|
|
112
|
+
'="',
|
|
113
|
+
escapeAttribute(uri),
|
|
114
|
+
'"',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
for (const attribute of [...element.attributes].sort(byNamespaceThenLocal)) {
|
|
118
|
+
out.push(" ", attribute.qname, '="', escapeAttribute(attribute.value), '"');
|
|
119
|
+
}
|
|
120
|
+
out.push(">");
|
|
121
|
+
|
|
122
|
+
for (const child of element.children) {
|
|
123
|
+
if (options.omit?.has(child)) continue;
|
|
124
|
+
writeNode(child, out, childRendered, options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Empty elements are written as a start/end pair, never self-closed.
|
|
128
|
+
out.push("</", element.qname, ">");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeNode(
|
|
132
|
+
node: XmlNode,
|
|
133
|
+
out: string[],
|
|
134
|
+
rendered: Map<string, string>,
|
|
135
|
+
options: CanonicalizeOptions,
|
|
136
|
+
): void {
|
|
137
|
+
switch (node.type) {
|
|
138
|
+
case "element":
|
|
139
|
+
writeElement(node, out, rendered, options);
|
|
140
|
+
return;
|
|
141
|
+
case "text":
|
|
142
|
+
out.push(escapeText(node.value));
|
|
143
|
+
return;
|
|
144
|
+
case "comment":
|
|
145
|
+
if (options.withComments) out.push("<!--", node.value, "-->");
|
|
146
|
+
return;
|
|
147
|
+
case "instruction":
|
|
148
|
+
out.push(
|
|
149
|
+
"<?",
|
|
150
|
+
node.target,
|
|
151
|
+
node.data === "" ? "" : ` ${node.data}`,
|
|
152
|
+
"?>",
|
|
153
|
+
);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Namespace URI first, then local name; an empty URI sorts least. */
|
|
159
|
+
function byNamespaceThenLocal(a: XmlAttribute, b: XmlAttribute): number {
|
|
160
|
+
const uri = compare(a.namespaceUri, b.namespaceUri);
|
|
161
|
+
return uri !== 0 ? uri : compare(a.local, b.local);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Byte order, which is what the specification means by lexicographic here. */
|
|
165
|
+
function compare(a: string, b: string): number {
|
|
166
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escapeText(value: string): string {
|
|
170
|
+
return value
|
|
171
|
+
.replace(/&/g, "&")
|
|
172
|
+
.replace(/</g, "<")
|
|
173
|
+
.replace(/>/g, ">")
|
|
174
|
+
.replace(/\r/g, "
");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function escapeAttribute(value: string): string {
|
|
178
|
+
return value
|
|
179
|
+
.replace(/&/g, "&")
|
|
180
|
+
.replace(/</g, "<")
|
|
181
|
+
.replace(/"/g, """)
|
|
182
|
+
.replace(/\t/g, "	")
|
|
183
|
+
.replace(/\n/g, "
")
|
|
184
|
+
.replace(/\r/g, "
");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export { XML_NS };
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author-time config for `config/transit.ts`.
|
|
3
|
+
*
|
|
4
|
+
* import { defineConfig, socials } from '@c9up/transit'
|
|
5
|
+
*
|
|
6
|
+
* export default defineConfig({
|
|
7
|
+
* google: socials.google({
|
|
8
|
+
* clientId: env.get('GOOGLE_CLIENT_ID'),
|
|
9
|
+
* clientSecret: env.get('GOOGLE_CLIENT_SECRET'),
|
|
10
|
+
* callbackUrl: 'https://acme.test/auth/google/callback',
|
|
11
|
+
* }),
|
|
12
|
+
* })
|
|
13
|
+
*
|
|
14
|
+
* The keys are yours. Two entries may reach the same provider with different
|
|
15
|
+
* credentials — a `staff` sign-in and a `customers` one, both on Google — and
|
|
16
|
+
* `use(name)` asks for the key, not for the provider.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { type AppleConfig, AppleDriver } from "./drivers/AppleDriver.js";
|
|
20
|
+
import { DiscordDriver } from "./drivers/DiscordDriver.js";
|
|
21
|
+
import { FacebookDriver } from "./drivers/FacebookDriver.js";
|
|
22
|
+
import { GitHubDriver } from "./drivers/GitHubDriver.js";
|
|
23
|
+
import { GoogleDriver } from "./drivers/GoogleDriver.js";
|
|
24
|
+
import { type LdapConfig, LdapDirectory } from "./drivers/LdapDirectory.js";
|
|
25
|
+
import { LinkedInDriver } from "./drivers/LinkedInDriver.js";
|
|
26
|
+
import { LinkedInOpenidConnectDriver } from "./drivers/LinkedInOpenidConnectDriver.js";
|
|
27
|
+
import { type OidcConfig, OidcDriver } from "./drivers/OidcDriver.js";
|
|
28
|
+
import { type SamlConfig, SamlDriver } from "./drivers/SamlDriver.js";
|
|
29
|
+
import { SpotifyDriver } from "./drivers/SpotifyDriver.js";
|
|
30
|
+
import { TwitterDriver } from "./drivers/TwitterDriver.js";
|
|
31
|
+
import { TwitterXDriver } from "./drivers/TwitterXDriver.js";
|
|
32
|
+
import type { OAuthConfig, TransitEntry } from "./types.js";
|
|
33
|
+
|
|
34
|
+
/** A provider, built when the manager is. */
|
|
35
|
+
export type TransitDriverFactory = () => TransitEntry;
|
|
36
|
+
|
|
37
|
+
/** One declared provider: the driver itself, or a factory answering one. */
|
|
38
|
+
export type TransitProviderEntry = TransitEntry | TransitDriverFactory;
|
|
39
|
+
|
|
40
|
+
export type TransitConfig = Record<string, TransitProviderEntry>;
|
|
41
|
+
|
|
42
|
+
export function defineConfig<T extends TransitConfig>(config: T): T {
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The social providers Transit speaks to.
|
|
48
|
+
*
|
|
49
|
+
* Factories are lazy, so a config naming a provider this environment never
|
|
50
|
+
* selects costs nothing to declare.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* Any provider that speaks OpenID Connect, from its issuer alone — Keycloak,
|
|
54
|
+
* Auth0, Okta, Entra ID, Authentik, Zitadel, and the rest.
|
|
55
|
+
*
|
|
56
|
+
* work: oidc({
|
|
57
|
+
* issuer: 'https://id.acme.com',
|
|
58
|
+
* clientId: env.get('OIDC_CLIENT_ID'),
|
|
59
|
+
* clientSecret: env.get('OIDC_CLIENT_SECRET'),
|
|
60
|
+
* callbackUrl: 'https://acme.test/auth/work/callback',
|
|
61
|
+
* })
|
|
62
|
+
*
|
|
63
|
+
* The endpoints, the signing keys and the algorithms come from what the
|
|
64
|
+
* provider publishes, so there is nothing else to configure and nothing to
|
|
65
|
+
* update when it rotates a key.
|
|
66
|
+
*/
|
|
67
|
+
export function oidc(config: OidcConfig): TransitDriverFactory {
|
|
68
|
+
return () => new OidcDriver(config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A SAML 2.0 identity provider — the enterprise directories that speak it:
|
|
73
|
+
* Okta, Entra ID, ADFS, OneLogin, Shibboleth.
|
|
74
|
+
*
|
|
75
|
+
* corp: saml({
|
|
76
|
+
* entityId: 'https://acme.test/saml',
|
|
77
|
+
* callbackUrl: 'https://acme.test/saml/acs',
|
|
78
|
+
* issuer: 'https://idp.acme.test/metadata',
|
|
79
|
+
* signOnUrl: 'https://idp.acme.test/sso',
|
|
80
|
+
* certificates: [env.get('IDP_CERTIFICATE')],
|
|
81
|
+
* })
|
|
82
|
+
*
|
|
83
|
+
* The certificates come from the provider's metadata and nowhere else: a
|
|
84
|
+
* response is never verified against a key it carries itself.
|
|
85
|
+
*/
|
|
86
|
+
export function saml(config: SamlConfig): TransitDriverFactory {
|
|
87
|
+
return () => new SamlDriver(config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* An LDAP or Active Directory directory.
|
|
92
|
+
*
|
|
93
|
+
* staff: ldap({
|
|
94
|
+
* url: 'ldaps://directory.acme.test',
|
|
95
|
+
* baseDn: 'dc=acme,dc=test',
|
|
96
|
+
* loginAttribute: 'uid',
|
|
97
|
+
* bindDn: 'cn=reader,dc=acme,dc=test',
|
|
98
|
+
* bindPassword: env.get('LDAP_PASSWORD'),
|
|
99
|
+
* })
|
|
100
|
+
*
|
|
101
|
+
* Reached with `transit.authenticate(name, username, password)`: nothing is
|
|
102
|
+
* redirected, so there is no `begin()` for it.
|
|
103
|
+
*/
|
|
104
|
+
export function ldap(config: LdapConfig): TransitDriverFactory {
|
|
105
|
+
return () => new LdapDirectory(config);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export const socials = {
|
|
109
|
+
/**
|
|
110
|
+
* Sign in with Apple. Required on iOS as soon as another social sign-in is
|
|
111
|
+
* offered.
|
|
112
|
+
*
|
|
113
|
+
* It takes a key rather than a secret — the Services ID, the Team ID, the
|
|
114
|
+
* Key ID and the contents of the `.p8` — and its callback arrives as a
|
|
115
|
+
* POST, because asking for a name forces `response_mode=form_post`. The
|
|
116
|
+
* name comes once, in that POST: read it with `parseAppleUser` and store
|
|
117
|
+
* it, because Apple never sends it again.
|
|
118
|
+
*/
|
|
119
|
+
apple(config: AppleConfig): TransitDriverFactory {
|
|
120
|
+
return () => new AppleDriver(config);
|
|
121
|
+
},
|
|
122
|
+
discord(config: OAuthConfig): TransitDriverFactory {
|
|
123
|
+
return () => new DiscordDriver(config);
|
|
124
|
+
},
|
|
125
|
+
facebook(config: OAuthConfig): TransitDriverFactory {
|
|
126
|
+
return () => new FacebookDriver(config);
|
|
127
|
+
},
|
|
128
|
+
github(config: OAuthConfig): TransitDriverFactory {
|
|
129
|
+
return () => new GitHubDriver(config);
|
|
130
|
+
},
|
|
131
|
+
google(config: OAuthConfig): TransitDriverFactory {
|
|
132
|
+
return () => new GoogleDriver(config);
|
|
133
|
+
},
|
|
134
|
+
/**
|
|
135
|
+
* LinkedIn through the member API, for an application whose LinkedIn app
|
|
136
|
+
* holds `r_liteprofile` / `r_emailaddress`.
|
|
137
|
+
*/
|
|
138
|
+
linkedin(config: OAuthConfig): TransitDriverFactory {
|
|
139
|
+
return () => new LinkedInDriver(config);
|
|
140
|
+
},
|
|
141
|
+
/** LinkedIn through OpenID Connect — what a new application is issued. */
|
|
142
|
+
linkedinOpenidConnect(config: OAuthConfig): TransitDriverFactory {
|
|
143
|
+
return () => new LinkedInOpenidConnectDriver(config);
|
|
144
|
+
},
|
|
145
|
+
spotify(config: OAuthConfig): TransitDriverFactory {
|
|
146
|
+
return () => new SpotifyDriver(config);
|
|
147
|
+
},
|
|
148
|
+
/**
|
|
149
|
+
* X through OAuth1 — the older flow, and the one whose profile call returns
|
|
150
|
+
* the address. Its redirect needs a request token from X, so it is reached
|
|
151
|
+
* through `begin()` rather than `redirect()`.
|
|
152
|
+
*/
|
|
153
|
+
twitter(config: OAuthConfig): TransitDriverFactory {
|
|
154
|
+
return () => new TwitterDriver(config);
|
|
155
|
+
},
|
|
156
|
+
/** X through OAuth2. It requires PKCE, which `begin()` handles. */
|
|
157
|
+
twitterX(config: OAuthConfig): TransitDriverFactory {
|
|
158
|
+
return () => new TwitterXDriver(config);
|
|
159
|
+
},
|
|
160
|
+
};
|