@m-kopa/launchpad-cli 0.23.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/CHANGELOG.md +854 -0
- package/README.md +109 -0
- package/dist/auth/browser.d.ts +18 -0
- package/dist/auth/browser.d.ts.map +1 -0
- package/dist/auth/callback-server.d.ts +24 -0
- package/dist/auth/callback-server.d.ts.map +1 -0
- package/dist/auth/discovery.d.ts +25 -0
- package/dist/auth/discovery.d.ts.map +1 -0
- package/dist/auth/flow.d.ts +39 -0
- package/dist/auth/flow.d.ts.map +1 -0
- package/dist/auth/jwt.d.ts +27 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/pkce.d.ts +26 -0
- package/dist/auth/pkce.d.ts.map +1 -0
- package/dist/auth/registration.d.ts +8 -0
- package/dist/auth/registration.d.ts.map +1 -0
- package/dist/auth/session.d.ts +54 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/token.d.ts +37 -0
- package/dist/auth/token.d.ts.map +1 -0
- package/dist/bundle/cron-bundle.d.ts +77 -0
- package/dist/bundle/cron-bundle.d.ts.map +1 -0
- package/dist/bundle/cwd-walker.d.ts +43 -0
- package/dist/bundle/cwd-walker.d.ts.map +1 -0
- package/dist/bundle/orchestrate.d.ts +51 -0
- package/dist/bundle/orchestrate.d.ts.map +1 -0
- package/dist/bundle/upload.d.ts +66 -0
- package/dist/bundle/upload.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +9757 -0
- package/dist/clone/git-init.d.ts +18 -0
- package/dist/clone/git-init.d.ts.map +1 -0
- package/dist/clone/tar-extract.d.ts +59 -0
- package/dist/clone/tar-extract.d.ts.map +1 -0
- package/dist/commands/apps.d.ts +14 -0
- package/dist/commands/apps.d.ts.map +1 -0
- package/dist/commands/channel-auth.d.ts +31 -0
- package/dist/commands/channel-auth.d.ts.map +1 -0
- package/dist/commands/clone.d.ts +3 -0
- package/dist/commands/clone.d.ts.map +1 -0
- package/dist/commands/create.d.ts +27 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/deploy-flags.d.ts +75 -0
- package/dist/commands/deploy-flags.d.ts.map +1 -0
- package/dist/commands/deploy-modes.d.ts +59 -0
- package/dist/commands/deploy-modes.d.ts.map +1 -0
- package/dist/commands/deploy.d.ts +29 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/destroy.d.ts +14 -0
- package/dist/commands/destroy.d.ts.map +1 -0
- package/dist/commands/envvars.d.ts +28 -0
- package/dist/commands/envvars.d.ts.map +1 -0
- package/dist/commands/generate.d.ts +3 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/groups-whoami.d.ts +3 -0
- package/dist/commands/groups-whoami.d.ts.map +1 -0
- package/dist/commands/groups.d.ts +3 -0
- package/dist/commands/groups.d.ts.map +1 -0
- package/dist/commands/init.d.ts +44 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logs.d.ts +16 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/merge.d.ts +29 -0
- package/dist/commands/merge.d.ts.map +1 -0
- package/dist/commands/plan.d.ts +3 -0
- package/dist/commands/plan.d.ts.map +1 -0
- package/dist/commands/pull.d.ts +12 -0
- package/dist/commands/pull.d.ts.map +1 -0
- package/dist/commands/review.d.ts +22 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/commands/rollback.d.ts +3 -0
- package/dist/commands/rollback.d.ts.map +1 -0
- package/dist/commands/secrets-template.d.ts +3 -0
- package/dist/commands/secrets-template.d.ts.map +1 -0
- package/dist/commands/secrets.d.ts +3 -0
- package/dist/commands/secrets.d.ts.map +1 -0
- package/dist/commands/skills.d.ts +13 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/status.d.ts +54 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/update.d.ts +114 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/validate.d.ts +3 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/deploy/apply.d.ts +29 -0
- package/dist/deploy/apply.d.ts.map +1 -0
- package/dist/deploy/dry-run.d.ts +13 -0
- package/dist/deploy/dry-run.d.ts.map +1 -0
- package/dist/deploy/git-files.d.ts +33 -0
- package/dist/deploy/git-files.d.ts.map +1 -0
- package/dist/deploy/group-pin.d.ts +66 -0
- package/dist/deploy/group-pin.d.ts.map +1 -0
- package/dist/deploy/manifest-state.d.ts +20 -0
- package/dist/deploy/manifest-state.d.ts.map +1 -0
- package/dist/deploy/manifest-status.d.ts +11 -0
- package/dist/deploy/manifest-status.d.ts.map +1 -0
- package/dist/deploy/resolve.d.ts +53 -0
- package/dist/deploy/resolve.d.ts.map +1 -0
- package/dist/deploy/rollback.d.ts +23 -0
- package/dist/deploy/rollback.d.ts.map +1 -0
- package/dist/deploy/runner.d.ts +29 -0
- package/dist/deploy/runner.d.ts.map +1 -0
- package/dist/deploy/stage-exit-codes.d.ts +41 -0
- package/dist/deploy/stage-exit-codes.d.ts.map +1 -0
- package/dist/deploy/status-polling.d.ts +37 -0
- package/dist/deploy/status-polling.d.ts.map +1 -0
- package/dist/deploy/tar-pack.d.ts +22 -0
- package/dist/deploy/tar-pack.d.ts.map +1 -0
- package/dist/detect/index.d.ts +53 -0
- package/dist/detect/index.d.ts.map +1 -0
- package/dist/dispatcher.d.ts +30 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/groups/client.d.ts +62 -0
- package/dist/groups/client.d.ts.map +1 -0
- package/dist/http/api-client.d.ts +33 -0
- package/dist/http/api-client.d.ts.map +1 -0
- package/dist/http/errors.d.ts +31 -0
- package/dist/http/errors.d.ts.map +1 -0
- package/dist/manifest/load.d.ts +38 -0
- package/dist/manifest/load.d.ts.map +1 -0
- package/dist/manifest/schema.d.ts +3 -0
- package/dist/manifest/schema.d.ts.map +1 -0
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +37 -0
- package/dist/secrets/env-parse.d.ts +19 -0
- package/dist/secrets/env-parse.d.ts.map +1 -0
- package/dist/secrets/push.d.ts +13 -0
- package/dist/secrets/push.d.ts.map +1 -0
- package/dist/secrets/set.d.ts +19 -0
- package/dist/secrets/set.d.ts.map +1 -0
- package/dist/secrets/status.d.ts +19 -0
- package/dist/secrets/status.d.ts.map +1 -0
- package/dist/types/api.d.ts +112 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/update-notifier.d.ts +69 -0
- package/dist/update-notifier.d.ts.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +62 -0
- package/skills/README.md +100 -0
- package/skills/_partials/shell-contract.md +42 -0
- package/skills/launchpad-content-pr/SKILL.md +255 -0
- package/skills/launchpad-deploy/SKILL.md +415 -0
- package/skills/launchpad-deploy-status/SKILL.md +231 -0
- package/skills/launchpad-destroy/SKILL.md +317 -0
- package/skills/launchpad-onboard/SKILL.md +179 -0
- package/skills/launchpad-status/SKILL.md +263 -0
- package/skills/marquee-share/README.md +155 -0
- package/skills/marquee-share/SKILL.md +94 -0
- package/skills/marquee-share/SYNC.md +27 -0
- package/skills/marquee-share/dist/cli.js +896 -0
- package/skills/marquee-share/eslint.config.mjs +71 -0
- package/skills/marquee-share/install.sh +103 -0
- package/skills/marquee-share/package-lock.json +3946 -0
- package/skills/marquee-share/package.json +30 -0
- package/skills/marquee-share/src/auth/PROVENANCE.md +103 -0
- package/skills/marquee-share/src/auth/browser.ts +75 -0
- package/skills/marquee-share/src/auth/callback-server.ts +171 -0
- package/skills/marquee-share/src/auth/discovery.ts +171 -0
- package/skills/marquee-share/src/auth/flow.ts +262 -0
- package/skills/marquee-share/src/auth/index.ts +171 -0
- package/skills/marquee-share/src/auth/jwt.ts +77 -0
- package/skills/marquee-share/src/auth/pkce.ts +79 -0
- package/skills/marquee-share/src/auth/registration.ts +87 -0
- package/skills/marquee-share/src/auth/session.ts +205 -0
- package/skills/marquee-share/src/auth/token.ts +162 -0
- package/skills/marquee-share/src/cli.ts +246 -0
- package/skills/marquee-share/src/config.ts +101 -0
- package/skills/marquee-share/src/render/template.ts +171 -0
- package/skills/marquee-share/src/upload/index.ts +11 -0
- package/skills/marquee-share/src/upload/upload.ts +191 -0
- package/skills/marquee-share/tests/cli.test.ts +281 -0
- package/skills/marquee-share/tests/config.test.ts +119 -0
- package/skills/marquee-share/tests/flow.test.ts +356 -0
- package/skills/marquee-share/tests/no-token-leak.test.ts +240 -0
- package/skills/marquee-share/tests/pkce.test.ts +121 -0
- package/skills/marquee-share/tests/session.test.ts +173 -0
- package/skills/marquee-share/tests/template.test.ts +170 -0
- package/skills/marquee-share/tests/upload.test.ts +311 -0
- package/skills/marquee-share/tsconfig.json +23 -0
- package/skills/marquee-share/vitest.config.ts +15 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Full-flow auth tests for the Marquee share skill.
|
|
2
|
+
//
|
|
3
|
+
// These wire the complete PKCE flow against a FAKE OAuth server
|
|
4
|
+
// (an injected `fetcher`) but use a REAL local callback server
|
|
5
|
+
// (port 0). The injected browser opener "drives the browser" by
|
|
6
|
+
// hitting the loopback callback URL directly with code + state.
|
|
7
|
+
//
|
|
8
|
+
// Covers: login → session persisted; getValidToken silently reuses a
|
|
9
|
+
// fresh cached session; getValidToken silently refreshes an expired
|
|
10
|
+
// one; getValidToken re-logins when there is no session; the
|
|
11
|
+
// non-interactive path throws LoginRequiredError; discovery against a
|
|
12
|
+
// Marquee-shaped base URL derives the `resource` from the
|
|
13
|
+
// protected-resource doc.
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
16
|
+
import * as fs from "node:fs/promises";
|
|
17
|
+
import * as os from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
login,
|
|
21
|
+
logout,
|
|
22
|
+
getValidToken,
|
|
23
|
+
LoginRequiredError,
|
|
24
|
+
type SkillConfig,
|
|
25
|
+
} from "../src/auth/index.js";
|
|
26
|
+
import { readSession, writeSession } from "../src/auth/session.js";
|
|
27
|
+
import { SESSION_VERSION } from "../src/auth/session.js";
|
|
28
|
+
|
|
29
|
+
// A Marquee-shaped base URL — the real prod host. Discovery walks the
|
|
30
|
+
// well-known docs under this; the fake server below answers them.
|
|
31
|
+
const RESOURCE_URL = "https://marquee.launchpad.m-kopa.us";
|
|
32
|
+
const AUTH_SERVER = "https://mkopa.cloudflareaccess.com";
|
|
33
|
+
const WELL_KNOWN_RESOURCE = `${RESOURCE_URL}/.well-known/cloudflare-access-protected-resource/`;
|
|
34
|
+
const WELL_KNOWN_SERVER = `${AUTH_SERVER}/.well-known/oauth-authorization-server`;
|
|
35
|
+
// The protected-resource doc's `resource` field. Deliberately
|
|
36
|
+
// distinct from RESOURCE_URL so an assertion can prove the flow uses
|
|
37
|
+
// the DISCOVERED value (not a hard-coded base URL).
|
|
38
|
+
const DISCOVERED_RESOURCE = "https://marquee-canonical.m-kopa.us";
|
|
39
|
+
const AUTH_ENDPOINT = `${AUTH_SERVER}/oauth/authorize`;
|
|
40
|
+
const TOKEN_ENDPOINT = `${AUTH_SERVER}/oauth/token`;
|
|
41
|
+
const REG_ENDPOINT = `${AUTH_SERVER}/oauth/register`;
|
|
42
|
+
|
|
43
|
+
let tmpDir: string;
|
|
44
|
+
let sessionPath: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(async () => {
|
|
47
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "marquee-skill-flow-"));
|
|
48
|
+
sessionPath = path.join(tmpDir, "session.json");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function cfg(): SkillConfig {
|
|
56
|
+
return { resourceUrl: RESOURCE_URL, sessionPath };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Captured {
|
|
60
|
+
tokenBodies: URLSearchParams[];
|
|
61
|
+
regBody?: Record<string, unknown>;
|
|
62
|
+
authUrl?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a fake OAuth server. `tokenResponder` lets a test override
|
|
67
|
+
* what the token endpoint returns per call (e.g. a 400 on refresh).
|
|
68
|
+
*/
|
|
69
|
+
function makeFakeServer(
|
|
70
|
+
cap: Captured,
|
|
71
|
+
tokenResponder?: (
|
|
72
|
+
grant: string,
|
|
73
|
+
callIndex: number,
|
|
74
|
+
) => { status: number; body: unknown } | undefined,
|
|
75
|
+
): typeof fetch {
|
|
76
|
+
let tokenCalls = 0;
|
|
77
|
+
return vi.fn(
|
|
78
|
+
async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
79
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
80
|
+
if (url === WELL_KNOWN_RESOURCE) {
|
|
81
|
+
return new Response(
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
authorization_servers: [AUTH_SERVER],
|
|
84
|
+
resource: DISCOVERED_RESOURCE,
|
|
85
|
+
}),
|
|
86
|
+
{ status: 200 },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (url === WELL_KNOWN_SERVER) {
|
|
90
|
+
return new Response(
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
authorization_endpoint: AUTH_ENDPOINT,
|
|
93
|
+
token_endpoint: TOKEN_ENDPOINT,
|
|
94
|
+
registration_endpoint: REG_ENDPOINT,
|
|
95
|
+
}),
|
|
96
|
+
{ status: 200 },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (url === REG_ENDPOINT) {
|
|
100
|
+
cap.regBody = JSON.parse(
|
|
101
|
+
typeof init?.body === "string" ? init.body : "{}",
|
|
102
|
+
);
|
|
103
|
+
return new Response(JSON.stringify({ client_id: "marquee-cid" }), {
|
|
104
|
+
status: 201,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (url === TOKEN_ENDPOINT) {
|
|
108
|
+
const body = new URLSearchParams(
|
|
109
|
+
typeof init?.body === "string" ? init.body : "",
|
|
110
|
+
);
|
|
111
|
+
cap.tokenBodies.push(body);
|
|
112
|
+
const grant = body.get("grant_type") ?? "";
|
|
113
|
+
const override = tokenResponder?.(grant, tokenCalls);
|
|
114
|
+
tokenCalls++;
|
|
115
|
+
if (override) {
|
|
116
|
+
return new Response(JSON.stringify(override.body), {
|
|
117
|
+
status: override.status,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return new Response(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
access_token: `access-${grant}-${tokenCalls}`,
|
|
123
|
+
refresh_token: `refresh-${grant}-${tokenCalls}`,
|
|
124
|
+
expires_in: 900,
|
|
125
|
+
}),
|
|
126
|
+
{ status: 200 },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`unexpected fetch ${url}`);
|
|
130
|
+
},
|
|
131
|
+
) as unknown as typeof fetch;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A browser opener that drives the loopback callback directly. */
|
|
135
|
+
function drivingOpener(cap: Captured): (url: string) => Promise<void> {
|
|
136
|
+
return async (url: string): Promise<void> => {
|
|
137
|
+
cap.authUrl = url;
|
|
138
|
+
const u = new URL(url);
|
|
139
|
+
const state = u.searchParams.get("state");
|
|
140
|
+
const redirect = u.searchParams.get("redirect_uri");
|
|
141
|
+
void fetch(`${redirect}?code=test-code&state=${state}`).catch(() => {
|
|
142
|
+
/* the flow awaits server.result, not this */
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("login — full PKCE flow against a fake OAuth server", () => {
|
|
148
|
+
test("opens browser, waits for callback, persists a 0600 session", async () => {
|
|
149
|
+
const cap: Captured = { tokenBodies: [] };
|
|
150
|
+
const session = await login({
|
|
151
|
+
config: cfg(),
|
|
152
|
+
fetcher: makeFakeServer(cap),
|
|
153
|
+
browserOpener: drivingOpener(cap),
|
|
154
|
+
onDiagnostic: () => {},
|
|
155
|
+
});
|
|
156
|
+
expect(session.version).toBe(SESSION_VERSION);
|
|
157
|
+
expect(session.accessToken).toMatch(/^access-authorization_code/);
|
|
158
|
+
expect(session.refreshToken).toMatch(/^refresh-authorization_code/);
|
|
159
|
+
|
|
160
|
+
// Session is on disk and re-readable.
|
|
161
|
+
const onDisk = await readSession(sessionPath);
|
|
162
|
+
expect(onDisk?.accessToken).toBe(session.accessToken);
|
|
163
|
+
if (process.platform !== "win32") {
|
|
164
|
+
const stat = await fs.stat(sessionPath);
|
|
165
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("registers as a PUBLIC client — no client secret", async () => {
|
|
170
|
+
const cap: Captured = { tokenBodies: [] };
|
|
171
|
+
await login({
|
|
172
|
+
config: cfg(),
|
|
173
|
+
fetcher: makeFakeServer(cap),
|
|
174
|
+
browserOpener: drivingOpener(cap),
|
|
175
|
+
onDiagnostic: () => {},
|
|
176
|
+
});
|
|
177
|
+
expect(cap.regBody?.token_endpoint_auth_method).toBe("none");
|
|
178
|
+
expect(cap.regBody).not.toHaveProperty("client_secret");
|
|
179
|
+
// The token exchange carries code_verifier (PKCE proof), never
|
|
180
|
+
// a client_secret.
|
|
181
|
+
const tokenBody = cap.tokenBodies[0];
|
|
182
|
+
expect(tokenBody?.get("code_verifier")).toBeTruthy();
|
|
183
|
+
expect(tokenBody?.has("client_secret")).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("discovery derives `resource` from the protected-resource doc, not the base URL", async () => {
|
|
187
|
+
const cap: Captured = { tokenBodies: [] };
|
|
188
|
+
const session = await login({
|
|
189
|
+
config: cfg(),
|
|
190
|
+
fetcher: makeFakeServer(cap),
|
|
191
|
+
browserOpener: drivingOpener(cap),
|
|
192
|
+
onDiagnostic: () => {},
|
|
193
|
+
});
|
|
194
|
+
// The discovered value — distinct from RESOURCE_URL — must be
|
|
195
|
+
// what lands in the session and on the auth + token requests.
|
|
196
|
+
expect(session.resource).toBe(DISCOVERED_RESOURCE);
|
|
197
|
+
expect(new URL(cap.authUrl ?? "").searchParams.get("resource")).toBe(
|
|
198
|
+
DISCOVERED_RESOURCE,
|
|
199
|
+
);
|
|
200
|
+
expect(cap.tokenBodies[0]?.get("resource")).toBe(DISCOVERED_RESOURCE);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("authorization URL carries PKCE S256 challenge + state", async () => {
|
|
204
|
+
const cap: Captured = { tokenBodies: [] };
|
|
205
|
+
await login({
|
|
206
|
+
config: cfg(),
|
|
207
|
+
fetcher: makeFakeServer(cap),
|
|
208
|
+
browserOpener: drivingOpener(cap),
|
|
209
|
+
onDiagnostic: () => {},
|
|
210
|
+
});
|
|
211
|
+
const u = new URL(cap.authUrl ?? "");
|
|
212
|
+
expect(u.searchParams.get("code_challenge_method")).toBe("S256");
|
|
213
|
+
expect(u.searchParams.get("code_challenge")).toBeTruthy();
|
|
214
|
+
expect(u.searchParams.get("state")).toBeTruthy();
|
|
215
|
+
expect(u.searchParams.get("response_type")).toBe("code");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("getValidToken — cached-session reuse", () => {
|
|
220
|
+
test("returns the cached token without any network call when fresh", async () => {
|
|
221
|
+
const fresh = {
|
|
222
|
+
version: SESSION_VERSION as typeof SESSION_VERSION,
|
|
223
|
+
accessToken: "cached-fresh",
|
|
224
|
+
refreshToken: "r",
|
|
225
|
+
accessTokenExpiresAt: Date.now() + 600_000,
|
|
226
|
+
clientId: "cid",
|
|
227
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
228
|
+
resource: DISCOVERED_RESOURCE,
|
|
229
|
+
issuedAt: new Date().toISOString(),
|
|
230
|
+
};
|
|
231
|
+
await writeSession(sessionPath, fresh);
|
|
232
|
+
const fetcher = vi.fn(async () => {
|
|
233
|
+
throw new Error("network must not be touched for a fresh session");
|
|
234
|
+
}) as unknown as typeof fetch;
|
|
235
|
+
const token = await getValidToken({ config: cfg(), fetcher });
|
|
236
|
+
expect(token).toBe("cached-fresh");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("silently refreshes an expired session and persists the new pair", async () => {
|
|
240
|
+
const expired = {
|
|
241
|
+
version: SESSION_VERSION as typeof SESSION_VERSION,
|
|
242
|
+
accessToken: "cached-stale",
|
|
243
|
+
refreshToken: "the-refresh-token",
|
|
244
|
+
accessTokenExpiresAt: Date.now() - 1_000,
|
|
245
|
+
clientId: "cid",
|
|
246
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
247
|
+
resource: DISCOVERED_RESOURCE,
|
|
248
|
+
issuedAt: new Date(Date.now() - 1_000_000).toISOString(),
|
|
249
|
+
};
|
|
250
|
+
await writeSession(sessionPath, expired);
|
|
251
|
+
const cap: Captured = { tokenBodies: [] };
|
|
252
|
+
const token = await getValidToken({
|
|
253
|
+
config: cfg(),
|
|
254
|
+
fetcher: makeFakeServer(cap),
|
|
255
|
+
onDiagnostic: () => {},
|
|
256
|
+
});
|
|
257
|
+
expect(token).toMatch(/^access-refresh_token/);
|
|
258
|
+
// The refresh grant was used, carrying the cached refresh token.
|
|
259
|
+
expect(cap.tokenBodies[0]?.get("grant_type")).toBe("refresh_token");
|
|
260
|
+
expect(cap.tokenBodies[0]?.get("refresh_token")).toBe("the-refresh-token");
|
|
261
|
+
// The fresh pair is persisted.
|
|
262
|
+
const onDisk = await readSession(sessionPath);
|
|
263
|
+
expect(onDisk?.accessToken).toBe(token);
|
|
264
|
+
expect(onDisk?.accessToken).not.toBe("cached-stale");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("re-logins via the browser when there is no session at all", async () => {
|
|
268
|
+
const cap: Captured = { tokenBodies: [] };
|
|
269
|
+
const token = await getValidToken({
|
|
270
|
+
config: cfg(),
|
|
271
|
+
fetcher: makeFakeServer(cap),
|
|
272
|
+
browserOpener: drivingOpener(cap),
|
|
273
|
+
onDiagnostic: () => {},
|
|
274
|
+
});
|
|
275
|
+
expect(token).toMatch(/^access-authorization_code/);
|
|
276
|
+
// A session now exists for the next call.
|
|
277
|
+
expect(await readSession(sessionPath)).not.toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("non-interactive mode throws LoginRequiredError instead of opening a browser", async () => {
|
|
281
|
+
const fetcher = vi.fn(async () => {
|
|
282
|
+
throw new Error("must not reach the network");
|
|
283
|
+
}) as unknown as typeof fetch;
|
|
284
|
+
await expect(
|
|
285
|
+
getValidToken({ config: cfg(), fetcher, interactive: false }),
|
|
286
|
+
).rejects.toBeInstanceOf(LoginRequiredError);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("a 4xx on refresh forces re-login (interactive) ", async () => {
|
|
290
|
+
const expired = {
|
|
291
|
+
version: SESSION_VERSION as typeof SESSION_VERSION,
|
|
292
|
+
accessToken: "cached-stale",
|
|
293
|
+
refreshToken: "revoked-refresh",
|
|
294
|
+
accessTokenExpiresAt: Date.now() - 1_000,
|
|
295
|
+
clientId: "cid",
|
|
296
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
297
|
+
resource: DISCOVERED_RESOURCE,
|
|
298
|
+
issuedAt: new Date().toISOString(),
|
|
299
|
+
};
|
|
300
|
+
await writeSession(sessionPath, expired);
|
|
301
|
+
const cap: Captured = { tokenBodies: [] };
|
|
302
|
+
// First token call is the refresh grant — reject it 400. The
|
|
303
|
+
// subsequent authorization_code call (from the re-login) succeeds.
|
|
304
|
+
const fetcher = makeFakeServer(cap, (grant) =>
|
|
305
|
+
grant === "refresh_token"
|
|
306
|
+
? { status: 400, body: { error: "invalid_grant" } }
|
|
307
|
+
: undefined,
|
|
308
|
+
);
|
|
309
|
+
const token = await getValidToken({
|
|
310
|
+
config: cfg(),
|
|
311
|
+
fetcher,
|
|
312
|
+
browserOpener: drivingOpener(cap),
|
|
313
|
+
onDiagnostic: () => {},
|
|
314
|
+
});
|
|
315
|
+
// Ended up with a fresh authorization_code token after re-login.
|
|
316
|
+
expect(token).toMatch(/^access-authorization_code/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("non-interactive: a 4xx on refresh surfaces LoginRequiredError", async () => {
|
|
320
|
+
const expired = {
|
|
321
|
+
version: SESSION_VERSION as typeof SESSION_VERSION,
|
|
322
|
+
accessToken: "cached-stale",
|
|
323
|
+
refreshToken: "revoked-refresh",
|
|
324
|
+
accessTokenExpiresAt: Date.now() - 1_000,
|
|
325
|
+
clientId: "cid",
|
|
326
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
327
|
+
resource: DISCOVERED_RESOURCE,
|
|
328
|
+
issuedAt: new Date().toISOString(),
|
|
329
|
+
};
|
|
330
|
+
await writeSession(sessionPath, expired);
|
|
331
|
+
const cap: Captured = { tokenBodies: [] };
|
|
332
|
+
const fetcher = makeFakeServer(cap, (grant) =>
|
|
333
|
+
grant === "refresh_token"
|
|
334
|
+
? { status: 400, body: { error: "invalid_grant" } }
|
|
335
|
+
: undefined,
|
|
336
|
+
);
|
|
337
|
+
await expect(
|
|
338
|
+
getValidToken({ config: cfg(), fetcher, interactive: false }),
|
|
339
|
+
).rejects.toBeInstanceOf(LoginRequiredError);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("logout", () => {
|
|
344
|
+
test("clears a session created by login; second logout is a no-op", async () => {
|
|
345
|
+
const cap: Captured = { tokenBodies: [] };
|
|
346
|
+
await login({
|
|
347
|
+
config: cfg(),
|
|
348
|
+
fetcher: makeFakeServer(cap),
|
|
349
|
+
browserOpener: drivingOpener(cap),
|
|
350
|
+
onDiagnostic: () => {},
|
|
351
|
+
});
|
|
352
|
+
expect(await logout({ config: cfg(), onDiagnostic: () => {} })).toBe(true);
|
|
353
|
+
expect(await readSession(sessionPath)).toBeNull();
|
|
354
|
+
expect(await logout({ config: cfg(), onDiagnostic: () => {} })).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Token-leak guard for the Marquee share skill auth layer.
|
|
2
|
+
//
|
|
3
|
+
// The single most security-sensitive property of this layer: the
|
|
4
|
+
// access + refresh tokens must NEVER reach stdout, stderr, or any
|
|
5
|
+
// logging sink. This test exercises every public auth operation
|
|
6
|
+
// (login, getValidToken with reuse, getValidToken with refresh,
|
|
7
|
+
// logout) while intercepting console.log / console.error / direct
|
|
8
|
+
// process.stdout|stderr writes, then asserts the captured output
|
|
9
|
+
// contains no token value.
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { login, logout, getValidToken } from "../src/auth/index.js";
|
|
16
|
+
import { writeSession, SESSION_VERSION } from "../src/auth/session.js";
|
|
17
|
+
import type { SkillConfig } from "../src/config.js";
|
|
18
|
+
|
|
19
|
+
const RESOURCE_URL = "https://marquee.launchpad.m-kopa.us";
|
|
20
|
+
const AUTH_SERVER = "https://mkopa.cloudflareaccess.com";
|
|
21
|
+
const WELL_KNOWN_RESOURCE = `${RESOURCE_URL}/.well-known/cloudflare-access-protected-resource/`;
|
|
22
|
+
const WELL_KNOWN_SERVER = `${AUTH_SERVER}/.well-known/oauth-authorization-server`;
|
|
23
|
+
const AUTH_ENDPOINT = `${AUTH_SERVER}/oauth/authorize`;
|
|
24
|
+
const TOKEN_ENDPOINT = `${AUTH_SERVER}/oauth/token`;
|
|
25
|
+
const REG_ENDPOINT = `${AUTH_SERVER}/oauth/register`;
|
|
26
|
+
|
|
27
|
+
// Distinctive sentinel token values. If any of these strings appears
|
|
28
|
+
// in captured output the leak guard fails.
|
|
29
|
+
const SECRET_ACCESS = "SENTINEL-ACCESS-TOKEN-9f8e7d6c5b4a";
|
|
30
|
+
const SECRET_REFRESH = "SENTINEL-REFRESH-TOKEN-1a2b3c4d5e6f";
|
|
31
|
+
const SECRET_ACCESS_2 = "SENTINEL-ACCESS-REFRESHED-aabbccdd";
|
|
32
|
+
const SECRET_REFRESH_2 = "SENTINEL-REFRESH-ROTATED-eeff0011";
|
|
33
|
+
|
|
34
|
+
let tmpDir: string;
|
|
35
|
+
let sessionPath: string;
|
|
36
|
+
let captured: string[];
|
|
37
|
+
const restorers: Array<() => void> = [];
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "marquee-skill-leak-"));
|
|
41
|
+
sessionPath = path.join(tmpDir, "session.json");
|
|
42
|
+
captured = [];
|
|
43
|
+
|
|
44
|
+
// Intercept every plausible output channel. We RECORD, not
|
|
45
|
+
// suppress — the assertion runs against everything written.
|
|
46
|
+
const record = (...args: unknown[]): void => {
|
|
47
|
+
captured.push(args.map((a) => String(a)).join(" "));
|
|
48
|
+
};
|
|
49
|
+
for (const ch of ["log", "error", "warn", "info", "debug"] as const) {
|
|
50
|
+
const original = console[ch];
|
|
51
|
+
console[ch] = record as typeof console.log;
|
|
52
|
+
restorers.push(() => {
|
|
53
|
+
console[ch] = original;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
for (const stream of [process.stdout, process.stderr] as const) {
|
|
57
|
+
const original = stream.write.bind(stream);
|
|
58
|
+
stream.write = ((chunk: unknown): boolean => {
|
|
59
|
+
captured.push(String(chunk));
|
|
60
|
+
return true;
|
|
61
|
+
}) as typeof stream.write;
|
|
62
|
+
restorers.push(() => {
|
|
63
|
+
stream.write = original;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
while (restorers.length > 0) restorers.pop()?.();
|
|
70
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
function cfg(): SkillConfig {
|
|
74
|
+
return { resourceUrl: RESOURCE_URL, sessionPath };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fakeServer(): typeof fetch {
|
|
78
|
+
return vi.fn(
|
|
79
|
+
async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
|
80
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
81
|
+
if (url === WELL_KNOWN_RESOURCE) {
|
|
82
|
+
return new Response(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
authorization_servers: [AUTH_SERVER],
|
|
85
|
+
resource: RESOURCE_URL,
|
|
86
|
+
}),
|
|
87
|
+
{ status: 200 },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (url === WELL_KNOWN_SERVER) {
|
|
91
|
+
return new Response(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
authorization_endpoint: AUTH_ENDPOINT,
|
|
94
|
+
token_endpoint: TOKEN_ENDPOINT,
|
|
95
|
+
registration_endpoint: REG_ENDPOINT,
|
|
96
|
+
}),
|
|
97
|
+
{ status: 200 },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (url === REG_ENDPOINT) {
|
|
101
|
+
return new Response(JSON.stringify({ client_id: "marquee-cid" }), {
|
|
102
|
+
status: 201,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (url === TOKEN_ENDPOINT) {
|
|
106
|
+
const body = new URLSearchParams(
|
|
107
|
+
typeof init?.body === "string" ? init.body : "",
|
|
108
|
+
);
|
|
109
|
+
const grant = body.get("grant_type");
|
|
110
|
+
// First exchange (authorization_code) returns the sentinel
|
|
111
|
+
// pair; a refresh returns the rotated sentinel pair.
|
|
112
|
+
const isRefresh = grant === "refresh_token";
|
|
113
|
+
return new Response(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
access_token: isRefresh ? SECRET_ACCESS_2 : SECRET_ACCESS,
|
|
116
|
+
refresh_token: isRefresh ? SECRET_REFRESH_2 : SECRET_REFRESH,
|
|
117
|
+
expires_in: 900,
|
|
118
|
+
}),
|
|
119
|
+
{ status: 200 },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`unexpected fetch ${url}`);
|
|
123
|
+
},
|
|
124
|
+
) as unknown as typeof fetch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function drivingOpener(): (url: string) => Promise<void> {
|
|
128
|
+
return async (url: string): Promise<void> => {
|
|
129
|
+
const u = new URL(url);
|
|
130
|
+
const state = u.searchParams.get("state");
|
|
131
|
+
const redirect = u.searchParams.get("redirect_uri");
|
|
132
|
+
void fetch(`${redirect}?code=test-code&state=${state}`).catch(() => {
|
|
133
|
+
/* flow awaits server.result */
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assertNoLeak(): void {
|
|
139
|
+
const haystack = captured.join("\n");
|
|
140
|
+
for (const secret of [
|
|
141
|
+
SECRET_ACCESS,
|
|
142
|
+
SECRET_REFRESH,
|
|
143
|
+
SECRET_ACCESS_2,
|
|
144
|
+
SECRET_REFRESH_2,
|
|
145
|
+
]) {
|
|
146
|
+
expect(
|
|
147
|
+
haystack.includes(secret),
|
|
148
|
+
`token value "${secret}" leaked to stdout/stderr/console`,
|
|
149
|
+
).toBe(false);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("no-token-leak — auth layer never prints a credential", () => {
|
|
154
|
+
test("login does not echo the access or refresh token", async () => {
|
|
155
|
+
// NOTE: no `onDiagnostic` override — the auth layer's DEFAULT
|
|
156
|
+
// sink (console.error) is exercised, so this proves the shipped
|
|
157
|
+
// default path is leak-free, not just an injected silent sink.
|
|
158
|
+
await login({
|
|
159
|
+
config: cfg(),
|
|
160
|
+
fetcher: fakeServer(),
|
|
161
|
+
browserOpener: drivingOpener(),
|
|
162
|
+
});
|
|
163
|
+
// Sanity: login DID produce diagnostic output (so the test is
|
|
164
|
+
// actually exercising the print path, not a no-op).
|
|
165
|
+
expect(captured.join("\n").length).toBeGreaterThan(0);
|
|
166
|
+
assertNoLeak();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("getValidToken (cached reuse) does not echo the token", async () => {
|
|
170
|
+
await writeSession(sessionPath, {
|
|
171
|
+
version: SESSION_VERSION,
|
|
172
|
+
accessToken: SECRET_ACCESS,
|
|
173
|
+
refreshToken: SECRET_REFRESH,
|
|
174
|
+
accessTokenExpiresAt: Date.now() + 600_000,
|
|
175
|
+
clientId: "cid",
|
|
176
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
177
|
+
resource: RESOURCE_URL,
|
|
178
|
+
issuedAt: new Date().toISOString(),
|
|
179
|
+
});
|
|
180
|
+
const token = await getValidToken({ config: cfg(), fetcher: fakeServer() });
|
|
181
|
+
// The token IS returned to the in-process caller — that is the
|
|
182
|
+
// contract. It must just never have been printed.
|
|
183
|
+
expect(token).toBe(SECRET_ACCESS);
|
|
184
|
+
assertNoLeak();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("getValidToken (silent refresh) does not echo either token", async () => {
|
|
188
|
+
await writeSession(sessionPath, {
|
|
189
|
+
version: SESSION_VERSION,
|
|
190
|
+
accessToken: "old-stale-token",
|
|
191
|
+
refreshToken: SECRET_REFRESH,
|
|
192
|
+
accessTokenExpiresAt: Date.now() - 1_000,
|
|
193
|
+
clientId: "cid",
|
|
194
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
195
|
+
resource: RESOURCE_URL,
|
|
196
|
+
issuedAt: new Date().toISOString(),
|
|
197
|
+
});
|
|
198
|
+
const token = await getValidToken({ config: cfg(), fetcher: fakeServer() });
|
|
199
|
+
expect(token).toBe(SECRET_ACCESS_2);
|
|
200
|
+
assertNoLeak();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("getValidToken (full re-login) does not echo the token", async () => {
|
|
204
|
+
const token = await getValidToken({
|
|
205
|
+
config: cfg(),
|
|
206
|
+
fetcher: fakeServer(),
|
|
207
|
+
browserOpener: drivingOpener(),
|
|
208
|
+
});
|
|
209
|
+
expect(token).toBe(SECRET_ACCESS);
|
|
210
|
+
assertNoLeak();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("logout does not echo a token", async () => {
|
|
214
|
+
await writeSession(sessionPath, {
|
|
215
|
+
version: SESSION_VERSION,
|
|
216
|
+
accessToken: SECRET_ACCESS,
|
|
217
|
+
refreshToken: SECRET_REFRESH,
|
|
218
|
+
accessTokenExpiresAt: Date.now() + 600_000,
|
|
219
|
+
clientId: "cid",
|
|
220
|
+
tokenEndpoint: TOKEN_ENDPOINT,
|
|
221
|
+
resource: RESOURCE_URL,
|
|
222
|
+
issuedAt: new Date().toISOString(),
|
|
223
|
+
});
|
|
224
|
+
await logout({ config: cfg() });
|
|
225
|
+
assertNoLeak();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("diagnostic output mentions the session PATH but not its contents", async () => {
|
|
229
|
+
await login({
|
|
230
|
+
config: cfg(),
|
|
231
|
+
fetcher: fakeServer(),
|
|
232
|
+
browserOpener: drivingOpener(),
|
|
233
|
+
});
|
|
234
|
+
const haystack = captured.join("\n");
|
|
235
|
+
// The path is fine to print (it's where the file lives, not the
|
|
236
|
+
// credential). The credential is not.
|
|
237
|
+
expect(haystack).toContain(sessionPath);
|
|
238
|
+
assertNoLeak();
|
|
239
|
+
});
|
|
240
|
+
});
|