@openparachute/hub 0.7.5-rc.4 → 0.7.5-rc.5
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/package.json +2 -1
- package/scripts/git-credential-parachute +50 -0
- package/src/__tests__/surface-command.test.ts +492 -0
- package/src/__tests__/surface-token.test.ts +276 -0
- package/src/cli.ts +6 -0
- package/src/commands/auth.ts +1 -25
- package/src/commands/surface.ts +493 -0
- package/src/help.ts +64 -0
- package/src/hub-issuer.ts +30 -0
- package/src/jwt-sign.ts +9 -1
- package/src/surface-token.ts +244 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
|
+
import { recordTokenMint, signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
7
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
8
|
+
import {
|
|
9
|
+
SURFACE_TOKEN_CREATED_VIA,
|
|
10
|
+
SURFACE_TOKEN_TTL_DEFAULT_SECONDS,
|
|
11
|
+
listSurfaceTokens,
|
|
12
|
+
mintSurfaceToken,
|
|
13
|
+
revokeSurfaceToken,
|
|
14
|
+
surfaceScope,
|
|
15
|
+
} from "../surface-token.ts";
|
|
16
|
+
import { createUser } from "../users.ts";
|
|
17
|
+
|
|
18
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
19
|
+
|
|
20
|
+
interface Harness {
|
|
21
|
+
db: ReturnType<typeof openHubDb>;
|
|
22
|
+
userId: string;
|
|
23
|
+
cleanup: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function makeHarness(): Promise<Harness> {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-surftok-"));
|
|
28
|
+
const db = openHubDb(hubDbPath(dir));
|
|
29
|
+
rotateSigningKey(db);
|
|
30
|
+
const u = await createUser(db, "owner", "pw");
|
|
31
|
+
return {
|
|
32
|
+
db,
|
|
33
|
+
userId: u.id,
|
|
34
|
+
cleanup: () => {
|
|
35
|
+
db.close();
|
|
36
|
+
rmSync(dir, { recursive: true, force: true });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("surfaceScope", () => {
|
|
42
|
+
test("builds the canonical scope", () => {
|
|
43
|
+
expect(surfaceScope("gitcoin-brain", "write")).toBe("surface:gitcoin-brain:write");
|
|
44
|
+
expect(surfaceScope("gitcoin-brain", "read")).toBe("surface:gitcoin-brain:read");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("mintSurfaceToken", () => {
|
|
49
|
+
test("mints a validatable, registered surface:<name>:write token", async () => {
|
|
50
|
+
const h = await makeHarness();
|
|
51
|
+
try {
|
|
52
|
+
const minted = await mintSurfaceToken(h.db, {
|
|
53
|
+
name: "gitcoin-brain",
|
|
54
|
+
access: "write",
|
|
55
|
+
issuer: ISSUER,
|
|
56
|
+
userId: h.userId,
|
|
57
|
+
});
|
|
58
|
+
expect(minted.scope).toBe("surface:gitcoin-brain:write");
|
|
59
|
+
expect(minted.token.split(".").length).toBe(3);
|
|
60
|
+
|
|
61
|
+
// It validates through the SAME path the git endpoint uses.
|
|
62
|
+
const validated = await validateAccessToken(h.db, minted.token, [ISSUER]);
|
|
63
|
+
expect(validated.payload.scope).toBe("surface:gitcoin-brain:write");
|
|
64
|
+
expect(validated.payload.aud).toBe("surface.gitcoin-brain");
|
|
65
|
+
expect(validated.payload.jti).toBe(minted.jti);
|
|
66
|
+
|
|
67
|
+
// A registry row exists, tagged as a deploy token (so list/revoke find it).
|
|
68
|
+
const listed = listSurfaceTokens(h.db);
|
|
69
|
+
expect(listed.map((r) => r.jti)).toContain(minted.jti);
|
|
70
|
+
} finally {
|
|
71
|
+
h.cleanup();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("read access mints surface:<name>:read", async () => {
|
|
76
|
+
const h = await makeHarness();
|
|
77
|
+
try {
|
|
78
|
+
const minted = await mintSurfaceToken(h.db, {
|
|
79
|
+
name: "docs",
|
|
80
|
+
access: "read",
|
|
81
|
+
issuer: ISSUER,
|
|
82
|
+
});
|
|
83
|
+
expect(minted.scope).toBe("surface:docs:read");
|
|
84
|
+
const validated = await validateAccessToken(h.db, minted.token, [ISSUER]);
|
|
85
|
+
expect(validated.payload.scope).toBe("surface:docs:read");
|
|
86
|
+
} finally {
|
|
87
|
+
h.cleanup();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("defaults to a 90-day TTL; honors an explicit ttlSeconds", async () => {
|
|
92
|
+
const h = await makeHarness();
|
|
93
|
+
try {
|
|
94
|
+
const now = () => new Date("2026-06-30T00:00:00Z");
|
|
95
|
+
const dflt = await mintSurfaceToken(h.db, {
|
|
96
|
+
name: "foo",
|
|
97
|
+
access: "write",
|
|
98
|
+
issuer: ISSUER,
|
|
99
|
+
now,
|
|
100
|
+
});
|
|
101
|
+
const expectedDefault = new Date(
|
|
102
|
+
Date.parse("2026-06-30T00:00:00Z") + SURFACE_TOKEN_TTL_DEFAULT_SECONDS * 1000,
|
|
103
|
+
).toISOString();
|
|
104
|
+
expect(dflt.expiresAt).toBe(expectedDefault);
|
|
105
|
+
|
|
106
|
+
const custom = await mintSurfaceToken(h.db, {
|
|
107
|
+
name: "bar",
|
|
108
|
+
access: "write",
|
|
109
|
+
issuer: ISSUER,
|
|
110
|
+
ttlSeconds: 3600,
|
|
111
|
+
now,
|
|
112
|
+
});
|
|
113
|
+
expect(custom.expiresAt).toBe(
|
|
114
|
+
new Date(Date.parse("2026-06-30T00:00:00Z") + 3600 * 1000).toISOString(),
|
|
115
|
+
);
|
|
116
|
+
} finally {
|
|
117
|
+
h.cleanup();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("rejects an invalid surface name (path-traversal safety)", async () => {
|
|
122
|
+
const h = await makeHarness();
|
|
123
|
+
try {
|
|
124
|
+
await expect(
|
|
125
|
+
mintSurfaceToken(h.db, { name: "../evil", access: "write", issuer: ISSUER }),
|
|
126
|
+
).rejects.toThrow(/invalid surface name/);
|
|
127
|
+
await expect(
|
|
128
|
+
mintSurfaceToken(h.db, { name: "a/b", access: "write", issuer: ISSUER }),
|
|
129
|
+
).rejects.toThrow(/invalid surface name/);
|
|
130
|
+
} finally {
|
|
131
|
+
h.cleanup();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("listSurfaceTokens", () => {
|
|
137
|
+
test("lists only deploy tokens, narrows by surface, ignores other mints", async () => {
|
|
138
|
+
const h = await makeHarness();
|
|
139
|
+
try {
|
|
140
|
+
const a = await mintSurfaceToken(h.db, { name: "alpha", access: "write", issuer: ISSUER });
|
|
141
|
+
const b = await mintSurfaceToken(h.db, { name: "beta", access: "read", issuer: ISSUER });
|
|
142
|
+
|
|
143
|
+
// A generic cli_mint token that ALSO names a surface scope must NOT appear
|
|
144
|
+
// (deploy tokens are a distinct class, keyed by created_via).
|
|
145
|
+
const other = await signAccessToken(h.db, {
|
|
146
|
+
sub: h.userId,
|
|
147
|
+
scopes: ["surface:alpha:write"],
|
|
148
|
+
audience: "surface",
|
|
149
|
+
clientId: "test",
|
|
150
|
+
issuer: ISSUER,
|
|
151
|
+
});
|
|
152
|
+
recordTokenMint(h.db, {
|
|
153
|
+
jti: other.jti,
|
|
154
|
+
createdVia: "cli_mint",
|
|
155
|
+
subject: "someone",
|
|
156
|
+
clientId: "test",
|
|
157
|
+
scopes: ["surface:alpha:write"],
|
|
158
|
+
expiresAt: other.expiresAt,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const all = listSurfaceTokens(h.db);
|
|
162
|
+
const jtis = all.map((r) => r.jti);
|
|
163
|
+
expect(jtis).toContain(a.jti);
|
|
164
|
+
expect(jtis).toContain(b.jti);
|
|
165
|
+
expect(jtis).not.toContain(other.jti);
|
|
166
|
+
|
|
167
|
+
const alphaOnly = listSurfaceTokens(h.db, "alpha");
|
|
168
|
+
expect(alphaOnly.map((r) => r.jti)).toEqual([a.jti]);
|
|
169
|
+
expect(alphaOnly[0]?.access).toBe("write");
|
|
170
|
+
expect(alphaOnly[0]?.name).toBe("alpha");
|
|
171
|
+
expect(alphaOnly[0]?.revokedAt).toBeNull();
|
|
172
|
+
} finally {
|
|
173
|
+
h.cleanup();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("empty when none minted", async () => {
|
|
178
|
+
const h = await makeHarness();
|
|
179
|
+
try {
|
|
180
|
+
expect(listSurfaceTokens(h.db)).toEqual([]);
|
|
181
|
+
expect(listSurfaceTokens(h.db, "nope")).toEqual([]);
|
|
182
|
+
} finally {
|
|
183
|
+
h.cleanup();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("revokeSurfaceToken", () => {
|
|
189
|
+
test("revokes a deploy token; the endpoint path then rejects it", async () => {
|
|
190
|
+
const h = await makeHarness();
|
|
191
|
+
try {
|
|
192
|
+
const minted = await mintSurfaceToken(h.db, {
|
|
193
|
+
name: "foo",
|
|
194
|
+
access: "write",
|
|
195
|
+
issuer: ISSUER,
|
|
196
|
+
});
|
|
197
|
+
// Valid before revoke.
|
|
198
|
+
await validateAccessToken(h.db, minted.token, [ISSUER]);
|
|
199
|
+
|
|
200
|
+
const res = revokeSurfaceToken(h.db, minted.jti, new Date());
|
|
201
|
+
expect(res.status).toBe("revoked");
|
|
202
|
+
|
|
203
|
+
// Revocation is enforced at validation (the git endpoint's path).
|
|
204
|
+
await expect(validateAccessToken(h.db, minted.token, [ISSUER])).rejects.toThrow(/revoked/);
|
|
205
|
+
|
|
206
|
+
// list reflects the revoked state.
|
|
207
|
+
const row = listSurfaceTokens(h.db, "foo")[0];
|
|
208
|
+
expect(row?.revokedAt).not.toBeNull();
|
|
209
|
+
} finally {
|
|
210
|
+
h.cleanup();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("re-revoke is idempotent (already-revoked)", async () => {
|
|
215
|
+
const h = await makeHarness();
|
|
216
|
+
try {
|
|
217
|
+
const minted = await mintSurfaceToken(h.db, {
|
|
218
|
+
name: "foo",
|
|
219
|
+
access: "write",
|
|
220
|
+
issuer: ISSUER,
|
|
221
|
+
});
|
|
222
|
+
const first = revokeSurfaceToken(h.db, minted.jti, new Date());
|
|
223
|
+
expect(first.status).toBe("revoked");
|
|
224
|
+
const second = revokeSurfaceToken(h.db, minted.jti, new Date());
|
|
225
|
+
expect(second.status).toBe("already-revoked");
|
|
226
|
+
if (second.status === "already-revoked") {
|
|
227
|
+
expect(second.revokedAt).toBeTruthy();
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
h.cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("unknown jti → not-found", async () => {
|
|
235
|
+
const h = await makeHarness();
|
|
236
|
+
try {
|
|
237
|
+
expect(revokeSurfaceToken(h.db, "no-such-jti", new Date()).status).toBe("not-found");
|
|
238
|
+
} finally {
|
|
239
|
+
h.cleanup();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("refuses to revoke a non-deploy-token jti (fails closed)", async () => {
|
|
244
|
+
const h = await makeHarness();
|
|
245
|
+
try {
|
|
246
|
+
const other = await signAccessToken(h.db, {
|
|
247
|
+
sub: h.userId,
|
|
248
|
+
scopes: ["vault:default:read"],
|
|
249
|
+
audience: "vault.default",
|
|
250
|
+
clientId: "test",
|
|
251
|
+
issuer: ISSUER,
|
|
252
|
+
});
|
|
253
|
+
recordTokenMint(h.db, {
|
|
254
|
+
jti: other.jti,
|
|
255
|
+
createdVia: "cli_mint",
|
|
256
|
+
subject: "someone",
|
|
257
|
+
clientId: "test",
|
|
258
|
+
scopes: ["vault:default:read"],
|
|
259
|
+
expiresAt: other.expiresAt,
|
|
260
|
+
});
|
|
261
|
+
const res = revokeSurfaceToken(h.db, other.jti, new Date());
|
|
262
|
+
expect(res.status).toBe("not-surface-token");
|
|
263
|
+
if (res.status === "not-surface-token") {
|
|
264
|
+
expect(res.createdVia).toBe("cli_mint");
|
|
265
|
+
}
|
|
266
|
+
// Confirm it was NOT revoked.
|
|
267
|
+
await validateAccessToken(h.db, other.token, [ISSUER]);
|
|
268
|
+
} finally {
|
|
269
|
+
h.cleanup();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("SURFACE_TOKEN_CREATED_VIA is the stable tag", () => {
|
|
274
|
+
expect(SURFACE_TOKEN_CREATED_VIA).toBe("surface_token");
|
|
275
|
+
});
|
|
276
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -1038,6 +1038,12 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1038
1038
|
return await mod.hub(rest);
|
|
1039
1039
|
}
|
|
1040
1040
|
|
|
1041
|
+
case "surface": {
|
|
1042
|
+
const mod = await loadCommand("surface", () => import("./commands/surface.ts"));
|
|
1043
|
+
if (!mod) return 1;
|
|
1044
|
+
return await mod.surface(rest);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1041
1047
|
case "vault": {
|
|
1042
1048
|
const mod = await loadCommand("vault", () => import("./commands/vault.ts"));
|
|
1043
1049
|
if (!mod) return 1;
|
package/src/commands/auth.ts
CHANGED
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
* `<hub-origin>/account/2fa` (QR + backup codes).
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { join } from "node:path";
|
|
27
26
|
import { createInterface } from "node:readline/promises";
|
|
28
27
|
import {
|
|
29
28
|
DEFAULT_REAP_AGE_MS,
|
|
@@ -36,11 +35,9 @@ import {
|
|
|
36
35
|
reapClient,
|
|
37
36
|
} from "../clients.ts";
|
|
38
37
|
import { CONFIG_DIR } from "../config.ts";
|
|
39
|
-
import { readExposeState } from "../expose-state.ts";
|
|
40
38
|
import { listGrantsForUser, revokeGrant } from "../grants.ts";
|
|
41
|
-
import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
|
|
42
39
|
import { openHubDb } from "../hub-db.ts";
|
|
43
|
-
import {
|
|
40
|
+
import { resolveHubIssuer } from "../hub-issuer.ts";
|
|
44
41
|
import { inferAudience } from "../jwt-audience.ts";
|
|
45
42
|
import {
|
|
46
43
|
findTokenRowByJti,
|
|
@@ -306,27 +303,6 @@ export interface AuthDeps {
|
|
|
306
303
|
hubOrigin?: string;
|
|
307
304
|
}
|
|
308
305
|
|
|
309
|
-
/**
|
|
310
|
-
* Resolve the hub origin used as `iss` for operator tokens. Mirrors
|
|
311
|
-
* lifecycle.resolveHubOrigin's order, but falls back to the canonical
|
|
312
|
-
* loopback (`http://127.0.0.1:1939`) instead of `undefined` — operator
|
|
313
|
-
* tokens MUST carry an issuer, and on first-run before any expose has
|
|
314
|
-
* happened the canonical loopback is what services will validate against.
|
|
315
|
-
*/
|
|
316
|
-
function resolveHubIssuer(override: string | undefined, configDir: string): string {
|
|
317
|
-
if (override) {
|
|
318
|
-
const fromOverride = deriveHubOrigin({ override });
|
|
319
|
-
if (fromOverride) return fromOverride;
|
|
320
|
-
}
|
|
321
|
-
const state = readExposeState(join(configDir, "expose-state.json"));
|
|
322
|
-
if (state?.hubOrigin) return state.hubOrigin;
|
|
323
|
-
const exposeFqdn = state?.canonicalFqdn;
|
|
324
|
-
return (
|
|
325
|
-
deriveHubOrigin({ exposeFqdn, hubPort: readHubPort(configDir) }) ??
|
|
326
|
-
`http://127.0.0.1:${HUB_DEFAULT_PORT}`
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
306
|
function defaultRotateKey(): { kid: string; createdAt: string } {
|
|
331
307
|
const db = openHubDb();
|
|
332
308
|
try {
|