@openparachute/hub 0.3.0-rc.1 → 0.5.1
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/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native OAuth handlers for the hub. Each handler is a pure function over
|
|
3
|
+
* `(db, req)` returning a `Response` — no global state, no side channels —
|
|
4
|
+
* so the test harness can drive the full OAuth dance without standing up
|
|
5
|
+
* `Bun.serve` or going near the network.
|
|
6
|
+
*
|
|
7
|
+
* Endpoints implemented:
|
|
8
|
+
* - GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
|
|
9
|
+
* - GET /oauth/authorize (login → consent → code)
|
|
10
|
+
* - POST /oauth/authorize (form posts: login + consent)
|
|
11
|
+
* - POST /oauth/token (grant_type=authorization_code | refresh_token)
|
|
12
|
+
* - POST /oauth/register (RFC 7591 DCR)
|
|
13
|
+
* - POST /oauth/revoke (RFC 7009 token revocation)
|
|
14
|
+
*
|
|
15
|
+
* `client_credentials` is intentionally unimplemented — it's not in the
|
|
16
|
+
* launch surface (no machine-to-machine clients yet); the token endpoint
|
|
17
|
+
* stubs it with `unsupported_grant_type`.
|
|
18
|
+
*
|
|
19
|
+
* HTML for login + consent + error views lives in `oauth-ui.ts` so the
|
|
20
|
+
* handlers stay focused on protocol logic and the templates stay focused
|
|
21
|
+
* on presentation.
|
|
22
|
+
*/
|
|
23
|
+
import type { Database } from "bun:sqlite";
|
|
24
|
+
import { AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
25
|
+
import {
|
|
26
|
+
AuthCodeExpiredError,
|
|
27
|
+
AuthCodeNotFoundError,
|
|
28
|
+
AuthCodePkceMismatchError,
|
|
29
|
+
AuthCodeRedirectMismatchError,
|
|
30
|
+
AuthCodeUsedError,
|
|
31
|
+
issueAuthCode,
|
|
32
|
+
redeemAuthCode,
|
|
33
|
+
} from "./auth-codes.ts";
|
|
34
|
+
import {
|
|
35
|
+
type ClientStatus,
|
|
36
|
+
type OAuthClient,
|
|
37
|
+
type RegisteredClient,
|
|
38
|
+
getClient,
|
|
39
|
+
isValidRedirectUri,
|
|
40
|
+
registerClient,
|
|
41
|
+
requireRegisteredRedirectUri,
|
|
42
|
+
verifyClientSecret,
|
|
43
|
+
} from "./clients.ts";
|
|
44
|
+
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
45
|
+
import { isCoveredByGrant, recordGrant } from "./grants.ts";
|
|
46
|
+
import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
|
|
47
|
+
import {
|
|
48
|
+
ACCESS_TOKEN_TTL_SECONDS,
|
|
49
|
+
RefreshTokenInsertError,
|
|
50
|
+
findRefreshToken,
|
|
51
|
+
findTokenRowByJti,
|
|
52
|
+
revokeFamily,
|
|
53
|
+
signAccessToken,
|
|
54
|
+
signRefreshToken,
|
|
55
|
+
} from "./jwt-sign.ts";
|
|
56
|
+
import { type AuthorizeFormParams, renderConsent, renderError, renderLogin } from "./oauth-ui.ts";
|
|
57
|
+
import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
|
|
58
|
+
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
59
|
+
import {
|
|
60
|
+
type ServicesManifest,
|
|
61
|
+
readManifest as readServicesManifest,
|
|
62
|
+
} from "./services-manifest.ts";
|
|
63
|
+
import {
|
|
64
|
+
SESSION_TTL_MS,
|
|
65
|
+
buildSessionCookie,
|
|
66
|
+
createSession,
|
|
67
|
+
findSession,
|
|
68
|
+
parseSessionCookie,
|
|
69
|
+
} from "./sessions.ts";
|
|
70
|
+
import { getUserByUsername, verifyPassword } from "./users.ts";
|
|
71
|
+
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
72
|
+
|
|
73
|
+
/** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
|
|
74
|
+
function unnamedVaultVerbs(scopes: string[]): string[] {
|
|
75
|
+
const verbs: string[] = [];
|
|
76
|
+
for (const s of scopes) {
|
|
77
|
+
const parts = s.split(":");
|
|
78
|
+
const verb = parts[1];
|
|
79
|
+
if (parts.length === 2 && parts[0] === "vault" && verb && VAULT_VERBS.has(verb)) {
|
|
80
|
+
verbs.push(verb);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return verbs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Vault instance names registered on this host, derived from services.json.
|
|
88
|
+
* Walks both manifest shapes — single-entry-multi-path (`paths: ["/vault/work",
|
|
89
|
+
* "/vault/personal"]`) and per-vault entries (`parachute-vault-work`) — by
|
|
90
|
+
* delegating each (name, path) pair to the canonical `vaultInstanceNameFor`
|
|
91
|
+
* helper. Entries with no paths still resolve to a name via the helper's
|
|
92
|
+
* manifest-suffix fallback (#143).
|
|
93
|
+
*/
|
|
94
|
+
function listVaultNames(manifest: ServicesManifest): string[] {
|
|
95
|
+
const names = new Set<string>();
|
|
96
|
+
for (const svc of manifest.services) {
|
|
97
|
+
if (!isVaultEntry(svc)) continue;
|
|
98
|
+
const paths = svc.paths.length > 0 ? svc.paths : [undefined];
|
|
99
|
+
for (const path of paths) {
|
|
100
|
+
names.add(vaultInstanceNameFor(svc.name, path));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return Array.from(names).sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Rewrite each unnamed `vault:<verb>` to `vault:<picked>:<verb>`. */
|
|
107
|
+
function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
|
|
108
|
+
return scopes.map((s) => {
|
|
109
|
+
const parts = s.split(":");
|
|
110
|
+
const verb = parts[1];
|
|
111
|
+
if (parts.length === 2 && parts[0] === "vault" && verb && VAULT_VERBS.has(verb)) {
|
|
112
|
+
return `vault:${pickedVault}:${verb}`;
|
|
113
|
+
}
|
|
114
|
+
return s;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface OAuthDeps {
|
|
119
|
+
/** Hub origin used for `iss`, `authorization_endpoint`, etc. */
|
|
120
|
+
issuer: string;
|
|
121
|
+
/** Override the clock for deterministic tests. */
|
|
122
|
+
now?: () => Date;
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the declared-scope set the issuer is willing to sign. Production
|
|
125
|
+
* walks `services.json` + each module's `.parachute/module.json`
|
|
126
|
+
* `scopes.defines` and unions with `FIRST_PARTY_SCOPES`. Tests inject a
|
|
127
|
+
* pinned set so the gate is deterministic without a fixture services.json.
|
|
128
|
+
* See cli#71 + `oauth-scopes.md`.
|
|
129
|
+
*/
|
|
130
|
+
loadDeclaredScopes?: () => ReadonlySet<string>;
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the installed-services manifest used to populate the `services`
|
|
133
|
+
* catalog in /oauth/token responses (cli#81). Production reads
|
|
134
|
+
* `~/.parachute/services.json`; tests inject a fixture.
|
|
135
|
+
*/
|
|
136
|
+
loadServicesManifest?: () => ServicesManifest;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ServicesCatalogEntry {
|
|
140
|
+
url: string;
|
|
141
|
+
version: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build the `services` map embedded in /oauth/token responses. Each entry maps
|
|
148
|
+
* a short service name (`vault`, `scribe`, `notes`, …) to its absolute URL +
|
|
149
|
+
* version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
|
|
150
|
+
* to know where vault lives.
|
|
151
|
+
*
|
|
152
|
+
* URL source: `entry.paths[0]` from services.json verbatim — never hardcode
|
|
153
|
+
* `/vault/default`. Users who installed with `parachute install vault
|
|
154
|
+
* --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
|
|
155
|
+
* catalog URL must follow that. The custom-vault-name regression test in
|
|
156
|
+
* oauth-handlers.test.ts pins this.
|
|
157
|
+
*
|
|
158
|
+
* Filtering: only services for which the token has at least one scope are
|
|
159
|
+
* included. A scope `vault:read` admits the `vault` service; a token with only
|
|
160
|
+
* `scribe:transcribe` gets a catalog with no vault entry. The check is on the
|
|
161
|
+
* audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
|
|
162
|
+
*
|
|
163
|
+
* Multi-vault: Phase 1 collapses every vault entry under the single key
|
|
164
|
+
* `vault`, first matching `parachute-vault*` row wins. Per-vault keys
|
|
165
|
+
* (`services.vault.work.url` or `services["vault:work"].url`) are deferred
|
|
166
|
+
* to a future design once notes ships its vault picker; multi-vault clients
|
|
167
|
+
* need to probe `/.well-known/parachute.json` for the full vaults array
|
|
168
|
+
* until then.
|
|
169
|
+
*/
|
|
170
|
+
export function buildServicesCatalog(
|
|
171
|
+
manifest: ServicesManifest,
|
|
172
|
+
issuer: string,
|
|
173
|
+
scopes: readonly string[],
|
|
174
|
+
): ServicesCatalog {
|
|
175
|
+
const audiences = new Set<string>();
|
|
176
|
+
for (const s of scopes) {
|
|
177
|
+
const colon = s.indexOf(":");
|
|
178
|
+
if (colon > 0) audiences.add(s.slice(0, colon));
|
|
179
|
+
}
|
|
180
|
+
const base = issuer.replace(/\/$/, "");
|
|
181
|
+
const catalog: ServicesCatalog = {};
|
|
182
|
+
for (const entry of manifest.services) {
|
|
183
|
+
const path = entry.paths[0] ?? "/";
|
|
184
|
+
const key = isVaultEntry(entry) ? "vault" : shortName(entry.name);
|
|
185
|
+
if (!audiences.has(key)) continue;
|
|
186
|
+
if (catalog[key]) continue; // first vault wins; deterministic for clients
|
|
187
|
+
catalog[key] = { url: `${base}${path}`, version: entry.version };
|
|
188
|
+
}
|
|
189
|
+
return catalog;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- helpers ---------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
function jsonResponse(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
|
|
195
|
+
return new Response(JSON.stringify(body), {
|
|
196
|
+
status,
|
|
197
|
+
headers: { "content-type": "application/json", ...extra },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
202
|
+
return new Response(body, {
|
|
203
|
+
status,
|
|
204
|
+
headers: { "content-type": "text/html; charset=utf-8", ...extra },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function redirectResponse(location: string, extra: Record<string, string> = {}): Response {
|
|
209
|
+
return new Response(null, { status: 302, headers: { location, ...extra } });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function htmlError(title: string, message: string, status: number): Response {
|
|
213
|
+
return htmlResponse(renderError({ title, message, status }), status);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function oauthErrorRedirect(
|
|
217
|
+
redirectUri: string,
|
|
218
|
+
error: string,
|
|
219
|
+
description: string,
|
|
220
|
+
state: string | null,
|
|
221
|
+
): Response {
|
|
222
|
+
const u = new URL(redirectUri);
|
|
223
|
+
u.searchParams.set("error", error);
|
|
224
|
+
u.searchParams.set("error_description", description);
|
|
225
|
+
if (state) u.searchParams.set("state", state);
|
|
226
|
+
return redirectResponse(u.toString());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- /.well-known/oauth-authorization-server -------------------------------
|
|
230
|
+
|
|
231
|
+
export function authorizationServerMetadata(deps: OAuthDeps): Response {
|
|
232
|
+
const iss = deps.issuer;
|
|
233
|
+
// Advertise the full declared-scope set — FIRST_PARTY ∪ each registered
|
|
234
|
+
// module's `scopes.defines` — so standards-following clients discover
|
|
235
|
+
// third-party scopes (e.g. parachute-agent's `agent:*`) the same way they discover
|
|
236
|
+
// first-party ones. The token-issuance path already consults
|
|
237
|
+
// `loadDeclaredScopes` (see #90); metadata had to follow or the issuer's
|
|
238
|
+
// public advertisement would be a strict subset of what it'll actually
|
|
239
|
+
// sign. Closes #91.
|
|
240
|
+
const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
|
|
241
|
+
return jsonResponse({
|
|
242
|
+
issuer: iss,
|
|
243
|
+
authorization_endpoint: `${iss}/oauth/authorize`,
|
|
244
|
+
token_endpoint: `${iss}/oauth/token`,
|
|
245
|
+
registration_endpoint: `${iss}/oauth/register`,
|
|
246
|
+
revocation_endpoint: `${iss}/oauth/revoke`,
|
|
247
|
+
jwks_uri: `${iss}/.well-known/jwks.json`,
|
|
248
|
+
response_types_supported: ["code"],
|
|
249
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
250
|
+
code_challenge_methods_supported: ["S256"],
|
|
251
|
+
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
|
|
252
|
+
// Operator-only scopes (NON_REQUESTABLE_SCOPES) are intentionally absent
|
|
253
|
+
// — RFC 8414 §2 frames `scopes_supported` as "the OAuth 2.0 [...] scope
|
|
254
|
+
// values that this authorization server supports" for clients to request.
|
|
255
|
+
// Advertising what we always reject would mislead clients.
|
|
256
|
+
scopes_supported: Array.from(declared).filter(isRequestableScope),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Find any requested scopes that the public flow refuses to mint. */
|
|
261
|
+
function findNonRequestableScopes(scopes: readonly string[]): string[] {
|
|
262
|
+
return scopes.filter(isNonRequestableScope);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- /oauth/authorize ------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: string } {
|
|
268
|
+
const required = (k: string) => {
|
|
269
|
+
const v = url.searchParams.get(k);
|
|
270
|
+
return v && v.length > 0 ? v : null;
|
|
271
|
+
};
|
|
272
|
+
const clientId = required("client_id");
|
|
273
|
+
const redirectUri = required("redirect_uri");
|
|
274
|
+
const responseType = required("response_type");
|
|
275
|
+
const scope = url.searchParams.get("scope") ?? "";
|
|
276
|
+
const codeChallenge = required("code_challenge");
|
|
277
|
+
const codeChallengeMethod = required("code_challenge_method");
|
|
278
|
+
if (!clientId) return { error: "missing client_id" };
|
|
279
|
+
if (!redirectUri) return { error: "missing redirect_uri" };
|
|
280
|
+
if (!responseType) return { error: "missing response_type" };
|
|
281
|
+
if (!codeChallenge) return { error: "missing code_challenge" };
|
|
282
|
+
if (!codeChallengeMethod) return { error: "missing code_challenge_method" };
|
|
283
|
+
return {
|
|
284
|
+
clientId,
|
|
285
|
+
redirectUri,
|
|
286
|
+
responseType,
|
|
287
|
+
scope,
|
|
288
|
+
codeChallenge,
|
|
289
|
+
codeChallengeMethod,
|
|
290
|
+
state: url.searchParams.get("state"),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** HTML response for pending clients hitting /oauth/authorize. */
|
|
295
|
+
function pendingClientHtml(): Response {
|
|
296
|
+
return htmlError(
|
|
297
|
+
"App not yet approved",
|
|
298
|
+
"This client_id is registered but has not been approved by the hub operator. Ask the operator to run `parachute auth approve-client` for this app, then try again.",
|
|
299
|
+
403,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** JSON response for pending clients hitting /oauth/token. */
|
|
304
|
+
function pendingClientJson(): Response {
|
|
305
|
+
return jsonResponse(
|
|
306
|
+
{
|
|
307
|
+
error: "invalid_client",
|
|
308
|
+
error_description: "client is registered but has not been approved by the hub operator (#74)",
|
|
309
|
+
},
|
|
310
|
+
401,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* GET /oauth/authorize — entrypoint. Validates client + redirect_uri, then
|
|
316
|
+
* either renders the login form (no session) or the consent screen (session
|
|
317
|
+
* present). All authorize-time params are echoed back via hidden inputs so
|
|
318
|
+
* the form POST keeps the binding intact.
|
|
319
|
+
*/
|
|
320
|
+
export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps): Response {
|
|
321
|
+
const url = new URL(req.url);
|
|
322
|
+
const parsed = parseAuthorizeFormParams(url);
|
|
323
|
+
if ("error" in parsed) {
|
|
324
|
+
return htmlError("Invalid authorization request", parsed.error, 400);
|
|
325
|
+
}
|
|
326
|
+
if (parsed.responseType !== "code") {
|
|
327
|
+
return oauthErrorRedirect(
|
|
328
|
+
parsed.redirectUri,
|
|
329
|
+
"unsupported_response_type",
|
|
330
|
+
"only response_type=code is supported",
|
|
331
|
+
parsed.state,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (parsed.codeChallengeMethod !== "S256") {
|
|
335
|
+
return oauthErrorRedirect(
|
|
336
|
+
parsed.redirectUri,
|
|
337
|
+
"invalid_request",
|
|
338
|
+
"PKCE S256 is required",
|
|
339
|
+
parsed.state,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
const client = getClient(db, parsed.clientId);
|
|
343
|
+
if (!client) {
|
|
344
|
+
// Can't safely redirect — we don't trust the redirect_uri until we've
|
|
345
|
+
// matched it against a registered client. Render an HTML error.
|
|
346
|
+
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
347
|
+
}
|
|
348
|
+
if (client.status !== "approved") return pendingClientHtml();
|
|
349
|
+
try {
|
|
350
|
+
requireRegisteredRedirectUri(client, parsed.redirectUri);
|
|
351
|
+
} catch {
|
|
352
|
+
return htmlError(
|
|
353
|
+
"Redirect mismatch",
|
|
354
|
+
"The redirect_uri does not match any URI registered for this app.",
|
|
355
|
+
400,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Operator-only scope gate (#96). Reject any request that names a scope
|
|
360
|
+
// we'll never mint via this flow — `parachute:host:admin` and friends.
|
|
361
|
+
// Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
|
|
362
|
+
// delivered by redirect with `error=invalid_scope`.
|
|
363
|
+
const requestedScopes = parsed.scope.split(" ").filter((s) => s.length > 0);
|
|
364
|
+
const blocked = findNonRequestableScopes(requestedScopes);
|
|
365
|
+
if (blocked.length > 0) {
|
|
366
|
+
return oauthErrorRedirect(
|
|
367
|
+
parsed.redirectUri,
|
|
368
|
+
"invalid_scope",
|
|
369
|
+
`requested scopes are not available via the public authorization endpoint: ${blocked.join(", ")}`,
|
|
370
|
+
parsed.state,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const sessionId = parseSessionCookie(req.headers.get("cookie"));
|
|
375
|
+
const session = sessionId ? findSession(db, sessionId) : null;
|
|
376
|
+
const csrf = ensureCsrfToken(req);
|
|
377
|
+
const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
|
|
378
|
+
if (!session) {
|
|
379
|
+
return htmlResponse(renderLogin({ params: parsed, csrfToken: csrf.token }), 200, extra);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Skip-consent gate (#75). If the user has previously granted every
|
|
383
|
+
// requested scope to this client, mint the auth code immediately. Two
|
|
384
|
+
// important constraints:
|
|
385
|
+
// - Unnamed vault verbs (`vault:read`) need the picker even if a prior
|
|
386
|
+
// grant exists, because the operator's vault choice isn't recorded
|
|
387
|
+
// literally — grants store narrowed `vault:<name>:<verb>` scopes, so
|
|
388
|
+
// a fresh unnamed request never matches. Force consent to re-pick.
|
|
389
|
+
// - The grant covers `requestedScopes` exactly when every requested
|
|
390
|
+
// scope appears in the stored set. A strict superset (client wants
|
|
391
|
+
// something new) falls through to the consent screen.
|
|
392
|
+
const hasUnnamedVault = unnamedVaultVerbs(requestedScopes).length > 0;
|
|
393
|
+
if (!hasUnnamedVault && isCoveredByGrant(db, session.userId, client.clientId, requestedScopes)) {
|
|
394
|
+
console.log(
|
|
395
|
+
`consent skipped: existing grant covers requested scope client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")}`,
|
|
396
|
+
);
|
|
397
|
+
return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
401
|
+
const vaultNames = listVaultNames(manifest);
|
|
402
|
+
return htmlResponse(
|
|
403
|
+
renderConsent(consentProps(client, parsed, vaultNames, csrf.token)),
|
|
404
|
+
200,
|
|
405
|
+
extra,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Mint an auth code and redirect to the client's redirect_uri. Shared by
|
|
411
|
+
* the consent-submit path (`handleConsentSubmit`) and the skip-consent path
|
|
412
|
+
* in `handleAuthorizeGet` (#75). Caller is responsible for having already
|
|
413
|
+
* validated the client + redirect_uri + scopes.
|
|
414
|
+
*/
|
|
415
|
+
function issueAuthCodeRedirect(
|
|
416
|
+
db: Database,
|
|
417
|
+
params: AuthorizeFormParams,
|
|
418
|
+
scopes: string[],
|
|
419
|
+
userId: string,
|
|
420
|
+
deps: OAuthDeps,
|
|
421
|
+
): Response {
|
|
422
|
+
const code = issueAuthCode(db, {
|
|
423
|
+
clientId: params.clientId,
|
|
424
|
+
userId,
|
|
425
|
+
redirectUri: params.redirectUri,
|
|
426
|
+
scopes,
|
|
427
|
+
codeChallenge: params.codeChallenge,
|
|
428
|
+
codeChallengeMethod: params.codeChallengeMethod,
|
|
429
|
+
now: deps.now,
|
|
430
|
+
});
|
|
431
|
+
const u = new URL(params.redirectUri);
|
|
432
|
+
u.searchParams.set("code", code.code);
|
|
433
|
+
if (params.state) u.searchParams.set("state", params.state);
|
|
434
|
+
return redirectResponse(u.toString());
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* POST /oauth/authorize — handles two distinct submissions:
|
|
439
|
+
* - login form: `__action=login` with username + password. On success,
|
|
440
|
+
* create a session, set the cookie, redirect back to GET /oauth/authorize
|
|
441
|
+
* so the user lands on the consent screen.
|
|
442
|
+
* - consent submission: `__action=consent` with `approve=yes|no`. On
|
|
443
|
+
* approve, mint an auth code and redirect to the client's redirect_uri.
|
|
444
|
+
* On deny, redirect with `error=access_denied`.
|
|
445
|
+
*/
|
|
446
|
+
export async function handleAuthorizePost(
|
|
447
|
+
db: Database,
|
|
448
|
+
req: Request,
|
|
449
|
+
deps: OAuthDeps,
|
|
450
|
+
): Promise<Response> {
|
|
451
|
+
const form = await req.formData();
|
|
452
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
453
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
454
|
+
// Same response shape for missing-cookie, missing-form-field, and mismatch
|
|
455
|
+
// — we don't want to leak which side failed. The browser can recover by
|
|
456
|
+
// GETting /oauth/authorize again, which mints a fresh cookie + token.
|
|
457
|
+
return htmlError(
|
|
458
|
+
"Invalid form submission",
|
|
459
|
+
"The form's CSRF token did not match. Reload the page and try again.",
|
|
460
|
+
400,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
// Token is already verified above; reuse the form value for re-rendering
|
|
464
|
+
// any error views so the next submit keeps the same cookie/form pairing.
|
|
465
|
+
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
466
|
+
const action = String(form.get("__action") ?? "");
|
|
467
|
+
if (action === "login") return await handleLoginSubmit(db, req, form, deps, csrfToken);
|
|
468
|
+
if (action === "consent") return await handleConsentSubmit(db, req, form, deps, csrfToken);
|
|
469
|
+
return htmlError("Invalid form submission", "Unknown form action.", 400);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function handleLoginSubmit(
|
|
473
|
+
db: Database,
|
|
474
|
+
_req: Request,
|
|
475
|
+
form: Awaited<ReturnType<Request["formData"]>>,
|
|
476
|
+
_deps: OAuthDeps,
|
|
477
|
+
csrfToken: string,
|
|
478
|
+
): Promise<Response> {
|
|
479
|
+
const username = String(form.get("username") ?? "");
|
|
480
|
+
const password = String(form.get("password") ?? "");
|
|
481
|
+
const params = paramsFromForm(form);
|
|
482
|
+
if (!username || !password) {
|
|
483
|
+
return htmlResponse(
|
|
484
|
+
renderLogin({ params, csrfToken, errorMessage: "Username and password are required." }),
|
|
485
|
+
400,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const user = getUserByUsername(db, username);
|
|
489
|
+
if (!user) {
|
|
490
|
+
return htmlResponse(
|
|
491
|
+
renderLogin({ params, csrfToken, errorMessage: "Invalid credentials." }),
|
|
492
|
+
401,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const ok = await verifyPassword(user, password);
|
|
496
|
+
if (!ok) {
|
|
497
|
+
return htmlResponse(
|
|
498
|
+
renderLogin({ params, csrfToken, errorMessage: "Invalid credentials." }),
|
|
499
|
+
401,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const session = createSession(db, { userId: user.id });
|
|
503
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
504
|
+
// Redirect back to GET /oauth/authorize with the original query string so
|
|
505
|
+
// the user lands on the consent screen with full params re-validated.
|
|
506
|
+
const u = new URL("/oauth/authorize", "http://placeholder");
|
|
507
|
+
for (const [k, v] of Object.entries(authorizeParamsToQuery(params))) {
|
|
508
|
+
u.searchParams.set(k, v);
|
|
509
|
+
}
|
|
510
|
+
return redirectResponse(`${u.pathname}${u.search}`, { "set-cookie": cookie });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function handleConsentSubmit(
|
|
514
|
+
db: Database,
|
|
515
|
+
req: Request,
|
|
516
|
+
form: Awaited<ReturnType<Request["formData"]>>,
|
|
517
|
+
deps: OAuthDeps,
|
|
518
|
+
csrfToken: string,
|
|
519
|
+
): Promise<Response> {
|
|
520
|
+
const params = paramsFromForm(form);
|
|
521
|
+
const approve = String(form.get("approve") ?? "") === "yes";
|
|
522
|
+
const sessionId = parseSessionCookie(req.headers.get("cookie"));
|
|
523
|
+
const session = sessionId ? findSession(db, sessionId) : null;
|
|
524
|
+
if (!session) {
|
|
525
|
+
// Session expired between login and consent submit. Send back to login.
|
|
526
|
+
return htmlResponse(
|
|
527
|
+
renderLogin({
|
|
528
|
+
params,
|
|
529
|
+
csrfToken,
|
|
530
|
+
errorMessage: "Your session expired — please sign in again.",
|
|
531
|
+
}),
|
|
532
|
+
401,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
const client = getClient(db, params.clientId);
|
|
536
|
+
if (!client) {
|
|
537
|
+
return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
|
|
538
|
+
}
|
|
539
|
+
if (client.status !== "approved") return pendingClientHtml();
|
|
540
|
+
try {
|
|
541
|
+
requireRegisteredRedirectUri(client, params.redirectUri);
|
|
542
|
+
} catch {
|
|
543
|
+
return htmlError(
|
|
544
|
+
"Redirect mismatch",
|
|
545
|
+
"The redirect_uri does not match any URI registered for this app.",
|
|
546
|
+
400,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (!approve) {
|
|
550
|
+
return oauthErrorRedirect(
|
|
551
|
+
params.redirectUri,
|
|
552
|
+
"access_denied",
|
|
553
|
+
"user denied the authorization request",
|
|
554
|
+
params.state,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
let scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
558
|
+
// Defense-in-depth (#96). The GET handler already rejects non-requestable
|
|
559
|
+
// scopes before consent renders, but a hand-crafted POST could carry one
|
|
560
|
+
// anyway — block it here too.
|
|
561
|
+
const blockedHere = findNonRequestableScopes(scopes);
|
|
562
|
+
if (blockedHere.length > 0) {
|
|
563
|
+
return oauthErrorRedirect(
|
|
564
|
+
params.redirectUri,
|
|
565
|
+
"invalid_scope",
|
|
566
|
+
`requested scopes are not available via the public authorization endpoint: ${blockedHere.join(", ")}`,
|
|
567
|
+
params.state,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
// Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
|
|
571
|
+
// `vault:<verb>` scope is ambiguous about which vault it grants access to.
|
|
572
|
+
// Force the operator to pick before the JWT is minted, then rewrite the
|
|
573
|
+
// unnamed scope to `vault:<picked>:<verb>` so vault's strict per-resource
|
|
574
|
+
// enforcement (Phase 1) sees a name it can match against the URL.
|
|
575
|
+
const unnamedVerbs = unnamedVaultVerbs(scopes);
|
|
576
|
+
if (unnamedVerbs.length > 0) {
|
|
577
|
+
const pickedVault = String(form.get("vault_pick") ?? "").trim();
|
|
578
|
+
if (!pickedVault) {
|
|
579
|
+
return htmlError(
|
|
580
|
+
"Pick a vault",
|
|
581
|
+
"This app requested vault access without naming a vault. Pick which vault to grant access to and try again.",
|
|
582
|
+
400,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
|
|
586
|
+
const validNames = listVaultNames(manifest);
|
|
587
|
+
if (!validNames.includes(pickedVault)) {
|
|
588
|
+
return htmlError(
|
|
589
|
+
"Unknown vault",
|
|
590
|
+
`vault "${pickedVault}" is not registered on this host.`,
|
|
591
|
+
400,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
scopes = narrowVaultScopes(scopes, pickedVault);
|
|
595
|
+
}
|
|
596
|
+
// Record (or extend) the grant so the next /oauth/authorize for this
|
|
597
|
+
// (user, client) with these scopes — or any subset — can skip the consent
|
|
598
|
+
// screen (#75). UNION semantics: if the user previously granted [a, b, c]
|
|
599
|
+
// and now grants [a, d], the row becomes [a, b, c, d]. Subset re-flows
|
|
600
|
+
// still match.
|
|
601
|
+
recordGrant(db, session.userId, client.clientId, scopes, deps.now?.() ?? new Date());
|
|
602
|
+
return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): AuthorizeFormParams {
|
|
606
|
+
return {
|
|
607
|
+
clientId: String(form.get("client_id") ?? ""),
|
|
608
|
+
redirectUri: String(form.get("redirect_uri") ?? ""),
|
|
609
|
+
responseType: String(form.get("response_type") ?? "code"),
|
|
610
|
+
scope: String(form.get("scope") ?? ""),
|
|
611
|
+
codeChallenge: String(form.get("code_challenge") ?? ""),
|
|
612
|
+
codeChallengeMethod: String(form.get("code_challenge_method") ?? "S256"),
|
|
613
|
+
state: (form.get("state") as string | null) ?? null,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function authorizeParamsToQuery(p: AuthorizeFormParams): Record<string, string> {
|
|
618
|
+
const q: Record<string, string> = {
|
|
619
|
+
client_id: p.clientId,
|
|
620
|
+
redirect_uri: p.redirectUri,
|
|
621
|
+
response_type: p.responseType,
|
|
622
|
+
scope: p.scope,
|
|
623
|
+
code_challenge: p.codeChallenge,
|
|
624
|
+
code_challenge_method: p.codeChallengeMethod,
|
|
625
|
+
};
|
|
626
|
+
if (p.state) q.state = p.state;
|
|
627
|
+
return q;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// --- /oauth/token ----------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Extract a presented client_secret from either the `Authorization: Basic`
|
|
634
|
+
* header (RFC 6749 §2.3.1 preferred) or the form-body `client_secret`. If
|
|
635
|
+
* both are present, the header wins — the spec says clients SHOULD use one
|
|
636
|
+
* mechanism per request; when they don't, picking deterministically (header
|
|
637
|
+
* = the more-secure form, harder to log accidentally than a body field)
|
|
638
|
+
* keeps the auth gate predictable.
|
|
639
|
+
*
|
|
640
|
+
* Returns `{ clientId, clientSecret }` so callers can cross-check the body's
|
|
641
|
+
* `client_id` against the header's. RFC §2.3.1 doesn't explicitly require
|
|
642
|
+
* matching, but a mismatch is a client bug we shouldn't paper over.
|
|
643
|
+
*
|
|
644
|
+
* Returns null secret when no credential was presented at all.
|
|
645
|
+
*/
|
|
646
|
+
function extractClientCredentials(
|
|
647
|
+
req: Request,
|
|
648
|
+
form: Awaited<ReturnType<Request["formData"]>>,
|
|
649
|
+
): { headerClientId: string | null; clientSecret: string | null } {
|
|
650
|
+
const auth = req.headers.get("authorization");
|
|
651
|
+
// RFC 7235 §2.1 — auth-scheme is case-insensitive ("Basic" / "basic" / "BASIC").
|
|
652
|
+
if (auth && /^basic\s+/i.test(auth)) {
|
|
653
|
+
try {
|
|
654
|
+
const decoded = atob(auth.replace(/^basic\s+/i, "").trim());
|
|
655
|
+
const colon = decoded.indexOf(":");
|
|
656
|
+
if (colon >= 0) {
|
|
657
|
+
// RFC 6749 §2.3.1 mandates form-encoding the basic-auth values
|
|
658
|
+
// (because client_id may legitimately contain `:`). Decode them
|
|
659
|
+
// back so a client that registered the spec-correct way works.
|
|
660
|
+
const headerClientId = decodeURIComponent(decoded.slice(0, colon));
|
|
661
|
+
const clientSecret = decodeURIComponent(decoded.slice(colon + 1));
|
|
662
|
+
return { headerClientId, clientSecret };
|
|
663
|
+
}
|
|
664
|
+
} catch {
|
|
665
|
+
// Malformed base64 → treat as no header credential, fall through to
|
|
666
|
+
// form body. The auth gate will reject if the client is confidential
|
|
667
|
+
// and didn't also send a body secret.
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const bodySecret = form.get("client_secret");
|
|
671
|
+
return {
|
|
672
|
+
headerClientId: null,
|
|
673
|
+
clientSecret: typeof bodySecret === "string" && bodySecret.length > 0 ? bodySecret : null,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* 401 response shape for token-endpoint client-auth failures. WWW-Authenticate
|
|
679
|
+
* declares Basic per RFC 6749 §5.2 + RFC 7235 — it tells a compliant client
|
|
680
|
+
* "this endpoint accepts Basic auth" so it can retry with credentials.
|
|
681
|
+
*/
|
|
682
|
+
function clientAuthFailure(description: string): Response {
|
|
683
|
+
return jsonResponse({ error: "invalid_client", error_description: description }, 401, {
|
|
684
|
+
"www-authenticate": 'Basic realm="hub"',
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Gate the per-grant handlers behind RFC 6749 §3.2.1 client authentication.
|
|
690
|
+
* Public clients (clientSecretHash == null) pass through unchanged — PKCE
|
|
691
|
+
* already binds their auth-code redemption. Confidential clients must
|
|
692
|
+
* present a matching client_secret via Basic header or form body.
|
|
693
|
+
*
|
|
694
|
+
* Returns null on success; a 401 Response on failure for the caller to
|
|
695
|
+
* return directly.
|
|
696
|
+
*/
|
|
697
|
+
function authenticateClient(
|
|
698
|
+
client: OAuthClient,
|
|
699
|
+
req: Request,
|
|
700
|
+
form: Awaited<ReturnType<Request["formData"]>>,
|
|
701
|
+
bodyClientId: string,
|
|
702
|
+
): Response | null {
|
|
703
|
+
if (!client.clientSecretHash) return null; // public client: no secret required
|
|
704
|
+
const { headerClientId, clientSecret } = extractClientCredentials(req, form);
|
|
705
|
+
if (!clientSecret) {
|
|
706
|
+
return clientAuthFailure("client_secret required for confidential client");
|
|
707
|
+
}
|
|
708
|
+
// If the Basic header was used, its client_id must match the body's —
|
|
709
|
+
// RFC 6749 §3.2.1 says the auth identifies the client; a body claiming
|
|
710
|
+
// a different client_id is a bug or an attempt to confuse the gate.
|
|
711
|
+
if (headerClientId !== null && headerClientId !== bodyClientId) {
|
|
712
|
+
return clientAuthFailure("authorization header client_id does not match request body");
|
|
713
|
+
}
|
|
714
|
+
if (!verifyClientSecret(client, clientSecret)) {
|
|
715
|
+
return clientAuthFailure("client_secret mismatch");
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* POST /oauth/token — supports `authorization_code` + `refresh_token`.
|
|
722
|
+
* Confidential clients (registered with a client_secret) must authenticate
|
|
723
|
+
* via the Authorization: Basic header or a form-body `client_secret` per
|
|
724
|
+
* RFC 6749 §2.3.1; public clients (PKCE-only) need no client_secret because
|
|
725
|
+
* PKCE already binds the redemption. Errors return the RFC 6749 §5.2 shape:
|
|
726
|
+
* 400/401 + `{error, error_description}`.
|
|
727
|
+
*/
|
|
728
|
+
export async function handleToken(db: Database, req: Request, deps: OAuthDeps): Promise<Response> {
|
|
729
|
+
const form = await req.formData();
|
|
730
|
+
const grantType = String(form.get("grant_type") ?? "");
|
|
731
|
+
if (grantType === "authorization_code")
|
|
732
|
+
return await handleTokenAuthorizationCode(db, req, form, deps);
|
|
733
|
+
if (grantType === "refresh_token") return await handleTokenRefresh(db, req, form, deps);
|
|
734
|
+
return jsonResponse(
|
|
735
|
+
{
|
|
736
|
+
error: "unsupported_grant_type",
|
|
737
|
+
error_description: `grant_type "${grantType}" is not supported`,
|
|
738
|
+
},
|
|
739
|
+
400,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function handleTokenAuthorizationCode(
|
|
744
|
+
db: Database,
|
|
745
|
+
req: Request,
|
|
746
|
+
form: Awaited<ReturnType<Request["formData"]>>,
|
|
747
|
+
deps: OAuthDeps,
|
|
748
|
+
): Promise<Response> {
|
|
749
|
+
const code = String(form.get("code") ?? "");
|
|
750
|
+
const clientId = String(form.get("client_id") ?? "");
|
|
751
|
+
const redirectUri = String(form.get("redirect_uri") ?? "");
|
|
752
|
+
const codeVerifier = String(form.get("code_verifier") ?? "");
|
|
753
|
+
if (!code || !clientId || !redirectUri || !codeVerifier) {
|
|
754
|
+
return jsonResponse(
|
|
755
|
+
{ error: "invalid_request", error_description: "missing required parameter" },
|
|
756
|
+
400,
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
const client = getClient(db, clientId);
|
|
760
|
+
if (!client) {
|
|
761
|
+
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
762
|
+
}
|
|
763
|
+
if (client.status !== "approved") return pendingClientJson();
|
|
764
|
+
const authFailure = authenticateClient(client, req, form, clientId);
|
|
765
|
+
if (authFailure) return authFailure;
|
|
766
|
+
let redeemed: ReturnType<typeof redeemAuthCode>;
|
|
767
|
+
try {
|
|
768
|
+
redeemed = redeemAuthCode(db, { code, clientId, redirectUri, codeVerifier, now: deps.now });
|
|
769
|
+
} catch (err) {
|
|
770
|
+
return mapAuthCodeError(err);
|
|
771
|
+
}
|
|
772
|
+
// Scope-validation gate (cli#71). Reject any requested scope that the
|
|
773
|
+
// issuer never declared — `FIRST_PARTY_SCOPES` ∪ each module's `module.json`
|
|
774
|
+
// `scopes.defines`. Per RFC 6749 §5.2: `error: "invalid_scope"`. We add
|
|
775
|
+
// `invalid_scopes: [...]` as an extension field so clients can report the
|
|
776
|
+
// exact culprits without re-parsing the description string.
|
|
777
|
+
const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
|
|
778
|
+
const unknown = findUnknownScopes(redeemed.scopes, declared);
|
|
779
|
+
if (unknown.length > 0) {
|
|
780
|
+
return jsonResponse(
|
|
781
|
+
{
|
|
782
|
+
error: "invalid_scope",
|
|
783
|
+
error_description: `unknown scopes: ${unknown.join(", ")}`,
|
|
784
|
+
invalid_scopes: unknown,
|
|
785
|
+
},
|
|
786
|
+
400,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
const audience = inferAudience(redeemed.scopes);
|
|
790
|
+
const access = await signAccessToken(db, {
|
|
791
|
+
sub: redeemed.userId,
|
|
792
|
+
scopes: redeemed.scopes,
|
|
793
|
+
audience,
|
|
794
|
+
clientId: redeemed.clientId,
|
|
795
|
+
issuer: deps.issuer,
|
|
796
|
+
now: deps.now,
|
|
797
|
+
});
|
|
798
|
+
const refresh = signRefreshToken(db, {
|
|
799
|
+
jti: access.jti,
|
|
800
|
+
userId: redeemed.userId,
|
|
801
|
+
clientId: redeemed.clientId,
|
|
802
|
+
scopes: redeemed.scopes,
|
|
803
|
+
now: deps.now,
|
|
804
|
+
});
|
|
805
|
+
const services = buildServicesCatalog(
|
|
806
|
+
(deps.loadServicesManifest ?? readServicesManifest)(),
|
|
807
|
+
deps.issuer,
|
|
808
|
+
redeemed.scopes,
|
|
809
|
+
);
|
|
810
|
+
return jsonResponse({
|
|
811
|
+
access_token: access.token,
|
|
812
|
+
token_type: "Bearer",
|
|
813
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
814
|
+
refresh_token: refresh.token,
|
|
815
|
+
scope: redeemed.scopes.join(" "),
|
|
816
|
+
services,
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function handleTokenRefresh(
|
|
821
|
+
db: Database,
|
|
822
|
+
req: Request,
|
|
823
|
+
form: Awaited<ReturnType<Request["formData"]>>,
|
|
824
|
+
deps: OAuthDeps,
|
|
825
|
+
): Promise<Response> {
|
|
826
|
+
const refreshToken = String(form.get("refresh_token") ?? "");
|
|
827
|
+
const clientId = String(form.get("client_id") ?? "");
|
|
828
|
+
if (!refreshToken || !clientId) {
|
|
829
|
+
return jsonResponse(
|
|
830
|
+
{ error: "invalid_request", error_description: "missing required parameter" },
|
|
831
|
+
400,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
const client = getClient(db, clientId);
|
|
835
|
+
if (!client) {
|
|
836
|
+
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
837
|
+
}
|
|
838
|
+
if (client.status !== "approved") return pendingClientJson();
|
|
839
|
+
const authFailure = authenticateClient(client, req, form, clientId);
|
|
840
|
+
if (authFailure) return authFailure;
|
|
841
|
+
const row = findRefreshToken(db, refreshToken);
|
|
842
|
+
if (!row) {
|
|
843
|
+
return jsonResponse(
|
|
844
|
+
{ error: "invalid_grant", error_description: "refresh_token not found" },
|
|
845
|
+
400,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (row.clientId !== clientId) {
|
|
849
|
+
return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
|
|
850
|
+
}
|
|
851
|
+
const now = deps.now?.() ?? new Date();
|
|
852
|
+
if (row.revokedAt) {
|
|
853
|
+
// Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
|
|
854
|
+
// working assumption is theft — the legitimate client received a new
|
|
855
|
+
// refresh token at the prior rotation, so anyone presenting the old one
|
|
856
|
+
// either lost a race (rare) or stole it (the case we must defend
|
|
857
|
+
// against). Either way: revoke every descendant in the family so the
|
|
858
|
+
// attacker can't keep refreshing, and force the legitimate client to
|
|
859
|
+
// re-authorize. Cheaper than tracking which call was first.
|
|
860
|
+
revokeFamily(db, row.familyId, now);
|
|
861
|
+
return jsonResponse(
|
|
862
|
+
{ error: "invalid_grant", error_description: "refresh_token revoked" },
|
|
863
|
+
400,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
if (now.getTime() > new Date(row.expiresAt).getTime()) {
|
|
867
|
+
return jsonResponse(
|
|
868
|
+
{ error: "invalid_grant", error_description: "refresh_token expired" },
|
|
869
|
+
400,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
// Rotate: revoke the old refresh row, mint a new access + refresh pair
|
|
873
|
+
// bound to the same family so a future replay of *any* descendant can
|
|
874
|
+
// walk the chain.
|
|
875
|
+
//
|
|
876
|
+
// Mint the access token *before* opening the rotation transaction. JWT
|
|
877
|
+
// signing is async (jose returns a Promise) and bun:sqlite's
|
|
878
|
+
// `db.transaction()` is sync — running async work inside the closure
|
|
879
|
+
// would silently break atomicity. Once we have the JWT, the UPDATE
|
|
880
|
+
// (revoke old) + INSERT (mint new refresh row) commit or roll back as
|
|
881
|
+
// a unit, so a mid-rotation crash can't dead-old-without-replacement
|
|
882
|
+
// (#107).
|
|
883
|
+
const audience = inferAudience(row.scopes);
|
|
884
|
+
const access = await signAccessToken(db, {
|
|
885
|
+
sub: row.userId,
|
|
886
|
+
scopes: row.scopes,
|
|
887
|
+
audience,
|
|
888
|
+
clientId: row.clientId,
|
|
889
|
+
issuer: deps.issuer,
|
|
890
|
+
now: deps.now,
|
|
891
|
+
});
|
|
892
|
+
let refresh: ReturnType<typeof signRefreshToken>;
|
|
893
|
+
try {
|
|
894
|
+
refresh = db.transaction(() => {
|
|
895
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
|
|
896
|
+
return signRefreshToken(db, {
|
|
897
|
+
jti: access.jti,
|
|
898
|
+
userId: row.userId,
|
|
899
|
+
clientId: row.clientId,
|
|
900
|
+
scopes: row.scopes,
|
|
901
|
+
familyId: row.familyId,
|
|
902
|
+
now: deps.now,
|
|
903
|
+
});
|
|
904
|
+
})();
|
|
905
|
+
} catch (err) {
|
|
906
|
+
// Concurrent rotation: a sibling refresh of the same row already
|
|
907
|
+
// committed and ours collides on the `tokens.jti` PRIMARY KEY (or any
|
|
908
|
+
// other INSERT-time DB error). Surface a clean `invalid_grant` 400 —
|
|
909
|
+
// RFC 6749 §5.2 — instead of letting the SQLite error bubble as a 500
|
|
910
|
+
// (#108). The transaction is already rolled back at this point, so
|
|
911
|
+
// the row's revoked_at is unchanged for the losing request.
|
|
912
|
+
if (err instanceof RefreshTokenInsertError) {
|
|
913
|
+
return jsonResponse(
|
|
914
|
+
{ error: "invalid_grant", error_description: "refresh_token rotation conflict" },
|
|
915
|
+
400,
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
throw err;
|
|
919
|
+
}
|
|
920
|
+
const services = buildServicesCatalog(
|
|
921
|
+
(deps.loadServicesManifest ?? readServicesManifest)(),
|
|
922
|
+
deps.issuer,
|
|
923
|
+
row.scopes,
|
|
924
|
+
);
|
|
925
|
+
return jsonResponse({
|
|
926
|
+
access_token: access.token,
|
|
927
|
+
token_type: "Bearer",
|
|
928
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
929
|
+
refresh_token: refresh.token,
|
|
930
|
+
scope: row.scopes.join(" "),
|
|
931
|
+
services,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// --- /oauth/revoke ---------------------------------------------------------
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* POST /oauth/revoke — RFC 7009 token revocation.
|
|
939
|
+
*
|
|
940
|
+
* Accepts `token` + optional `token_type_hint` (`refresh_token` or
|
|
941
|
+
* `access_token`) form-encoded. Authenticates the client (confidential
|
|
942
|
+
* clients via `client_secret`; public clients pass through with PKCE-style
|
|
943
|
+
* client_id-only auth, same gate as the token endpoint).
|
|
944
|
+
*
|
|
945
|
+
* Lookup strategy: try the refresh-token-hash first when the hint is
|
|
946
|
+
* `refresh_token` or absent (the common case — clients usually revoke
|
|
947
|
+
* refresh tokens), then fall back to JWT decode + jti lookup for access
|
|
948
|
+
* tokens. JWT decode here is unverified-decode of the payload only; we
|
|
949
|
+
* just need the jti to find the row. A signature check would be
|
|
950
|
+
* ceremonial — if the row exists we own it; if it doesn't, we return 200
|
|
951
|
+
* anyway per spec.
|
|
952
|
+
*
|
|
953
|
+
* Response: 200 with empty body on success OR when the token is unknown
|
|
954
|
+
* (RFC 7009 §2.2 — "the authorization server responds with HTTP status
|
|
955
|
+
* code 200 [...] or if the client submitted an invalid token"). We
|
|
956
|
+
* intentionally don't surface "found vs not-found" so a caller probing
|
|
957
|
+
* with random strings can't enumerate live tokens.
|
|
958
|
+
*
|
|
959
|
+
* Closes #73.
|
|
960
|
+
*/
|
|
961
|
+
export async function handleRevoke(
|
|
962
|
+
db: Database,
|
|
963
|
+
req: Request,
|
|
964
|
+
_deps: OAuthDeps,
|
|
965
|
+
): Promise<Response> {
|
|
966
|
+
const form = await req.formData();
|
|
967
|
+
const token = String(form.get("token") ?? "");
|
|
968
|
+
const hint = String(form.get("token_type_hint") ?? "");
|
|
969
|
+
const bodyClientId = String(form.get("client_id") ?? "");
|
|
970
|
+
if (!token || !bodyClientId) {
|
|
971
|
+
return jsonResponse(
|
|
972
|
+
{ error: "invalid_request", error_description: "missing required parameter" },
|
|
973
|
+
400,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
const client = getClient(db, bodyClientId);
|
|
977
|
+
if (!client) {
|
|
978
|
+
return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
|
|
979
|
+
}
|
|
980
|
+
const authFailure = authenticateClient(client, req, form, bodyClientId);
|
|
981
|
+
if (authFailure) return authFailure;
|
|
982
|
+
|
|
983
|
+
// Lookup. Hint is advisory per RFC 7009 §2.1 — clients that get it wrong
|
|
984
|
+
// still expect revocation to succeed, so we always try both shapes.
|
|
985
|
+
const now = new Date();
|
|
986
|
+
let row = hint === "access_token" ? null : findRefreshToken(db, token);
|
|
987
|
+
if (!row) {
|
|
988
|
+
const jti = unverifiedJtiOf(token);
|
|
989
|
+
if (jti) row = findTokenRowByJti(db, jti);
|
|
990
|
+
if (!row && hint === "access_token" && !row) {
|
|
991
|
+
// hint said access_token but the JWT didn't decode; check
|
|
992
|
+
// refresh-token shape as a last resort.
|
|
993
|
+
row = findRefreshToken(db, token);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (row && row.clientId !== client.clientId) {
|
|
997
|
+
// RFC 7009 §2.1: revocation must be authenticated to the same client
|
|
998
|
+
// the token was issued to. A different client presenting a valid
|
|
999
|
+
// token is invalid_grant; we collapse it to 200 to avoid existence
|
|
1000
|
+
// disclosure to unrelated clients.
|
|
1001
|
+
return new Response(null, { status: 200 });
|
|
1002
|
+
}
|
|
1003
|
+
if (row && !row.revokedAt) {
|
|
1004
|
+
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
|
|
1005
|
+
}
|
|
1006
|
+
return new Response(null, { status: 200 });
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Best-effort jti extraction for revocation lookup. Not signature-checked —
|
|
1011
|
+
* we only need the claim to find a row. If the row doesn't exist or the
|
|
1012
|
+
* client doesn't own it, the caller bails out anyway.
|
|
1013
|
+
*/
|
|
1014
|
+
function unverifiedJtiOf(token: string): string | null {
|
|
1015
|
+
const parts = token.split(".");
|
|
1016
|
+
if (parts.length !== 3) return null;
|
|
1017
|
+
const payload = parts[1];
|
|
1018
|
+
if (!payload) return null;
|
|
1019
|
+
try {
|
|
1020
|
+
const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
|
|
1021
|
+
jti?: unknown;
|
|
1022
|
+
};
|
|
1023
|
+
return typeof json.jti === "string" ? json.jti : null;
|
|
1024
|
+
} catch {
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function mapAuthCodeError(err: unknown): Response {
|
|
1030
|
+
if (err instanceof AuthCodeNotFoundError) {
|
|
1031
|
+
return jsonResponse({ error: "invalid_grant", error_description: "code not found" }, 400);
|
|
1032
|
+
}
|
|
1033
|
+
if (err instanceof AuthCodeExpiredError) {
|
|
1034
|
+
return jsonResponse({ error: "invalid_grant", error_description: "code expired" }, 400);
|
|
1035
|
+
}
|
|
1036
|
+
if (err instanceof AuthCodeUsedError) {
|
|
1037
|
+
return jsonResponse(
|
|
1038
|
+
{ error: "invalid_grant", error_description: "code already redeemed" },
|
|
1039
|
+
400,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (err instanceof AuthCodePkceMismatchError) {
|
|
1043
|
+
return jsonResponse(
|
|
1044
|
+
{ error: "invalid_grant", error_description: "code_verifier mismatch" },
|
|
1045
|
+
400,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
if (err instanceof AuthCodeRedirectMismatchError) {
|
|
1049
|
+
return jsonResponse(
|
|
1050
|
+
{ error: "invalid_grant", error_description: "redirect_uri mismatch" },
|
|
1051
|
+
400,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1055
|
+
return jsonResponse({ error: "server_error", error_description: msg }, 500);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// --- /oauth/register -------------------------------------------------------
|
|
1059
|
+
|
|
1060
|
+
interface RegisterRequestBody {
|
|
1061
|
+
redirect_uris?: string[];
|
|
1062
|
+
scope?: string;
|
|
1063
|
+
client_name?: string;
|
|
1064
|
+
token_endpoint_auth_method?: string;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* POST /oauth/register — RFC 7591 Dynamic Client Registration.
|
|
1069
|
+
*
|
|
1070
|
+
* Approval gate (closes #74). New rows land as `pending` by default and
|
|
1071
|
+
* cannot participate in OAuth flows until an operator runs
|
|
1072
|
+
* `parachute auth approve-client <id>`. The single bypass is presenting an
|
|
1073
|
+
* `Authorization: Bearer <operator-token>` whose token carries the
|
|
1074
|
+
* `hub:admin` scope — the install-time path used by first-party modules so
|
|
1075
|
+
* `parachute install vault` can self-register without a human follow-up.
|
|
1076
|
+
*
|
|
1077
|
+
* If a bearer is presented but invalid or insufficient, we reject with the
|
|
1078
|
+
* RFC 6750 shape rather than silently downgrading to the public path: a
|
|
1079
|
+
* caller who tried to authenticate but failed wants to know why, not get
|
|
1080
|
+
* `pending` back and wonder why their module can't OAuth.
|
|
1081
|
+
*/
|
|
1082
|
+
export async function handleRegister(
|
|
1083
|
+
db: Database,
|
|
1084
|
+
req: Request,
|
|
1085
|
+
deps: OAuthDeps,
|
|
1086
|
+
): Promise<Response> {
|
|
1087
|
+
let body: RegisterRequestBody;
|
|
1088
|
+
try {
|
|
1089
|
+
body = (await req.json()) as RegisterRequestBody;
|
|
1090
|
+
} catch {
|
|
1091
|
+
return jsonResponse(
|
|
1092
|
+
{ error: "invalid_client_metadata", error_description: "body must be JSON" },
|
|
1093
|
+
400,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
|
1097
|
+
if (redirectUris.length === 0) {
|
|
1098
|
+
return jsonResponse(
|
|
1099
|
+
{
|
|
1100
|
+
error: "invalid_redirect_uri",
|
|
1101
|
+
error_description: "redirect_uris is required and must be non-empty",
|
|
1102
|
+
},
|
|
1103
|
+
400,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
for (const uri of redirectUris) {
|
|
1107
|
+
if (typeof uri !== "string" || !isValidRedirectUri(uri)) {
|
|
1108
|
+
return jsonResponse(
|
|
1109
|
+
{ error: "invalid_redirect_uri", error_description: `invalid redirect_uri "${uri}"` },
|
|
1110
|
+
400,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
// Operator-bearer auto-approve. No header → public DCR path (status=pending).
|
|
1115
|
+
// Header present → must validate as a hub:admin operator token; any failure
|
|
1116
|
+
// is surfaced (don't silently fall through to pending).
|
|
1117
|
+
let status: ClientStatus = "pending";
|
|
1118
|
+
if (req.headers.get("authorization")) {
|
|
1119
|
+
try {
|
|
1120
|
+
await requireScope(db, req, "hub:admin", deps.issuer);
|
|
1121
|
+
status = "approved";
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
if (err instanceof AdminAuthError) return adminAuthErrorResponse(err);
|
|
1124
|
+
throw err;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const confidential = body.token_endpoint_auth_method === "client_secret_post";
|
|
1128
|
+
const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
|
|
1129
|
+
let registered: RegisteredClient;
|
|
1130
|
+
try {
|
|
1131
|
+
registered = registerClient(db, {
|
|
1132
|
+
redirectUris,
|
|
1133
|
+
scopes,
|
|
1134
|
+
clientName: body.client_name,
|
|
1135
|
+
confidential,
|
|
1136
|
+
status,
|
|
1137
|
+
now: deps.now,
|
|
1138
|
+
});
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1141
|
+
return jsonResponse({ error: "invalid_client_metadata", error_description: msg }, 400);
|
|
1142
|
+
}
|
|
1143
|
+
const respBody: Record<string, unknown> = {
|
|
1144
|
+
client_id: registered.client.clientId,
|
|
1145
|
+
redirect_uris: registered.client.redirectUris,
|
|
1146
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
1147
|
+
response_types: ["code"],
|
|
1148
|
+
token_endpoint_auth_method: confidential ? "client_secret_post" : "none",
|
|
1149
|
+
client_id_issued_at: Math.floor(new Date(registered.client.registeredAt).getTime() / 1000),
|
|
1150
|
+
status: registered.client.status,
|
|
1151
|
+
};
|
|
1152
|
+
if (registered.client.scopes.length > 0) respBody.scope = registered.client.scopes.join(" ");
|
|
1153
|
+
if (registered.client.clientName) respBody.client_name = registered.client.clientName;
|
|
1154
|
+
if (registered.clientSecret) respBody.client_secret = registered.clientSecret;
|
|
1155
|
+
return jsonResponse(respBody, 201);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function consentProps(
|
|
1159
|
+
client: OAuthClient,
|
|
1160
|
+
params: AuthorizeFormParams,
|
|
1161
|
+
vaultNames: string[],
|
|
1162
|
+
csrfToken: string,
|
|
1163
|
+
) {
|
|
1164
|
+
const scopes = params.scope.split(" ").filter((s) => s.length > 0);
|
|
1165
|
+
const unnamedVerbs = unnamedVaultVerbs(scopes);
|
|
1166
|
+
return {
|
|
1167
|
+
params,
|
|
1168
|
+
clientId: client.clientId,
|
|
1169
|
+
clientName: client.clientName ?? client.clientId,
|
|
1170
|
+
scopes,
|
|
1171
|
+
csrfToken,
|
|
1172
|
+
vaultPicker:
|
|
1173
|
+
unnamedVerbs.length > 0 ? { unnamedVerbs, availableVaults: vaultNames } : undefined,
|
|
1174
|
+
};
|
|
1175
|
+
}
|