@openparachute/hub 0.5.7 → 0.5.10-rc.10
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 +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +70 -323
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-modules-ops.test.ts +658 -0
- package/src/__tests__/api-modules.test.ts +426 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/csrf.test.ts +40 -1
- package/src/__tests__/expose-2fa-warning.test.ts +3 -5
- package/src/__tests__/expose-cloudflare.test.ts +1 -1
- package/src/__tests__/expose.test.ts +2 -2
- package/src/__tests__/hub-server.test.ts +584 -67
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +123 -53
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/oauth-handlers.test.ts +522 -5
- package/src/__tests__/operator-token.test.ts +427 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/serve-boot.test.ts +193 -0
- package/src/__tests__/serve.test.ts +100 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +222 -0
- package/src/__tests__/setup-wizard.test.ts +2089 -0
- package/src/__tests__/status.test.ts +199 -0
- package/src/__tests__/supervisor.test.ts +482 -0
- package/src/__tests__/upgrade.test.ts +247 -4
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +37 -254
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-modules-ops.ts +585 -0
- package/src/api-modules.ts +367 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/cli.ts +28 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +6 -6
- package/src/commands/serve-boot.ts +133 -0
- package/src/commands/serve.ts +214 -0
- package/src/commands/status.ts +74 -10
- package/src/commands/upgrade.ts +33 -6
- package/src/csrf.ts +34 -13
- package/src/help.ts +55 -5
- package/src/hub-control.ts +1 -0
- package/src/hub-db.ts +87 -0
- package/src/hub-server.ts +767 -136
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +298 -150
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/oauth-handlers.ts +262 -56
- package/src/oauth-ui.ts +23 -2
- package/src/operator-token.ts +349 -18
- package/src/origin-check.ts +127 -0
- package/src/rate-limit.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/scope-explanations.ts +33 -2
- package/src/sessions.ts +30 -18
- package/src/setup-wizard.ts +2009 -0
- package/src/supervisor.ts +411 -0
- package/src/vault-name.ts +71 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
- package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `GET /api/me` — the public who-am-I endpoint that powers the
|
|
3
|
+
* signed-in indicator on hub-served surfaces (discovery server-rendered
|
|
4
|
+
* + admin SPA fetched). Covers:
|
|
5
|
+
* - Method gate (non-GET → 405).
|
|
6
|
+
* - No session → minimal `{ hasSession: false }`, no CSRF, no Set-Cookie.
|
|
7
|
+
* - Active session → full payload with displayName + CSRF.
|
|
8
|
+
* - Session-cookie present but row deleted → `{ hasSession: false }`.
|
|
9
|
+
* - CSRF cookie reused when present, minted + Set-Cookie when absent.
|
|
10
|
+
* - CSRF token bound to the cookie (consumer can submit it back).
|
|
11
|
+
*/
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { handleApiMe } from "../api-me.ts";
|
|
18
|
+
import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
19
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
|
+
import { SESSION_TTL_MS, buildSessionCookie, createSession, deleteSession } from "../sessions.ts";
|
|
21
|
+
import { createUser } from "../users.ts";
|
|
22
|
+
|
|
23
|
+
interface Harness {
|
|
24
|
+
db: Database;
|
|
25
|
+
cleanup: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeHarness(): Harness {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-me-"));
|
|
30
|
+
const db = openHubDb(hubDbPath(dir));
|
|
31
|
+
return {
|
|
32
|
+
db,
|
|
33
|
+
cleanup: () => {
|
|
34
|
+
db.close();
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let harness: Harness;
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
harness = makeHarness();
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
harness.cleanup();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
async function withSession(): Promise<{ cookie: string; userId: string; sid: string }> {
|
|
49
|
+
const user = await createUser(harness.db, "aaron", "pw");
|
|
50
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
51
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
52
|
+
return { cookie, userId: user.id, sid: session.id };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("handleApiMe", () => {
|
|
56
|
+
test("405 on non-GET", async () => {
|
|
57
|
+
const req = new Request("http://hub.test/api/me", { method: "POST" });
|
|
58
|
+
const res = handleApiMe(req, { db: harness.db });
|
|
59
|
+
expect(res.status).toBe(405);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("no session cookie → { hasSession: false }, no CSRF, no Set-Cookie", async () => {
|
|
63
|
+
const req = new Request("http://hub.test/api/me");
|
|
64
|
+
const res = handleApiMe(req, { db: harness.db });
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
67
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
68
|
+
const body = (await res.json()) as { hasSession: boolean; user?: unknown; csrf?: string };
|
|
69
|
+
expect(body.hasSession).toBe(false);
|
|
70
|
+
expect(body.user).toBeUndefined();
|
|
71
|
+
expect(body.csrf).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("session cookie pointing at deleted session → { hasSession: false }", async () => {
|
|
75
|
+
const { cookie, sid } = await withSession();
|
|
76
|
+
deleteSession(harness.db, sid);
|
|
77
|
+
const req = new Request("http://hub.test/api/me", { headers: { cookie } });
|
|
78
|
+
const res = handleApiMe(req, { db: harness.db });
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
const body = (await res.json()) as { hasSession: boolean };
|
|
81
|
+
expect(body.hasSession).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("active session → full payload with user + CSRF token", async () => {
|
|
85
|
+
const { cookie, userId } = await withSession();
|
|
86
|
+
const req = new Request("http://hub.test/api/me", { headers: { cookie } });
|
|
87
|
+
const res = handleApiMe(req, { db: harness.db });
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
90
|
+
const body = (await res.json()) as {
|
|
91
|
+
hasSession: boolean;
|
|
92
|
+
user?: { id: string; displayName: string };
|
|
93
|
+
csrf?: string;
|
|
94
|
+
};
|
|
95
|
+
expect(body.hasSession).toBe(true);
|
|
96
|
+
expect(body.user?.id).toBe(userId);
|
|
97
|
+
expect(body.user?.displayName).toBe("aaron");
|
|
98
|
+
expect(typeof body.csrf).toBe("string");
|
|
99
|
+
expect((body.csrf ?? "").length).toBeGreaterThan(20);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("active session + no CSRF cookie → mints token + Set-Cookie attached", async () => {
|
|
103
|
+
const { cookie } = await withSession();
|
|
104
|
+
const req = new Request("http://hub.test/api/me", { headers: { cookie } });
|
|
105
|
+
const res = handleApiMe(req, { db: harness.db });
|
|
106
|
+
expect(res.status).toBe(200);
|
|
107
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
108
|
+
expect(setCookie).toContain(`${CSRF_COOKIE_NAME}=`);
|
|
109
|
+
// The token in the JSON body must match the value being set in the
|
|
110
|
+
// cookie — that's the consumer's contract for submitting it back.
|
|
111
|
+
const body = (await res.json()) as { csrf?: string };
|
|
112
|
+
const cookieValueMatch = setCookie.match(new RegExp(`${CSRF_COOKIE_NAME}=([A-Za-z0-9_-]+)`));
|
|
113
|
+
expect(cookieValueMatch?.[1]).toBe(body.csrf);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("active session + CSRF cookie already present → reuses token, no Set-Cookie", async () => {
|
|
117
|
+
const { cookie } = await withSession();
|
|
118
|
+
const csrfToken = generateCsrfToken();
|
|
119
|
+
const csrfCookie = buildCsrfCookie(csrfToken).split(";")[0]!; // just `name=value`
|
|
120
|
+
const combinedCookie = `${cookie}; ${csrfCookie}`;
|
|
121
|
+
const req = new Request("http://hub.test/api/me", { headers: { cookie: combinedCookie } });
|
|
122
|
+
const res = handleApiMe(req, { db: harness.db });
|
|
123
|
+
expect(res.status).toBe(200);
|
|
124
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
125
|
+
const body = (await res.json()) as { csrf?: string };
|
|
126
|
+
expect(body.csrf).toBe(csrfToken);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("two distinct requests (no CSRF cookie either way) get distinct freshly-minted tokens", async () => {
|
|
130
|
+
// Pin that the CSRF token is request-randomized when no cookie is
|
|
131
|
+
// present. Two requests from the same session, neither carrying a
|
|
132
|
+
// CSRF cookie, get two different tokens. Real consumers carry the
|
|
133
|
+
// first response's Set-Cookie back on subsequent requests, so this
|
|
134
|
+
// path is operationally rare — but the entropy property is still
|
|
135
|
+
// load-bearing for the no-cookie cold-start case.
|
|
136
|
+
const { cookie } = await withSession();
|
|
137
|
+
const resA = handleApiMe(new Request("http://hub.test/api/me", { headers: { cookie } }), {
|
|
138
|
+
db: harness.db,
|
|
139
|
+
});
|
|
140
|
+
const resB = handleApiMe(new Request("http://hub.test/api/me", { headers: { cookie } }), {
|
|
141
|
+
db: harness.db,
|
|
142
|
+
});
|
|
143
|
+
const bodyA = (await resA.json()) as { csrf?: string };
|
|
144
|
+
const bodyB = (await resB.json()) as { csrf?: string };
|
|
145
|
+
expect(bodyA.csrf).toBeDefined();
|
|
146
|
+
expect(bodyB.csrf).toBeDefined();
|
|
147
|
+
expect(bodyA.csrf).not.toBe(bodyB.csrf);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,381 @@
|
|
|
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 { handleApiMintToken } from "../api-mint-token.ts";
|
|
6
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
8
|
+
import { mintOperatorToken } from "../operator-token.ts";
|
|
9
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
10
|
+
import { createUser } from "../users.ts";
|
|
11
|
+
|
|
12
|
+
interface Harness {
|
|
13
|
+
dir: string;
|
|
14
|
+
cleanup: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeHarness(): Harness {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-mint-"));
|
|
19
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
23
|
+
|
|
24
|
+
async function bootstrap(
|
|
25
|
+
dir: string,
|
|
26
|
+
): Promise<{ db: ReturnType<typeof openHubDb>; userId: string }> {
|
|
27
|
+
const db = openHubDb(hubDbPath(dir));
|
|
28
|
+
rotateSigningKey(db);
|
|
29
|
+
const u = await createUser(db, "owner", "pw");
|
|
30
|
+
return { db, userId: u.id };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function jsonRequest(body: unknown, headers: Record<string, string> = {}): Request {
|
|
34
|
+
return new Request("http://localhost/api/auth/mint-token", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
headers: { "content-type": "application/json", ...headers },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
|
|
42
|
+
test("401 when no Authorization header", async () => {
|
|
43
|
+
const h = makeHarness();
|
|
44
|
+
try {
|
|
45
|
+
const { db } = await bootstrap(h.dir);
|
|
46
|
+
try {
|
|
47
|
+
const resp = await handleApiMintToken(jsonRequest({ scope: "vault:read" }), {
|
|
48
|
+
db,
|
|
49
|
+
issuer: ISSUER,
|
|
50
|
+
});
|
|
51
|
+
expect(resp.status).toBe(401);
|
|
52
|
+
const body = (await resp.json()) as { error: string };
|
|
53
|
+
expect(body.error).toBe("unauthenticated");
|
|
54
|
+
} finally {
|
|
55
|
+
db.close();
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
h.cleanup();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("401 when Authorization is not Bearer", async () => {
|
|
63
|
+
const h = makeHarness();
|
|
64
|
+
try {
|
|
65
|
+
const { db } = await bootstrap(h.dir);
|
|
66
|
+
try {
|
|
67
|
+
const resp = await handleApiMintToken(
|
|
68
|
+
jsonRequest({ scope: "vault:read" }, { authorization: "Basic xyz" }),
|
|
69
|
+
{ db, issuer: ISSUER },
|
|
70
|
+
);
|
|
71
|
+
expect(resp.status).toBe(401);
|
|
72
|
+
} finally {
|
|
73
|
+
db.close();
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
h.cleanup();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("401 when bearer fails signature/issuer validation", async () => {
|
|
81
|
+
const h = makeHarness();
|
|
82
|
+
try {
|
|
83
|
+
const { db } = await bootstrap(h.dir);
|
|
84
|
+
try {
|
|
85
|
+
const resp = await handleApiMintToken(
|
|
86
|
+
jsonRequest({ scope: "vault:read" }, { authorization: "Bearer not-a-real-jwt" }),
|
|
87
|
+
{ db, issuer: ISSUER },
|
|
88
|
+
);
|
|
89
|
+
expect(resp.status).toBe(401);
|
|
90
|
+
} finally {
|
|
91
|
+
db.close();
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
h.cleanup();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("403 when bearer scope lacks parachute:host:auth", async () => {
|
|
99
|
+
const h = makeHarness();
|
|
100
|
+
try {
|
|
101
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
102
|
+
try {
|
|
103
|
+
// Hand-mint a JWT with hub:admin only — covers the operator-bearer
|
|
104
|
+
// happy path for OTHER routes but is intentionally insufficient for
|
|
105
|
+
// /api/auth/mint-token (we want a narrow `parachute:host:auth` gate).
|
|
106
|
+
const narrow = await signAccessToken(db, {
|
|
107
|
+
sub: userId,
|
|
108
|
+
scopes: ["hub:admin"],
|
|
109
|
+
audience: "hub",
|
|
110
|
+
clientId: "parachute-hub",
|
|
111
|
+
issuer: ISSUER,
|
|
112
|
+
ttlSeconds: 3600,
|
|
113
|
+
});
|
|
114
|
+
const resp = await handleApiMintToken(
|
|
115
|
+
jsonRequest({ scope: "vault:read" }, { authorization: `Bearer ${narrow.token}` }),
|
|
116
|
+
{ db, issuer: ISSUER },
|
|
117
|
+
);
|
|
118
|
+
expect(resp.status).toBe(403);
|
|
119
|
+
const body = (await resp.json()) as { error: string };
|
|
120
|
+
expect(body.error).toBe("insufficient_scope");
|
|
121
|
+
} finally {
|
|
122
|
+
db.close();
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
h.cleanup();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("happy path: admin operator-token mints a scope-narrow JWT + writes registry row", async () => {
|
|
130
|
+
const h = makeHarness();
|
|
131
|
+
try {
|
|
132
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
133
|
+
try {
|
|
134
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
135
|
+
const resp = await handleApiMintToken(
|
|
136
|
+
jsonRequest(
|
|
137
|
+
{ scope: "scribe:transcribe", expires_in: 3600 },
|
|
138
|
+
{ authorization: `Bearer ${op.token}` },
|
|
139
|
+
),
|
|
140
|
+
{ db, issuer: ISSUER },
|
|
141
|
+
);
|
|
142
|
+
expect(resp.status).toBe(200);
|
|
143
|
+
const body = (await resp.json()) as {
|
|
144
|
+
jti: string;
|
|
145
|
+
token: string;
|
|
146
|
+
expires_at: string;
|
|
147
|
+
scope: string;
|
|
148
|
+
};
|
|
149
|
+
expect(body.token.split(".")).toHaveLength(3);
|
|
150
|
+
expect(body.scope).toBe("scribe:transcribe");
|
|
151
|
+
expect(typeof body.jti).toBe("string");
|
|
152
|
+
// Round-trip the minted JWT through hub validation.
|
|
153
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
154
|
+
expect(validated.payload.scope).toBe("scribe:transcribe");
|
|
155
|
+
expect(validated.payload.aud).toBe("scribe");
|
|
156
|
+
expect(validated.payload.jti).toBe(body.jti);
|
|
157
|
+
// Registry row was written.
|
|
158
|
+
const row = db
|
|
159
|
+
.query<{ jti: string; created_via: string; subject: string }, [string]>(
|
|
160
|
+
"SELECT jti, created_via, subject FROM tokens WHERE jti = ?",
|
|
161
|
+
)
|
|
162
|
+
.get(body.jti);
|
|
163
|
+
expect(row).not.toBeNull();
|
|
164
|
+
expect(row?.created_via).toBe("cli_mint");
|
|
165
|
+
// Default subject = bearer's sub = the userId.
|
|
166
|
+
expect(row?.subject).toBe(userId);
|
|
167
|
+
} finally {
|
|
168
|
+
db.close();
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
h.cleanup();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("happy path: --scope-set=auth narrow operator token also passes the scope gate", async () => {
|
|
176
|
+
const h = makeHarness();
|
|
177
|
+
try {
|
|
178
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
179
|
+
try {
|
|
180
|
+
const op = await mintOperatorToken(db, userId, {
|
|
181
|
+
issuer: ISSUER,
|
|
182
|
+
scopeSet: "auth",
|
|
183
|
+
});
|
|
184
|
+
const resp = await handleApiMintToken(
|
|
185
|
+
jsonRequest({ scope: "vault:read" }, { authorization: `Bearer ${op.token}` }),
|
|
186
|
+
{ db, issuer: ISSUER },
|
|
187
|
+
);
|
|
188
|
+
expect(resp.status).toBe(200);
|
|
189
|
+
} finally {
|
|
190
|
+
db.close();
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
h.cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("permissions object round-trips into JWT + registry row", async () => {
|
|
198
|
+
const h = makeHarness();
|
|
199
|
+
try {
|
|
200
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
201
|
+
try {
|
|
202
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
203
|
+
const permissions = { vault: { default: { write_tags: ["health"] } } };
|
|
204
|
+
const resp = await handleApiMintToken(
|
|
205
|
+
jsonRequest(
|
|
206
|
+
{ scope: "vault:default:write", permissions },
|
|
207
|
+
{ authorization: `Bearer ${op.token}` },
|
|
208
|
+
),
|
|
209
|
+
{ db, issuer: ISSUER },
|
|
210
|
+
);
|
|
211
|
+
expect(resp.status).toBe(200);
|
|
212
|
+
const body = (await resp.json()) as { token: string; jti: string; permissions: unknown };
|
|
213
|
+
expect(body.permissions).toEqual(permissions);
|
|
214
|
+
const validated = await validateAccessToken(db, body.token, ISSUER);
|
|
215
|
+
expect(validated.payload.permissions).toEqual(permissions);
|
|
216
|
+
const row = db
|
|
217
|
+
.query<{ permissions: string }, [string]>("SELECT permissions FROM tokens WHERE jti = ?")
|
|
218
|
+
.get(body.jti);
|
|
219
|
+
expect(JSON.parse(row!.permissions)).toEqual(permissions);
|
|
220
|
+
} finally {
|
|
221
|
+
db.close();
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
h.cleanup();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("400 when scope is missing", async () => {
|
|
229
|
+
const h = makeHarness();
|
|
230
|
+
try {
|
|
231
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
232
|
+
try {
|
|
233
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
234
|
+
const resp = await handleApiMintToken(
|
|
235
|
+
jsonRequest({}, { authorization: `Bearer ${op.token}` }),
|
|
236
|
+
{ db, issuer: ISSUER },
|
|
237
|
+
);
|
|
238
|
+
expect(resp.status).toBe(400);
|
|
239
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
240
|
+
expect(body.error).toBe("invalid_request");
|
|
241
|
+
expect(body.error_description).toContain("scope");
|
|
242
|
+
} finally {
|
|
243
|
+
db.close();
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
h.cleanup();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("400 when expires_in exceeds 365d cap", async () => {
|
|
251
|
+
const h = makeHarness();
|
|
252
|
+
try {
|
|
253
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
254
|
+
try {
|
|
255
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
256
|
+
const resp = await handleApiMintToken(
|
|
257
|
+
jsonRequest(
|
|
258
|
+
{ scope: "vault:read", expires_in: 366 * 86400 },
|
|
259
|
+
{ authorization: `Bearer ${op.token}` },
|
|
260
|
+
),
|
|
261
|
+
{ db, issuer: ISSUER },
|
|
262
|
+
);
|
|
263
|
+
expect(resp.status).toBe(400);
|
|
264
|
+
const body = (await resp.json()) as { error_description: string };
|
|
265
|
+
expect(body.error_description).toContain("365d cap");
|
|
266
|
+
} finally {
|
|
267
|
+
db.close();
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
h.cleanup();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("400 when permissions is not an object", async () => {
|
|
275
|
+
const h = makeHarness();
|
|
276
|
+
try {
|
|
277
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
278
|
+
try {
|
|
279
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
280
|
+
const resp = await handleApiMintToken(
|
|
281
|
+
jsonRequest(
|
|
282
|
+
{ scope: "vault:read", permissions: ["not", "an", "object"] },
|
|
283
|
+
{ authorization: `Bearer ${op.token}` },
|
|
284
|
+
),
|
|
285
|
+
{ db, issuer: ISSUER },
|
|
286
|
+
);
|
|
287
|
+
expect(resp.status).toBe(400);
|
|
288
|
+
} finally {
|
|
289
|
+
db.close();
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
h.cleanup();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// closes #215 reviewer F1 — privilege-diffusion guard.
|
|
297
|
+
test("400 invalid_scope when minting parachute:host:auth (non-requestable)", async () => {
|
|
298
|
+
const h = makeHarness();
|
|
299
|
+
try {
|
|
300
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
301
|
+
try {
|
|
302
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
303
|
+
const resp = await handleApiMintToken(
|
|
304
|
+
jsonRequest({ scope: "parachute:host:auth" }, { authorization: `Bearer ${op.token}` }),
|
|
305
|
+
{ db, issuer: ISSUER },
|
|
306
|
+
);
|
|
307
|
+
expect(resp.status).toBe(400);
|
|
308
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
309
|
+
expect(body.error).toBe("invalid_scope");
|
|
310
|
+
expect(body.error_description).toContain("parachute:host:auth");
|
|
311
|
+
expect(body.error_description).toContain("not requestable");
|
|
312
|
+
} finally {
|
|
313
|
+
db.close();
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
h.cleanup();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("400 invalid_scope when multi-scope includes a non-requestable", async () => {
|
|
321
|
+
const h = makeHarness();
|
|
322
|
+
try {
|
|
323
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
324
|
+
try {
|
|
325
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
326
|
+
const resp = await handleApiMintToken(
|
|
327
|
+
jsonRequest(
|
|
328
|
+
{ scope: "vault:default:write parachute:host:admin" },
|
|
329
|
+
{ authorization: `Bearer ${op.token}` },
|
|
330
|
+
),
|
|
331
|
+
{ db, issuer: ISSUER },
|
|
332
|
+
);
|
|
333
|
+
expect(resp.status).toBe(400);
|
|
334
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
335
|
+
expect(body.error).toBe("invalid_scope");
|
|
336
|
+
expect(body.error_description).toContain("parachute:host:admin");
|
|
337
|
+
} finally {
|
|
338
|
+
db.close();
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
h.cleanup();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("400 invalid_scope when minting vault:<name>:admin (regex non-requestable)", async () => {
|
|
346
|
+
const h = makeHarness();
|
|
347
|
+
try {
|
|
348
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
349
|
+
try {
|
|
350
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
351
|
+
const resp = await handleApiMintToken(
|
|
352
|
+
jsonRequest({ scope: "vault:work:admin" }, { authorization: `Bearer ${op.token}` }),
|
|
353
|
+
{ db, issuer: ISSUER },
|
|
354
|
+
);
|
|
355
|
+
expect(resp.status).toBe(400);
|
|
356
|
+
const body = (await resp.json()) as { error: string };
|
|
357
|
+
expect(body.error).toBe("invalid_scope");
|
|
358
|
+
} finally {
|
|
359
|
+
db.close();
|
|
360
|
+
}
|
|
361
|
+
} finally {
|
|
362
|
+
h.cleanup();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("405 on non-POST", async () => {
|
|
367
|
+
const h = makeHarness();
|
|
368
|
+
try {
|
|
369
|
+
const { db } = await bootstrap(h.dir);
|
|
370
|
+
try {
|
|
371
|
+
const req = new Request("http://localhost/api/auth/mint-token", { method: "GET" });
|
|
372
|
+
const resp = await handleApiMintToken(req, { db, issuer: ISSUER });
|
|
373
|
+
expect(resp.status).toBe(405);
|
|
374
|
+
} finally {
|
|
375
|
+
db.close();
|
|
376
|
+
}
|
|
377
|
+
} finally {
|
|
378
|
+
h.cleanup();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|