@openparachute/hub 0.5.1 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-handlers.test.ts +92 -0
- package/src/__tests__/expose-2fa-warning.test.ts +125 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +1227 -1
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +13 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +737 -1
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +367 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +173 -0
- package/src/admin-handlers.ts +38 -13
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +28 -1
- package/src/help.ts +3 -3
- package/src/hub-server.ts +266 -32
- package/src/module-manifest.ts +19 -0
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +249 -12
- package/src/oauth-ui.ts +167 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +163 -0
- package/src/service-spec.ts +66 -13
- package/src/services-manifest.ts +83 -3
- package/src/sessions.ts +19 -0
package/src/notes-serve.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { existsSync } from "node:fs";
|
|
28
|
+
import { homedir } from "node:os";
|
|
28
29
|
import { dirname, join, resolve } from "node:path";
|
|
29
30
|
|
|
30
31
|
interface Args {
|
|
@@ -67,16 +68,76 @@ export function normalizeMount(raw: string): string {
|
|
|
67
68
|
return raw.replace(/\/+$/, "");
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Candidate base directories that `Bun.resolveSync` walks from when looking
|
|
73
|
+
* for `@openparachute/notes/package.json`. Order matters:
|
|
74
|
+
*
|
|
75
|
+
* 1. `process.cwd()` — works when notes-serve is invoked from inside the
|
|
76
|
+
* notes checkout (e.g. via `installDir` cwd in lifecycle.ts) or from
|
|
77
|
+
* any project that depends on `@openparachute/notes`.
|
|
78
|
+
* 2. `~/.bun/install/global/node_modules` — modern Bun's global-install
|
|
79
|
+
* layout. This is where `bun add -g @openparachute/notes` lands the
|
|
80
|
+
* package, and where `bun link @openparachute/notes` symlinks it.
|
|
81
|
+
* 3. `~/.bun/install/global` — defensive fallback for older Bun layouts.
|
|
82
|
+
*
|
|
83
|
+
* Hub itself does NOT depend on `@openparachute/notes`, so when
|
|
84
|
+
* `parachute start notes` is run from the hub repo dir, the cwd-relative
|
|
85
|
+
* resolve walks ancestral node_modules and finds nothing. Bun does not
|
|
86
|
+
* auto-consult the global install dir, so bun-linked installs fail to
|
|
87
|
+
* resolve without (2)/(3). hub#194: Aaron hit silent 502 on tailnet
|
|
88
|
+
* `/notes/` because of this — fixed by trying the global install dirs.
|
|
89
|
+
*
|
|
90
|
+
* Exported (and parameterized via `cwd`/`home`) so tests can drive the
|
|
91
|
+
* resolution order against a real fixture install without monkey-patching
|
|
92
|
+
* `Bun.resolveSync`.
|
|
93
|
+
*/
|
|
94
|
+
export function notesDistCandidates(cwd: string, home: string): string[] {
|
|
95
|
+
return [cwd, join(home, ".bun/install/global/node_modules"), join(home, ".bun/install/global")];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ResolveNotesDistDeps {
|
|
99
|
+
cwd?: string;
|
|
100
|
+
home?: string;
|
|
101
|
+
/** Override `Bun.resolveSync` for tests. */
|
|
102
|
+
resolveSync?: (specifier: string, base: string) => string;
|
|
103
|
+
existsSync?: (path: string) => boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveNotesDistFrom(deps: ResolveNotesDistDeps = {}): string {
|
|
107
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
108
|
+
const home = deps.home ?? homedir();
|
|
109
|
+
const resolveSync = deps.resolveSync ?? Bun.resolveSync;
|
|
110
|
+
const exists = deps.existsSync ?? existsSync;
|
|
111
|
+
const candidates = notesDistCandidates(cwd, home);
|
|
112
|
+
const resolveErrors: string[] = [];
|
|
113
|
+
for (const base of candidates) {
|
|
114
|
+
let pkgPath: string;
|
|
115
|
+
try {
|
|
116
|
+
pkgPath = resolveSync("@openparachute/notes/package.json", base);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
resolveErrors.push(` - ${base}: ${err instanceof Error ? err.message : String(err)}`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const root = dirname(pkgPath);
|
|
122
|
+
const dist = join(root, "dist");
|
|
123
|
+
if (!exists(dist)) {
|
|
124
|
+
// Found the package but it has no dist/. This is a hard error
|
|
125
|
+
// (package shipped without a prebuilt bundle); don't fall through to
|
|
126
|
+
// other candidates — they'd resolve to the same package and report
|
|
127
|
+
// the same problem.
|
|
128
|
+
throw new Error(
|
|
129
|
+
`@openparachute/notes resolved at ${root} has no dist/ directory at ${dist}. The package may not ship a prebuilt bundle — ask the notes maintainer to add a prepublishOnly build step.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return dist;
|
|
78
133
|
}
|
|
79
|
-
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Could not resolve @openparachute/notes from any of:\n${resolveErrors.join("\n")}\nIs the package installed? Try \`bun add -g @openparachute/notes\` or \`parachute install notes\`.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveNotesDist(): string {
|
|
140
|
+
return resolveNotesDistFrom();
|
|
80
141
|
}
|
|
81
142
|
|
|
82
143
|
function mimeFor(path: string): string | undefined {
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
|
|
9
9
|
* - GET /oauth/authorize (login → consent → code)
|
|
10
10
|
* - POST /oauth/authorize (form posts: login + consent)
|
|
11
|
+
* - POST /oauth/authorize/approve (operator-driven inline DCR approval, #208)
|
|
11
12
|
* - POST /oauth/token (grant_type=authorization_code | refresh_token)
|
|
12
13
|
* - POST /oauth/register (RFC 7591 DCR)
|
|
13
14
|
* - POST /oauth/revoke (RFC 7009 token revocation)
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
type ClientStatus,
|
|
36
37
|
type OAuthClient,
|
|
37
38
|
type RegisteredClient,
|
|
39
|
+
approveClient,
|
|
38
40
|
getClient,
|
|
39
41
|
isValidRedirectUri,
|
|
40
42
|
registerClient,
|
|
@@ -53,7 +55,13 @@ import {
|
|
|
53
55
|
signAccessToken,
|
|
54
56
|
signRefreshToken,
|
|
55
57
|
} from "./jwt-sign.ts";
|
|
56
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
type AuthorizeFormParams,
|
|
60
|
+
renderApprovePending,
|
|
61
|
+
renderConsent,
|
|
62
|
+
renderError,
|
|
63
|
+
renderLogin,
|
|
64
|
+
} from "./oauth-ui.ts";
|
|
57
65
|
import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
|
|
58
66
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
59
67
|
import {
|
|
@@ -64,6 +72,7 @@ import {
|
|
|
64
72
|
SESSION_TTL_MS,
|
|
65
73
|
buildSessionCookie,
|
|
66
74
|
createSession,
|
|
75
|
+
findActiveSession,
|
|
67
76
|
findSession,
|
|
68
77
|
parseSessionCookie,
|
|
69
78
|
} from "./sessions.ts";
|
|
@@ -291,12 +300,63 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
|
|
|
291
300
|
};
|
|
292
301
|
}
|
|
293
302
|
|
|
294
|
-
/**
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
303
|
+
/**
|
|
304
|
+
* "App not yet approved" page (#74) for /oauth/authorize. When the request
|
|
305
|
+
* carries a valid operator session AND a same-origin Origin/Referer, render
|
|
306
|
+
* the inline approve form (#208) so one click flips the client to `approved`
|
|
307
|
+
* and the OAuth flow re-enters at consent. Otherwise fall back to the
|
|
308
|
+
* pre-#208 CLI-only message ("ask operator to run `parachute auth
|
|
309
|
+
* approve-client <id>`").
|
|
310
|
+
*
|
|
311
|
+
* The session-bound approve gate mirrors the same-origin DCR auto-approve
|
|
312
|
+
* gate on `/oauth/register` (#199, #200): valid session cookie + matching
|
|
313
|
+
* Origin/Referer = trusted operator action. Cross-origin or session-less
|
|
314
|
+
* GETs see the CLI-fallback message; the button never renders for them, so
|
|
315
|
+
* the POST handler can't be tricked into approving via a hand-crafted form
|
|
316
|
+
* either (CSRF token won't match).
|
|
317
|
+
*
|
|
318
|
+
* The form's `return_to` carries the original `/oauth/authorize?...` URL so
|
|
319
|
+
* the post-approve redirect lands the operator back on the same flow with
|
|
320
|
+
* the now-approved client. The POST handler validates `return_to` is a
|
|
321
|
+
* hub-relative path before following it (open-redirect defense).
|
|
322
|
+
*/
|
|
323
|
+
function pendingClientResponse(
|
|
324
|
+
db: Database,
|
|
325
|
+
req: Request,
|
|
326
|
+
client: OAuthClient,
|
|
327
|
+
authorizeUrl: URL,
|
|
328
|
+
deps: OAuthDeps,
|
|
329
|
+
): Response {
|
|
330
|
+
const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
|
|
331
|
+
.split(" ")
|
|
332
|
+
.filter((s) => s.length > 0);
|
|
333
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
334
|
+
const sameOrigin = originMatchesIssuer(req, deps.issuer);
|
|
335
|
+
const csrf = ensureCsrfToken(req);
|
|
336
|
+
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
337
|
+
if (session && sameOrigin) {
|
|
338
|
+
const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
|
|
339
|
+
return htmlResponse(
|
|
340
|
+
renderApprovePending({
|
|
341
|
+
clientName: client.clientName ?? client.clientId,
|
|
342
|
+
clientId: client.clientId,
|
|
343
|
+
redirectUris: client.redirectUris,
|
|
344
|
+
requestedScopes,
|
|
345
|
+
approveForm: { csrfToken: csrf.token, returnTo },
|
|
346
|
+
}),
|
|
347
|
+
403,
|
|
348
|
+
extra,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return htmlResponse(
|
|
352
|
+
renderApprovePending({
|
|
353
|
+
clientName: client.clientName ?? client.clientId,
|
|
354
|
+
clientId: client.clientId,
|
|
355
|
+
redirectUris: client.redirectUris,
|
|
356
|
+
requestedScopes,
|
|
357
|
+
}),
|
|
299
358
|
403,
|
|
359
|
+
extra,
|
|
300
360
|
);
|
|
301
361
|
}
|
|
302
362
|
|
|
@@ -345,7 +405,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
345
405
|
// matched it against a registered client. Render an HTML error.
|
|
346
406
|
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
347
407
|
}
|
|
348
|
-
if (client.status !== "approved")
|
|
408
|
+
if (client.status !== "approved") {
|
|
409
|
+
return pendingClientResponse(db, req, client, url, deps);
|
|
410
|
+
}
|
|
349
411
|
try {
|
|
350
412
|
requireRegisteredRedirectUri(client, parsed.redirectUri);
|
|
351
413
|
} catch {
|
|
@@ -536,7 +598,18 @@ async function handleConsentSubmit(
|
|
|
536
598
|
if (!client) {
|
|
537
599
|
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
538
600
|
}
|
|
539
|
-
if (client.status !== "approved")
|
|
601
|
+
if (client.status !== "approved") {
|
|
602
|
+
// Defensive: consent only renders for approved clients, so a non-approved
|
|
603
|
+
// status here means the row was unapproved between render and submit (or
|
|
604
|
+
// the form was hand-crafted). The approve UI requires a known authorize
|
|
605
|
+
// URL to round-trip via `return_to`, which we don't reconstruct here —
|
|
606
|
+
// surface the static error and let the operator restart from the SPA.
|
|
607
|
+
return htmlError(
|
|
608
|
+
"App not yet approved",
|
|
609
|
+
`This client_id is registered but has not been approved. Run \`parachute auth approve-client ${client.clientId}\` from a terminal, then try again.`,
|
|
610
|
+
403,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
540
613
|
try {
|
|
541
614
|
requireRegisteredRedirectUri(client, params.redirectUri);
|
|
542
615
|
} catch {
|
|
@@ -602,6 +675,104 @@ async function handleConsentSubmit(
|
|
|
602
675
|
return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
|
|
603
676
|
}
|
|
604
677
|
|
|
678
|
+
/**
|
|
679
|
+
* POST /oauth/authorize/approve — operator-driven inline approval of a
|
|
680
|
+
* pending DCR client (closes #208). The cross-origin SPA case the
|
|
681
|
+
* same-origin DCR auto-approve (#199, #200) doesn't cover: an SPA on a
|
|
682
|
+
* different origin can't ride the cookie path during DCR, so its
|
|
683
|
+
* freshly-registered client_id lands `pending` and the operator hits
|
|
684
|
+
* "App not yet approved" on /oauth/authorize. This endpoint flips that
|
|
685
|
+
* client to `approved` in one click and redirects back into the OAuth flow.
|
|
686
|
+
*
|
|
687
|
+
* Three-belt security model. All three must pass:
|
|
688
|
+
*
|
|
689
|
+
* 1. Valid CSRF token (double-submit cookie). Defends against a malicious
|
|
690
|
+
* cross-origin POST that rides the session cookie's SameSite=Lax.
|
|
691
|
+
* Token was minted at GET render time and embedded in the form.
|
|
692
|
+
* 2. Active operator session (`findActiveSession`). The operator must be
|
|
693
|
+
* logged into this hub from the browser submitting the form — no
|
|
694
|
+
* session means no operator authority to approve anything.
|
|
695
|
+
* 3. Origin/Referer matches the issuer (`originMatchesIssuer`). Same
|
|
696
|
+
* shape as the DCR auto-approve gate (#199, #200): a same-origin POST
|
|
697
|
+
* proves the form was rendered by *this hub*, not a forged page.
|
|
698
|
+
*
|
|
699
|
+
* `return_to` validation: the form embeds the original authorize URL so
|
|
700
|
+
* the post-approve redirect lands the operator back on `/oauth/authorize`
|
|
701
|
+
* with the now-approved client. We refuse anything that doesn't start with
|
|
702
|
+
* `/oauth/authorize?` — open-redirect defense, plus a hand-crafted form
|
|
703
|
+
* trying to use this endpoint as a generic redirect-after-approve gadget
|
|
704
|
+
* shouldn't succeed at smuggling an off-path target.
|
|
705
|
+
*/
|
|
706
|
+
export async function handleApproveClientPost(
|
|
707
|
+
db: Database,
|
|
708
|
+
req: Request,
|
|
709
|
+
deps: OAuthDeps,
|
|
710
|
+
): Promise<Response> {
|
|
711
|
+
const form = await req.formData();
|
|
712
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
713
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
714
|
+
return htmlError(
|
|
715
|
+
"Invalid form submission",
|
|
716
|
+
"The form's CSRF token did not match. Reload the page and try again.",
|
|
717
|
+
403,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
721
|
+
if (!session) {
|
|
722
|
+
return htmlError(
|
|
723
|
+
"Sign in required",
|
|
724
|
+
"You must be signed in to this hub to approve an app. Sign in and try again.",
|
|
725
|
+
401,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
if (!originMatchesIssuer(req, deps.issuer)) {
|
|
729
|
+
return htmlError(
|
|
730
|
+
"Cross-origin request rejected",
|
|
731
|
+
"The approve form must be submitted from this hub's own origin.",
|
|
732
|
+
403,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
const clientId = String(form.get("client_id") ?? "");
|
|
736
|
+
if (!clientId) {
|
|
737
|
+
return htmlError("Invalid form submission", "Missing client_id.", 400);
|
|
738
|
+
}
|
|
739
|
+
const client = getClient(db, clientId);
|
|
740
|
+
if (!client) {
|
|
741
|
+
return htmlError("Unknown application", "This client_id is not registered with this hub.", 404);
|
|
742
|
+
}
|
|
743
|
+
// Validate return_to BEFORE the DB mutation: if an authenticated operator
|
|
744
|
+
// submits a hand-crafted form with a bad return_to, we refuse without
|
|
745
|
+
// committing the client to `approved`. Practical risk is low (all three
|
|
746
|
+
// belts already passed), but ordering matters — validate, then mutate.
|
|
747
|
+
const returnTo = String(form.get("return_to") ?? "");
|
|
748
|
+
if (!isSafeAuthorizeReturnTo(returnTo)) {
|
|
749
|
+
return htmlError(
|
|
750
|
+
"Invalid form submission",
|
|
751
|
+
"The return_to value is not a hub-relative /oauth/authorize URL.",
|
|
752
|
+
400,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
approveClient(db, clientId);
|
|
756
|
+
return redirectResponse(returnTo);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Validate a form-submitted `return_to` value. Must be a hub-relative URL
|
|
761
|
+
* (no scheme, no double-slash) targeting `/oauth/authorize` with a query
|
|
762
|
+
* string — anything else is either an open-redirect attempt or a misuse of
|
|
763
|
+
* the endpoint. Empty string is rejected (the form always supplies one).
|
|
764
|
+
*/
|
|
765
|
+
function isSafeAuthorizeReturnTo(value: string): boolean {
|
|
766
|
+
if (!value) return false;
|
|
767
|
+
// Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
|
|
768
|
+
// single-slash root-relative paths are allowed.
|
|
769
|
+
if (!value.startsWith("/") || value.startsWith("//")) return false;
|
|
770
|
+
// Must target the authorize endpoint with a query string. The OAuth flow
|
|
771
|
+
// re-enters via GET /oauth/authorize?<original-params>; anything off-path
|
|
772
|
+
// is a misuse.
|
|
773
|
+
return value.startsWith("/oauth/authorize?");
|
|
774
|
+
}
|
|
775
|
+
|
|
605
776
|
function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): AuthorizeFormParams {
|
|
606
777
|
return {
|
|
607
778
|
clientId: String(form.get("client_id") ?? ""),
|
|
@@ -1064,20 +1235,72 @@ interface RegisterRequestBody {
|
|
|
1064
1235
|
token_endpoint_auth_method?: string;
|
|
1065
1236
|
}
|
|
1066
1237
|
|
|
1238
|
+
/**
|
|
1239
|
+
* CSRF defense for the cookie-based DCR auto-approve path (closes #199).
|
|
1240
|
+
*
|
|
1241
|
+
* Compares the request's `Origin` (or `Referer` as fallback) against the
|
|
1242
|
+
* configured issuer origin. URL.origin compares scheme + host + port —
|
|
1243
|
+
* port-only mismatches reject. A request with neither header is treated as
|
|
1244
|
+
* suspicious and rejected: cookie-bearing POSTs from same-origin browsers
|
|
1245
|
+
* always send Origin (per Fetch standard) and almost always send Referer,
|
|
1246
|
+
* so a header-stripped request is more likely a curl probe or a privacy
|
|
1247
|
+
* extension on a third-party site than a legitimate same-origin caller.
|
|
1248
|
+
*
|
|
1249
|
+
* SameSite=Lax on the session cookie (sessions.ts:buildSessionCookie) is the
|
|
1250
|
+
* browser-side defense layer; this function is the server-side belt.
|
|
1251
|
+
*/
|
|
1252
|
+
function originMatchesIssuer(req: Request, issuer: string): boolean {
|
|
1253
|
+
const origin = req.headers.get("origin");
|
|
1254
|
+
if (origin) {
|
|
1255
|
+
try {
|
|
1256
|
+
return new URL(origin).origin === new URL(issuer).origin;
|
|
1257
|
+
} catch {
|
|
1258
|
+
return false;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const referer = req.headers.get("referer");
|
|
1262
|
+
if (referer) {
|
|
1263
|
+
try {
|
|
1264
|
+
return new URL(referer).origin === new URL(issuer).origin;
|
|
1265
|
+
} catch {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1067
1272
|
/**
|
|
1068
1273
|
* POST /oauth/register — RFC 7591 Dynamic Client Registration.
|
|
1069
1274
|
*
|
|
1070
1275
|
* Approval gate (closes #74). New rows land as `pending` by default and
|
|
1071
1276
|
* cannot participate in OAuth flows until an operator runs
|
|
1072
|
-
* `parachute auth approve-client <id>`.
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1277
|
+
* `parachute auth approve-client <id>`. Two bypass paths:
|
|
1278
|
+
*
|
|
1279
|
+
* 1. **Operator-bearer** (#74). `Authorization: Bearer <operator-token>` whose
|
|
1280
|
+
* token carries the `hub:admin` scope — the install-time path used by
|
|
1281
|
+
* first-party modules so `parachute install vault` can self-register
|
|
1282
|
+
* without a human follow-up.
|
|
1283
|
+
* 2. **Operator-session** (#199). A valid `parachute_hub_session` cookie
|
|
1284
|
+
* plus a same-origin `Origin`/`Referer` header. The browser path: an
|
|
1285
|
+
* operator hitting their own SPA from their own browser is by definition
|
|
1286
|
+
* operator-authenticated, so re-requiring approval is friction without
|
|
1287
|
+
* benefit. CSRF defense is `originMatchesIssuer` + the cookie's
|
|
1288
|
+
* `SameSite=Lax` attribute.
|
|
1076
1289
|
*
|
|
1077
1290
|
* If a bearer is presented but invalid or insufficient, we reject with the
|
|
1078
1291
|
* RFC 6750 shape rather than silently downgrading to the public path: a
|
|
1079
1292
|
* caller who tried to authenticate but failed wants to know why, not get
|
|
1080
1293
|
* `pending` back and wonder why their module can't OAuth.
|
|
1294
|
+
*
|
|
1295
|
+
* Access-control matrix:
|
|
1296
|
+
* no auth → pending
|
|
1297
|
+
* bearer (hub:admin) → approved (#74)
|
|
1298
|
+
* bearer (other scope) → 403 insufficient_scope
|
|
1299
|
+
* bearer (malformed) → 401 invalid_token
|
|
1300
|
+
* session cookie + same-origin → approved (#199)
|
|
1301
|
+
* session cookie + cross-origin → pending (CSRF defense)
|
|
1302
|
+
* session cookie + no Origin/Referer → pending
|
|
1303
|
+
* expired/unknown session → pending
|
|
1081
1304
|
*/
|
|
1082
1305
|
export async function handleRegister(
|
|
1083
1306
|
db: Database,
|
|
@@ -1124,6 +1347,20 @@ export async function handleRegister(
|
|
|
1124
1347
|
throw err;
|
|
1125
1348
|
}
|
|
1126
1349
|
}
|
|
1350
|
+
// Operator-session auto-approve (closes #199). The browser path:
|
|
1351
|
+
// operator-authenticated SPA on the hub's own origin can self-register a
|
|
1352
|
+
// client without dropping to a terminal. Two gates: (1) a live (un-expired)
|
|
1353
|
+
// session row keyed by the cookie, (2) Origin/Referer matches the issuer
|
|
1354
|
+
// origin so a cross-site forgery can't ride the cookie. Quietly stays
|
|
1355
|
+
// `pending` on any failure — unlike the bearer path, we don't surface an
|
|
1356
|
+
// error, because absence of session/origin is the *normal* unauthenticated
|
|
1357
|
+
// public-DCR shape.
|
|
1358
|
+
if (status === "pending") {
|
|
1359
|
+
const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
|
|
1360
|
+
if (session && originMatchesIssuer(req, deps.issuer)) {
|
|
1361
|
+
status = "approved";
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1127
1364
|
const confidential = body.token_endpoint_auth_method === "client_secret_post";
|
|
1128
1365
|
const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
|
|
1129
1366
|
let registered: RegisteredClient;
|
package/src/oauth-ui.ts
CHANGED
|
@@ -97,6 +97,33 @@ export interface ErrorViewProps {
|
|
|
97
97
|
status: number;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Props for the "App not yet approved" view rendered when an unapproved
|
|
102
|
+
* client lands on `/oauth/authorize`. When `session` is true the operator is
|
|
103
|
+
* authenticated to this hub from the browser making the request, so we render
|
|
104
|
+
* an inline approve form (closes #208). When false we fall back to the
|
|
105
|
+
* pre-#208 CLI-only message.
|
|
106
|
+
*/
|
|
107
|
+
export interface ApprovePendingViewProps {
|
|
108
|
+
/** Display name to show — falls back to client_id when no name was supplied at DCR. */
|
|
109
|
+
clientName: string;
|
|
110
|
+
clientId: string;
|
|
111
|
+
redirectUris: string[];
|
|
112
|
+
/** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
|
|
113
|
+
requestedScopes: string[];
|
|
114
|
+
/**
|
|
115
|
+
* When set, render the inline approve form. The form posts to
|
|
116
|
+
* `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
|
|
117
|
+
* server will redirect to after the approve commits — the original
|
|
118
|
+
* `/oauth/authorize?...` URL so the OAuth flow re-enters with the now-
|
|
119
|
+
* approved client and lands on the consent screen.
|
|
120
|
+
*/
|
|
121
|
+
approveForm?: {
|
|
122
|
+
csrfToken: string;
|
|
123
|
+
returnTo: string;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
100
127
|
export function renderLogin(props: LoginViewProps): string {
|
|
101
128
|
const { params, errorMessage, csrfToken } = props;
|
|
102
129
|
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
@@ -204,6 +231,79 @@ function renderVaultPicker(picker: VaultPicker): string {
|
|
|
204
231
|
</section>`;
|
|
205
232
|
}
|
|
206
233
|
|
|
234
|
+
/**
|
|
235
|
+
* "App not yet approved" page (#74). When the request carries a valid
|
|
236
|
+
* operator session (#208), render the inline approve form so one click lands
|
|
237
|
+
* the client as `approved` and re-enters the OAuth flow at consent. Without
|
|
238
|
+
* a session, fall back to the original CLI-only message — anyone hitting
|
|
239
|
+
* /oauth/authorize unauthenticated to the hub itself can't be trusted to
|
|
240
|
+
* approve a DCR client from the browser, so they need to drop to a terminal
|
|
241
|
+
* and run `parachute auth approve-client <id>`.
|
|
242
|
+
*
|
|
243
|
+
* The CLI fallback hint is shown in BOTH branches: a button-equipped operator
|
|
244
|
+
* may still want the CLI invocation handy (different machine, scriptable
|
|
245
|
+
* context). The button is the easy path; the CLI is always-available.
|
|
246
|
+
*/
|
|
247
|
+
export function renderApprovePending(props: ApprovePendingViewProps): string {
|
|
248
|
+
const { clientName, clientId, redirectUris, requestedScopes, approveForm } = props;
|
|
249
|
+
const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
|
|
250
|
+
const scopeRows =
|
|
251
|
+
requestedScopes.length === 0
|
|
252
|
+
? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
|
|
253
|
+
: requestedScopes.map(renderScopeRow).join("\n");
|
|
254
|
+
const formSection = approveForm
|
|
255
|
+
? `
|
|
256
|
+
<form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
|
|
257
|
+
${renderCsrfHiddenInput(approveForm.csrfToken)}
|
|
258
|
+
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
|
|
259
|
+
<input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
|
|
260
|
+
<button type="submit" class="btn btn-primary">Approve and continue</button>
|
|
261
|
+
</form>
|
|
262
|
+
<p class="approve-cli-hint">
|
|
263
|
+
Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
|
|
264
|
+
</p>`
|
|
265
|
+
: `
|
|
266
|
+
<p class="approve-cli-hint">
|
|
267
|
+
Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
|
|
268
|
+
from a terminal, then try again.
|
|
269
|
+
</p>`;
|
|
270
|
+
const body = `
|
|
271
|
+
<div class="card">
|
|
272
|
+
<div class="card-header">
|
|
273
|
+
<div class="brand">
|
|
274
|
+
<span class="brand-mark">⌬</span>
|
|
275
|
+
<span class="brand-name">Parachute</span>
|
|
276
|
+
</div>
|
|
277
|
+
<h1>App not yet approved</h1>
|
|
278
|
+
<p class="subtitle">
|
|
279
|
+
${escapeHtml(clientName)} is registered with this hub but hasn't been approved yet.
|
|
280
|
+
Review the details below before approving.
|
|
281
|
+
</p>
|
|
282
|
+
</div>
|
|
283
|
+
<section class="approve-meta">
|
|
284
|
+
<h2 class="scopes-title">Application</h2>
|
|
285
|
+
<p class="approve-meta-row">
|
|
286
|
+
<span class="approve-meta-label">name</span>
|
|
287
|
+
<code class="approve-meta-value">${escapeHtml(clientName)}</code>
|
|
288
|
+
</p>
|
|
289
|
+
<p class="approve-meta-row">
|
|
290
|
+
<span class="approve-meta-label">client_id</span>
|
|
291
|
+
<code class="approve-meta-value">${escapeHtml(clientId)}</code>
|
|
292
|
+
</p>
|
|
293
|
+
<div class="approve-meta-row approve-meta-row-block">
|
|
294
|
+
<span class="approve-meta-label">redirect_uris</span>
|
|
295
|
+
<ul class="approve-redirect-list">${redirectList}</ul>
|
|
296
|
+
</div>
|
|
297
|
+
</section>
|
|
298
|
+
<section class="scopes">
|
|
299
|
+
<h2 class="scopes-title">Permissions requested</h2>
|
|
300
|
+
<ul class="scope-list">${scopeRows}</ul>
|
|
301
|
+
</section>
|
|
302
|
+
${formSection}
|
|
303
|
+
</div>`;
|
|
304
|
+
return baseDocument("App not yet approved", body);
|
|
305
|
+
}
|
|
306
|
+
|
|
207
307
|
export function renderError(props: ErrorViewProps): string {
|
|
208
308
|
const body = `
|
|
209
309
|
<div class="card">
|
|
@@ -542,6 +642,73 @@ const STYLES = `
|
|
|
542
642
|
.vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
|
|
543
643
|
.vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
|
|
544
644
|
|
|
645
|
+
.approve-meta {
|
|
646
|
+
margin: 0 0 1.25rem;
|
|
647
|
+
padding: 0.75rem 0.85rem;
|
|
648
|
+
border: 1px solid ${PALETTE.borderLight};
|
|
649
|
+
border-radius: 6px;
|
|
650
|
+
background: ${PALETTE.bgSoft};
|
|
651
|
+
}
|
|
652
|
+
.approve-meta .scopes-title { margin-bottom: 0.5rem; }
|
|
653
|
+
.approve-meta-row {
|
|
654
|
+
margin: 0 0 0.4rem;
|
|
655
|
+
display: flex;
|
|
656
|
+
gap: 0.5rem;
|
|
657
|
+
align-items: baseline;
|
|
658
|
+
flex-wrap: wrap;
|
|
659
|
+
}
|
|
660
|
+
.approve-meta-row:last-child { margin-bottom: 0; }
|
|
661
|
+
.approve-meta-row-block { flex-direction: column; gap: 0.25rem; }
|
|
662
|
+
.approve-meta-label {
|
|
663
|
+
text-transform: uppercase;
|
|
664
|
+
letter-spacing: 0.05em;
|
|
665
|
+
font-size: 0.7rem;
|
|
666
|
+
color: ${PALETTE.fgDim};
|
|
667
|
+
}
|
|
668
|
+
.approve-meta-value {
|
|
669
|
+
font-family: ${FONT_MONO};
|
|
670
|
+
font-size: 0.82rem;
|
|
671
|
+
background: ${PALETTE.cardBg};
|
|
672
|
+
padding: 0.1rem 0.4rem;
|
|
673
|
+
border-radius: 4px;
|
|
674
|
+
color: ${PALETTE.fg};
|
|
675
|
+
word-break: break-all;
|
|
676
|
+
}
|
|
677
|
+
.approve-redirect-list {
|
|
678
|
+
list-style: none;
|
|
679
|
+
margin: 0;
|
|
680
|
+
padding: 0;
|
|
681
|
+
display: flex;
|
|
682
|
+
flex-direction: column;
|
|
683
|
+
gap: 0.25rem;
|
|
684
|
+
}
|
|
685
|
+
.approve-redirect-list li code {
|
|
686
|
+
font-family: ${FONT_MONO};
|
|
687
|
+
font-size: 0.82rem;
|
|
688
|
+
background: ${PALETTE.cardBg};
|
|
689
|
+
padding: 0.1rem 0.4rem;
|
|
690
|
+
border-radius: 4px;
|
|
691
|
+
color: ${PALETTE.fg};
|
|
692
|
+
word-break: break-all;
|
|
693
|
+
}
|
|
694
|
+
.approve-form { gap: 0; }
|
|
695
|
+
.approve-cli-hint {
|
|
696
|
+
margin-top: 1rem;
|
|
697
|
+
padding-top: 0.85rem;
|
|
698
|
+
border-top: 1px solid ${PALETTE.borderLight};
|
|
699
|
+
color: ${PALETTE.fgMuted};
|
|
700
|
+
font-size: 0.85rem;
|
|
701
|
+
}
|
|
702
|
+
.approve-cli-hint code {
|
|
703
|
+
font-family: ${FONT_MONO};
|
|
704
|
+
font-size: 0.8rem;
|
|
705
|
+
background: ${PALETTE.bgSoft};
|
|
706
|
+
padding: 0.1rem 0.4rem;
|
|
707
|
+
border-radius: 4px;
|
|
708
|
+
color: ${PALETTE.fg};
|
|
709
|
+
word-break: break-all;
|
|
710
|
+
}
|
|
711
|
+
|
|
545
712
|
.badge {
|
|
546
713
|
display: inline-block;
|
|
547
714
|
font-size: 0.7rem;
|