@mutmutco/installer-gate 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/README.md +147 -0
- package/dist/allowlist.d.ts +17 -0
- package/dist/canonical.d.ts +10 -0
- package/dist/github.d.ts +31 -0
- package/dist/google.d.ts +15 -0
- package/dist/handler.d.ts +9 -0
- package/dist/http.d.ts +18 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +532 -0
- package/dist/release.d.ts +34 -0
- package/dist/tokens.d.ts +24 -0
- package/dist/types.d.ts +76 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @mutmutco/installer-gate
|
|
2
|
+
|
|
3
|
+
A small, framework-agnostic **node:http** server library a product mounts to run its *installer
|
|
4
|
+
gate*: it drives the GitHub device flow (or verifies a Google bearer), enforces an allowlist, and
|
|
5
|
+
serves an Ed25519-signed release manifest plus the release files it describes.
|
|
6
|
+
|
|
7
|
+
- Node **>= 22**, **ESM**, **zero runtime dependencies** (node: builtins only).
|
|
8
|
+
- The library never hardcodes a credential: the GitHub client id/secret and every product-specific
|
|
9
|
+
piece (manifest, signer, file reader, bearer verifier) are **injected** by the adopter.
|
|
10
|
+
- The published package ships a built ESM bundle (`dist/index.js`) plus declarations (`dist/*.d.ts`);
|
|
11
|
+
`npm run build` produces both — esbuild bundles the entry and `tsc -p tsconfig.build.json` emits
|
|
12
|
+
types only. The adopter imports `@mutmutco/installer-gate` directly; no bundler step is required.
|
|
13
|
+
|
|
14
|
+
## Mounting
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
import { sign as cryptoSign } from 'node:crypto';
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { createGateHandler } from '@mutmutco/installer-gate';
|
|
22
|
+
|
|
23
|
+
const gate = createGateHandler({
|
|
24
|
+
kind: 'github',
|
|
25
|
+
githubClientId: process.env.GITHUB_CLIENT_ID!,
|
|
26
|
+
githubClientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
27
|
+
allowlist: { source: 'roster', getLogins: async () => rosterLogins() },
|
|
28
|
+
release: {
|
|
29
|
+
manifest: async () => ({
|
|
30
|
+
version: currentVersion(),
|
|
31
|
+
files: releaseFiles(), // [{ path, sha256, size }]
|
|
32
|
+
}),
|
|
33
|
+
sign: (canonicalBytes) => cryptoSign(null, canonicalBytes, privateKey), // Ed25519, detached
|
|
34
|
+
readFile: async (path) => readFile(join(releaseRoot, path)),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
http.createServer((req, res) => {
|
|
39
|
+
if (!gate(req, res)) myOwnRouter(req, res); // false = not a /gate or /release path
|
|
40
|
+
}).listen(8787);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`createGateHandler(config)` returns `(req, res) => boolean`. It owns **`/gate/*`** and
|
|
44
|
+
**`/release/*`**; for those it handles the request and returns `true`. For every other path it
|
|
45
|
+
returns `false` and touches nothing, so the adopter's router continues.
|
|
46
|
+
|
|
47
|
+
## Config reference
|
|
48
|
+
|
|
49
|
+
| Field | Type | Notes |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| `kind` | `'github' \| 'google'` | Selects the identity lane. |
|
|
52
|
+
| `githubClientId` | `string` | github kind. The gate rejects a device-flow `client_id` that does not match it. |
|
|
53
|
+
| `githubClientSecret` | `string` | github kind. Passed in by the adopter — **never committed here**. |
|
|
54
|
+
| `githubApiBase` | `string?` | github kind. Default `https://github.com`; override in tests. |
|
|
55
|
+
| `verifyBearer` | `(token) => Promise<{sub}\|null>` | google kind. The product's own OAuth server. |
|
|
56
|
+
| `allowlist` | `{ source: 'roster' \| 'logins', getLogins?: () => Promise<string[]> }` | Allowed identities. |
|
|
57
|
+
| `release` | `{ manifest, sign, readFile }` | How release artifacts are described, signed and read. |
|
|
58
|
+
| `tokenTtlSeconds` | `number?` | Default **3600**; values above 3600 are clamped to 3600. |
|
|
59
|
+
| `revoke` | `string[] \| (() => Promise<string[]>)?` | Explicit revocations, re-read every call. |
|
|
60
|
+
| `tokenSecret` | `string?` | HMAC key for the gate's own tokens. github derives one from the client secret; google mints no tokens. |
|
|
61
|
+
| `now` | `() => number?` | Injectable clock (epoch ms) for tests. |
|
|
62
|
+
|
|
63
|
+
`release.manifest()` returns `{ version, files: [{ path, sha256, size }] }`. The gate stamps
|
|
64
|
+
`created` (ISO 8601) and signs; `release.readFile(path)` throws when a file is absent.
|
|
65
|
+
|
|
66
|
+
## Wire contract v1
|
|
67
|
+
|
|
68
|
+
| Method | Path | Body / headers | Response |
|
|
69
|
+
| --- | --- | --- | --- |
|
|
70
|
+
| POST | `/gate/device/code` | `{ client_id }` | `200 { device_code, user_code, verification_uri, verification_uri_complete?, expires_in, interval }` |
|
|
71
|
+
| POST | `/gate/device/token` | `{ client_id, device_code }` | `200 { access_token, refresh_token, expires_in }` or `400 { error }` |
|
|
72
|
+
| POST | `/gate/refresh` | `{ refresh_token }` | `200 { access_token, expires_in }` or `403 { error }` |
|
|
73
|
+
| GET | `/release/manifest` | `Authorization: Bearer <access_token>` | `200 { version, created, files, signature }` |
|
|
74
|
+
| GET | `/release/<path>` | `Authorization: Bearer <access_token>` | raw bytes, or `404 { error }` |
|
|
75
|
+
|
|
76
|
+
Device-flow `400` errors are exactly `authorization_pending`, `slow_down`, `expired_token`,
|
|
77
|
+
`denied` (GitHub's `access_denied` maps to `denied`; any other GitHub error is terminal → `denied`).
|
|
78
|
+
|
|
79
|
+
Refusals: **`401 { error: "unauthorized" }`** for a missing/invalid/expired bearer;
|
|
80
|
+
**`403 { error: "forbidden" }`** for an allowlist miss or a revoked identity.
|
|
81
|
+
|
|
82
|
+
## Token format
|
|
83
|
+
|
|
84
|
+
The gate mints its own opaque bearer credentials after the identity is proven:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
<base64url(JSON payload)> . <base64url(HMAC-SHA256(payloadB64, tokenSecret))>
|
|
88
|
+
payload = { sub, exp, jti, typ }
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`sub` is the identity (GitHub login or Google `sub`), `exp` is epoch seconds, `jti` a random id,
|
|
92
|
+
and `typ` is `'access'` or `'refresh'`. **HMAC-SHA256 is the chosen scheme**: the token only ever
|
|
93
|
+
travels back to the gate that minted it, so a symmetric MAC is sufficient and needs no keypair.
|
|
94
|
+
Tokens are opaque to the client; `mintToken`/`verifyToken` are exported for adopters that need to
|
|
95
|
+
inspect one. `expires_in` is always `<= 3600`.
|
|
96
|
+
|
|
97
|
+
## Allowlists
|
|
98
|
+
|
|
99
|
+
- `'logins'` — an explicit list served by `getLogins()`.
|
|
100
|
+
- `'roster'` — the **same interface**; the adopter wires a roster adapter later. The gate treats
|
|
101
|
+
both sources identically.
|
|
102
|
+
- Matching is case-insensitive and trims whitespace (GitHub logins are case-insensitive).
|
|
103
|
+
- **Fail closed**: a missing `getLogins`, an empty list, or a throw allows nobody.
|
|
104
|
+
- The allowlist is checked **at token mint and on every gated read**, and `revoke` (a list or a
|
|
105
|
+
function) is re-read on every call — so an allowlist edit or a revocation refuses the very next
|
|
106
|
+
refresh and release read. Refresh mints a new access token only if the identity still passes.
|
|
107
|
+
|
|
108
|
+
## Release signing
|
|
109
|
+
|
|
110
|
+
`GET /release/manifest` returns a **base64 detached Ed25519 signature** over exactly the UTF-8
|
|
111
|
+
bytes of the canonical JSON:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
{"created":<created>,"files":[{"path":<path>,"sha256":<sha256>,"size":<size>}],"version":<version>}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Keys sorted lexicographically, arrays in given order, no whitespace. The launcher side verifies
|
|
118
|
+
with the exported helpers:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { verifySignedManifest, verifyReleaseFile } from '@mutmutco/installer-gate';
|
|
122
|
+
|
|
123
|
+
verifySignedManifest(manifest, publicKey); // signature over the canonical bytes
|
|
124
|
+
verifyReleaseFile(manifest.files[i], fileBytes); // size + SHA-256 of a downloaded file
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`buildCanonicalManifest(manifest)` returns the exact bytes if you need them; `canonicalJson(value)`
|
|
128
|
+
is the underlying serializer. `GET /release/<path>` refuses any path that escapes the release root
|
|
129
|
+
(`..`, empty/`.` segments, backslashes, absolute paths) with `404 { error: "not_found" }`.
|
|
130
|
+
|
|
131
|
+
## The google kind
|
|
132
|
+
|
|
133
|
+
`kind: 'google'` does **not** implement OAuth. `verifyBearer(token)` is the product's own OAuth
|
|
134
|
+
server; the library verifies the returned `sub` against the allowlist on every gated read. There is
|
|
135
|
+
no device flow and no gate-issued refresh token for this kind, so `/gate/device/*` returns
|
|
136
|
+
`404 { error: "not_found" }`.
|
|
137
|
+
|
|
138
|
+
## Contract-compliance notes (decisions taken)
|
|
139
|
+
|
|
140
|
+
- **Allowlist miss at token mint** is `403 { error: "forbidden" }`, matching the contract's global
|
|
141
|
+
refusal rule; the device-token `400` set is reserved for GitHub's poll errors.
|
|
142
|
+
- **`created`** is an ISO 8601 UTC string (`new Date(now).toISOString()`), so it is JSON-quoted in
|
|
143
|
+
the canonical form.
|
|
144
|
+
- **Token secret** is an added config field. github derives it from `githubClientSecret` when
|
|
145
|
+
absent; google (which mints nothing) generates an ephemeral one.
|
|
146
|
+
- **`/release/<path>` traversal** is refused rather than delegated to the adopter's `readFile`.
|
|
147
|
+
- **No secrets are committed**; the client secret and signer key are injected at mount time.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AllowlistConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Allowlist resolution.
|
|
4
|
+
*
|
|
5
|
+
* Both sources (`'logins'` and `'roster'`) share one interface: `getLogins()` returns the allowed
|
|
6
|
+
* identities. The gate treats them identically — `'roster'` is the name an adopter uses when a
|
|
7
|
+
* roster adapter is wired in later, and nothing in the gate changes. Matching is case-insensitive
|
|
8
|
+
* (GitHub logins are case-insensitive) and trims surrounding whitespace. A missing `getLogins`,
|
|
9
|
+
* an empty list, or a throw fails CLOSED: the identity is refused.
|
|
10
|
+
*/
|
|
11
|
+
type RevokeSource = string[] | (() => Promise<string[]>) | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* True only when `identity` is on the allowlist and not on the revoke list. Re-reads both sources
|
|
14
|
+
* on every call, so an allowlist edit or a revocation takes effect on the very next request.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isIdentityAllowed(allowlist: AllowlistConfig, revoke: RevokeSource, identity: string): Promise<boolean>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON — the byte contract the manifest signature covers.
|
|
3
|
+
*
|
|
4
|
+
* Rules (wire contract v1): object keys sorted lexicographically (UTF-16 code-unit order, which
|
|
5
|
+
* matches byte order for ASCII keys), arrays kept in the given order, `undefined` object members
|
|
6
|
+
* dropped, and no whitespace anywhere. This is intentionally NOT RFC 8785/JCS (no number
|
|
7
|
+
* canonicalization, no Unicode escaping rules) — it is exactly the shape the launcher team signs
|
|
8
|
+
* against, and every producer and verifier here funnels through this one function.
|
|
9
|
+
*/
|
|
10
|
+
export declare function canonicalJson(value: unknown): string;
|
package/dist/github.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub device flow (OAuth 2.0 Device Authorization Grant) client, plus the identity lookup.
|
|
3
|
+
*
|
|
4
|
+
* Everything talks to `githubApiBase` (default `https://github.com`), which tests point at a local
|
|
5
|
+
* fake server so no real network is touched. The gate never hardcodes credentials: the client id
|
|
6
|
+
* and secret arrive in config.
|
|
7
|
+
*/
|
|
8
|
+
export interface GitHubDeviceCodeResponse {
|
|
9
|
+
device_code: string;
|
|
10
|
+
user_code: string;
|
|
11
|
+
verification_uri: string;
|
|
12
|
+
verification_uri_complete?: string;
|
|
13
|
+
expires_in: number;
|
|
14
|
+
interval: number;
|
|
15
|
+
}
|
|
16
|
+
export type GitHubDeviceTokenResult = {
|
|
17
|
+
access_token: string;
|
|
18
|
+
} | {
|
|
19
|
+
error: string;
|
|
20
|
+
};
|
|
21
|
+
/** The four poll errors the wire contract exposes. */
|
|
22
|
+
export type GateDeviceError = 'authorization_pending' | 'slow_down' | 'expired_token' | 'denied';
|
|
23
|
+
export declare function normalizeGitHubApiBase(base?: string): string;
|
|
24
|
+
/** Starts a device authorization. `POST /gate/device/code` proxies this. */
|
|
25
|
+
export declare function requestDeviceCode(apiBase: string, clientId: string): Promise<GitHubDeviceCodeResponse>;
|
|
26
|
+
/** Exchanges a device code for a GitHub access token. Returns the raw result, error included. */
|
|
27
|
+
export declare function pollDeviceToken(apiBase: string, clientId: string, clientSecret: string, deviceCode: string): Promise<GitHubDeviceTokenResult>;
|
|
28
|
+
/** Maps a GitHub poll error onto the wire contract's four-value error set. */
|
|
29
|
+
export declare function mapDeviceError(error: string): GateDeviceError;
|
|
30
|
+
/** Resolves the authenticated GitHub login for a token. */
|
|
31
|
+
export declare function fetchGitHubLogin(apiBase: string, accessToken: string): Promise<string>;
|
package/dist/google.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google gate identity resolution.
|
|
3
|
+
*
|
|
4
|
+
* The gate does NOT implement OAuth: bearer verification is the product's own OAuth server,
|
|
5
|
+
* injected as `verifyBearer`. This module only names the contract and wraps the call so a throw
|
|
6
|
+
* fails closed as "unauthorized" rather than crashing the request.
|
|
7
|
+
*/
|
|
8
|
+
export type BearerVerifier = (token: string) => Promise<{
|
|
9
|
+
sub: string;
|
|
10
|
+
} | null>;
|
|
11
|
+
/**
|
|
12
|
+
* Runs the injected verifier. Returns the identity `sub`, or null when the token is invalid, the
|
|
13
|
+
* verifier returns nothing usable, or the verifier throws.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveGoogleIdentity(verifyBearer: BearerVerifier, token: string): Promise<string | null>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GateConfig, GateHandler } from './types.js';
|
|
2
|
+
/** Rejects any path that could escape the release root. Returns the normalized relative path. */
|
|
3
|
+
export declare function safeReleasePath(rawPath: string): string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Builds the mountable gate handler. The returned function owns `/gate/*` and `/release/*`:
|
|
6
|
+
* it handles them and returns `true`; every other path returns `false` so the product's own
|
|
7
|
+
* router keeps going. Errors never escape — an unexpected failure becomes a 500 JSON body.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createGateHandler(config: GateConfig): GateHandler;
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/** Small node:http helpers — no framework, no dependencies. */
|
|
3
|
+
/** Request path without query string. */
|
|
4
|
+
export declare function requestPath(req: IncomingMessage): string;
|
|
5
|
+
/** HTTP error carrying a status and the JSON `error` code the wire contract uses. */
|
|
6
|
+
export declare class GateHttpError extends Error {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly code: string;
|
|
9
|
+
constructor(status: number, code: string);
|
|
10
|
+
}
|
|
11
|
+
/** Reads and parses a JSON request body. Empty body parses to `{}`. */
|
|
12
|
+
export declare function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>>;
|
|
13
|
+
/** Returns the bearer token from the Authorization header, or null. */
|
|
14
|
+
export declare function bearerToken(req: IncomingMessage): string | null;
|
|
15
|
+
export declare function sendJson(res: ServerResponse, status: number, body: unknown): void;
|
|
16
|
+
export declare function sendBytes(res: ServerResponse, status: number, bytes: Buffer): void;
|
|
17
|
+
/** Reads a string field from a parsed body, or undefined when absent/not a string. */
|
|
18
|
+
export declare function stringField(body: Record<string, unknown>, key: string): string | undefined;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mutmutco/installer-gate — a small node:http server library a product mounts to run its
|
|
3
|
+
* installer gate: GitHub device flow (or Google bearer verification), allowlisted identities,
|
|
4
|
+
* and Ed25519-signed release reads.
|
|
5
|
+
*
|
|
6
|
+
* Zero runtime dependencies (node: builtins only). Node >= 22, ESM.
|
|
7
|
+
*
|
|
8
|
+
* import { createGateHandler } from '@mutmutco/installer-gate';
|
|
9
|
+
* const gate = createGateHandler(config);
|
|
10
|
+
* http.createServer((req, res) => { if (!gate(req, res)) myRouter(req, res); }).listen(8787);
|
|
11
|
+
*/
|
|
12
|
+
export { createGateHandler, safeReleasePath } from './handler.js';
|
|
13
|
+
export { buildCanonicalManifest, sha256Hex, signManifest, verifyManifestSignature, verifyReleaseFile, verifySignedManifest, } from './release.js';
|
|
14
|
+
export { canonicalJson } from './canonical.js';
|
|
15
|
+
export { isIdentityAllowed } from './allowlist.js';
|
|
16
|
+
export { mintToken, verifyToken } from './tokens.js';
|
|
17
|
+
export type { GateTokenPayload } from './tokens.js';
|
|
18
|
+
export { fetchGitHubLogin, mapDeviceError, normalizeGitHubApiBase, pollDeviceToken, requestDeviceCode, } from './github.js';
|
|
19
|
+
export type { GateDeviceError, GitHubDeviceCodeResponse, GitHubDeviceTokenResult, } from './github.js';
|
|
20
|
+
export { resolveGoogleIdentity } from './google.js';
|
|
21
|
+
export type { BearerVerifier } from './google.js';
|
|
22
|
+
export type { AllowlistConfig, GateConfig, GateHandler, GitHubGateConfig, GoogleGateConfig, ReleaseConfig, ReleaseFileEntry, ReleaseManifestInput, SignedReleaseManifest, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
// src/handler.ts
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
// src/allowlist.ts
|
|
5
|
+
function normalize(identity) {
|
|
6
|
+
return identity.trim().toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
async function readList(source) {
|
|
9
|
+
if (source === void 0) return [];
|
|
10
|
+
try {
|
|
11
|
+
const value = typeof source === "function" ? await source() : source;
|
|
12
|
+
return Array.isArray(value) ? value : [];
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function isIdentityAllowed(allowlist, revoke, identity) {
|
|
18
|
+
const normalized = normalize(identity);
|
|
19
|
+
if (normalized.length === 0) return false;
|
|
20
|
+
const revoked = new Set((await readList(revoke)).map(normalize));
|
|
21
|
+
if (revoked.has(normalized)) return false;
|
|
22
|
+
const allowed = new Set((await readList(allowlist.getLogins)).map(normalize));
|
|
23
|
+
return allowed.has(normalized);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/github.ts
|
|
27
|
+
function normalizeGitHubApiBase(base) {
|
|
28
|
+
return (base ?? "https://github.com").replace(/\/+$/, "");
|
|
29
|
+
}
|
|
30
|
+
async function formPost(url, params) {
|
|
31
|
+
const response = await fetch(url, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: {
|
|
34
|
+
accept: "application/json",
|
|
35
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
36
|
+
"user-agent": "mmi-installer-gate"
|
|
37
|
+
},
|
|
38
|
+
body: new URLSearchParams(params).toString()
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
throw new Error(`github ${url} responded ${response.status}`);
|
|
42
|
+
}
|
|
43
|
+
const data = await response.json();
|
|
44
|
+
if (typeof data !== "object" || data === null) throw new Error("github returned a non-object body");
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
async function requestDeviceCode(apiBase, clientId) {
|
|
48
|
+
const data = await formPost(`${apiBase}/login/device/code`, { client_id: clientId });
|
|
49
|
+
const deviceCode = data.device_code;
|
|
50
|
+
const userCode = data.user_code;
|
|
51
|
+
const verificationUri = data.verification_uri;
|
|
52
|
+
const expiresIn = data.expires_in;
|
|
53
|
+
const interval = data.interval;
|
|
54
|
+
if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string" || typeof expiresIn !== "number" || typeof interval !== "number") {
|
|
55
|
+
throw new Error("github device code response is missing required fields");
|
|
56
|
+
}
|
|
57
|
+
const result = {
|
|
58
|
+
device_code: deviceCode,
|
|
59
|
+
user_code: userCode,
|
|
60
|
+
verification_uri: verificationUri,
|
|
61
|
+
expires_in: expiresIn,
|
|
62
|
+
interval
|
|
63
|
+
};
|
|
64
|
+
if (typeof data.verification_uri_complete === "string") {
|
|
65
|
+
result.verification_uri_complete = data.verification_uri_complete;
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
async function pollDeviceToken(apiBase, clientId, clientSecret, deviceCode) {
|
|
70
|
+
const data = await formPost(`${apiBase}/login/oauth/access_token`, {
|
|
71
|
+
client_id: clientId,
|
|
72
|
+
client_secret: clientSecret,
|
|
73
|
+
device_code: deviceCode,
|
|
74
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
75
|
+
});
|
|
76
|
+
if (typeof data.access_token === "string" && data.access_token.length > 0) {
|
|
77
|
+
return { access_token: data.access_token };
|
|
78
|
+
}
|
|
79
|
+
const error = typeof data.error === "string" ? data.error : "unknown_error";
|
|
80
|
+
return { error };
|
|
81
|
+
}
|
|
82
|
+
function mapDeviceError(error) {
|
|
83
|
+
switch (error) {
|
|
84
|
+
case "authorization_pending":
|
|
85
|
+
return "authorization_pending";
|
|
86
|
+
case "slow_down":
|
|
87
|
+
return "slow_down";
|
|
88
|
+
case "expired_token":
|
|
89
|
+
return "expired_token";
|
|
90
|
+
// GitHub's `access_denied` is the user declining; every other code is terminal too.
|
|
91
|
+
default:
|
|
92
|
+
return "denied";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function fetchGitHubLogin(apiBase, accessToken) {
|
|
96
|
+
const response = await fetch(`${apiBase}/user`, {
|
|
97
|
+
headers: {
|
|
98
|
+
accept: "application/vnd.github+json",
|
|
99
|
+
authorization: `Bearer ${accessToken}`,
|
|
100
|
+
"user-agent": "mmi-installer-gate"
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (!response.ok) throw new Error(`github /user responded ${response.status}`);
|
|
104
|
+
const data = await response.json();
|
|
105
|
+
const login = data?.login;
|
|
106
|
+
if (typeof login !== "string" || login.length === 0) {
|
|
107
|
+
throw new Error("github /user response has no login");
|
|
108
|
+
}
|
|
109
|
+
return login;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/google.ts
|
|
113
|
+
async function resolveGoogleIdentity(verifyBearer, token) {
|
|
114
|
+
try {
|
|
115
|
+
const identity = await verifyBearer(token);
|
|
116
|
+
if (identity && typeof identity.sub === "string" && identity.sub.length > 0) {
|
|
117
|
+
return identity.sub;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/http.ts
|
|
126
|
+
function requestPath(req) {
|
|
127
|
+
const raw = req.url ?? "/";
|
|
128
|
+
const query = raw.indexOf("?");
|
|
129
|
+
return query === -1 ? raw : raw.slice(0, query);
|
|
130
|
+
}
|
|
131
|
+
var GateHttpError = class extends Error {
|
|
132
|
+
constructor(status, code) {
|
|
133
|
+
super(`${status} ${code}`);
|
|
134
|
+
this.status = status;
|
|
135
|
+
this.code = code;
|
|
136
|
+
this.name = "GateHttpError";
|
|
137
|
+
}
|
|
138
|
+
status;
|
|
139
|
+
code;
|
|
140
|
+
};
|
|
141
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
142
|
+
async function readJsonBody(req) {
|
|
143
|
+
const chunks = [];
|
|
144
|
+
let size = 0;
|
|
145
|
+
for await (const chunk of req) {
|
|
146
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
147
|
+
size += buffer.length;
|
|
148
|
+
if (size > MAX_BODY_BYTES) throw new GateHttpError(413, "payload_too_large");
|
|
149
|
+
chunks.push(buffer);
|
|
150
|
+
}
|
|
151
|
+
if (size === 0) return {};
|
|
152
|
+
let parsed;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
155
|
+
} catch {
|
|
156
|
+
throw new GateHttpError(400, "invalid_json");
|
|
157
|
+
}
|
|
158
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
159
|
+
throw new GateHttpError(400, "invalid_json");
|
|
160
|
+
}
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
function bearerToken(req) {
|
|
164
|
+
const header = req.headers.authorization;
|
|
165
|
+
if (typeof header !== "string") return null;
|
|
166
|
+
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
167
|
+
return match ? match[1] : null;
|
|
168
|
+
}
|
|
169
|
+
function sendJson(res, status, body) {
|
|
170
|
+
const payload = Buffer.from(JSON.stringify(body), "utf8");
|
|
171
|
+
res.writeHead(status, {
|
|
172
|
+
"content-type": "application/json; charset=utf-8",
|
|
173
|
+
"content-length": String(payload.length),
|
|
174
|
+
"cache-control": "no-store"
|
|
175
|
+
});
|
|
176
|
+
res.end(payload);
|
|
177
|
+
}
|
|
178
|
+
function sendBytes(res, status, bytes) {
|
|
179
|
+
res.writeHead(status, {
|
|
180
|
+
"content-type": "application/octet-stream",
|
|
181
|
+
"content-length": String(bytes.length),
|
|
182
|
+
"cache-control": "no-store"
|
|
183
|
+
});
|
|
184
|
+
res.end(bytes);
|
|
185
|
+
}
|
|
186
|
+
function stringField(body, key) {
|
|
187
|
+
const value = body[key];
|
|
188
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/release.ts
|
|
192
|
+
import { createHash, verify } from "node:crypto";
|
|
193
|
+
|
|
194
|
+
// src/canonical.ts
|
|
195
|
+
function canonicalJson(value) {
|
|
196
|
+
return encode(value);
|
|
197
|
+
}
|
|
198
|
+
function encode(value) {
|
|
199
|
+
if (value === null) return "null";
|
|
200
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
201
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
202
|
+
if (typeof value === "number") {
|
|
203
|
+
if (!Number.isFinite(value)) {
|
|
204
|
+
throw new TypeError("canonicalJson: cannot encode a non-finite number");
|
|
205
|
+
}
|
|
206
|
+
return JSON.stringify(value);
|
|
207
|
+
}
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
return `[${value.map((entry) => encode(entry)).join(",")}]`;
|
|
210
|
+
}
|
|
211
|
+
if (typeof value === "object") {
|
|
212
|
+
const record = value;
|
|
213
|
+
const keys = Object.keys(record).filter((key) => record[key] !== void 0).sort();
|
|
214
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(",")}}`;
|
|
215
|
+
}
|
|
216
|
+
throw new TypeError(`canonicalJson: cannot encode a value of type ${typeof value}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/release.ts
|
|
220
|
+
function canonicalManifestObject(manifest) {
|
|
221
|
+
return {
|
|
222
|
+
created: manifest.created,
|
|
223
|
+
files: manifest.files.map((file) => ({
|
|
224
|
+
path: file.path,
|
|
225
|
+
sha256: file.sha256,
|
|
226
|
+
size: file.size
|
|
227
|
+
})),
|
|
228
|
+
version: manifest.version
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function buildCanonicalManifest(manifest) {
|
|
232
|
+
return Buffer.from(canonicalJson(canonicalManifestObject(manifest)), "utf8");
|
|
233
|
+
}
|
|
234
|
+
function sha256Hex(bytes) {
|
|
235
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
236
|
+
}
|
|
237
|
+
function signManifest(manifest, sign2) {
|
|
238
|
+
return sign2(buildCanonicalManifest(manifest)).toString("base64");
|
|
239
|
+
}
|
|
240
|
+
function verifyManifestSignature(manifest, signature, publicKey) {
|
|
241
|
+
try {
|
|
242
|
+
const signatureBytes = Buffer.from(signature, "base64");
|
|
243
|
+
if (signatureBytes.length === 0) return false;
|
|
244
|
+
return verify(null, buildCanonicalManifest(manifest), publicKey, signatureBytes);
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function verifyReleaseFile(entry, bytes) {
|
|
250
|
+
return entry.size === bytes.length && sha256Hex(bytes) === entry.sha256;
|
|
251
|
+
}
|
|
252
|
+
function verifySignedManifest(manifest, publicKey) {
|
|
253
|
+
return verifyManifestSignature(manifest, manifest.signature, publicKey);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/tokens.ts
|
|
257
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
258
|
+
function mintToken(secret, identity, typ, expiresInSeconds, nowMs) {
|
|
259
|
+
const payload = {
|
|
260
|
+
sub: identity,
|
|
261
|
+
exp: Math.floor(nowMs / 1e3) + Math.max(1, Math.floor(expiresInSeconds)),
|
|
262
|
+
jti: randomUUID(),
|
|
263
|
+
typ
|
|
264
|
+
};
|
|
265
|
+
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
266
|
+
return `${body}.${sign(secret, body)}`;
|
|
267
|
+
}
|
|
268
|
+
function sign(secret, body) {
|
|
269
|
+
return createHmac("sha256", secret).update(body).digest().toString("base64url");
|
|
270
|
+
}
|
|
271
|
+
function verifyToken(secret, token) {
|
|
272
|
+
const dot = token.indexOf(".");
|
|
273
|
+
if (dot <= 0 || dot === token.length - 1) return null;
|
|
274
|
+
const body = token.slice(0, dot);
|
|
275
|
+
const provided = token.slice(dot + 1);
|
|
276
|
+
const expected = sign(secret, body);
|
|
277
|
+
if (provided.length !== expected.length) return null;
|
|
278
|
+
if (!timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) return null;
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
281
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
282
|
+
const payload = parsed;
|
|
283
|
+
if (typeof payload.sub !== "string" || payload.sub.length === 0) return null;
|
|
284
|
+
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) return null;
|
|
285
|
+
if (typeof payload.jti !== "string" || payload.jti.length === 0) return null;
|
|
286
|
+
if (payload.typ !== "access" && payload.typ !== "refresh") return null;
|
|
287
|
+
return { sub: payload.sub, exp: payload.exp, jti: payload.jti, typ: payload.typ };
|
|
288
|
+
} catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/handler.ts
|
|
294
|
+
var DEFAULT_TTL_SECONDS = 3600;
|
|
295
|
+
var MAX_TTL_SECONDS = 3600;
|
|
296
|
+
function resolveTtl(config) {
|
|
297
|
+
const requested = config.tokenTtlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
298
|
+
if (!Number.isFinite(requested) || requested <= 0) return DEFAULT_TTL_SECONDS;
|
|
299
|
+
return Math.min(Math.floor(requested), MAX_TTL_SECONDS);
|
|
300
|
+
}
|
|
301
|
+
function resolveSecret(config) {
|
|
302
|
+
if (config.tokenSecret) return config.tokenSecret;
|
|
303
|
+
if (config.kind === "github") {
|
|
304
|
+
return `installer-gate:github:${config.githubClientId}:${config.githubClientSecret}`;
|
|
305
|
+
}
|
|
306
|
+
return randomBytes(32).toString("base64url");
|
|
307
|
+
}
|
|
308
|
+
function safeReleasePath(rawPath) {
|
|
309
|
+
let decoded;
|
|
310
|
+
try {
|
|
311
|
+
decoded = decodeURIComponent(rawPath);
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (decoded.length === 0 || decoded.includes("\0") || decoded.includes("\\")) return null;
|
|
316
|
+
const segments = decoded.split("/");
|
|
317
|
+
for (const segment of segments) {
|
|
318
|
+
if (segment === "" || segment === "." || segment === "..") return null;
|
|
319
|
+
}
|
|
320
|
+
return segments.join("/");
|
|
321
|
+
}
|
|
322
|
+
function createGateHandler(config) {
|
|
323
|
+
const now = config.now ?? (() => Date.now());
|
|
324
|
+
const ttlSeconds = resolveTtl(config);
|
|
325
|
+
const secret = resolveSecret(config);
|
|
326
|
+
const githubApiBase = config.kind === "github" ? normalizeGitHubApiBase(config.githubApiBase) : "";
|
|
327
|
+
return function gateHandler(req, res) {
|
|
328
|
+
const pathname = requestPath(req);
|
|
329
|
+
if (!pathname.startsWith("/gate/") && !pathname.startsWith("/release/")) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
void route(req, res, pathname).catch((error) => {
|
|
333
|
+
if (res.headersSent) {
|
|
334
|
+
res.end();
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (error instanceof GateHttpError) {
|
|
338
|
+
sendJson(res, error.status, { error: error.code });
|
|
339
|
+
} else {
|
|
340
|
+
sendJson(res, 500, { error: "internal_error" });
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
return true;
|
|
344
|
+
};
|
|
345
|
+
async function route(req, res, pathname) {
|
|
346
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
347
|
+
if (pathname === "/gate/device/code") {
|
|
348
|
+
requireMethod(method, "POST");
|
|
349
|
+
if (config.kind !== "github") throw new GateHttpError(404, "not_found");
|
|
350
|
+
await deviceCode(req, res);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (pathname === "/gate/device/token") {
|
|
354
|
+
requireMethod(method, "POST");
|
|
355
|
+
if (config.kind !== "github") throw new GateHttpError(404, "not_found");
|
|
356
|
+
await deviceToken(req, res);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (pathname === "/gate/refresh") {
|
|
360
|
+
requireMethod(method, "POST");
|
|
361
|
+
await refresh(req, res);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (pathname === "/release/manifest") {
|
|
365
|
+
requireMethod(method, "GET");
|
|
366
|
+
await releaseManifest(req, res);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (pathname.startsWith("/release/")) {
|
|
370
|
+
requireMethod(method, "GET");
|
|
371
|
+
await releaseFile(req, res, pathname.slice("/release/".length));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
throw new GateHttpError(404, "not_found");
|
|
375
|
+
}
|
|
376
|
+
function requireMethod(actual, expected) {
|
|
377
|
+
if (actual !== expected) throw new GateHttpError(405, "method_not_allowed");
|
|
378
|
+
}
|
|
379
|
+
function requireClientId(body) {
|
|
380
|
+
if (config.kind !== "github") throw new GateHttpError(404, "not_found");
|
|
381
|
+
const clientId = stringField(body, "client_id");
|
|
382
|
+
if (!clientId) throw new GateHttpError(400, "invalid_request");
|
|
383
|
+
if (clientId !== config.githubClientId) throw new GateHttpError(400, "invalid_client");
|
|
384
|
+
return clientId;
|
|
385
|
+
}
|
|
386
|
+
async function deviceCode(req, res) {
|
|
387
|
+
if (config.kind !== "github") throw new GateHttpError(404, "not_found");
|
|
388
|
+
const body = await readJsonBody(req);
|
|
389
|
+
requireClientId(body);
|
|
390
|
+
let issued;
|
|
391
|
+
try {
|
|
392
|
+
issued = await requestDeviceCode(githubApiBase, config.githubClientId);
|
|
393
|
+
} catch {
|
|
394
|
+
throw new GateHttpError(502, "upstream_error");
|
|
395
|
+
}
|
|
396
|
+
sendJson(res, 200, issued);
|
|
397
|
+
}
|
|
398
|
+
async function deviceToken(req, res) {
|
|
399
|
+
if (config.kind !== "github") throw new GateHttpError(404, "not_found");
|
|
400
|
+
const body = await readJsonBody(req);
|
|
401
|
+
requireClientId(body);
|
|
402
|
+
const deviceCodeValue = stringField(body, "device_code");
|
|
403
|
+
if (!deviceCodeValue) throw new GateHttpError(400, "invalid_request");
|
|
404
|
+
let result;
|
|
405
|
+
try {
|
|
406
|
+
result = await pollDeviceToken(
|
|
407
|
+
githubApiBase,
|
|
408
|
+
config.githubClientId,
|
|
409
|
+
config.githubClientSecret,
|
|
410
|
+
deviceCodeValue
|
|
411
|
+
);
|
|
412
|
+
} catch {
|
|
413
|
+
throw new GateHttpError(502, "upstream_error");
|
|
414
|
+
}
|
|
415
|
+
if ("error" in result) {
|
|
416
|
+
sendJson(res, 400, { error: mapDeviceError(result.error) });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
let identity;
|
|
420
|
+
try {
|
|
421
|
+
identity = await fetchGitHubLogin(githubApiBase, result.access_token);
|
|
422
|
+
} catch {
|
|
423
|
+
throw new GateHttpError(502, "upstream_error");
|
|
424
|
+
}
|
|
425
|
+
if (!await isIdentityAllowed(config.allowlist, config.revoke, identity)) {
|
|
426
|
+
sendJson(res, 403, { error: "forbidden" });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const nowMs = now();
|
|
430
|
+
sendJson(res, 200, {
|
|
431
|
+
access_token: mintToken(secret, identity, "access", ttlSeconds, nowMs),
|
|
432
|
+
refresh_token: mintToken(secret, identity, "refresh", ttlSeconds, nowMs),
|
|
433
|
+
expires_in: ttlSeconds
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
async function refresh(req, res) {
|
|
437
|
+
const body = await readJsonBody(req);
|
|
438
|
+
const token = stringField(body, "refresh_token");
|
|
439
|
+
if (!token) throw new GateHttpError(403, "invalid_refresh_token");
|
|
440
|
+
const payload = verifyToken(secret, token);
|
|
441
|
+
if (!payload || payload.typ !== "refresh") throw new GateHttpError(403, "invalid_refresh_token");
|
|
442
|
+
if (payload.exp <= Math.floor(now() / 1e3)) throw new GateHttpError(403, "invalid_refresh_token");
|
|
443
|
+
if (!await isIdentityAllowed(config.allowlist, config.revoke, payload.sub)) {
|
|
444
|
+
sendJson(res, 403, { error: "forbidden" });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
sendJson(res, 200, {
|
|
448
|
+
access_token: mintToken(secret, payload.sub, "access", ttlSeconds, now()),
|
|
449
|
+
expires_in: ttlSeconds
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
async function authenticate(req, res) {
|
|
453
|
+
const token = bearerToken(req);
|
|
454
|
+
if (!token) {
|
|
455
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
let identity;
|
|
459
|
+
if (config.kind === "github") {
|
|
460
|
+
const payload = verifyToken(secret, token);
|
|
461
|
+
if (!payload || payload.typ !== "access" || payload.exp <= Math.floor(now() / 1e3)) {
|
|
462
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
identity = payload.sub;
|
|
466
|
+
} else {
|
|
467
|
+
identity = await resolveGoogleIdentity(config.verifyBearer, token);
|
|
468
|
+
if (!identity) {
|
|
469
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (!await isIdentityAllowed(config.allowlist, config.revoke, identity)) {
|
|
474
|
+
sendJson(res, 403, { error: "forbidden" });
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
return identity;
|
|
478
|
+
}
|
|
479
|
+
async function releaseManifest(req, res) {
|
|
480
|
+
if (await authenticate(req, res) === null) return;
|
|
481
|
+
let input;
|
|
482
|
+
try {
|
|
483
|
+
input = await config.release.manifest();
|
|
484
|
+
} catch {
|
|
485
|
+
throw new GateHttpError(500, "internal_error");
|
|
486
|
+
}
|
|
487
|
+
const created = new Date(now()).toISOString();
|
|
488
|
+
const manifest = {
|
|
489
|
+
version: input.version,
|
|
490
|
+
created,
|
|
491
|
+
files: input.files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size })),
|
|
492
|
+
signature: signManifest({ version: input.version, created, files: input.files }, config.release.sign)
|
|
493
|
+
};
|
|
494
|
+
sendJson(res, 200, manifest);
|
|
495
|
+
}
|
|
496
|
+
async function releaseFile(req, res, rawPath) {
|
|
497
|
+
if (await authenticate(req, res) === null) return;
|
|
498
|
+
const relativePath = safeReleasePath(rawPath);
|
|
499
|
+
if (!relativePath) {
|
|
500
|
+
sendJson(res, 404, { error: "not_found" });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
let bytes;
|
|
504
|
+
try {
|
|
505
|
+
bytes = await config.release.readFile(relativePath);
|
|
506
|
+
} catch {
|
|
507
|
+
sendJson(res, 404, { error: "not_found" });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
sendBytes(res, 200, bytes);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
export {
|
|
514
|
+
buildCanonicalManifest,
|
|
515
|
+
canonicalJson,
|
|
516
|
+
createGateHandler,
|
|
517
|
+
fetchGitHubLogin,
|
|
518
|
+
isIdentityAllowed,
|
|
519
|
+
mapDeviceError,
|
|
520
|
+
mintToken,
|
|
521
|
+
normalizeGitHubApiBase,
|
|
522
|
+
pollDeviceToken,
|
|
523
|
+
requestDeviceCode,
|
|
524
|
+
resolveGoogleIdentity,
|
|
525
|
+
safeReleasePath,
|
|
526
|
+
sha256Hex,
|
|
527
|
+
signManifest,
|
|
528
|
+
verifyManifestSignature,
|
|
529
|
+
verifyReleaseFile,
|
|
530
|
+
verifySignedManifest,
|
|
531
|
+
verifyToken
|
|
532
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { KeyObject } from 'node:crypto';
|
|
2
|
+
import type { ReleaseFileEntry, SignedReleaseManifest } from './types.js';
|
|
3
|
+
/** The UTF-8 canonical bytes a manifest signature is computed over. */
|
|
4
|
+
export declare function buildCanonicalManifest(manifest: {
|
|
5
|
+
version: string;
|
|
6
|
+
created: string;
|
|
7
|
+
files: ReleaseFileEntry[];
|
|
8
|
+
}): Buffer;
|
|
9
|
+
/** Lowercase hex SHA-256 of the given bytes. */
|
|
10
|
+
export declare function sha256Hex(bytes: Uint8Array): string;
|
|
11
|
+
/** Signs a manifest with the injected Ed25519 signer and returns the base64 detached signature. */
|
|
12
|
+
export declare function signManifest(manifest: {
|
|
13
|
+
version: string;
|
|
14
|
+
created: string;
|
|
15
|
+
files: ReleaseFileEntry[];
|
|
16
|
+
}, sign: (canonicalBytes: Buffer) => Buffer): string;
|
|
17
|
+
/**
|
|
18
|
+
* Documented verify helper for launchers. Returns true only when `signature` is a valid detached
|
|
19
|
+
* Ed25519 signature over the canonical bytes of `manifest`, checked against `publicKey`
|
|
20
|
+
* (a KeyObject or a PEM/DER string/Buffer). Never throws — a malformed signature is simply false.
|
|
21
|
+
*/
|
|
22
|
+
export declare function verifyManifestSignature(manifest: {
|
|
23
|
+
version: string;
|
|
24
|
+
created: string;
|
|
25
|
+
files: ReleaseFileEntry[];
|
|
26
|
+
}, signature: string, publicKey: KeyObject | string | Buffer): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Documented verify helper for launchers. Returns true only when `bytes` matches the manifest
|
|
29
|
+
* entry's `size` and SHA-256. A tampered body (or a manifest entry that does not describe the
|
|
30
|
+
* served bytes) is false.
|
|
31
|
+
*/
|
|
32
|
+
export declare function verifyReleaseFile(entry: ReleaseFileEntry, bytes: Uint8Array): boolean;
|
|
33
|
+
/** Convenience: verify a full signed manifest against a public key. */
|
|
34
|
+
export declare function verifySignedManifest(manifest: SignedReleaseManifest, publicKey: KeyObject | string | Buffer): boolean;
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate's own access/refresh tokens.
|
|
3
|
+
*
|
|
4
|
+
* Format (documented): `<base64url(payload JSON)>.<base64url(HMAC-SHA256(payloadB64))>`.
|
|
5
|
+
* The payload is `{ sub, exp, jti, typ }` where `sub` is the identity (GitHub login or Google
|
|
6
|
+
* `sub`), `exp` is an epoch-seconds expiry, `jti` a random id, and `typ` is `'access'` or
|
|
7
|
+
* `'refresh'`. HMAC-SHA256 over a shared secret is the chosen signing scheme: the token only ever
|
|
8
|
+
* travels to the gate that minted it, so a symmetric MAC is sufficient and needs no keypair.
|
|
9
|
+
* Tokens are opaque to the client and must be treated as bearer credentials.
|
|
10
|
+
*/
|
|
11
|
+
export interface GateTokenPayload {
|
|
12
|
+
sub: string;
|
|
13
|
+
exp: number;
|
|
14
|
+
jti: string;
|
|
15
|
+
typ: 'access' | 'refresh';
|
|
16
|
+
}
|
|
17
|
+
/** Mints a signed token. `expiresInSeconds` is added to `nowMs`. */
|
|
18
|
+
export declare function mintToken(secret: string, identity: string, typ: GateTokenPayload['typ'], expiresInSeconds: number, nowMs: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Verifies a token's signature and shape. Returns the payload or null. Expiry is NOT checked here —
|
|
21
|
+
* callers compare `payload.exp` against their clock so the same token can be distinguished from a
|
|
22
|
+
* malformed one (expired refresh vs forged refresh).
|
|
23
|
+
*/
|
|
24
|
+
export declare function verifyToken(secret: string, token: string): GateTokenPayload | null;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
/** One file in a release manifest. `path` is relative to the release root and is what
|
|
3
|
+
* `GET /release/<path>` serves; `sha256` is the lowercase hex digest of the file bytes. */
|
|
4
|
+
export interface ReleaseFileEntry {
|
|
5
|
+
path: string;
|
|
6
|
+
sha256: string;
|
|
7
|
+
size: number;
|
|
8
|
+
}
|
|
9
|
+
/** What `release.manifest()` returns: the gate stamps `created` and signs the result. */
|
|
10
|
+
export interface ReleaseManifestInput {
|
|
11
|
+
version: string;
|
|
12
|
+
files: ReleaseFileEntry[];
|
|
13
|
+
}
|
|
14
|
+
/** The signed manifest exactly as `GET /release/manifest` returns it. */
|
|
15
|
+
export interface SignedReleaseManifest {
|
|
16
|
+
version: string;
|
|
17
|
+
created: string;
|
|
18
|
+
files: ReleaseFileEntry[];
|
|
19
|
+
/** base64 (standard, padded) detached Ed25519 signature over `buildCanonicalManifest(manifest)`. */
|
|
20
|
+
signature: string;
|
|
21
|
+
}
|
|
22
|
+
/** Where the gate reads the set of identities allowed to install. */
|
|
23
|
+
export interface AllowlistConfig {
|
|
24
|
+
/** `'logins'` is an explicit list served by `getLogins`. `'roster'` uses the SAME interface;
|
|
25
|
+
* the adopter wires a roster adapter later (the gate treats both identically). */
|
|
26
|
+
source: 'roster' | 'logins';
|
|
27
|
+
/** Returns the allowed identities: GitHub logins for the `github` kind, `sub` claims for
|
|
28
|
+
* the `google` kind. Absent (or throwing) means an empty allowlist — every identity is refused. */
|
|
29
|
+
getLogins?: () => Promise<string[]>;
|
|
30
|
+
}
|
|
31
|
+
/** How the gate serves a product's release artifacts. */
|
|
32
|
+
export interface ReleaseConfig {
|
|
33
|
+
/** Describes the release to serve. Called per request so a re-released product is picked up. */
|
|
34
|
+
manifest: () => Promise<ReleaseManifestInput>;
|
|
35
|
+
/** Detached Ed25519 signer. Receives the exact canonical bytes and returns the raw signature. */
|
|
36
|
+
sign: (canonicalBytes: Buffer) => Buffer;
|
|
37
|
+
/** Reads one release file by manifest-relative path. Throws when absent. */
|
|
38
|
+
readFile: (path: string) => Promise<Buffer>;
|
|
39
|
+
}
|
|
40
|
+
interface GateConfigBase {
|
|
41
|
+
allowlist: AllowlistConfig;
|
|
42
|
+
release: ReleaseConfig;
|
|
43
|
+
/** Access-token lifetime in seconds. Default 3600; values above 3600 are clamped to 3600. */
|
|
44
|
+
tokenTtlSeconds?: number;
|
|
45
|
+
/** Explicit revocation list: identities that must be refused even if the allowlist still names
|
|
46
|
+
* them. A function is re-read on every call, so a revocation takes effect on the next request. */
|
|
47
|
+
revoke?: string[] | (() => Promise<string[]>);
|
|
48
|
+
/** HMAC key for the gate's own access/refresh tokens. Absent, the github kind derives it from
|
|
49
|
+
* `githubClientSecret`; the google kind generates an ephemeral one (it mints no tokens). */
|
|
50
|
+
tokenSecret?: string;
|
|
51
|
+
/** Injectable clock (epoch milliseconds). Tests use it to exercise expiry deterministically. */
|
|
52
|
+
now?: () => number;
|
|
53
|
+
}
|
|
54
|
+
export interface GitHubGateConfig extends GateConfigBase {
|
|
55
|
+
kind: 'github';
|
|
56
|
+
githubClientId: string;
|
|
57
|
+
/** Passed in by the adopter — never hardcoded in this library. */
|
|
58
|
+
githubClientSecret: string;
|
|
59
|
+
/** GitHub origin for the device flow. Default `https://github.com`; override for tests. */
|
|
60
|
+
githubApiBase?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface GoogleGateConfig extends GateConfigBase {
|
|
63
|
+
kind: 'google';
|
|
64
|
+
/** Verifies a bearer token against the product's own OAuth server. Returns the identity or null. */
|
|
65
|
+
verifyBearer: (token: string) => Promise<{
|
|
66
|
+
sub: string;
|
|
67
|
+
} | null>;
|
|
68
|
+
}
|
|
69
|
+
export type GateConfig = GitHubGateConfig | GoogleGateConfig;
|
|
70
|
+
/**
|
|
71
|
+
* A framework-agnostic node:http handler. Mount it as the fallback in the product's server:
|
|
72
|
+
* it owns `/gate/*` and `/release/*` and returns `true` after handling those; every other path
|
|
73
|
+
* returns `false` so the adopter's own router can continue.
|
|
74
|
+
*/
|
|
75
|
+
export type GateHandler = (req: IncomingMessage, res: ServerResponse) => boolean;
|
|
76
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mutmutco/installer-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Installer gate server library — GitHub device flow, allowlists, and signed release manifests. Mounted by a product's own server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node build.mjs",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"esbuild": "^0.28.1",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"vitest": "^4.1.0"
|
|
35
|
+
}
|
|
36
|
+
}
|