@openparachute/hub 0.6.1-rc.1 → 0.6.1-rc.3
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
|
@@ -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
|
}
|
|
@@ -95,7 +95,10 @@ function fixtureLoadServicesManifest(): ServicesManifest {
|
|
|
95
95
|
|
|
96
96
|
describe("authorizationServerMetadata", () => {
|
|
97
97
|
test("emits RFC 8414 fields rooted at the issuer", async () => {
|
|
98
|
-
const res = authorizationServerMetadata({
|
|
98
|
+
const res = authorizationServerMetadata({
|
|
99
|
+
issuer: ISSUER,
|
|
100
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
101
|
+
});
|
|
99
102
|
expect(res.status).toBe(200);
|
|
100
103
|
const body = (await res.json()) as Record<string, unknown>;
|
|
101
104
|
expect(body.issuer).toBe(ISSUER);
|
|
@@ -110,15 +113,21 @@ describe("authorizationServerMetadata", () => {
|
|
|
110
113
|
const scopesSupported = body.scopes_supported as string[];
|
|
111
114
|
expect(scopesSupported).toContain("vault:read");
|
|
112
115
|
expect(scopesSupported).toContain("vault:admin");
|
|
113
|
-
expect(scopesSupported).toContain("scribe:transcribe");
|
|
116
|
+
expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
|
|
114
117
|
expect(scopesSupported).toContain("hub:admin");
|
|
118
|
+
// channel isn't in the fixture manifest → its scopes aren't advertised
|
|
119
|
+
// (hub#…: optional-module scopes only surface when the module is installed).
|
|
120
|
+
expect(scopesSupported).not.toContain("channel:send");
|
|
115
121
|
});
|
|
116
122
|
|
|
117
123
|
test("does NOT advertise non-requestable operator-only scopes", async () => {
|
|
118
124
|
// #96: parachute:host:admin is operator-only. RFC 8414 §2 frames
|
|
119
125
|
// scopes_supported as scopes a client *can* request — advertising what
|
|
120
126
|
// we always reject would mislead clients.
|
|
121
|
-
const res = authorizationServerMetadata({
|
|
127
|
+
const res = authorizationServerMetadata({
|
|
128
|
+
issuer: ISSUER,
|
|
129
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
130
|
+
});
|
|
122
131
|
const body = (await res.json()) as Record<string, unknown>;
|
|
123
132
|
const scopesSupported = body.scopes_supported as string[];
|
|
124
133
|
expect(scopesSupported).not.toContain("parachute:host:admin");
|
|
@@ -143,6 +152,7 @@ describe("authorizationServerMetadata", () => {
|
|
|
143
152
|
const res = authorizationServerMetadata({
|
|
144
153
|
issuer: ISSUER,
|
|
145
154
|
loadDeclaredScopes: () => declared,
|
|
155
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
146
156
|
});
|
|
147
157
|
const body = (await res.json()) as Record<string, unknown>;
|
|
148
158
|
const scopesSupported = body.scopes_supported as string[];
|
|
@@ -157,11 +167,84 @@ describe("authorizationServerMetadata", () => {
|
|
|
157
167
|
// NON_REQUESTABLE filter still applies even when the scope is declared
|
|
158
168
|
expect(scopesSupported).not.toContain("parachute:host:admin");
|
|
159
169
|
});
|
|
170
|
+
|
|
171
|
+
test("advertises an optional module's scopes only when it's installed", async () => {
|
|
172
|
+
// FIRST_PARTY_SCOPES carries scribe:* + channel:send statically. On a
|
|
173
|
+
// vault-only hub they must NOT be advertised — a discovery client (e.g.
|
|
174
|
+
// claude.ai's connector UI) lists the catalog verbatim, so a friend
|
|
175
|
+
// connecting one vault was shown Scribe + Channel access the hub can't
|
|
176
|
+
// honor. Vault + hub are core and always advertised.
|
|
177
|
+
const declared = new Set<string>([
|
|
178
|
+
"vault:read",
|
|
179
|
+
"vault:write",
|
|
180
|
+
"vault:admin",
|
|
181
|
+
"scribe:transcribe",
|
|
182
|
+
"scribe:admin",
|
|
183
|
+
"channel:send",
|
|
184
|
+
"hub:admin",
|
|
185
|
+
]);
|
|
186
|
+
const vaultOnly = {
|
|
187
|
+
services: [
|
|
188
|
+
{
|
|
189
|
+
name: "parachute-vault",
|
|
190
|
+
port: 1940,
|
|
191
|
+
paths: ["/vault/default"],
|
|
192
|
+
health: "/health",
|
|
193
|
+
version: "0.5.1",
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
const res = authorizationServerMetadata({
|
|
198
|
+
issuer: ISSUER,
|
|
199
|
+
loadDeclaredScopes: () => declared,
|
|
200
|
+
loadServicesManifest: () => vaultOnly as unknown as ServicesManifest,
|
|
201
|
+
});
|
|
202
|
+
const scopes = ((await res.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
203
|
+
// core scopes survive
|
|
204
|
+
expect(scopes).toContain("vault:read");
|
|
205
|
+
expect(scopes).toContain("vault:admin");
|
|
206
|
+
expect(scopes).toContain("hub:admin");
|
|
207
|
+
// uninstalled optional-module scopes are dropped
|
|
208
|
+
expect(scopes).not.toContain("scribe:transcribe");
|
|
209
|
+
expect(scopes).not.toContain("scribe:admin");
|
|
210
|
+
expect(scopes).not.toContain("channel:send");
|
|
211
|
+
|
|
212
|
+
// ...but once scribe is installed, its scopes ARE advertised again.
|
|
213
|
+
const withScribe = {
|
|
214
|
+
services: [
|
|
215
|
+
{
|
|
216
|
+
name: "parachute-vault",
|
|
217
|
+
port: 1940,
|
|
218
|
+
paths: ["/vault/default"],
|
|
219
|
+
health: "/health",
|
|
220
|
+
version: "0.5.1",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "parachute-scribe",
|
|
224
|
+
port: 1943,
|
|
225
|
+
paths: ["/scribe"],
|
|
226
|
+
health: "/health",
|
|
227
|
+
version: "0.4.5",
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
const res2 = authorizationServerMetadata({
|
|
232
|
+
issuer: ISSUER,
|
|
233
|
+
loadDeclaredScopes: () => declared,
|
|
234
|
+
loadServicesManifest: () => withScribe as unknown as ServicesManifest,
|
|
235
|
+
});
|
|
236
|
+
const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
|
|
237
|
+
expect(scopes2).toContain("scribe:transcribe");
|
|
238
|
+
expect(scopes2).not.toContain("channel:send"); // channel still not installed
|
|
239
|
+
});
|
|
160
240
|
});
|
|
161
241
|
|
|
162
242
|
describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
163
243
|
test("emits the required RFC 9728 fields rooted at the issuer", async () => {
|
|
164
|
-
const res = protectedResourceMetadata({
|
|
244
|
+
const res = protectedResourceMetadata({
|
|
245
|
+
issuer: ISSUER,
|
|
246
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
247
|
+
});
|
|
165
248
|
expect(res.status).toBe(200);
|
|
166
249
|
expect(res.headers.get("content-type")).toMatch(/application\/json/);
|
|
167
250
|
const body = (await res.json()) as Record<string, unknown>;
|
|
@@ -185,6 +268,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
|
|
|
185
268
|
const res = protectedResourceMetadata({
|
|
186
269
|
issuer: ISSUER,
|
|
187
270
|
loadDeclaredScopes: () => declared,
|
|
271
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
188
272
|
});
|
|
189
273
|
const body = (await res.json()) as Record<string, unknown>;
|
|
190
274
|
const scopes = body.scopes_supported as string[];
|
|
@@ -3904,7 +3988,10 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
|
|
|
3904
3988
|
});
|
|
3905
3989
|
|
|
3906
3990
|
test("authorizationServerMetadata advertises revocation_endpoint", async () => {
|
|
3907
|
-
const res = authorizationServerMetadata({
|
|
3991
|
+
const res = authorizationServerMetadata({
|
|
3992
|
+
issuer: ISSUER,
|
|
3993
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
3994
|
+
});
|
|
3908
3995
|
const body = (await res.json()) as Record<string, unknown>;
|
|
3909
3996
|
expect(body.revocation_endpoint).toBe(`${ISSUER}/oauth/revoke`);
|
|
3910
3997
|
});
|
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
|
@@ -388,13 +388,53 @@ function oauthErrorRedirect(
|
|
|
388
388
|
*
|
|
389
389
|
* Closes hub#393.
|
|
390
390
|
*/
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Optional first-party modules whose scopes `FIRST_PARTY_SCOPES` carries
|
|
394
|
+
* statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the
|
|
395
|
+
* services.json entry that means "installed." Vault + hub are core and always
|
|
396
|
+
* advertised; these are the modules a hub may not have.
|
|
397
|
+
*/
|
|
398
|
+
const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, service: string]> = [
|
|
399
|
+
["scribe:", "parachute-scribe"],
|
|
400
|
+
["channel:", "parachute-channel"],
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The scope set to advertise in `scopes_supported` (RFC 8414 + RFC 9728): the
|
|
405
|
+
* requestable declared scopes, minus any OPTIONAL module's scopes when that
|
|
406
|
+
* module isn't installed.
|
|
407
|
+
*
|
|
408
|
+
* Why: `FIRST_PARTY_SCOPES` is static, so a vault-only hub still advertised
|
|
409
|
+
* `scribe:*` + `channel:send`. Discovery clients list the advertised catalog
|
|
410
|
+
* verbatim — claude.ai's connector UI showed a friend connecting ONE vault a
|
|
411
|
+
* request for Scribe + Channel access the hub can't even honor. So advertise an
|
|
412
|
+
* optional module's scopes only when its service is present in services.json.
|
|
413
|
+
* (Trims the ADVERTISEMENT only; issuance/validation still use the full
|
|
414
|
+
* `loadDeclaredScopes` set, and the per-vault PRM stays vault-narrowed.)
|
|
415
|
+
*/
|
|
416
|
+
function advertisedScopes(declared: ReadonlySet<string>, manifest: ServicesManifest): string[] {
|
|
417
|
+
const installed = new Set(manifest.services.map((s) => s.name));
|
|
418
|
+
return Array.from(declared)
|
|
419
|
+
.filter(isRequestableScope)
|
|
420
|
+
.filter((scope) => {
|
|
421
|
+
for (const [prefix, service] of OPTIONAL_MODULE_SCOPES) {
|
|
422
|
+
if (scope.startsWith(prefix) && !installed.has(service)) return false;
|
|
423
|
+
}
|
|
424
|
+
return true;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
391
428
|
export function protectedResourceMetadata(deps: OAuthDeps): Response {
|
|
392
429
|
const iss = deps.issuer;
|
|
393
430
|
const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
|
|
394
431
|
return jsonResponse({
|
|
395
432
|
resource: iss,
|
|
396
433
|
authorization_servers: [iss],
|
|
397
|
-
scopes_supported:
|
|
434
|
+
scopes_supported: advertisedScopes(
|
|
435
|
+
declared,
|
|
436
|
+
(deps.loadServicesManifest ?? readServicesManifest)(),
|
|
437
|
+
),
|
|
398
438
|
bearer_methods_supported: ["header"],
|
|
399
439
|
resource_documentation: "https://parachute.computer",
|
|
400
440
|
// Intentional omission: `resource_signing_alg_values_supported` +
|
|
@@ -435,7 +475,10 @@ export function authorizationServerMetadata(deps: OAuthDeps): Response {
|
|
|
435
475
|
// — RFC 8414 §2 frames `scopes_supported` as "the OAuth 2.0 [...] scope
|
|
436
476
|
// values that this authorization server supports" for clients to request.
|
|
437
477
|
// Advertising what we always reject would mislead clients.
|
|
438
|
-
scopes_supported:
|
|
478
|
+
scopes_supported: advertisedScopes(
|
|
479
|
+
declared,
|
|
480
|
+
(deps.loadServicesManifest ?? readServicesManifest)(),
|
|
481
|
+
),
|
|
439
482
|
});
|
|
440
483
|
}
|
|
441
484
|
|
|
@@ -45,6 +45,12 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
|
|
|
45
45
|
label: "Full vault access plus configuration changes (rotate tokens, change settings).",
|
|
46
46
|
level: "admin",
|
|
47
47
|
},
|
|
48
|
+
// Optional-module scopes (scribe / channel). These are in FIRST_PARTY_SCOPES
|
|
49
|
+
// (= Object.keys(this map)) but the modules may not be installed — so they're
|
|
50
|
+
// GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts) and only advertised in
|
|
51
|
+
// `scopes_supported` when the service is in services.json. If you add scopes
|
|
52
|
+
// for another optional module here, add a matching gate there too, or a
|
|
53
|
+
// vault-only hub will over-advertise them (the bug behind hub#489).
|
|
48
54
|
"scribe:transcribe": {
|
|
49
55
|
label: "Send audio to Scribe for transcription.",
|
|
50
56
|
level: "write",
|