@openparachute/hub 0.5.13-rc.37 → 0.5.13-rc.38

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.37",
3
+ "version": "0.5.13-rc.38",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -3970,13 +3970,20 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3970
3970
  });
3971
3971
  }
3972
3972
 
3973
- test("session absent → page renders Sign-in CTA + shareable deep link (no CLI message)", async () => {
3974
- // Approval-UX rc.19: the unauthenticated viewer no longer sees a
3975
- // "Ask the operator to run `parachute auth approve-client <id>`"
3976
- // message. The web approval path (#277) is the canonical recovery
3977
- // now render a primary Sign-in CTA wired to /login?next=/admin/...
3978
- // and a shareable deep link the operator can send to whoever runs
3979
- // the hub.
3973
+ test("session absent → Sign-in CTA preserves the authorize URL through login + shareable deep link", async () => {
3974
+ // Approval-UX rc.19: the unauthenticated viewer sees no CLI hint
3975
+ // the web approval path (#277) is the canonical recovery now. The
3976
+ // primary Sign-in CTA wires `/login?next=<authorize URL>` so post-
3977
+ // login the operator lands BACK on the same `/oauth/authorize?...`
3978
+ // request now authenticated, they see the inline approve form, one
3979
+ // click resumes the OAuth flow through consent → redirect_uri. The
3980
+ // shareable secondary deep link still points at the SPA approve page
3981
+ // (it's for sharing with another admin, not for the in-flight flow).
3982
+ //
3983
+ // Pre-fix the Sign-in CTA also pointed at the SPA approve page —
3984
+ // approving the client but discarding the authorize URL params, so
3985
+ // the calling app (e.g. Claude.ai MCP) was never told and the user
3986
+ // looped on retry. Caught by Aaron on the Render deploy.
3980
3987
  const { db, cleanup } = await makeDb();
3981
3988
  try {
3982
3989
  const reg = registerClient(db, {
@@ -3984,19 +3991,34 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3984
3991
  clientName: "MyApp",
3985
3992
  status: "pending",
3986
3993
  });
3987
- const req = new Request(pendingAuthorizeUrl(reg.client.clientId));
3994
+ const authorizePath = pendingAuthorizeUrl(reg.client.clientId);
3995
+ const req = new Request(authorizePath);
3988
3996
  const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
3989
3997
  expect(res.status).toBe(403);
3990
3998
  const html = await res.text();
3991
3999
  expect(html).toContain("App not yet approved");
3992
- // Primary CTA: Sign-in link wired to land the admin directly on
3993
- // the approval page after authenticating.
4000
+ // Primary CTA: Sign-in link wired to round-trip the operator back
4001
+ // to the original /oauth/authorize?... URL after login (resumes the
4002
+ // OAuth flow rather than dead-ending at the SPA approve page).
3994
4003
  expect(html).toContain("Sign in as admin to approve");
3995
- const expectedLoginHref = `/login?next=${encodeURIComponent(
4004
+ const requestUrl = new URL(authorizePath);
4005
+ const returnTo = `${requestUrl.pathname}${requestUrl.search}`;
4006
+ const expectedLoginHref = `/login?next=${encodeURIComponent(returnTo)}`;
4007
+ expect(html).toContain(`href="${expectedLoginHref}"`);
4008
+ // Sanity: the next= target carries the authorize path + the
4009
+ // client_id + state so the flow can resume verbatim post-login.
4010
+ expect(returnTo).toContain("/oauth/authorize");
4011
+ expect(returnTo).toContain(encodeURIComponent(reg.client.clientId));
4012
+ expect(returnTo).toContain("state=rt-208");
4013
+ // The legacy SPA approve path is NOT what the Sign-in CTA points
4014
+ // at any more (regression guard for the fix).
4015
+ const legacyHref = `/login?next=${encodeURIComponent(
3996
4016
  `/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
3997
4017
  )}`;
3998
- expect(html).toContain(`href="${expectedLoginHref}"`);
3999
- // Secondary CTA: shareable, fully-qualified deep link + Copy button.
4018
+ expect(html).not.toContain(`href="${legacyHref}"`);
4019
+ // Secondary CTA: shareable, fully-qualified deep link + Copy button
4020
+ // — still points at the SPA approve page (no OAuth flow context to
4021
+ // preserve for the share-with-another-admin case).
4000
4022
  expect(html).toContain(
4001
4023
  `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
4002
4024
  );
@@ -408,13 +408,43 @@ describe("renderApprovePending unauthenticated CTAs", () => {
408
408
  hubOrigin: "https://hub.example.com",
409
409
  };
410
410
 
411
- test("renders Sign in CTA wired to /login?next=/admin/approve-client/<id>", () => {
411
+ test("without loginNextUrl: Sign in CTA falls back to /admin/approve-client/<id>", () => {
412
+ // Back-compat fallback for callers that don't have an in-flight OAuth
413
+ // URL to resume (e.g. direct share-page entry). The SPA approve path
414
+ // approves the client but discards any OAuth context — fine here
415
+ // because there's no OAuth flow to discard.
412
416
  const html = renderApprovePending(COMMON);
413
417
  expect(html).toContain("Sign in as admin to approve");
414
418
  const expectedHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
415
419
  expect(html).toContain(`href="${expectedHref}"`);
416
420
  });
417
421
 
422
+ test("with loginNextUrl set: Sign in CTA preserves the authorize URL through login", () => {
423
+ // The OAuth-flow entry case (the bug fix): the page is rendered for an
424
+ // `/oauth/authorize?...` GET, the operator isn't signed in, the CTA
425
+ // sends them through `/login?next=<authorize URL>` so post-login they
426
+ // land BACK on the OAuth flow — now authenticated, see the inline
427
+ // approve form, click once, OAuth flow resumes through consent →
428
+ // redirect_uri callback. Without this the SPA approves the client but
429
+ // the calling app (Claude MCP) is never told and the user loops on
430
+ // retry. Caught when Aaron tested via Claude.ai's MCP connector
431
+ // against the Render deploy.
432
+ const authorizeUrl =
433
+ "/oauth/authorize?client_id=client-xyz&redirect_uri=https%3A%2F%2Fapp.example%2Fcb" +
434
+ "&response_type=code&code_challenge=abc&code_challenge_method=S256&state=rt";
435
+ const html = renderApprovePending({ ...COMMON, loginNextUrl: authorizeUrl });
436
+ expect(html).toContain("Sign in as admin to approve");
437
+ const expectedHref = `/login?next=${encodeURIComponent(authorizeUrl)}`;
438
+ expect(html).toContain(`href="${expectedHref}"`);
439
+ // Sanity: the legacy SPA approve path is NOT what `next` points at.
440
+ const legacyHref = `/login?next=${encodeURIComponent("/admin/approve-client/client-xyz")}`;
441
+ expect(html).not.toContain(`href="${legacyHref}"`);
442
+ // The shareable deep link (secondary CTA, not Sign-in) still uses
443
+ // the SPA approve path — it's for sharing with another admin who
444
+ // isn't in an OAuth flow.
445
+ expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
446
+ });
447
+
418
448
  test("renders fully-qualified shareable deep link + Copy button + clipboard JS", () => {
419
449
  const html = renderApprovePending(COMMON);
420
450
  expect(html).toContain("https://hub.example.com/admin/approve-client/client-xyz");
@@ -417,21 +417,34 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
417
417
  * "App not yet approved" page (#74) for /oauth/authorize. When the request
418
418
  * carries a valid operator session AND a same-origin Origin/Referer, render
419
419
  * the inline approve form (#208) so one click flips the client to `approved`
420
- * and the OAuth flow re-enters at consent. Otherwise fall back to the
421
- * pre-#208 CLI-only message ("ask operator to run `parachute auth
422
- * approve-client <id>`").
420
+ * and the OAuth flow re-enters at consent. Otherwise render the unauth CTAs
421
+ * (Sign-in primary + shareable deep link secondary; the CLI fallback was
422
+ * retired in rc.19).
423
423
  *
424
424
  * The session-bound approve gate mirrors the same-origin DCR auto-approve
425
425
  * gate on `/oauth/register` (#199, #200): valid session cookie + matching
426
426
  * Origin/Referer = trusted operator action. Cross-origin or session-less
427
- * GETs see the CLI-fallback message; the button never renders for them, so
428
- * the POST handler can't be tricked into approving via a hand-crafted form
429
- * either (CSRF token won't match).
427
+ * GETs see the unauth CTA; the button never renders for them, so the POST
428
+ * handler can't be tricked into approving via a hand-crafted form either
429
+ * (CSRF token won't match).
430
430
  *
431
- * The form's `return_to` carries the original `/oauth/authorize?...` URL so
432
- * the post-approve redirect lands the operator back on the same flow with
433
- * the now-approved client. The POST handler validates `return_to` is a
434
- * hub-relative path before following it (open-redirect defense).
431
+ * BOTH branches plumb the original `/oauth/authorize?...` URL into the
432
+ * rendered page so the OAuth flow can resume after the operator's action:
433
+ *
434
+ * - Authed branch: form's `return_to` is the authorize URL; the approve
435
+ * POST handler 302s there after flipping status (open-redirect defense
436
+ * in the POST handler validates `return_to` is hub-relative).
437
+ * - Unauth branch: CTA's `next` is the authorize URL; `/login` 302s there
438
+ * after sign-in (`safeNext` in admin-handlers.ts gates the target to
439
+ * hub-relative paths). The operator lands back on this same page,
440
+ * now authenticated → enters the authed branch above → one-click
441
+ * approve resumes the OAuth flow.
442
+ *
443
+ * Pre-fix the unauth CTA pointed at `/admin/approve-client/<id>` (the SPA
444
+ * approve page) — which approves the client but discards the in-flight
445
+ * authorize URL, so the calling app (e.g. Claude MCP via Claude.ai) is
446
+ * never told and the user loops on retry. Caught when Aaron hit it on the
447
+ * Render deploy via Claude.ai's MCP connector.
435
448
  */
436
449
  function pendingClientResponse(
437
450
  db: Database,
@@ -453,8 +466,18 @@ function pendingClientResponse(
453
466
  const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
454
467
  const csrf = ensureCsrfToken(req);
455
468
  const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
469
+ // Hub-relative URL of the original `/oauth/authorize?...` request. Used in
470
+ // BOTH branches so the post-approve path (authed: form's `return_to`) and
471
+ // the post-login path (unauthed: CTA's `next`) round-trip the operator
472
+ // back to the same OAuth flow. Without `loginNextUrl` on the unauth
473
+ // branch, the operator post-login would land on the SPA approve page
474
+ // with no knowledge of the in-flight authorize URL — the SPA approves
475
+ // the client but the OAuth flow never completes, the calling app (e.g.
476
+ // Claude MCP) is never told, and the user loops on retry. Fix lands the
477
+ // unauth flow on the same authorize URL, post-login the operator hits
478
+ // the authed branch's inline approve form, and the flow resumes.
479
+ const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
456
480
  if (session && sameOrigin) {
457
- const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
458
481
  return htmlResponse(
459
482
  renderApprovePending({
460
483
  clientName: client.clientName ?? client.clientId,
@@ -464,6 +487,7 @@ function pendingClientResponse(
464
487
  ...(requestedVault !== undefined && { requestedVault }),
465
488
  hubOrigin: deps.issuer,
466
489
  approveForm: { csrfToken: csrf.token, returnTo },
490
+ loginNextUrl: returnTo,
467
491
  }),
468
492
  403,
469
493
  extra,
@@ -477,6 +501,7 @@ function pendingClientResponse(
477
501
  requestedScopes,
478
502
  ...(requestedVault !== undefined && { requestedVault }),
479
503
  hubOrigin: deps.issuer,
504
+ loginNextUrl: returnTo,
480
505
  }),
481
506
  403,
482
507
  extra,
package/src/oauth-ui.ts CHANGED
@@ -169,11 +169,29 @@ export interface ErrorViewProps {
169
169
  * `approved` and re-enters the OAuth flow at consent.
170
170
  * - Unauthenticated viewer — render TWO CTAs (no terminal mention):
171
171
  * 1. Primary: "Sign in as admin to approve" → links to
172
- * `/login?next=/admin/approve-client/<client_id>` so the admin
173
- * lands directly on the approval page after sign-in.
172
+ * `/login?next=<loginNextUrl>`. When the page was rendered in
173
+ * response to an `/oauth/authorize?...` request, `loginNextUrl`
174
+ * is that same authorize URL (pathname+search) so the operator
175
+ * lands BACK on the OAuth flow after sign-in — now authenticated,
176
+ * they see the inline "Approve and continue" form, click once,
177
+ * and the OAuth flow resumes through consent → redirect_uri.
178
+ * Without this, post-login the operator lands on the SPA approve
179
+ * page, which approves the client but discards the original
180
+ * authorize URL and its params — Claude is never told, the user
181
+ * retries, and the prompt loop never closes (the bug Aaron hit
182
+ * via Claude.ai MCP on the Render deploy).
183
+ *
184
+ * Callers that don't pass `loginNextUrl` (the deep-link share-
185
+ * page entry point, or hand-crafted callers) fall back to the
186
+ * legacy SPA approve path; that's still useful for the
187
+ * "share with another admin" case, just not for resuming an
188
+ * in-flight OAuth flow.
174
189
  * 2. Secondary: a fully-qualified shareable deep link to
175
190
  * `<hub_origin>/admin/approve-client/<client_id>` with a copy
176
191
  * button — the operator can send it to whoever runs the hub.
192
+ * This deep link intentionally stays SPA-routed (it has no
193
+ * authorize-URL context to preserve — the recipient isn't in
194
+ * an OAuth flow).
177
195
  *
178
196
  * The CLI fallback (`parachute auth approve-client <id>`) was retired —
179
197
  * the web path is the path now. Operators who want the CLI still have it
@@ -212,6 +230,24 @@ export interface ApprovePendingViewProps {
212
230
  csrfToken: string;
213
231
  returnTo: string;
214
232
  };
233
+ /**
234
+ * Same-origin hub-relative URL (path + search) to send the operator to
235
+ * after they sign in via the unauthenticated "Sign in as admin to
236
+ * approve" CTA. When this page is rendered in response to an
237
+ * `/oauth/authorize?...` GET, the caller should pass that authorize
238
+ * URL here so post-login the operator lands BACK on the OAuth flow
239
+ * (now authenticated → sees inline approve form → one click → consent →
240
+ * redirect_uri callback). Omitted → CTA falls back to the legacy
241
+ * `/login?next=/admin/approve-client/<id>` path which dead-ends the
242
+ * OAuth flow at the SPA approve page (kept only for back-compat with
243
+ * non-authorize callers).
244
+ *
245
+ * The path is validated server-side at `/login` via `safeNext`
246
+ * (`src/admin-handlers.ts`), which only honors hub-relative paths
247
+ * starting with `/` and excluding `//` — so an attacker-controlled
248
+ * authorize URL can't be wielded for open-redirect.
249
+ */
250
+ loginNextUrl?: string;
215
251
  }
216
252
 
217
253
  export function renderLogin(props: LoginViewProps): string {
@@ -424,6 +460,7 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
424
460
  requestedVault,
425
461
  hubOrigin,
426
462
  approveForm,
463
+ loginNextUrl,
427
464
  } = props;
428
465
  const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
429
466
  // Substitute unnamed `vault:<verb>` rows with the wildcard display form
@@ -470,7 +507,7 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
470
507
  <input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
471
508
  <button type="submit" class="btn btn-primary">Approve and continue</button>
472
509
  </form>`
473
- : renderUnauthenticatedApproveCtas(hubOrigin, clientId);
510
+ : renderUnauthenticatedApproveCtas(hubOrigin, clientId, loginNextUrl);
474
511
  const body = `
475
512
  <div class="card">
476
513
  <div class="card-header">
@@ -512,20 +549,41 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
512
549
  * Unauthenticated branch of `renderApprovePending`. Two CTAs:
513
550
  *
514
551
  * 1. Primary: "Sign in as admin to approve" → links to
515
- * `/login?next=/admin/approve-client/<client_id>` so the admin lands
516
- * on the approval page after sign-in.
552
+ * `/login?next=<loginNextUrl>`. When `loginNextUrl` is provided,
553
+ * that's the URL the operator lands on after sign-in — for the
554
+ * OAuth-flow entry point that's the original `/oauth/authorize?...`
555
+ * URL, so the operator resumes the in-flight OAuth flow (now
556
+ * authenticated → inline approve form → consent → redirect_uri)
557
+ * instead of dead-ending at the SPA approve page with the OAuth
558
+ * params discarded. When `loginNextUrl` is absent, fall back to
559
+ * `/admin/approve-client/<id>` (the legacy SPA path) — useful for
560
+ * non-authorize entry points where there's no OAuth flow to resume.
517
561
  * 2. Secondary: a fully-qualified shareable deep-link to
518
562
  * `<hub_origin>/admin/approve-client/<client_id>` with a Copy button
519
- * so the operator can send it to whoever runs the hub.
563
+ * so the operator can send it to whoever runs the hub. This stays
564
+ * SPA-routed because the recipient isn't in an OAuth flow — the
565
+ * deep-link is for the share-with-another-admin case where there's
566
+ * no in-flight authorize URL context to preserve.
520
567
  *
521
568
  * Inline JS is scoped to the Copy button only — `navigator.clipboard.writeText`
522
569
  * with a brief "Copied!" affordance. The button degrades gracefully when
523
570
  * scripting is unavailable (the URL is still selectable + copyable from the
524
571
  * `<code>` block via the OS clipboard).
525
572
  */
526
- function renderUnauthenticatedApproveCtas(hubOrigin: string, clientId: string): string {
573
+ function renderUnauthenticatedApproveCtas(
574
+ hubOrigin: string,
575
+ clientId: string,
576
+ loginNextUrl?: string,
577
+ ): string {
527
578
  const approvalPath = `/admin/approve-client/${encodeURIComponent(clientId)}`;
528
- const loginHref = `/login?next=${encodeURIComponent(approvalPath)}`;
579
+ // Prefer the caller-supplied loginNextUrl (the original authorize URL when
580
+ // this page was rendered for an OAuth-flow GET) so post-login resumes the
581
+ // flow. Fall back to the SPA approve path for entry points that don't have
582
+ // an authorize URL to resume. `/login`'s `safeNext` (admin-handlers.ts)
583
+ // gates the redirect target to hub-relative paths only — open-redirect is
584
+ // not reachable here regardless of caller.
585
+ const loginNext = loginNextUrl ?? approvalPath;
586
+ const loginHref = `/login?next=${encodeURIComponent(loginNext)}`;
529
587
  const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
530
588
  const deepLink = `${trimmedOrigin}${approvalPath}`;
531
589
  return `