@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,262 @@
|
|
|
1
|
+
// Orchestration: putting discovery + registration + PKCE +
|
|
2
|
+
// callback server + browser open + token exchange together into
|
|
3
|
+
// a single "log the user in" function.
|
|
4
|
+
//
|
|
5
|
+
// Vendored from @m-kopa/launchpad-cli src/auth/flow.ts — see
|
|
6
|
+
// PROVENANCE.md. Marquee adaptations:
|
|
7
|
+
// * `botUrl` → `resourceUrl` (the option that names the protected
|
|
8
|
+
// app — for Marquee, `https://marquee.launchpad.m-kopa.us`).
|
|
9
|
+
// * `CliSession` → `MarqueeSession`.
|
|
10
|
+
// * User-facing error strings say `marquee login`.
|
|
11
|
+
// * The callback wait is bounded by `CALLBACK_TIMEOUT_MS` so a
|
|
12
|
+
// never-arriving callback can't hang the command forever.
|
|
13
|
+
// The state-machine logic itself is otherwise unchanged.
|
|
14
|
+
//
|
|
15
|
+
// Also: `getValidAccessToken` — the symmetric read path the upload
|
|
16
|
+
// task (a later task) will go through. Returns the current
|
|
17
|
+
// `accessToken` if it's still valid, otherwise silently refreshes
|
|
18
|
+
// using the `refreshToken`. If the refresh fails (grant session
|
|
19
|
+
// expired, token revoked, etc.) the caller gets a typed
|
|
20
|
+
// `LoginRequiredError`.
|
|
21
|
+
//
|
|
22
|
+
// The login flow is a state machine in narrative form:
|
|
23
|
+
//
|
|
24
|
+
// 1. discoverOauthEndpoints(resourceUrl) — two GETs of well-known
|
|
25
|
+
// metadata.
|
|
26
|
+
// 2. bindCallbackServer(state) — pick a free localhost port.
|
|
27
|
+
// 3. registerClient(registrationEndpoint, redirectUri) — get a
|
|
28
|
+
// `client_id` for this loopback URI (public client, no secret).
|
|
29
|
+
// 4. generatePkcePair() — verifier + challenge with the
|
|
30
|
+
// Cloudflare-quirk regen.
|
|
31
|
+
// 5. openBrowser(authUrl) — kick the user into Cf Access.
|
|
32
|
+
// 6. server.result — wait for /callback?code=...
|
|
33
|
+
// 7. exchangeCodeForTokens(...) — code → access + refresh.
|
|
34
|
+
// 8. writeSession(...) — persist with mode 0600.
|
|
35
|
+
// 9. server.close().
|
|
36
|
+
//
|
|
37
|
+
// The whole thing is wrapped in a try/finally that always closes
|
|
38
|
+
// the server, even on cancellation or thrown errors.
|
|
39
|
+
|
|
40
|
+
import { randomBytes } from "node:crypto";
|
|
41
|
+
import { bindCallbackServer } from "./callback-server.js";
|
|
42
|
+
import { discoverOauthEndpoints } from "./discovery.js";
|
|
43
|
+
import { generatePkcePair } from "./pkce.js";
|
|
44
|
+
import { registerClient } from "./registration.js";
|
|
45
|
+
import { exchangeCodeForTokens, refreshTokens, TokenError } from "./token.js";
|
|
46
|
+
import {
|
|
47
|
+
readSession,
|
|
48
|
+
writeSession,
|
|
49
|
+
type MarqueeSession,
|
|
50
|
+
SESSION_VERSION,
|
|
51
|
+
} from "./session.js";
|
|
52
|
+
import { openBrowser } from "./browser.js";
|
|
53
|
+
|
|
54
|
+
export class LoginRequiredError extends Error {
|
|
55
|
+
readonly code = "login_required" as const;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Refresh-window buffer: refresh `REFRESH_SKEW_MS` before expiry
|
|
59
|
+
* so a request fired right at the edge doesn't hit a 401. */
|
|
60
|
+
export const REFRESH_SKEW_MS = 30_000;
|
|
61
|
+
|
|
62
|
+
/** Upper bound on how long `login` waits for the browser to round-trip
|
|
63
|
+
* back to the localhost callback. Without it a closed tab, a blocked
|
|
64
|
+
* loopback, or a bad redirect hangs `marquee login` indefinitely. */
|
|
65
|
+
export const CALLBACK_TIMEOUT_MS = 5 * 60_000;
|
|
66
|
+
|
|
67
|
+
export interface LoginOptions {
|
|
68
|
+
/** Base URL of the protected app — for Marquee,
|
|
69
|
+
* `https://marquee.launchpad.m-kopa.us`. Discovery walks the
|
|
70
|
+
* well-known docs under this host. */
|
|
71
|
+
readonly resourceUrl: string;
|
|
72
|
+
readonly sessionPath: string;
|
|
73
|
+
/** Receives the auth URL once the localhost server is bound +
|
|
74
|
+
* the browser opener is about to fire. The caller prints it to
|
|
75
|
+
* stdout so a headless dev can copy-paste. */
|
|
76
|
+
readonly onAuthUrl?: (url: string) => void;
|
|
77
|
+
/** Injection points for tests. */
|
|
78
|
+
readonly fetcher?: typeof fetch;
|
|
79
|
+
readonly browserOpener?: (url: string) => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run the full PKCE flow and persist the session. Returns the
|
|
84
|
+
* resulting session for the caller (so it can print confirmation
|
|
85
|
+
* diagnostics — never the token itself).
|
|
86
|
+
*/
|
|
87
|
+
export async function login(opts: LoginOptions): Promise<MarqueeSession> {
|
|
88
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
89
|
+
const opener = opts.browserOpener ?? ((url: string) => openBrowser(url));
|
|
90
|
+
|
|
91
|
+
const endpoints = await discoverOauthEndpoints(opts.resourceUrl, fetcher);
|
|
92
|
+
const state = randomBytes(16).toString("hex");
|
|
93
|
+
const server = await bindCallbackServer(state);
|
|
94
|
+
try {
|
|
95
|
+
const redirectUri = `http://127.0.0.1:${server.port}/callback`;
|
|
96
|
+
const reg = await registerClient(
|
|
97
|
+
endpoints.registrationEndpoint,
|
|
98
|
+
redirectUri,
|
|
99
|
+
fetcher,
|
|
100
|
+
);
|
|
101
|
+
const pkce = generatePkcePair();
|
|
102
|
+
const authUrl = buildAuthorizationUrl({
|
|
103
|
+
authorizationEndpoint: endpoints.authorizationEndpoint,
|
|
104
|
+
clientId: reg.clientId,
|
|
105
|
+
redirectUri,
|
|
106
|
+
challenge: pkce.challenge,
|
|
107
|
+
state,
|
|
108
|
+
resource: endpoints.resource,
|
|
109
|
+
});
|
|
110
|
+
opts.onAuthUrl?.(authUrl);
|
|
111
|
+
// Opener failure (no `xdg-open` on a headless box, etc.) must NOT
|
|
112
|
+
// tear down the flow — `onAuthUrl` already printed the URL for
|
|
113
|
+
// the user to copy-paste. Log the error to the onAuthUrl sink so
|
|
114
|
+
// the caller sees something, then keep waiting on the callback.
|
|
115
|
+
try {
|
|
116
|
+
await opener(authUrl);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
opts.onAuthUrl?.(
|
|
119
|
+
`(could not auto-open browser: ${describe(e)} — copy the URL above into a browser instead)`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
// Bound the callback wait — the `finally` below still closes the
|
|
123
|
+
// server when the timeout fires, so no listener / port leaks.
|
|
124
|
+
let callbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
125
|
+
const callback = await Promise.race([
|
|
126
|
+
server.result,
|
|
127
|
+
new Promise<never>((_, reject) => {
|
|
128
|
+
callbackTimer = setTimeout(
|
|
129
|
+
() =>
|
|
130
|
+
reject(
|
|
131
|
+
new LoginRequiredError(
|
|
132
|
+
"timed out waiting for the browser callback — run `marquee login` again",
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
CALLBACK_TIMEOUT_MS,
|
|
136
|
+
);
|
|
137
|
+
}),
|
|
138
|
+
]).finally(() => {
|
|
139
|
+
if (callbackTimer !== undefined) clearTimeout(callbackTimer);
|
|
140
|
+
});
|
|
141
|
+
const tokens = await exchangeCodeForTokens(
|
|
142
|
+
{
|
|
143
|
+
tokenEndpoint: endpoints.tokenEndpoint,
|
|
144
|
+
clientId: reg.clientId,
|
|
145
|
+
code: callback.code,
|
|
146
|
+
codeVerifier: pkce.verifier,
|
|
147
|
+
redirectUri,
|
|
148
|
+
resource: endpoints.resource,
|
|
149
|
+
},
|
|
150
|
+
fetcher,
|
|
151
|
+
);
|
|
152
|
+
const session: MarqueeSession = {
|
|
153
|
+
version: SESSION_VERSION,
|
|
154
|
+
accessToken: tokens.accessToken,
|
|
155
|
+
refreshToken: tokens.refreshToken,
|
|
156
|
+
accessTokenExpiresAt: Date.now() + tokens.expiresInSec * 1000,
|
|
157
|
+
clientId: reg.clientId,
|
|
158
|
+
tokenEndpoint: endpoints.tokenEndpoint,
|
|
159
|
+
resource: endpoints.resource,
|
|
160
|
+
issuedAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
await writeSession(opts.sessionPath, session);
|
|
163
|
+
return session;
|
|
164
|
+
} finally {
|
|
165
|
+
await server.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read the session and return a still-valid access token. If the
|
|
171
|
+
* persisted access token is past (or near) expiry, silently
|
|
172
|
+
* refresh and persist the new pair before returning. If the
|
|
173
|
+
* refresh fails because the grant has expired, throws
|
|
174
|
+
* `LoginRequiredError`.
|
|
175
|
+
*
|
|
176
|
+
* `now` is injected for tests so we can simulate expiry without
|
|
177
|
+
* mocking the system clock.
|
|
178
|
+
*/
|
|
179
|
+
export async function getValidAccessToken(
|
|
180
|
+
sessionPath: string,
|
|
181
|
+
fetcher: typeof fetch = fetch,
|
|
182
|
+
now: () => number = Date.now,
|
|
183
|
+
): Promise<{ accessToken: string; session: MarqueeSession }> {
|
|
184
|
+
const session = await readSession(sessionPath);
|
|
185
|
+
if (session === null) {
|
|
186
|
+
throw new LoginRequiredError("no session — run `marquee login`");
|
|
187
|
+
}
|
|
188
|
+
if (session.accessTokenExpiresAt - REFRESH_SKEW_MS > now()) {
|
|
189
|
+
return { accessToken: session.accessToken, session };
|
|
190
|
+
}
|
|
191
|
+
// Expired (or close to it). Try silent refresh.
|
|
192
|
+
//
|
|
193
|
+
// A session written without `resource` cannot be refreshed (Cf
|
|
194
|
+
// Access would fail the `invalid_target` check). Force the user
|
|
195
|
+
// back through `marquee login` rather than send a doomed request.
|
|
196
|
+
if (session.resource === undefined) {
|
|
197
|
+
throw new LoginRequiredError(
|
|
198
|
+
"session is missing the resource indicator — run `marquee login`",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
let next;
|
|
202
|
+
try {
|
|
203
|
+
next = await refreshTokens(
|
|
204
|
+
{
|
|
205
|
+
tokenEndpoint: session.tokenEndpoint,
|
|
206
|
+
clientId: session.clientId,
|
|
207
|
+
refreshToken: session.refreshToken,
|
|
208
|
+
resource: session.resource,
|
|
209
|
+
},
|
|
210
|
+
fetcher,
|
|
211
|
+
);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
if (
|
|
214
|
+
e instanceof TokenError &&
|
|
215
|
+
e.httpStatus !== undefined &&
|
|
216
|
+
e.httpStatus >= 400 &&
|
|
217
|
+
e.httpStatus < 500
|
|
218
|
+
) {
|
|
219
|
+
// 4xx on refresh = grant rejected by the server. The user
|
|
220
|
+
// must re-auth via browser; nothing the skill can do silently.
|
|
221
|
+
throw new LoginRequiredError(
|
|
222
|
+
`session expired — run \`marquee login\` (token endpoint said: ${e.message})`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
throw e;
|
|
226
|
+
}
|
|
227
|
+
const refreshed: MarqueeSession = {
|
|
228
|
+
...session,
|
|
229
|
+
accessToken: next.accessToken,
|
|
230
|
+
refreshToken: next.refreshToken,
|
|
231
|
+
accessTokenExpiresAt: now() + next.expiresInSec * 1000,
|
|
232
|
+
issuedAt: new Date(now()).toISOString(),
|
|
233
|
+
};
|
|
234
|
+
await writeSession(sessionPath, refreshed);
|
|
235
|
+
return { accessToken: refreshed.accessToken, session: refreshed };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function describe(e: unknown): string {
|
|
239
|
+
return e instanceof Error ? e.message : String(e);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function buildAuthorizationUrl(params: {
|
|
243
|
+
readonly authorizationEndpoint: string;
|
|
244
|
+
readonly clientId: string;
|
|
245
|
+
readonly redirectUri: string;
|
|
246
|
+
readonly challenge: string;
|
|
247
|
+
readonly state: string;
|
|
248
|
+
readonly resource: string;
|
|
249
|
+
}): string {
|
|
250
|
+
const u = new URL(params.authorizationEndpoint);
|
|
251
|
+
u.searchParams.set("client_id", params.clientId);
|
|
252
|
+
u.searchParams.set("redirect_uri", params.redirectUri);
|
|
253
|
+
u.searchParams.set("response_type", "code");
|
|
254
|
+
u.searchParams.set("code_challenge", params.challenge);
|
|
255
|
+
u.searchParams.set("code_challenge_method", "S256");
|
|
256
|
+
u.searchParams.set("state", params.state);
|
|
257
|
+
// RFC 8707 — Cf Access requires this on the auth request
|
|
258
|
+
// (`invalid_target` otherwise). The resulting access_token's `aud`
|
|
259
|
+
// is bound to this value; Marquee's JWT validation expects a match.
|
|
260
|
+
u.searchParams.set("resource", params.resource);
|
|
261
|
+
return u.toString();
|
|
262
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Public auth surface for the Marquee share skill.
|
|
2
|
+
//
|
|
3
|
+
// This is the ONLY module later tasks (HTML render, upload) should
|
|
4
|
+
// import from. It wraps the vendored OAuth/PKCE machinery (see
|
|
5
|
+
// PROVENANCE.md) with Marquee config loading and exposes three
|
|
6
|
+
// operations:
|
|
7
|
+
//
|
|
8
|
+
// * `login` — one-time browser PKCE login; persists a
|
|
9
|
+
// session at `~/.marquee/session.json` (0600).
|
|
10
|
+
// * `logout` — clears the cached session.
|
|
11
|
+
// * `getValidToken` — returns a still-valid access token, silently
|
|
12
|
+
// reusing a cached session while valid and
|
|
13
|
+
// refreshing on expiry. If the refresh grant
|
|
14
|
+
// has also expired it runs a fresh browser
|
|
15
|
+
// login (when `interactive` is allowed) so the
|
|
16
|
+
// upload task gets a token without the caller
|
|
17
|
+
// orchestrating the re-auth.
|
|
18
|
+
//
|
|
19
|
+
// SECURITY POSTURE
|
|
20
|
+
// * The access + refresh tokens are NEVER returned to stdout, never
|
|
21
|
+
// logged, and never passed to a logging sink. `getValidToken`
|
|
22
|
+
// returns the token string to its in-process caller only; the
|
|
23
|
+
// caller (the upload task) puts it in an `Authorization` header.
|
|
24
|
+
// * PKCE public client — no client secret is generated, received,
|
|
25
|
+
// or stored anywhere in this skill.
|
|
26
|
+
// * JWT signatures are NOT verified here. The skill is an OAuth
|
|
27
|
+
// client; Marquee's Pages Functions verify. `whoami`-style
|
|
28
|
+
// identity readout uses the payload-only decoder in `jwt.ts`.
|
|
29
|
+
|
|
30
|
+
import { loadConfig, type SkillConfig } from "../config.js";
|
|
31
|
+
import { login as runLoginFlow, getValidAccessToken } from "./flow.js";
|
|
32
|
+
import { LoginRequiredError } from "./flow.js";
|
|
33
|
+
import { clearSession, type MarqueeSession } from "./session.js";
|
|
34
|
+
|
|
35
|
+
export { LoginRequiredError } from "./flow.js";
|
|
36
|
+
export type { MarqueeSession } from "./session.js";
|
|
37
|
+
export type { SkillConfig } from "../config.js";
|
|
38
|
+
|
|
39
|
+
/** A logging sink restricted to non-secret diagnostics. The auth
|
|
40
|
+
* layer only ever passes auth URLs and human-readable status here —
|
|
41
|
+
* never a token. Defaults to `console.error` (stderr). */
|
|
42
|
+
export type DiagnosticSink = (line: string) => void;
|
|
43
|
+
|
|
44
|
+
export interface AuthOptions {
|
|
45
|
+
/** Override the resolved config (resourceUrl + sessionPath).
|
|
46
|
+
* Defaults to `loadConfig()` which reads `MARQUEE_*` env vars. */
|
|
47
|
+
readonly config?: SkillConfig;
|
|
48
|
+
/** Non-secret diagnostic sink. Defaults to stderr. NEVER receives
|
|
49
|
+
* a token. */
|
|
50
|
+
readonly onDiagnostic?: DiagnosticSink;
|
|
51
|
+
/** Injected for tests. */
|
|
52
|
+
readonly fetcher?: typeof fetch;
|
|
53
|
+
readonly browserOpener?: (url: string) => Promise<void>;
|
|
54
|
+
/** Injected for tests — the clock `getValidToken` uses to decide
|
|
55
|
+
* whether the cached token is still fresh. */
|
|
56
|
+
readonly now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolveConfig(opts: AuthOptions): SkillConfig {
|
|
60
|
+
return opts.config ?? loadConfig();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function diag(opts: AuthOptions, line: string): void {
|
|
64
|
+
(opts.onDiagnostic ?? ((l: string) => console.error(l)))(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* One-time interactive login. Opens the browser to Cloudflare Access,
|
|
69
|
+
* completes the PKCE exchange, and persists the session.
|
|
70
|
+
*
|
|
71
|
+
* Returns the session for diagnostics. The caller MUST NOT print
|
|
72
|
+
* `session.accessToken` / `session.refreshToken`.
|
|
73
|
+
*/
|
|
74
|
+
export async function login(
|
|
75
|
+
opts: AuthOptions = {},
|
|
76
|
+
): Promise<MarqueeSession> {
|
|
77
|
+
const cfg = resolveConfig(opts);
|
|
78
|
+
diag(opts, "Opening browser to authenticate with Cloudflare Access…");
|
|
79
|
+
diag(opts, "(if it doesn't open automatically, copy the URL below)");
|
|
80
|
+
const session = await runLoginFlow({
|
|
81
|
+
resourceUrl: cfg.resourceUrl,
|
|
82
|
+
sessionPath: cfg.sessionPath,
|
|
83
|
+
onAuthUrl: (url) => diag(opts, url),
|
|
84
|
+
...(opts.fetcher !== undefined ? { fetcher: opts.fetcher } : {}),
|
|
85
|
+
...(opts.browserOpener !== undefined
|
|
86
|
+
? { browserOpener: opts.browserOpener }
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
89
|
+
diag(opts, `Logged in. Session stored at ${cfg.sessionPath}`);
|
|
90
|
+
return session;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clear the persisted session. Idempotent — returns `true` if a
|
|
95
|
+
* session existed and was removed, `false` if there was nothing to
|
|
96
|
+
* clear.
|
|
97
|
+
*/
|
|
98
|
+
export async function logout(opts: AuthOptions = {}): Promise<boolean> {
|
|
99
|
+
const cfg = resolveConfig(opts);
|
|
100
|
+
const had = await clearSession(cfg.sessionPath);
|
|
101
|
+
diag(
|
|
102
|
+
opts,
|
|
103
|
+
had
|
|
104
|
+
? `Logged out. Session cleared at ${cfg.sessionPath}`
|
|
105
|
+
: "Already logged out.",
|
|
106
|
+
);
|
|
107
|
+
return had;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface GetTokenOptions extends AuthOptions {
|
|
111
|
+
/**
|
|
112
|
+
* Whether `getValidToken` may fall back to an interactive browser
|
|
113
|
+
* login when there is no session, or when the refresh grant has
|
|
114
|
+
* expired. Defaults to `true` — the skill runs on a developer's
|
|
115
|
+
* machine and the whole point is a frictionless token. Set to
|
|
116
|
+
* `false` in non-interactive contexts (CI, tests) to get a thrown
|
|
117
|
+
* `LoginRequiredError` instead of a hung browser open.
|
|
118
|
+
*/
|
|
119
|
+
readonly interactive?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Return a still-valid Marquee access token.
|
|
124
|
+
*
|
|
125
|
+
* Resolution order:
|
|
126
|
+
* 1. Cached session present and the access token is comfortably
|
|
127
|
+
* within its lifetime → return it directly (no network).
|
|
128
|
+
* 2. Cached session present but the access token is expired (or
|
|
129
|
+
* within the refresh-skew window) → silently refresh via the
|
|
130
|
+
* refresh token, persist the new pair, return the fresh token.
|
|
131
|
+
* 3. No session, or the refresh grant itself has expired → if
|
|
132
|
+
* `interactive` (default), run a fresh browser PKCE login and
|
|
133
|
+
* return the resulting token; otherwise throw
|
|
134
|
+
* `LoginRequiredError`.
|
|
135
|
+
*
|
|
136
|
+
* The returned string is a bearer token. The caller is responsible
|
|
137
|
+
* for transmitting it ONLY in an `Authorization` header to Marquee
|
|
138
|
+
* over HTTPS, and for never logging it.
|
|
139
|
+
*/
|
|
140
|
+
export async function getValidToken(
|
|
141
|
+
opts: GetTokenOptions = {},
|
|
142
|
+
): Promise<string> {
|
|
143
|
+
const cfg = resolveConfig(opts);
|
|
144
|
+
const interactive = opts.interactive ?? true;
|
|
145
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
146
|
+
const now = opts.now ?? Date.now;
|
|
147
|
+
try {
|
|
148
|
+
const { accessToken } = await getValidAccessToken(
|
|
149
|
+
cfg.sessionPath,
|
|
150
|
+
fetcher,
|
|
151
|
+
now,
|
|
152
|
+
);
|
|
153
|
+
return accessToken;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
if (e instanceof LoginRequiredError) {
|
|
156
|
+
if (!interactive) {
|
|
157
|
+
// Re-throw so a non-interactive caller (CI, tests) gets a
|
|
158
|
+
// typed signal instead of an unexpected browser launch.
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
// No usable session — fall back to a fresh interactive login.
|
|
162
|
+
diag(
|
|
163
|
+
opts,
|
|
164
|
+
"No valid Marquee session — starting a browser login…",
|
|
165
|
+
);
|
|
166
|
+
const session = await login(opts);
|
|
167
|
+
return session.accessToken;
|
|
168
|
+
}
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Tiny payload-only JWT decoder used for identity display.
|
|
2
|
+
//
|
|
3
|
+
// Vendored from @m-kopa/launchpad-cli src/auth/jwt.ts — see
|
|
4
|
+
// PROVENANCE.md. Unchanged from upstream.
|
|
5
|
+
//
|
|
6
|
+
// We are NOT validating the signature. The token came back from
|
|
7
|
+
// Cloudflare's OAuth token endpoint over HTTPS; we trust its content
|
|
8
|
+
// for *display* purposes only (e.g. showing "logged in as <email>").
|
|
9
|
+
//
|
|
10
|
+
// Marquee's Pages Functions are the authority for actual authz — the
|
|
11
|
+
// skill is a client. The marquee repo's ANTI-PATTERNS.md is explicit:
|
|
12
|
+
// "Do not implement custom JWT verification." This decoder NEVER
|
|
13
|
+
// verifies a signature; it only base64-decodes the payload segment.
|
|
14
|
+
// If real client-side validation were ever needed (it should not be),
|
|
15
|
+
// the answer is `jose`, not crypto rolled here.
|
|
16
|
+
|
|
17
|
+
export interface JwtPayload {
|
|
18
|
+
/** Cf Access user-scoped JWT subject — the stable Entra UUID.
|
|
19
|
+
* Marquee keys ownership off this `sub`, never off `email`. */
|
|
20
|
+
readonly sub?: string;
|
|
21
|
+
/** Email / UPN claim. Cloudflare populates this from the IdP. */
|
|
22
|
+
readonly email?: string;
|
|
23
|
+
/** Cf Access aud (the App's UUID). */
|
|
24
|
+
readonly aud?: string | readonly string[];
|
|
25
|
+
/** Issued-at + expiry, seconds since epoch (RFC 7519). */
|
|
26
|
+
readonly iat?: number;
|
|
27
|
+
readonly exp?: number;
|
|
28
|
+
/** Token type — "app" for Cf Access app tokens. */
|
|
29
|
+
readonly type?: string;
|
|
30
|
+
/** Anything else the IdP / Cf added; we don't validate it. */
|
|
31
|
+
readonly [k: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class JwtParseError extends Error {
|
|
35
|
+
readonly code = "jwt_parse_error" as const;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Decode the payload of a JWT. Returns `null` for inputs that
|
|
40
|
+
* don't look like a JWT at all (e.g. an opaque token). Throws
|
|
41
|
+
* `JwtParseError` only for shapes that look JWT-like but have an
|
|
42
|
+
* undecodable payload — callers should treat both null and throw
|
|
43
|
+
* as "show empty identity".
|
|
44
|
+
*/
|
|
45
|
+
export function decodeJwtPayload(token: string): JwtPayload | null {
|
|
46
|
+
const parts = token.split(".");
|
|
47
|
+
if (parts.length !== 3) return null;
|
|
48
|
+
const payload = parts[1] ?? "";
|
|
49
|
+
let json: string;
|
|
50
|
+
try {
|
|
51
|
+
json = Buffer.from(b64UrlToB64(payload), "base64").toString("utf8");
|
|
52
|
+
} catch (e) {
|
|
53
|
+
throw new JwtParseError(
|
|
54
|
+
`could not base64-decode JWT payload: ${describe(e)}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(json);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new JwtParseError(`JWT payload is not JSON: ${describe(e)}`);
|
|
62
|
+
}
|
|
63
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
64
|
+
throw new JwtParseError(`JWT payload is not an object`);
|
|
65
|
+
}
|
|
66
|
+
return parsed as JwtPayload;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function b64UrlToB64(s: string): string {
|
|
70
|
+
// Pad to length-multiple-of-4 + swap URL-safe alphabet back.
|
|
71
|
+
const padded = s.padEnd(s.length + ((4 - (s.length % 4)) % 4), "=");
|
|
72
|
+
return padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function describe(e: unknown): string {
|
|
76
|
+
return e instanceof Error ? e.message : String(e);
|
|
77
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// PKCE (RFC 7636) primitives, with one Cloudflare-specific deviation.
|
|
2
|
+
//
|
|
3
|
+
// Vendored from @m-kopa/launchpad-cli src/auth/pkce.ts — see
|
|
4
|
+
// PROVENANCE.md. Unchanged from upstream: the protocol logic is
|
|
5
|
+
// generic and not Marquee-specific.
|
|
6
|
+
//
|
|
7
|
+
// **The quirk.** The RFC says the `code_challenge` is base64url
|
|
8
|
+
// (i.e. `[A-Za-z0-9_-]*`). Cloudflare's Access OAuth server rejects
|
|
9
|
+
// challenges that don't START with `[a-zA-Z0-9]` — so a leading `-`
|
|
10
|
+
// or `_` in the base64url output silently fails. Strategy: generate;
|
|
11
|
+
// if the challenge starts with `-` or `_`, throw away both the
|
|
12
|
+
// verifier AND the challenge and regenerate. We do NOT reuse the
|
|
13
|
+
// verifier with a salt — that would not produce a fresh challenge.
|
|
14
|
+
//
|
|
15
|
+
// Verifier length: 32 bytes of entropy → 43 base64url chars (no
|
|
16
|
+
// padding). Within the RFC's 43–128-char window.
|
|
17
|
+
|
|
18
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
export interface PkcePair {
|
|
21
|
+
/** Plain-text random secret, kept on the client. */
|
|
22
|
+
readonly verifier: string;
|
|
23
|
+
/** S256 hash of the verifier, sent in the authorization URL. */
|
|
24
|
+
readonly challenge: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Hard cap on regeneration attempts — defends against an
|
|
28
|
+
* algorithmic mistake silently spinning forever. The probability
|
|
29
|
+
* of a `-` or `_` first byte is ~2/64 per attempt, so even 5 tries
|
|
30
|
+
* is enough for ~6-nines reliability; we pick a higher bound so a
|
|
31
|
+
* pathological RNG (or a future encoding change) surfaces as a
|
|
32
|
+
* clear error rather than a hang. */
|
|
33
|
+
export const MAX_PKCE_REGEN_ATTEMPTS = 32;
|
|
34
|
+
|
|
35
|
+
export class PkceGenerationError extends Error {
|
|
36
|
+
readonly code = "pkce_generation_failed" as const;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate a fresh PKCE pair satisfying both RFC 7636 and the
|
|
41
|
+
* Cloudflare Access "challenge must start with [a-zA-Z0-9]" rule.
|
|
42
|
+
* Pure (modulo `randomBytes`) — caller can re-test by injecting
|
|
43
|
+
* a deterministic RNG via the optional `rng` parameter.
|
|
44
|
+
*/
|
|
45
|
+
export function generatePkcePair(
|
|
46
|
+
rng: (size: number) => Uint8Array = (s) => new Uint8Array(randomBytes(s)),
|
|
47
|
+
): PkcePair {
|
|
48
|
+
for (let attempt = 0; attempt < MAX_PKCE_REGEN_ATTEMPTS; attempt++) {
|
|
49
|
+
const verifierBytes = rng(32);
|
|
50
|
+
const verifier = base64Url(verifierBytes);
|
|
51
|
+
const challenge = sha256Base64Url(verifier);
|
|
52
|
+
if (/^[a-zA-Z0-9]/.test(challenge)) {
|
|
53
|
+
return { verifier, challenge };
|
|
54
|
+
}
|
|
55
|
+
// Else: leading `-` or `_` — re-roll. Discarding the verifier
|
|
56
|
+
// entirely is intentional: re-hashing the same verifier with a
|
|
57
|
+
// salt would mean the server-side `verifier` derivation no
|
|
58
|
+
// longer matches the challenge.
|
|
59
|
+
}
|
|
60
|
+
throw new PkceGenerationError(
|
|
61
|
+
`could not produce a Cloudflare-acceptable PKCE challenge after ${MAX_PKCE_REGEN_ATTEMPTS} attempts; check the RNG`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** S256 challenge for an arbitrary verifier. Exposed for tests. */
|
|
66
|
+
export function sha256Base64Url(verifier: string): string {
|
|
67
|
+
const hash = createHash("sha256").update(verifier).digest();
|
|
68
|
+
return base64Url(hash);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** RFC 4648 §5 unpadded base64url. */
|
|
72
|
+
function base64Url(bytes: Uint8Array | Buffer): string {
|
|
73
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
74
|
+
return buf
|
|
75
|
+
.toString("base64")
|
|
76
|
+
.replace(/\+/g, "-")
|
|
77
|
+
.replace(/\//g, "_")
|
|
78
|
+
.replace(/=+$/, "");
|
|
79
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Dynamic client registration (RFC 7591) for Cloudflare Access
|
|
2
|
+
// Managed OAuth.
|
|
3
|
+
//
|
|
4
|
+
// Vendored from @m-kopa/launchpad-cli src/auth/registration.ts — see
|
|
5
|
+
// PROVENANCE.md. Marquee adaptation: `client_name` is
|
|
6
|
+
// "marquee-share-skill" (was "launchpad-cli") so the registered
|
|
7
|
+
// public client is identifiable in Cloudflare Access logs.
|
|
8
|
+
//
|
|
9
|
+
// The first time a skill installation runs `login`, it POSTs a tiny
|
|
10
|
+
// JSON body to the OAuth server's `registration_endpoint` announcing
|
|
11
|
+
// itself as a PUBLIC client (`token_endpoint_auth_method: "none"`)
|
|
12
|
+
// with the loopback redirect URI it just bound. The server returns a
|
|
13
|
+
// `client_id` we use for this session and persist alongside the
|
|
14
|
+
// tokens.
|
|
15
|
+
//
|
|
16
|
+
// We do NOT persist — or even receive and keep — a `client_secret`.
|
|
17
|
+
// Public clients don't have one; PKCE replaces it. This is the
|
|
18
|
+
// "PKCE public client — no client secret anywhere in the shipped
|
|
19
|
+
// skill" requirement, enforced structurally: `RegistrationResult`
|
|
20
|
+
// carries only `clientId`.
|
|
21
|
+
|
|
22
|
+
export interface RegistrationResult {
|
|
23
|
+
readonly clientId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class RegistrationError extends Error {
|
|
27
|
+
readonly code = "registration_error" as const;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function registerClient(
|
|
31
|
+
registrationEndpoint: string,
|
|
32
|
+
redirectUri: string,
|
|
33
|
+
fetcher: typeof fetch = fetch,
|
|
34
|
+
): Promise<RegistrationResult> {
|
|
35
|
+
const body = {
|
|
36
|
+
client_name: "marquee-share-skill",
|
|
37
|
+
redirect_uris: [redirectUri],
|
|
38
|
+
// Public client: no secret. PKCE is the proof-of-possession.
|
|
39
|
+
token_endpoint_auth_method: "none",
|
|
40
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
41
|
+
response_types: ["code"],
|
|
42
|
+
};
|
|
43
|
+
let res: Response;
|
|
44
|
+
try {
|
|
45
|
+
res = await fetcher(registrationEndpoint, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"content-type": "application/json",
|
|
49
|
+
accept: "application/json",
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(body),
|
|
52
|
+
});
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new RegistrationError(`registration: network error: ${describe(e)}`);
|
|
55
|
+
}
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const detail = await res.text().catch(() => "");
|
|
58
|
+
throw new RegistrationError(
|
|
59
|
+
`registration: ${registrationEndpoint} returned HTTP ${res.status}: ${detail.slice(0, 200)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
let parsed: unknown;
|
|
63
|
+
try {
|
|
64
|
+
parsed = await res.json();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
throw new RegistrationError(
|
|
67
|
+
`registration: non-JSON response: ${describe(e)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
parsed === null ||
|
|
72
|
+
typeof parsed !== "object" ||
|
|
73
|
+
typeof (parsed as { client_id?: unknown }).client_id !== "string"
|
|
74
|
+
) {
|
|
75
|
+
throw new RegistrationError(
|
|
76
|
+
`registration: response missing string client_id`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
// Deliberately read ONLY client_id. Even if the server echoes a
|
|
80
|
+
// `client_secret`, we never capture it — a public client has no
|
|
81
|
+
// use for one and it must not reach disk.
|
|
82
|
+
return { clientId: (parsed as { client_id: string }).client_id };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function describe(e: unknown): string {
|
|
86
|
+
return e instanceof Error ? e.message : String(e);
|
|
87
|
+
}
|