@openparachute/hub 0.6.0 → 0.6.1-rc.2
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__/auth.test.ts +70 -1
- package/src/__tests__/oauth-handlers.test.ts +201 -0
- package/src/__tests__/resource-binding.test.ts +36 -10
- package/src/commands/auth.ts +34 -6
- package/src/oauth-handlers.ts +41 -35
- package/src/resource-binding.ts +25 -10
package/package.json
CHANGED
|
@@ -1696,7 +1696,76 @@ describe("parachute auth mint-token", () => {
|
|
|
1696
1696
|
expect(stdout.trim().split(".").length).toBe(3);
|
|
1697
1697
|
expect(stderr).toContain("--ttl is deprecated");
|
|
1698
1698
|
expect(stderr).toContain("--expires-in");
|
|
1699
|
-
expect(stderr).toContain("
|
|
1699
|
+
expect(stderr).toContain("future release");
|
|
1700
|
+
} finally {
|
|
1701
|
+
tmp.cleanup();
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
test("--ephemeral mints a short-lived (1h) token", async () => {
|
|
1706
|
+
const tmp = makeTmp();
|
|
1707
|
+
try {
|
|
1708
|
+
const deps: AuthDeps = {
|
|
1709
|
+
dbPath: tmp.dbPath,
|
|
1710
|
+
configDir: tmp.dir,
|
|
1711
|
+
isInteractive: () => false,
|
|
1712
|
+
};
|
|
1713
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1714
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1715
|
+
auth(["mint-token", "--scope", "vault:default:read", "--ephemeral"], deps),
|
|
1716
|
+
);
|
|
1717
|
+
expect(code).toBe(0);
|
|
1718
|
+
const token = stdout.trim();
|
|
1719
|
+
const db = openHubDb(tmp.dbPath);
|
|
1720
|
+
try {
|
|
1721
|
+
const validated = await validateAccessToken(db, token);
|
|
1722
|
+
const exp = validated.payload.exp as number;
|
|
1723
|
+
const iat = validated.payload.iat as number;
|
|
1724
|
+
expect(exp - iat).toBe(60 * 60);
|
|
1725
|
+
} finally {
|
|
1726
|
+
db.close();
|
|
1727
|
+
}
|
|
1728
|
+
} finally {
|
|
1729
|
+
tmp.cleanup();
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test("--ephemeral is mutually exclusive with --expires-in", async () => {
|
|
1734
|
+
const tmp = makeTmp();
|
|
1735
|
+
try {
|
|
1736
|
+
const deps: AuthDeps = {
|
|
1737
|
+
dbPath: tmp.dbPath,
|
|
1738
|
+
configDir: tmp.dir,
|
|
1739
|
+
isInteractive: () => false,
|
|
1740
|
+
};
|
|
1741
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1742
|
+
const { code, stdout, stderr } = await captureOutput(() =>
|
|
1743
|
+
auth(["mint-token", "--scope", "vault:read", "--ephemeral", "--expires-in", "3600"], deps),
|
|
1744
|
+
);
|
|
1745
|
+
expect(code).toBe(1);
|
|
1746
|
+
expect(stderr).toContain("--ephemeral");
|
|
1747
|
+
// No token leaked to stdout on the conflict error.
|
|
1748
|
+
expect(stdout).toBe("");
|
|
1749
|
+
} finally {
|
|
1750
|
+
tmp.cleanup();
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
test("--ephemeral is mutually exclusive with the deprecated --ttl too", async () => {
|
|
1755
|
+
const tmp = makeTmp();
|
|
1756
|
+
try {
|
|
1757
|
+
const deps: AuthDeps = {
|
|
1758
|
+
dbPath: tmp.dbPath,
|
|
1759
|
+
configDir: tmp.dir,
|
|
1760
|
+
isInteractive: () => false,
|
|
1761
|
+
};
|
|
1762
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1763
|
+
const { code, stdout, stderr } = await captureOutput(() =>
|
|
1764
|
+
auth(["mint-token", "--scope", "vault:read", "--ephemeral", "--ttl", "1h"], deps),
|
|
1765
|
+
);
|
|
1766
|
+
expect(code).toBe(1);
|
|
1767
|
+
expect(stderr).toContain("--ephemeral");
|
|
1768
|
+
expect(stdout).toBe("");
|
|
1700
1769
|
} finally {
|
|
1701
1770
|
tmp.cleanup();
|
|
1702
1771
|
}
|
|
@@ -609,6 +609,207 @@ describe("handleAuthorizeGet — vault picker", () => {
|
|
|
609
609
|
});
|
|
610
610
|
});
|
|
611
611
|
|
|
612
|
+
describe("handleAuthorizeGet — RFC 8707 resource binding drops foreign scopes (scary-consent fix)", () => {
|
|
613
|
+
// claude.ai connecting to ONE vault reads the hub's whole-hub AS-metadata
|
|
614
|
+
// `scopes_supported` and over-requests the full catalog. Bound to the vault
|
|
615
|
+
// resource (`aud=vault.<name>`), scribe/channel/hub scopes are unusable, so
|
|
616
|
+
// they must be DROPPED before consent — Aaron hit them as "a fuck ton of
|
|
617
|
+
// privileges that don't make sense" (scribe isn't even installed here).
|
|
618
|
+
const FOREIGN_AND_VAULT =
|
|
619
|
+
"vault:read vault:write scribe:transcribe scribe:admin channel:send hub:admin";
|
|
620
|
+
|
|
621
|
+
test("session consent for a vault MCP resource drops scribe/channel/hub scopes", async () => {
|
|
622
|
+
const { db, cleanup } = await makeDb();
|
|
623
|
+
try {
|
|
624
|
+
const user = await createUser(db, "owner", "pw");
|
|
625
|
+
const session = createSession(db, { userId: user.id });
|
|
626
|
+
const reg = registerClient(db, {
|
|
627
|
+
redirectUris: ["https://app.example/cb"],
|
|
628
|
+
clientName: "Claude",
|
|
629
|
+
});
|
|
630
|
+
const { challenge } = makePkce();
|
|
631
|
+
const req = new Request(
|
|
632
|
+
authorizeUrl({
|
|
633
|
+
client_id: reg.client.clientId,
|
|
634
|
+
redirect_uri: "https://app.example/cb",
|
|
635
|
+
response_type: "code",
|
|
636
|
+
code_challenge: challenge,
|
|
637
|
+
code_challenge_method: "S256",
|
|
638
|
+
scope: FOREIGN_AND_VAULT,
|
|
639
|
+
resource: `${ISSUER}/vault/default/mcp`,
|
|
640
|
+
}),
|
|
641
|
+
{
|
|
642
|
+
headers: {
|
|
643
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
);
|
|
647
|
+
const res = handleAuthorizeGet(db, req, {
|
|
648
|
+
issuer: ISSUER,
|
|
649
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
650
|
+
});
|
|
651
|
+
// Renders consent (200) — NOT a 302 invalid_scope. Pre-fix the
|
|
652
|
+
// pass-through left non-requestable `hub:admin` + `scribe:admin` in the
|
|
653
|
+
// request, which the gate would reject; dropping them clears the gate.
|
|
654
|
+
expect(res.status).toBe(200);
|
|
655
|
+
const html = await res.text();
|
|
656
|
+
// Vault scopes survive, narrowed to the bound vault → picker is gone.
|
|
657
|
+
expect(html).not.toContain("Pick a vault");
|
|
658
|
+
expect(html).toContain("Create, edit, and delete notes, tags, and attachments."); // vault:write
|
|
659
|
+
// The foreign scopes are gone.
|
|
660
|
+
expect(html).not.toContain("Send audio to Scribe for transcription."); // scribe:transcribe
|
|
661
|
+
expect(html).not.toContain("Manage Scribe configuration"); // scribe:admin
|
|
662
|
+
expect(html).not.toContain("Post messages to your Channel."); // channel:send
|
|
663
|
+
expect(html).not.toContain("Manage hub identity"); // hub:admin
|
|
664
|
+
} finally {
|
|
665
|
+
cleanup();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("session-less 'App not yet approved' page also drops foreign scopes", async () => {
|
|
670
|
+
const { db, cleanup } = await makeDb();
|
|
671
|
+
try {
|
|
672
|
+
const reg = registerClient(db, {
|
|
673
|
+
redirectUris: ["https://app.example/cb"],
|
|
674
|
+
clientName: "Claude",
|
|
675
|
+
status: "pending",
|
|
676
|
+
});
|
|
677
|
+
const { challenge } = makePkce();
|
|
678
|
+
const req = new Request(
|
|
679
|
+
authorizeUrl({
|
|
680
|
+
client_id: reg.client.clientId,
|
|
681
|
+
redirect_uri: "https://app.example/cb",
|
|
682
|
+
response_type: "code",
|
|
683
|
+
code_challenge: challenge,
|
|
684
|
+
code_challenge_method: "S256",
|
|
685
|
+
scope: "vault:read scribe:transcribe channel:send",
|
|
686
|
+
resource: `${ISSUER}/vault/default/mcp`,
|
|
687
|
+
}),
|
|
688
|
+
// No session cookie → the unauth pending page renders.
|
|
689
|
+
);
|
|
690
|
+
const res = handleAuthorizeGet(db, req, {
|
|
691
|
+
issuer: ISSUER,
|
|
692
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
693
|
+
});
|
|
694
|
+
expect(res.status).toBe(403);
|
|
695
|
+
const html = await res.text();
|
|
696
|
+
expect(html).toContain("App not yet approved");
|
|
697
|
+
// Foreign scopes absent from the rendered rows...
|
|
698
|
+
expect(html).not.toContain("Send audio to Scribe for transcription.");
|
|
699
|
+
expect(html).not.toContain("Post messages to your Channel.");
|
|
700
|
+
// ...and from the login round-trip URL embedded in the page (the
|
|
701
|
+
// narrowed scope was written back onto `url` before this render).
|
|
702
|
+
expect(html).not.toContain("scribe:transcribe");
|
|
703
|
+
expect(html).not.toContain("channel:send");
|
|
704
|
+
expect(html).not.toContain("scribe%3Atranscribe");
|
|
705
|
+
expect(html).not.toContain("channel%3Asend");
|
|
706
|
+
} finally {
|
|
707
|
+
cleanup();
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("a vault-bound request of ONLY non-vault scopes narrows to empty (consent, not invalid_scope)", async () => {
|
|
712
|
+
// Edge: a client over-asks but names zero vault scopes against a vault
|
|
713
|
+
// resource. Narrowing drops everything → empty scope. We render consent
|
|
714
|
+
// (zero scope rows) rather than a 302 invalid_scope; an empty grant is
|
|
715
|
+
// harmless and the operator can simply deny.
|
|
716
|
+
const { db, cleanup } = await makeDb();
|
|
717
|
+
try {
|
|
718
|
+
const user = await createUser(db, "owner", "pw");
|
|
719
|
+
const session = createSession(db, { userId: user.id });
|
|
720
|
+
const reg = registerClient(db, {
|
|
721
|
+
redirectUris: ["https://app.example/cb"],
|
|
722
|
+
clientName: "Claude",
|
|
723
|
+
});
|
|
724
|
+
const { challenge } = makePkce();
|
|
725
|
+
const req = new Request(
|
|
726
|
+
authorizeUrl({
|
|
727
|
+
client_id: reg.client.clientId,
|
|
728
|
+
redirect_uri: "https://app.example/cb",
|
|
729
|
+
response_type: "code",
|
|
730
|
+
code_challenge: challenge,
|
|
731
|
+
code_challenge_method: "S256",
|
|
732
|
+
scope: "scribe:transcribe channel:send",
|
|
733
|
+
resource: `${ISSUER}/vault/default/mcp`,
|
|
734
|
+
}),
|
|
735
|
+
{
|
|
736
|
+
headers: {
|
|
737
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
);
|
|
741
|
+
const res = handleAuthorizeGet(db, req, {
|
|
742
|
+
issuer: ISSUER,
|
|
743
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
744
|
+
});
|
|
745
|
+
expect(res.status).toBe(200);
|
|
746
|
+
const html = await res.text();
|
|
747
|
+
expect(html).not.toContain("Send audio to Scribe for transcription.");
|
|
748
|
+
expect(html).not.toContain("Post messages to your Channel.");
|
|
749
|
+
} finally {
|
|
750
|
+
cleanup();
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("trust-by-client_name no longer re-prompts when the request over-asks the whole-hub catalog", async () => {
|
|
755
|
+
// Before the narrowing was moved ahead of the status branch, the
|
|
756
|
+
// trust-by-client_name coverage check compared the RAW request
|
|
757
|
+
// (`vault:read scribe:transcribe channel:send`) against a vault-only prior
|
|
758
|
+
// grant — never matched — re-prompting consent every session for a client
|
|
759
|
+
// the operator had already approved. Narrowing first makes the comparison
|
|
760
|
+
// vault-only-vs-vault-only, so the silent re-link fires.
|
|
761
|
+
const { db, cleanup } = await makeDb();
|
|
762
|
+
try {
|
|
763
|
+
const user = await createUser(db, "owner", "pw");
|
|
764
|
+
const session = createSession(db, { userId: user.id });
|
|
765
|
+
// Prior approval under client_name "Claude" (an earlier DCR client_id).
|
|
766
|
+
const oldReg = registerClient(db, {
|
|
767
|
+
redirectUris: ["https://app.example/cb"],
|
|
768
|
+
clientName: "Claude",
|
|
769
|
+
});
|
|
770
|
+
const { recordGrant } = await import("../grants.ts");
|
|
771
|
+
recordGrant(db, user.id, oldReg.client.clientId, ["vault:default:read"]);
|
|
772
|
+
// Fresh per-session DCR: new client_id, same name, pending.
|
|
773
|
+
const newReg = registerClient(db, {
|
|
774
|
+
redirectUris: ["https://app.example/cb"],
|
|
775
|
+
clientName: "Claude",
|
|
776
|
+
status: "pending",
|
|
777
|
+
});
|
|
778
|
+
expect(newReg.client.clientId).not.toBe(oldReg.client.clientId);
|
|
779
|
+
const { challenge } = makePkce();
|
|
780
|
+
const req = new Request(
|
|
781
|
+
authorizeUrl({
|
|
782
|
+
client_id: newReg.client.clientId,
|
|
783
|
+
redirect_uri: "https://app.example/cb",
|
|
784
|
+
response_type: "code",
|
|
785
|
+
code_challenge: challenge,
|
|
786
|
+
code_challenge_method: "S256",
|
|
787
|
+
scope: "vault:read scribe:transcribe channel:send",
|
|
788
|
+
resource: `${ISSUER}/vault/default/mcp`,
|
|
789
|
+
}),
|
|
790
|
+
{
|
|
791
|
+
headers: {
|
|
792
|
+
// Same-origin → the trust-by-client_name carry-over is allowed.
|
|
793
|
+
origin: ISSUER,
|
|
794
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
);
|
|
798
|
+
const res = handleAuthorizeGet(db, req, {
|
|
799
|
+
issuer: ISSUER,
|
|
800
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
801
|
+
});
|
|
802
|
+
// Silent re-link: 302 back to the client with a code — NOT a 200 re-prompt.
|
|
803
|
+
expect(res.status).toBe(302);
|
|
804
|
+
const loc = res.headers.get("location") ?? "";
|
|
805
|
+
expect(loc).toContain("https://app.example/cb");
|
|
806
|
+
expect(loc).toContain("code=");
|
|
807
|
+
} finally {
|
|
808
|
+
cleanup();
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
612
813
|
describe("handleAuthorizePost — vault picker", () => {
|
|
613
814
|
test("approve with vault_pick narrows vault:read → vault:<picked>:read in the issued JWT", async () => {
|
|
614
815
|
const { db, cleanup } = await makeDb();
|
|
@@ -77,16 +77,42 @@ describe("narrowResourceVaultScopes", () => {
|
|
|
77
77
|
expect(narrowResourceVaultScopes(["vault:other:read"], "jon")).toEqual(["vault:other:read"]);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
test("
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
test("drops non-vault scopes — unusable in a vault-audience token", () => {
|
|
81
|
+
// A vault-bound flow mints `aud=vault.jon`; scribe/channel/hub scopes
|
|
82
|
+
// inside that token are dead weight, so they're removed rather than shown
|
|
83
|
+
// on the consent screen.
|
|
84
|
+
expect(
|
|
85
|
+
narrowResourceVaultScopes(
|
|
86
|
+
["scribe:transcribe", "channel:send", "hub:admin", "vault:read"],
|
|
87
|
+
"jon",
|
|
88
|
+
),
|
|
89
|
+
).toEqual(["vault:jon:read"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a one-vault connection drops the whole-hub catalog claude.ai over-requests", () => {
|
|
93
|
+
// claude.ai reads the hub AS-metadata `scopes_supported` (the full
|
|
94
|
+
// catalog) and requests all of it. Bound to one vault, only that vault's
|
|
95
|
+
// verbs survive — no scribe (uninstalled) or channel:send on the consent.
|
|
96
|
+
// Regression lock for the "scary consent" bug.
|
|
97
|
+
expect(
|
|
98
|
+
narrowResourceVaultScopes(
|
|
99
|
+
[
|
|
100
|
+
"vault:read",
|
|
101
|
+
"vault:write",
|
|
102
|
+
"vault:admin",
|
|
103
|
+
"scribe:admin",
|
|
104
|
+
"scribe:transcribe",
|
|
105
|
+
"channel:send",
|
|
106
|
+
"hub:admin",
|
|
107
|
+
],
|
|
108
|
+
"default",
|
|
109
|
+
),
|
|
110
|
+
).toEqual(["vault:default:read", "vault:default:write", "vault:default:admin"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("narrows the admin verb too (requestable-scope gate decides downstream)", () => {
|
|
114
|
+
// narrowResourceVaultScopes only rewrites shape; `vault:<name>:admin` is
|
|
115
|
+
// requestable post-#484, so this named form survives the downstream gate.
|
|
90
116
|
expect(narrowResourceVaultScopes(["vault:admin"], "jon")).toEqual(["vault:jon:admin"]);
|
|
91
117
|
});
|
|
92
118
|
|
package/src/commands/auth.ts
CHANGED
|
@@ -120,10 +120,13 @@ Usage:
|
|
|
120
120
|
Mint a fresh ~/.parachute/operator.token
|
|
121
121
|
(set = install|start|expose|auth|vault|admin,
|
|
122
122
|
default admin)
|
|
123
|
-
parachute auth mint-token --scope <scope> [--aud <aud>]
|
|
123
|
+
parachute auth mint-token --scope <scope> [--aud <aud>]
|
|
124
|
+
[--ephemeral | --expires-in <seconds>]
|
|
124
125
|
[--sub <sub>] [--permissions <json>]
|
|
125
126
|
Mint a scope-narrow JWT against the
|
|
126
127
|
operator's identity (stdout = JWT).
|
|
128
|
+
--ephemeral = short-lived (1h), ideal for
|
|
129
|
+
scripting; default lifetime is 90d.
|
|
127
130
|
--ttl <duration> is the deprecated
|
|
128
131
|
alias (use --expires-in seconds).
|
|
129
132
|
parachute auth revoke-token <jti> Mark a registry-row token revoked
|
|
@@ -194,6 +197,12 @@ audience defaults via the same inference rule the OAuth flow uses
|
|
|
194
197
|
colon-prefixed scope's namespace, fallback \`hub\`). Lifetime defaults
|
|
195
198
|
to 90d, caps at 365d.
|
|
196
199
|
|
|
200
|
+
--ephemeral mints a short-lived (1h) token — the right default for
|
|
201
|
+
scripting, where the credential only needs to outlive the script run.
|
|
202
|
+
Prefer it over a long-lived token for one-off automation:
|
|
203
|
+
\`parachute auth mint-token --scope vault:default:read --ephemeral\`.
|
|
204
|
+
Mutually exclusive with --expires-in / --ttl.
|
|
205
|
+
|
|
197
206
|
--scope accepts space-separated multi-scope (e.g.
|
|
198
207
|
\`--scope "vault:default:read agent:wovenboulder:invoke"\`).
|
|
199
208
|
|
|
@@ -201,7 +210,7 @@ to 90d, caps at 365d.
|
|
|
201
210
|
\`--expires-in 86400\` for 1 day). The legacy \`--ttl\` flag accepts a
|
|
202
211
|
duration suffix (\`90d\` / \`24h\` / \`30m\` / \`60s\`) and is supported as
|
|
203
212
|
a deprecated alias; passing it emits a one-line stderr deprecation
|
|
204
|
-
notice. \`--ttl\` will be removed in
|
|
213
|
+
notice. \`--ttl\` will be removed in a future release.
|
|
205
214
|
|
|
206
215
|
--permissions accepts a JSON object encoding fine-grained constraints
|
|
207
216
|
beyond OAuth scope (e.g.
|
|
@@ -748,6 +757,8 @@ interface MintTokenFlags {
|
|
|
748
757
|
permissions?: string;
|
|
749
758
|
/** True when --ttl was used (deprecated alias). Triggers a one-line stderr warning. */
|
|
750
759
|
ttlDeprecationSeen?: boolean;
|
|
760
|
+
/** --ephemeral: short-lived token (EPHEMERAL_TTL_SECONDS), the scripting default. */
|
|
761
|
+
ephemeral?: boolean;
|
|
751
762
|
error?: string;
|
|
752
763
|
}
|
|
753
764
|
|
|
@@ -759,6 +770,7 @@ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
|
|
|
759
770
|
let sub: string | undefined;
|
|
760
771
|
let permissions: string | undefined;
|
|
761
772
|
let ttlDeprecationSeen = false;
|
|
773
|
+
let ephemeral = false;
|
|
762
774
|
for (let i = 0; i < args.length; i++) {
|
|
763
775
|
const a = args[i];
|
|
764
776
|
if (a === "--scope") {
|
|
@@ -805,6 +817,8 @@ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
|
|
|
805
817
|
} else if (a?.startsWith("--permissions=")) {
|
|
806
818
|
permissions = a.slice("--permissions=".length);
|
|
807
819
|
if (!permissions) return { error: "--permissions requires a value" };
|
|
820
|
+
} else if (a === "--ephemeral") {
|
|
821
|
+
ephemeral = true;
|
|
808
822
|
} else {
|
|
809
823
|
return { error: `unknown flag "${a}"` };
|
|
810
824
|
}
|
|
@@ -812,11 +826,21 @@ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
|
|
|
812
826
|
if (ttl !== undefined && expiresIn !== undefined) {
|
|
813
827
|
return { error: "pass --expires-in OR --ttl, not both (--ttl is the deprecated alias)" };
|
|
814
828
|
}
|
|
815
|
-
|
|
829
|
+
if (ephemeral && (ttl !== undefined || expiresIn !== undefined)) {
|
|
830
|
+
return {
|
|
831
|
+
error: "pass --ephemeral OR an explicit lifetime (--expires-in/--ttl), not both",
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
return { scope, aud, ttl, expiresIn, sub, permissions, ttlDeprecationSeen, ephemeral };
|
|
816
835
|
}
|
|
817
836
|
|
|
818
837
|
const MINT_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
|
|
819
838
|
const MINT_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
|
|
839
|
+
// --ephemeral: short-lived token for scripting, where the credential only needs
|
|
840
|
+
// to outlive the script run. 1h is long enough to mint → iterate → run, short
|
|
841
|
+
// enough that a leaked scripting token isn't a standing liability like the 90d
|
|
842
|
+
// default would be.
|
|
843
|
+
const EPHEMERAL_TTL_SECONDS = 60 * 60;
|
|
820
844
|
|
|
821
845
|
/**
|
|
822
846
|
* Parse a Go-ish duration string: integer + one of d/h/m/s. Caps at 365d.
|
|
@@ -870,7 +894,7 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
|
|
|
870
894
|
if (!flags.scope) {
|
|
871
895
|
console.error("parachute auth mint-token: --scope is required");
|
|
872
896
|
console.error(
|
|
873
|
-
"usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--expires-in <seconds>] [--sub <sub>] [--permissions <json>]",
|
|
897
|
+
"usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--ephemeral | --expires-in <seconds>] [--sub <sub>] [--permissions <json>]",
|
|
874
898
|
);
|
|
875
899
|
return 1;
|
|
876
900
|
}
|
|
@@ -918,7 +942,11 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
|
|
|
918
942
|
}
|
|
919
943
|
|
|
920
944
|
let ttlSeconds = MINT_TOKEN_TTL_DEFAULT_SECONDS;
|
|
921
|
-
if (flags.
|
|
945
|
+
if (flags.ephemeral) {
|
|
946
|
+
// Short-lived scripting default — takes precedence (the parse layer already
|
|
947
|
+
// rejected --ephemeral alongside an explicit --expires-in/--ttl).
|
|
948
|
+
ttlSeconds = EPHEMERAL_TTL_SECONDS;
|
|
949
|
+
} else if (flags.expiresIn) {
|
|
922
950
|
const parsed = parseExpiresIn(flags.expiresIn);
|
|
923
951
|
if ("error" in parsed) {
|
|
924
952
|
console.error(`parachute auth mint-token: ${parsed.error}`);
|
|
@@ -935,7 +963,7 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
|
|
|
935
963
|
}
|
|
936
964
|
if (flags.ttlDeprecationSeen) {
|
|
937
965
|
console.error(
|
|
938
|
-
"parachute auth mint-token: --ttl is deprecated; use --expires-in <seconds> instead (will be removed in
|
|
966
|
+
"parachute auth mint-token: --ttl is deprecated; use --expires-in <seconds> instead (will be removed in a future release)",
|
|
939
967
|
);
|
|
940
968
|
}
|
|
941
969
|
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -844,6 +844,47 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
844
844
|
// localStorage, the recovery is "clear that key and reload the SPA").
|
|
845
845
|
return unknownClientResponse(parsed.clientId, parsed.redirectUri, deps);
|
|
846
846
|
}
|
|
847
|
+
|
|
848
|
+
// RFC 8707 resource binding. When the client named a per-vault MCP resource
|
|
849
|
+
// (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the requested scopes
|
|
850
|
+
// to that vault BEFORE the pending/consent branches below, so EVERY
|
|
851
|
+
// downstream consumer sees the narrowed set:
|
|
852
|
+
//
|
|
853
|
+
// 1. The consent screen — and the session-less "App not yet approved"
|
|
854
|
+
// page (`pendingClientResponse`) — shows ONLY that vault's scopes
|
|
855
|
+
// instead of the whole-hub catalog. Narrowing DROPS non-vault scopes
|
|
856
|
+
// (`scribe:*`, `channel:send`, `hub:admin`) outright: the token this
|
|
857
|
+
// flow mints is stamped `aud=vault.<name>`, so they're unusable inside
|
|
858
|
+
// it and only inflate the consent surface — the exact "scary consent"
|
|
859
|
+
// a friend hit connecting Claude to ONE vault (scribe isn't even
|
|
860
|
+
// installed; `channel:send` is meaningless to them).
|
|
861
|
+
// 2. The minted token carries the named scope, so `inferAudience` stamps
|
|
862
|
+
// `aud=vault.<name>` and a current-line vault accepts it (an unnamed
|
|
863
|
+
// `vault:read` token is rejected by `findBroadVaultScopes`).
|
|
864
|
+
// 3. The trust-by-client_name coverage checks (the auto-approve branch
|
|
865
|
+
// below + the one inside `pendingClientResponse`) compare the narrowed
|
|
866
|
+
// request against the narrowed prior grant. Before this ran early they
|
|
867
|
+
// compared the RAW whole-hub request against a vault-only grant, never
|
|
868
|
+
// matched, and re-prompted consent every session for a client the
|
|
869
|
+
// operator had already approved.
|
|
870
|
+
//
|
|
871
|
+
// We rewrite both `parsed.scope` (consent/grant/mint read it) AND the `url`
|
|
872
|
+
// scope param (`pendingClientResponse` + its login round-trip read it off
|
|
873
|
+
// the URL) so the two never diverge. Re-entry after login re-narrows
|
|
874
|
+
// idempotently (no foreign scopes left to drop).
|
|
875
|
+
//
|
|
876
|
+
// No resource, or one that isn't a per-vault MCP resource (off-origin,
|
|
877
|
+
// malformed, non-vault path) → `boundVault` is null and the flow is
|
|
878
|
+
// byte-for-byte the pre-#461 behavior (manual picker, etc.).
|
|
879
|
+
const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
|
|
880
|
+
if (boundVault) {
|
|
881
|
+
parsed.scope = narrowResourceVaultScopes(
|
|
882
|
+
parsed.scope.split(" ").filter((s) => s.length > 0),
|
|
883
|
+
boundVault,
|
|
884
|
+
).join(" ");
|
|
885
|
+
url.searchParams.set("scope", parsed.scope);
|
|
886
|
+
}
|
|
887
|
+
|
|
847
888
|
if (client.status !== "approved") {
|
|
848
889
|
// Single-consent change (2026-05-29): the separate operator "approve this
|
|
849
890
|
// client" gate is retired — the user's OAuth consent IS the authorization.
|
|
@@ -921,41 +962,6 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
|
|
|
921
962
|
);
|
|
922
963
|
}
|
|
923
964
|
|
|
924
|
-
// RFC 8707 resource binding. When the client named a per-vault MCP
|
|
925
|
-
// resource (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the
|
|
926
|
-
// requested vault verbs to the named `vault:<name>:<verb>` form BEFORE any
|
|
927
|
-
// downstream processing. Two effects:
|
|
928
|
-
//
|
|
929
|
-
// 1. The consent screen shows ONLY that vault's scopes (the picker locks
|
|
930
|
-
// to <name>) instead of the whole-hub catalog — a friend connecting to
|
|
931
|
-
// one vault no longer sees `hub:admin`, `scribe:admin`, or every other
|
|
932
|
-
// vault's verbs.
|
|
933
|
-
// 2. The minted token carries the named scope, so `inferAudience` stamps
|
|
934
|
-
// `aud=vault.<name>` and a current-line vault accepts it (an unnamed
|
|
935
|
-
// `vault:read` token is rejected by `findBroadVaultScopes`).
|
|
936
|
-
//
|
|
937
|
-
// Narrowing happens before the non-requestable gate (below) on purpose: if
|
|
938
|
-
// a resource-bound client somehow asked for `vault:admin`, narrowing makes
|
|
939
|
-
// it `vault:<name>:admin`, which IS non-requestable — so the gate correctly
|
|
940
|
-
// blocks it. Read/write narrow to the requestable named form. Non-vault
|
|
941
|
-
// scopes and already-named scopes for other vaults pass through unchanged.
|
|
942
|
-
//
|
|
943
|
-
// No resource, or a resource that isn't one of our per-vault MCP resources
|
|
944
|
-
// (off-origin, malformed, non-vault path) → `boundVault` is null and the
|
|
945
|
-
// flow is byte-for-byte the pre-#461 behavior (manual picker, etc.).
|
|
946
|
-
const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
|
|
947
|
-
if (boundVault) {
|
|
948
|
-
const narrowed = narrowResourceVaultScopes(
|
|
949
|
-
parsed.scope.split(" ").filter((s) => s.length > 0),
|
|
950
|
-
boundVault,
|
|
951
|
-
);
|
|
952
|
-
// Rewrite `parsed.scope` so the narrowed named scopes flow through every
|
|
953
|
-
// downstream consumer: the login-redirect query round-trip, the consent
|
|
954
|
-
// props + hidden inputs, the skip-consent grant lookup, and the
|
|
955
|
-
// auth-code mint.
|
|
956
|
-
parsed.scope = narrowed.join(" ");
|
|
957
|
-
}
|
|
958
|
-
|
|
959
965
|
// Operator-only scope gate (#96). Reject any request that names a scope
|
|
960
966
|
// we'll never mint via this flow — `parachute:host:admin` and friends.
|
|
961
967
|
// Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
|
package/src/resource-binding.ts
CHANGED
|
@@ -109,26 +109,41 @@ function decodeVaultName(segment: string): string | null {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
|
-
* Rewrite the requested scope list for a resource-bound vault flow
|
|
112
|
+
* Rewrite the requested scope list for a resource-bound vault flow, returning
|
|
113
|
+
* ONLY scopes usable in the resulting vault-audience token:
|
|
113
114
|
*
|
|
114
115
|
* - unnamed `vault:<verb>` → `vault:<name>:<verb>` (the narrow,
|
|
115
116
|
* audience-correct shape vault accepts);
|
|
116
117
|
* - already-named `vault:<other>:<verb>` is LEFT UNTOUCHED — a client that
|
|
117
118
|
* explicitly named a different vault is not silently re-pointed; the
|
|
118
119
|
* downstream picker / assignment defenses decide whether that's allowed.
|
|
119
|
-
* - non-vault scopes (`scribe:
|
|
120
|
-
*
|
|
120
|
+
* - non-vault scopes (`scribe:*`, `channel:send`, `hub:admin`, …) are
|
|
121
|
+
* DROPPED. This flow mints a token stamped `aud=vault.<name>` (RFC 8707),
|
|
122
|
+
* so a scribe/channel/hub scope inside it is unusable — keeping it only
|
|
123
|
+
* inflates the consent surface a friend sees when connecting ONE vault.
|
|
124
|
+
* That "scary consent" is the failure mode this module exists to kill
|
|
125
|
+
* (see the header docstring): the verb-narrowing alone left the foreign
|
|
126
|
+
* scopes riding through, so a client that over-requests the whole-hub
|
|
127
|
+
* catalog (claude.ai reads it from the AS-metadata `scopes_supported`)
|
|
128
|
+
* still surfaced `scribe:admin` + `channel:send` on the consent screen.
|
|
129
|
+
* A client that genuinely wants a scribe token runs a separate flow
|
|
130
|
+
* naming the scribe resource.
|
|
121
131
|
*
|
|
122
|
-
* Idempotent:
|
|
123
|
-
*
|
|
132
|
+
* Idempotent: an already-narrowed list contains only `vault:` scopes, so a
|
|
133
|
+
* second pass has nothing left to drop and `vault:<name>:<verb>` for THIS name
|
|
134
|
+
* is returned as-is.
|
|
124
135
|
*/
|
|
125
136
|
export function narrowResourceVaultScopes(scopes: readonly string[], vaultName: string): string[] {
|
|
126
|
-
|
|
137
|
+
const out: string[] = [];
|
|
138
|
+
for (const s of scopes) {
|
|
127
139
|
const parts = s.split(":");
|
|
140
|
+
if (parts[0] !== "vault") continue; // drop scribe:/channel:/hub:/… — foreign to a vault-audience token
|
|
128
141
|
const verb = parts[1];
|
|
129
|
-
if (parts.length === 2 &&
|
|
130
|
-
|
|
142
|
+
if (parts.length === 2 && verb && VAULT_VERBS.has(verb)) {
|
|
143
|
+
out.push(`vault:${vaultName}:${verb}`);
|
|
144
|
+
} else {
|
|
145
|
+
out.push(s); // already-named (incl. other vaults) or malformed vault scope — downstream defenses decide
|
|
131
146
|
}
|
|
132
|
-
|
|
133
|
-
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
134
149
|
}
|