@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 +1 -1
- package/src/__tests__/oauth-handlers.test.ts +35 -13
- package/src/__tests__/oauth-ui.test.ts +31 -1
- package/src/oauth-handlers.ts +36 -11
- package/src/oauth-ui.ts +66 -8
package/package.json
CHANGED
|
@@ -3970,13 +3970,20 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3970
3970
|
});
|
|
3971
3971
|
}
|
|
3972
3972
|
|
|
3973
|
-
test("session absent →
|
|
3974
|
-
// Approval-UX rc.19: the unauthenticated viewer no
|
|
3975
|
-
//
|
|
3976
|
-
//
|
|
3977
|
-
//
|
|
3978
|
-
//
|
|
3979
|
-
// the
|
|
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
|
|
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
|
|
3993
|
-
// the
|
|
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
|
|
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="${
|
|
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("
|
|
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");
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -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
|
|
421
|
-
*
|
|
422
|
-
*
|
|
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
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
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
|
|
173
|
-
*
|
|
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
|
|
516
|
-
*
|
|
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(
|
|
573
|
+
function renderUnauthenticatedApproveCtas(
|
|
574
|
+
hubOrigin: string,
|
|
575
|
+
clientId: string,
|
|
576
|
+
loginNextUrl?: string,
|
|
577
|
+
): string {
|
|
527
578
|
const approvalPath = `/admin/approve-client/${encodeURIComponent(clientId)}`;
|
|
528
|
-
|
|
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 `
|