@opendatalabs/personal-server-ts-server 1.33.8 → 1.34.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/dist/__fixtures__/bound-record-store.d.ts +19 -0
- package/dist/__fixtures__/bound-record-store.d.ts.map +1 -0
- package/dist/__fixtures__/bound-record-store.js +60 -0
- package/dist/__fixtures__/bound-record-store.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts +41 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js +34 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts +112 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js +226 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts +135 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js +21 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js.map +1 -0
- package/dist/api-auth.d.ts +18 -2
- package/dist/api-auth.d.ts.map +1 -1
- package/dist/api-auth.js +114 -22
- package/dist/api-auth.js.map +1 -1
- package/dist/app.d.ts +60 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +139 -1
- package/dist/app.js.map +1 -1
- package/dist/bootstrap.d.ts +10 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +359 -8
- package/dist/bootstrap.js.map +1 -1
- package/dist/enclave-main.d.ts +4 -0
- package/dist/enclave-main.d.ts.map +1 -1
- package/dist/enclave-main.js +152 -1
- package/dist/enclave-main.js.map +1 -1
- package/dist/middleware/body-limit.d.ts +1 -1
- package/dist/middleware/body-limit.d.ts.map +1 -1
- package/dist/middleware/body-limit.js +8 -3
- package/dist/middleware/body-limit.js.map +1 -1
- package/dist/middleware/cors.d.ts.map +1 -1
- package/dist/middleware/cors.js +1 -0
- package/dist/middleware/cors.js.map +1 -1
- package/dist/package.json +9 -1
- package/dist/pdpp/bootstrap.d.ts +72 -0
- package/dist/pdpp/bootstrap.d.ts.map +1 -0
- package/dist/pdpp/bootstrap.js +270 -0
- package/dist/pdpp/bootstrap.js.map +1 -0
- package/dist/pdpp/chain-enforcement.d.ts +95 -0
- package/dist/pdpp/chain-enforcement.d.ts.map +1 -0
- package/dist/pdpp/chain-enforcement.js +145 -0
- package/dist/pdpp/chain-enforcement.js.map +1 -0
- package/dist/pdpp/client-document-fetch.d.ts +63 -0
- package/dist/pdpp/client-document-fetch.d.ts.map +1 -0
- package/dist/pdpp/client-document-fetch.js +254 -0
- package/dist/pdpp/client-document-fetch.js.map +1 -0
- package/dist/pdpp/declaration-registry.d.ts +83 -0
- package/dist/pdpp/declaration-registry.d.ts.map +1 -0
- package/dist/pdpp/declaration-registry.js +251 -0
- package/dist/pdpp/declaration-registry.js.map +1 -0
- package/dist/pdpp/deployment.d.ts +70 -0
- package/dist/pdpp/deployment.d.ts.map +1 -0
- package/dist/pdpp/deployment.js +104 -0
- package/dist/pdpp/deployment.js.map +1 -0
- package/dist/pdpp/record-schema.d.ts +2 -0
- package/dist/pdpp/record-schema.d.ts.map +1 -0
- package/dist/pdpp/record-schema.js +2 -0
- package/dist/pdpp/record-schema.js.map +1 -0
- package/dist/pdpp/records-bootstrap.d.ts +111 -0
- package/dist/pdpp/records-bootstrap.d.ts.map +1 -0
- package/dist/pdpp/records-bootstrap.js +353 -0
- package/dist/pdpp/records-bootstrap.js.map +1 -0
- package/dist/pdpp/source-id-compat.d.ts +5 -0
- package/dist/pdpp/source-id-compat.d.ts.map +1 -0
- package/dist/pdpp/source-id-compat.js +16 -0
- package/dist/pdpp/source-id-compat.js.map +1 -0
- package/dist/pdpp/test-connections.d.ts +6 -0
- package/dist/pdpp/test-connections.d.ts.map +1 -0
- package/dist/pdpp/test-connections.js +37 -0
- package/dist/pdpp/test-connections.js.map +1 -0
- package/dist/routes/auth-device.d.ts.map +1 -1
- package/dist/routes/auth-device.js +3 -0
- package/dist/routes/auth-device.js.map +1 -1
- package/dist/routes/chunked-scope-import.d.ts +41 -0
- package/dist/routes/chunked-scope-import.d.ts.map +1 -0
- package/dist/routes/chunked-scope-import.js +901 -0
- package/dist/routes/chunked-scope-import.js.map +1 -0
- package/dist/routes/data.d.ts +23 -1
- package/dist/routes/data.d.ts.map +1 -1
- package/dist/routes/data.js +112 -1
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/grants.d.ts.map +1 -1
- package/dist/routes/grants.js +2 -0
- package/dist/routes/grants.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +3 -0
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/pdpp-auth.d.ts +157 -0
- package/dist/routes/pdpp-auth.d.ts.map +1 -0
- package/dist/routes/pdpp-auth.js +953 -0
- package/dist/routes/pdpp-auth.js.map +1 -0
- package/dist/routes/pdpp-blobs.d.ts +26 -0
- package/dist/routes/pdpp-blobs.d.ts.map +1 -0
- package/dist/routes/pdpp-blobs.js +179 -0
- package/dist/routes/pdpp-blobs.js.map +1 -0
- package/dist/routes/pdpp-connections.d.ts +28 -0
- package/dist/routes/pdpp-connections.d.ts.map +1 -0
- package/dist/routes/pdpp-connections.js +144 -0
- package/dist/routes/pdpp-connections.js.map +1 -0
- package/dist/routes/pdpp-declarations.d.ts +58 -0
- package/dist/routes/pdpp-declarations.d.ts.map +1 -0
- package/dist/routes/pdpp-declarations.js +94 -0
- package/dist/routes/pdpp-declarations.js.map +1 -0
- package/dist/routes/pdpp-instance-bindings.d.ts +26 -0
- package/dist/routes/pdpp-instance-bindings.d.ts.map +1 -0
- package/dist/routes/pdpp-instance-bindings.js +124 -0
- package/dist/routes/pdpp-instance-bindings.js.map +1 -0
- package/dist/routes/pdpp-records.d.ts +158 -0
- package/dist/routes/pdpp-records.d.ts.map +1 -0
- package/dist/routes/pdpp-records.js +1537 -0
- package/dist/routes/pdpp-records.js.map +1 -0
- package/dist/routes/pdpp-well-known.d.ts +13 -0
- package/dist/routes/pdpp-well-known.d.ts.map +1 -0
- package/dist/routes/pdpp-well-known.js +20 -0
- package/dist/routes/pdpp-well-known.js.map +1 -0
- package/dist/storage/declaration-witness.d.ts +56 -0
- package/dist/storage/declaration-witness.d.ts.map +1 -0
- package/dist/storage/declaration-witness.js +273 -0
- package/dist/storage/declaration-witness.js.map +1 -0
- package/dist/storage/hierarchy.d.ts +25 -1
- package/dist/storage/hierarchy.d.ts.map +1 -1
- package/dist/storage/hierarchy.js +210 -16
- package/dist/storage/hierarchy.js.map +1 -1
- package/dist/storage/import-operations.d.ts +11 -0
- package/dist/storage/import-operations.d.ts.map +1 -0
- package/dist/storage/import-operations.js +261 -0
- package/dist/storage/import-operations.js.map +1 -0
- package/dist/storage/index-manager.d.ts +24 -2
- package/dist/storage/index-manager.d.ts.map +1 -1
- package/dist/storage/index-manager.js +279 -30
- package/dist/storage/index-manager.js.map +1 -1
- package/dist/storage/index-schema.d.ts +1 -1
- package/dist/storage/index-schema.d.ts.map +1 -1
- package/dist/storage/index-schema.js +189 -8
- package/dist/storage/index-schema.js.map +1 -1
- package/dist/storage/legacy-scope-authority.d.ts +14 -0
- package/dist/storage/legacy-scope-authority.d.ts.map +1 -0
- package/dist/storage/legacy-scope-authority.js +298 -0
- package/dist/storage/legacy-scope-authority.js.map +1 -0
- package/dist/storage/node-data-storage.d.ts +6 -0
- package/dist/storage/node-data-storage.d.ts.map +1 -1
- package/dist/storage/node-data-storage.js +161 -2
- package/dist/storage/node-data-storage.js.map +1 -1
- package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts +17 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts.map +1 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.js +159 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.js.map +1 -0
- package/dist/storage/pdpp-records-sqlite-store.d.ts +112 -0
- package/dist/storage/pdpp-records-sqlite-store.d.ts.map +1 -0
- package/dist/storage/pdpp-records-sqlite-store.js +1440 -0
- package/dist/storage/pdpp-records-sqlite-store.js.map +1 -0
- package/dist/storage/pdpp-stream-completion.d.ts +5 -0
- package/dist/storage/pdpp-stream-completion.d.ts.map +1 -0
- package/dist/storage/pdpp-stream-completion.js +10 -0
- package/dist/storage/pdpp-stream-completion.js.map +1 -0
- package/dist/storage/scope-deletion-history.d.ts +8 -0
- package/dist/storage/scope-deletion-history.d.ts.map +1 -0
- package/dist/storage/scope-deletion-history.js +40 -0
- package/dist/storage/scope-deletion-history.js.map +1 -0
- package/dist/ui/ps-lite-debug.js +3981 -1109
- package/package.json +12 -4
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDPP Core v0.1.0 Authorization Server HTTP surface.
|
|
3
|
+
*
|
|
4
|
+
* The endpoints under `/pdpp/v1`:
|
|
5
|
+
*
|
|
6
|
+
* POST /authorize — accept an RFC 9396 selection request
|
|
7
|
+
* GET /authorize/:session_id/review — the owner's consent review model
|
|
8
|
+
* POST /authorize/:session_id/approve — authenticated owner approval
|
|
9
|
+
* POST /authorize/:session_id/deny — authenticated owner denial
|
|
10
|
+
* POST /token — code redemption + refresh rotation
|
|
11
|
+
* POST /introspect — RFC 7662 introspection
|
|
12
|
+
* GET /grants — the owner's grants, with status
|
|
13
|
+
* POST /revoke — owner-initiated grant revocation
|
|
14
|
+
*
|
|
15
|
+
* This is deliberately a NEW surface rather than an extension of
|
|
16
|
+
* `oauth-token.ts`. That endpoint serves the control-plane and CLI device
|
|
17
|
+
* flows against the legacy `TokenStore`; PDPP tokens are grant-bound and live
|
|
18
|
+
* in their own store with their own lifecycle. Mixing them would mean one
|
|
19
|
+
* endpoint with two token authorities, which §8 explicitly warns against
|
|
20
|
+
* ("do not query or create a second grant authority"). Existing clients of
|
|
21
|
+
* `/oauth/token` are untouched.
|
|
22
|
+
*
|
|
23
|
+
* Every response carries `Cache-Control: no-store` and `Pragma: no-cache`
|
|
24
|
+
* (RFC 6749 §5.1 for token responses; delivery scope §1 requires it on every
|
|
25
|
+
* token response specifically).
|
|
26
|
+
*
|
|
27
|
+
* Two things that cost integrators a debug cycle, so stated plainly:
|
|
28
|
+
*
|
|
29
|
+
* - **Content types differ by endpoint.** `/authorize`, `/approve` and
|
|
30
|
+
* `/deny` take JSON. `/token`, `/introspect` and `/revoke` take
|
|
31
|
+
* `application/x-www-form-urlencoded`, because they follow the RFC 6749 /
|
|
32
|
+
* 7662 / 7009 wire conventions a standard OAuth client library already
|
|
33
|
+
* speaks. Sending JSON to the form endpoints parses to an empty body and
|
|
34
|
+
* reads as a missing parameter.
|
|
35
|
+
* - **The review digest is nested.** `GET /authorize/:id/review` returns
|
|
36
|
+
* `{ session_id, review, expires_at }`, so the digest is at
|
|
37
|
+
* `review.review_digest`, not at the top level. The envelope also carries
|
|
38
|
+
* `instance_choice_required` instead of `review` when the owner still has
|
|
39
|
+
* an instance to pick.
|
|
40
|
+
*/
|
|
41
|
+
import { Hono } from "hono";
|
|
42
|
+
import { approveAuthorization, denyAuthorization, fetchReview, PDPP_API_VERSION, PDPP_DATA_ACCESS_TYPE, PDPP_DATA_ACCESS_TYPE_V02, resolveRequesterIdentity, validateCodeChallenge, validateRedirectUri, validateSelectionRequest, } from "@opendatalabs/personal-server-ts-core/pdpp";
|
|
43
|
+
import { createWeb3AuthMiddleware } from "../middleware/web3-auth.js";
|
|
44
|
+
import { createOwnerCheckMiddleware } from "../middleware/owner-check.js";
|
|
45
|
+
import { createBodyLimit, DEFAULT_MAX_SIZE } from "../middleware/body-limit.js";
|
|
46
|
+
import { canonicalSourceIdCandidate } from "../pdpp/source-id-compat.js";
|
|
47
|
+
/** No-store on everything: tokens, codes, and review models are all sensitive. */
|
|
48
|
+
const NO_STORE = {
|
|
49
|
+
"Cache-Control": "no-store",
|
|
50
|
+
Pragma: "no-cache",
|
|
51
|
+
};
|
|
52
|
+
/** Owner tokens are short-lived: they authorize consent decisions. */
|
|
53
|
+
export const OWNER_TOKEN_TTL_SECONDS = 15 * 60;
|
|
54
|
+
function errorResponse(c, status, error, description) {
|
|
55
|
+
return c.json({ error, error_description: description }, status, NO_STORE);
|
|
56
|
+
}
|
|
57
|
+
/** Bearer token from the Authorization header, if present. */
|
|
58
|
+
function bearer(c) {
|
|
59
|
+
const header = c.req.header("authorization");
|
|
60
|
+
if (!header?.toLowerCase().startsWith("bearer "))
|
|
61
|
+
return undefined;
|
|
62
|
+
const token = header.slice(7).trim();
|
|
63
|
+
return token.length > 0 ? token : undefined;
|
|
64
|
+
}
|
|
65
|
+
function resolveScopedOwnerToken(deps, ownerToken, subjectId, sourceId) {
|
|
66
|
+
if (!ownerToken) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
failure: {
|
|
70
|
+
code: "unauthorized",
|
|
71
|
+
message: "a scoped owner token is required",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const context = deps.tokens.resolveToken(ownerToken);
|
|
76
|
+
if (!context.active || context.tokenKind !== "owner" || !context.subjectId) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
failure: {
|
|
80
|
+
code: "unauthorized",
|
|
81
|
+
message: "an active scoped owner token is required",
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (subjectId && context.subjectId !== subjectId) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
failure: {
|
|
89
|
+
code: "session_not_found",
|
|
90
|
+
message: "authorization session not found",
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (context.sourceId && sourceId && context.sourceId !== sourceId) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
failure: {
|
|
98
|
+
code: "access_denied",
|
|
99
|
+
message: "owner token is not scoped to the requested source",
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
subjectId: context.subjectId,
|
|
106
|
+
...(context.sourceId && { sourceId: context.sourceId }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function scopedOwnerCoversSnapshot(deps, subjectId, snapshot) {
|
|
110
|
+
const inventory = deps.inventoryFor(subjectId, snapshot.source_id);
|
|
111
|
+
return snapshot.streams.some((stream) => inventory.eligibleFor(stream.name).length > 0);
|
|
112
|
+
}
|
|
113
|
+
function grantContainedByOwnerScope(subjectId, stored, ownerSourceId, ownerInstanceIds) {
|
|
114
|
+
return (stored.subjectId === subjectId &&
|
|
115
|
+
(!ownerSourceId || stored.grant.source.id === ownerSourceId) &&
|
|
116
|
+
(!ownerInstanceIds ||
|
|
117
|
+
stored.grant.streams.every((stream) => stream.instance_ids.every((instance) => ownerInstanceIds.includes(instance)))));
|
|
118
|
+
}
|
|
119
|
+
function scopedOwnerFailureResponse(c, failure) {
|
|
120
|
+
return errorResponse(c, failure.code === "access_denied" ? 403 : approvalStatus(failure.code), approvalOAuthError(failure.code), failure.message);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Negotiate `PDPP-Version` (§7 version layering, §9 AS item 17).
|
|
124
|
+
*
|
|
125
|
+
* An absent header selects the current stable version. An unsupported one is
|
|
126
|
+
* a 400 `unsupported_version` — not a silent downgrade, because a client that
|
|
127
|
+
* asked for a version it needs must not be handed a different contract.
|
|
128
|
+
*/
|
|
129
|
+
function negotiateVersion(c) {
|
|
130
|
+
const requested = c.req.header("pdpp-version");
|
|
131
|
+
if (!requested)
|
|
132
|
+
return { ok: true };
|
|
133
|
+
if (requested !== PDPP_API_VERSION)
|
|
134
|
+
return { ok: false, requested };
|
|
135
|
+
return { ok: true };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The PDPP AS metadata document, as a handler both of its locations share.
|
|
139
|
+
*
|
|
140
|
+
* Exported because the two URLs it is served at live in different routers: the
|
|
141
|
+
* path-first one inside `/pdpp/v1`, and the RFC 8414 §3 one
|
|
142
|
+
* (`/.well-known/oauth-authorization-server/pdpp/v1`) at the app root, outside
|
|
143
|
+
* this prefix. One handler rather than two so the documents can never drift --
|
|
144
|
+
* a client discovering through either must find the same authority.
|
|
145
|
+
*
|
|
146
|
+
* `issuer` is accepted as a thunk for deployments that resolve their own origin
|
|
147
|
+
* late (a bound port unknown at construction). It is resolved per request so
|
|
148
|
+
* the document never advertises a stale origin.
|
|
149
|
+
*/
|
|
150
|
+
export function pdppAsMetadataHandler(issuerSource) {
|
|
151
|
+
return (c) => {
|
|
152
|
+
const issuer = typeof issuerSource === "function" ? issuerSource() : issuerSource;
|
|
153
|
+
if (!issuer) {
|
|
154
|
+
return errorResponse(c, 404, "not_found", "this deployment does not publish PDPP authorization-server metadata");
|
|
155
|
+
}
|
|
156
|
+
const base = `${issuer.replace(/\/$/, "")}/pdpp/v1`;
|
|
157
|
+
return c.json({
|
|
158
|
+
issuer,
|
|
159
|
+
authorization_endpoint: `${base}/authorize`,
|
|
160
|
+
token_endpoint: `${base}/token`,
|
|
161
|
+
introspection_endpoint: `${base}/introspect`,
|
|
162
|
+
revocation_endpoint: `${base}/revoke`,
|
|
163
|
+
authorization_details_types_supported: [
|
|
164
|
+
PDPP_DATA_ACCESS_TYPE,
|
|
165
|
+
PDPP_DATA_ACCESS_TYPE_V02,
|
|
166
|
+
],
|
|
167
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
168
|
+
response_types_supported: ["code"],
|
|
169
|
+
// PKCE is required on this flow, not merely supported: PDPP clients
|
|
170
|
+
// are public clients, so an intercepted code without a verifier is
|
|
171
|
+
// redeemable by whoever intercepted it.
|
|
172
|
+
code_challenge_methods_supported: ["S256"],
|
|
173
|
+
pdpp_api_version: PDPP_API_VERSION,
|
|
174
|
+
}, 200, NO_STORE);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export function pdppAuthRoutes(deps) {
|
|
178
|
+
const app = new Hono();
|
|
179
|
+
// The selected version echoes back on every response (§9 AS item 17).
|
|
180
|
+
app.use("*", async (c, next) => {
|
|
181
|
+
const version = negotiateVersion(c);
|
|
182
|
+
if (!version.ok) {
|
|
183
|
+
return errorResponse(c, 400, "unsupported_version", `PDPP-Version '${version.requested}' is not supported; this server implements ${PDPP_API_VERSION}`);
|
|
184
|
+
}
|
|
185
|
+
await next();
|
|
186
|
+
c.header("PDPP-Version", PDPP_API_VERSION);
|
|
187
|
+
});
|
|
188
|
+
// The owner-proof chain, when a deployment wires one. `web3-auth` verifies
|
|
189
|
+
// the Web3Signed wallet signature and populates `c.get("auth")`;
|
|
190
|
+
// `owner-check` compares the recovered signer against the configured server
|
|
191
|
+
// owner. Scoped to the token-exchange path only — the rest of the surface
|
|
192
|
+
// authenticates with the PDPP owner token that exchange produces.
|
|
193
|
+
if (deps.ownerAuth) {
|
|
194
|
+
app.use("/owner/token", createBodyLimit(DEFAULT_MAX_SIZE));
|
|
195
|
+
app.use("/owner/token", createWeb3AuthMiddleware({
|
|
196
|
+
serverOrigin: deps.ownerAuth.serverOrigin,
|
|
197
|
+
devToken: deps.ownerAuth.devToken,
|
|
198
|
+
accessToken: deps.ownerAuth.accessToken,
|
|
199
|
+
tokenStore: deps.ownerAuth.tokenStore,
|
|
200
|
+
serverOwner: deps.ownerAuth.serverOwner,
|
|
201
|
+
}));
|
|
202
|
+
app.use("/owner/token", createOwnerCheckMiddleware(deps.ownerAuth.serverOwner));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* OAuth authorization-server metadata for the PDPP AS.
|
|
206
|
+
*
|
|
207
|
+
* v0.2 §8 requires an AS supporting the revision to advertise
|
|
208
|
+
* `https://pdpp.dev/data-access/0.2` in
|
|
209
|
+
* `authorization_details_types_supported`, and requires a client to
|
|
210
|
+
* establish support before requesting the type. Without this document a
|
|
211
|
+
* conformant client must not request v0.2 at all, so the implementation
|
|
212
|
+
* would be unreachable to exactly the clients that follow the spec.
|
|
213
|
+
*
|
|
214
|
+
* Both types are advertised: both are implemented, each resolves under its
|
|
215
|
+
* own revision, and dropping v0.1 would strand existing clients.
|
|
216
|
+
*
|
|
217
|
+
* This sits under `/pdpp/v1/` rather than at the origin root because the
|
|
218
|
+
* root document already describes the MCP OAuth authorization server — a
|
|
219
|
+
* different authority over different tokens. §8 warns against a second grant
|
|
220
|
+
* authority; merging the documents would present the two as one.
|
|
221
|
+
*
|
|
222
|
+
* The SAME document is also served at the RFC 8414 §3 location
|
|
223
|
+
* (`/.well-known/oauth-authorization-server/pdpp/v1` — well-known first, the
|
|
224
|
+
* issuer's path appended), mounted by the app because it lies outside this
|
|
225
|
+
* router's prefix. Publishing only the path-first form above left the
|
|
226
|
+
* document undiscoverable to any client that follows RFC 8414, which is the
|
|
227
|
+
* only discovery rule the spec gives. The RFC location keeps the `/pdpp/v1`
|
|
228
|
+
* suffix, so it does not collide with the bare URL the MCP AS owns and the
|
|
229
|
+
* separation this comment describes is preserved.
|
|
230
|
+
*/
|
|
231
|
+
app.get("/.well-known/oauth-authorization-server", pdppAsMetadataHandler(deps.issuer));
|
|
232
|
+
/**
|
|
233
|
+
* Exchange an already-verified owner proof for a PDPP owner token.
|
|
234
|
+
*
|
|
235
|
+
* This endpoint mints no authority of its own. The caller must already have
|
|
236
|
+
* satisfied the PS's existing owner proof — the `web3-auth` middleware
|
|
237
|
+
* verifies a Web3Signed wallet signature and `owner-check` compares the
|
|
238
|
+
* recovered signer against the configured server owner. This route only
|
|
239
|
+
* converts that proof into the short-lived, grant-system credential the
|
|
240
|
+
* consent decision endpoints require.
|
|
241
|
+
*
|
|
242
|
+
* The indirection is deliberate and is the whole point of the design in
|
|
243
|
+
* `approval.ts`: a consent broker (Account/Web) never mints an owner token
|
|
244
|
+
* and never asserts owner identity. It holds a credential the PS issued to
|
|
245
|
+
* a verified wallet signer, so compromising the broker yields a token that
|
|
246
|
+
* expires, not the authority to approve anything for any subject.
|
|
247
|
+
*
|
|
248
|
+
* Mount this behind the same middleware chain as other owner routes:
|
|
249
|
+
*
|
|
250
|
+
* app.use("/pdpp/v1/owner/token", createWeb3AuthMiddleware(...));
|
|
251
|
+
* app.use("/pdpp/v1/owner/token", createOwnerCheckMiddleware(serverOwner));
|
|
252
|
+
*
|
|
253
|
+
* `ownerSubjectId` resolves the verified signer to the PDPP subject; a
|
|
254
|
+
* deployment that maps wallets to subjects differently supplies its own.
|
|
255
|
+
*/
|
|
256
|
+
app.post("/owner/token", async (c) => {
|
|
257
|
+
// With `ownerAuth` wired, the middleware above has already verified a
|
|
258
|
+
// wallet signature and confirmed the signer is the server owner, so
|
|
259
|
+
// `c.get("auth").signer` is a proven identity rather than a claim.
|
|
260
|
+
const verifiedSigner = c.get("auth")
|
|
261
|
+
?.signer;
|
|
262
|
+
const subjectId = deps.ownerSubjectId?.(c) ??
|
|
263
|
+
(deps.ownerAuth && verifiedSigner ? verifiedSigner : null) ??
|
|
264
|
+
deps.currentSubjectId(c);
|
|
265
|
+
if (!subjectId) {
|
|
266
|
+
// Reaching here means the owner middleware did not run or did not
|
|
267
|
+
// populate a verified signer. Fail closed rather than inventing a
|
|
268
|
+
// subject — an owner token for an unidentified subject is exactly the
|
|
269
|
+
// credential this design exists to prevent.
|
|
270
|
+
return errorResponse(c, 401, "unauthorized", "a verified owner proof is required to obtain a PDPP owner token");
|
|
271
|
+
}
|
|
272
|
+
let body;
|
|
273
|
+
try {
|
|
274
|
+
body = await c.req.json();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return errorResponse(c, 400, "invalid_request", "owner token exchange requires JSON body {source_id}");
|
|
278
|
+
}
|
|
279
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
280
|
+
return errorResponse(c, 400, "invalid_request", "owner token exchange requires JSON body {source_id}");
|
|
281
|
+
}
|
|
282
|
+
const tokenRequest = body;
|
|
283
|
+
const sourceId = tokenRequest.source_id;
|
|
284
|
+
if (typeof sourceId !== "string" ||
|
|
285
|
+
Object.keys(tokenRequest).some((key) => key !== "source_id" && key !== "instance_id") ||
|
|
286
|
+
(tokenRequest.instance_id !== undefined &&
|
|
287
|
+
(typeof tokenRequest.instance_id !== "string" ||
|
|
288
|
+
tokenRequest.instance_id.length === 0))) {
|
|
289
|
+
return errorResponse(c, 400, "invalid_request", "owner token exchange accepts source_id and an optional instance_id");
|
|
290
|
+
}
|
|
291
|
+
const canonicalCandidate = canonicalSourceIdCandidate(sourceId);
|
|
292
|
+
const declaration = deps.resolveDeclaration(sourceId) ??
|
|
293
|
+
(canonicalCandidate ? deps.resolveDeclaration(canonicalCandidate) : null);
|
|
294
|
+
const connectionSourceId = deps.resolveConnectionSourceId?.(sourceId) ??
|
|
295
|
+
(canonicalCandidate
|
|
296
|
+
? deps.resolveConnectionSourceId?.(canonicalCandidate)
|
|
297
|
+
: undefined);
|
|
298
|
+
const resolvedSourceId = declaration?.source_id ?? connectionSourceId;
|
|
299
|
+
if (!resolvedSourceId) {
|
|
300
|
+
return errorResponse(c, 404, "not_found", "source is not enabled on this server");
|
|
301
|
+
}
|
|
302
|
+
const issued = deps.tokens.issueOwnerToken({
|
|
303
|
+
subjectId,
|
|
304
|
+
sourceId: resolvedSourceId,
|
|
305
|
+
...(typeof tokenRequest.instance_id === "string" && {
|
|
306
|
+
instanceIds: [tokenRequest.instance_id],
|
|
307
|
+
}),
|
|
308
|
+
ttlSeconds: OWNER_TOKEN_TTL_SECONDS,
|
|
309
|
+
});
|
|
310
|
+
deps.logger.info({ subject_id: subjectId }, "PDPP owner token issued to a verified owner");
|
|
311
|
+
return c.json(issued, 200, NO_STORE);
|
|
312
|
+
});
|
|
313
|
+
/**
|
|
314
|
+
* Accept an RFC 9396 selection request and open an authorization session.
|
|
315
|
+
*
|
|
316
|
+
* Requires an authenticated owner: the session is bound to that subject at
|
|
317
|
+
* creation, which is what makes the later approval check meaningful. A
|
|
318
|
+
* session created for an unauthenticated caller would have no subject to
|
|
319
|
+
* bind an approval against.
|
|
320
|
+
*/
|
|
321
|
+
app.post("/authorize", async (c) => {
|
|
322
|
+
const subjectId = deps.currentSubjectId(c);
|
|
323
|
+
if (!subjectId) {
|
|
324
|
+
return errorResponse(c, 401, "unauthorized", "an authenticated owner session is required to start an authorization");
|
|
325
|
+
}
|
|
326
|
+
let body;
|
|
327
|
+
try {
|
|
328
|
+
body = await c.req.json();
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return errorResponse(c, 400, "invalid_request", "body must be JSON");
|
|
332
|
+
}
|
|
333
|
+
const details = body.authorization_details;
|
|
334
|
+
if (!Array.isArray(details) || details.length !== 1) {
|
|
335
|
+
// v0.1 issues one grant per authorization. A package of several details
|
|
336
|
+
// would need package-level access-mode rules (§9 AS item 20) that Core
|
|
337
|
+
// does not yet pin down, so we reject rather than guess.
|
|
338
|
+
return errorResponse(c, 400, "invalid_authorization_details", "exactly one authorization_details entry is supported");
|
|
339
|
+
}
|
|
340
|
+
if (!body.client_id || !body.redirect_uri) {
|
|
341
|
+
return errorResponse(c, 400, "invalid_request", "client_id and redirect_uri are required");
|
|
342
|
+
}
|
|
343
|
+
// The authorization code travels in this redirect, so an unvalidated
|
|
344
|
+
// target is code exfiltration, not just an open redirect. PKCE does not
|
|
345
|
+
// help: an attacker who chose the redirect also chose the challenge and
|
|
346
|
+
// holds the verifier. RFC 6749 §4.1.2.1 forbids reporting this failure BY
|
|
347
|
+
// redirecting, so it is returned directly to the caller.
|
|
348
|
+
// Local registration has highest precedence and costs no network call.
|
|
349
|
+
// Only when a client is NOT registered does §6 require us to try its
|
|
350
|
+
// URL-hosted identity: rejecting solely for absence of preregistration is
|
|
351
|
+
// the one reason the spec names as insufficient.
|
|
352
|
+
let redirectPolicy = deps.registeredClient?.(body.client_id) ?? null;
|
|
353
|
+
// The document that earned redirect admission, carried forward so the
|
|
354
|
+
// consent surface shows the identity the verified domain asserted rather
|
|
355
|
+
// than whatever the client put in `client_display`. §6 ranks validated
|
|
356
|
+
// binding metadata above inline metadata; admitting on the document and
|
|
357
|
+
// then displaying the inline name would break that.
|
|
358
|
+
let validatedClientDocument;
|
|
359
|
+
if (!redirectPolicy && deps.resolveClientIdentity) {
|
|
360
|
+
const resolved = await deps.resolveClientIdentity(body.client_id);
|
|
361
|
+
if (resolved.ok) {
|
|
362
|
+
redirectPolicy = resolved.policy;
|
|
363
|
+
validatedClientDocument = resolved.document;
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
// The refusal names the actual reason -- untrusted URL, unreachable,
|
|
367
|
+
// mismatched document -- rather than "not registered", so an operator
|
|
368
|
+
// can tell a policy denial from a broken client.
|
|
369
|
+
deps.logger.warn({ client_id: body.client_id, reason: resolved.failure.code }, "PDPP authorization refused: URL-hosted client identity rejected");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const redirectFailure = validateRedirectUri(body.redirect_uri, redirectPolicy);
|
|
373
|
+
if (redirectFailure) {
|
|
374
|
+
deps.logger.warn({
|
|
375
|
+
client_id: body.client_id,
|
|
376
|
+
redirect_uri: body.redirect_uri,
|
|
377
|
+
reason: redirectFailure.code,
|
|
378
|
+
}, "PDPP authorization refused: redirect_uri failed validation");
|
|
379
|
+
return errorResponse(c, 400, "invalid_request", redirectFailure.message);
|
|
380
|
+
}
|
|
381
|
+
// PKCE is validated before consent, not at redemption: a client whose flow
|
|
382
|
+
// is unusable should learn that before a human is asked to decide anything.
|
|
383
|
+
const pkceFailure = validateCodeChallenge(body.code_challenge, body.code_challenge_method, { required: deps.requirePkce ?? true });
|
|
384
|
+
if (pkceFailure) {
|
|
385
|
+
return errorResponse(c, 400, "invalid_request", pkceFailure.message);
|
|
386
|
+
}
|
|
387
|
+
const request = details[0];
|
|
388
|
+
const snapshot = deps.resolveDeclaration(request.source?.id ?? "");
|
|
389
|
+
if (!snapshot) {
|
|
390
|
+
return errorResponse(c, 400, "invalid_authorization_details", "no retained declaration snapshot for the requested source");
|
|
391
|
+
}
|
|
392
|
+
const owner = resolveScopedOwnerToken(deps, bearer(c), subjectId, snapshot.source_id);
|
|
393
|
+
if (!owner.ok) {
|
|
394
|
+
return scopedOwnerFailureResponse(c, owner.failure);
|
|
395
|
+
}
|
|
396
|
+
if (!scopedOwnerCoversSnapshot(deps, subjectId, snapshot)) {
|
|
397
|
+
return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
|
|
398
|
+
}
|
|
399
|
+
const validation = validateSelectionRequest(request, snapshot);
|
|
400
|
+
if (!validation.ok) {
|
|
401
|
+
// §9 AS item 5: the binding maps a Source validation failure to RFC 9396
|
|
402
|
+
// `invalid_authorization_details`. Other shape failures map to
|
|
403
|
+
// `invalid_request`.
|
|
404
|
+
//
|
|
405
|
+
// v0.2 names the same code for a malformed minimum ("Malformed or
|
|
406
|
+
// unsupported authorization details instead produce
|
|
407
|
+
// `invalid_authorization_details`"), and `invalid_minimum` reaches it
|
|
408
|
+
// through this default. That is deliberately the *shape* half of the
|
|
409
|
+
// v0.2 error split: an unsatisfiable-but-well-formed minimum is an
|
|
410
|
+
// owner-decision outcome and surfaces at approval as `access_denied`,
|
|
411
|
+
// not here.
|
|
412
|
+
const oauthError = validation.failure.code === "invalid_request"
|
|
413
|
+
? "invalid_request"
|
|
414
|
+
: "invalid_authorization_details";
|
|
415
|
+
return errorResponse(c, 400, oauthError, validation.failure.message);
|
|
416
|
+
}
|
|
417
|
+
const session = deps.sessions.create({
|
|
418
|
+
subjectId,
|
|
419
|
+
request,
|
|
420
|
+
snapshot,
|
|
421
|
+
requester: resolveRequesterIdentity({
|
|
422
|
+
client_id: body.client_id,
|
|
423
|
+
document: validatedClientDocument,
|
|
424
|
+
inline: body.client_display,
|
|
425
|
+
}),
|
|
426
|
+
redirectUri: body.redirect_uri,
|
|
427
|
+
stateParam: body.state,
|
|
428
|
+
codeChallenge: body.code_challenge,
|
|
429
|
+
codeChallengeMethod: body.code_challenge_method,
|
|
430
|
+
grantExpiresAt: deps.grantExpiryFor?.(request, body.client_id),
|
|
431
|
+
});
|
|
432
|
+
deps.logger.info({ session_id: session.session_id, client_id: body.client_id }, "PDPP authorization session opened");
|
|
433
|
+
return c.json({ session_id: session.session_id, expires_at: session.expires_at }, 201, NO_STORE);
|
|
434
|
+
});
|
|
435
|
+
/**
|
|
436
|
+
* The consent review model, for the authenticated owner only.
|
|
437
|
+
*
|
|
438
|
+
* When a stream has several eligible instances and the request named none,
|
|
439
|
+
* the response carries `instance_choice_required` instead of a review: the
|
|
440
|
+
* owner picks, then re-fetches with `?instance[<stream>]=<handle>` repeated
|
|
441
|
+
* per handle. §6 forbids inferring fan-in from omission, so this is a
|
|
442
|
+
* consent step rather than a failure.
|
|
443
|
+
*/
|
|
444
|
+
app.get("/authorize/:session_id/review", (c) => {
|
|
445
|
+
const sessionId = c.req.param("session_id");
|
|
446
|
+
const session = deps.sessions.get(sessionId);
|
|
447
|
+
const owner = resolveScopedOwnerToken(deps, bearer(c), session?.subject_id, session?.snapshot.source_id);
|
|
448
|
+
if (!owner.ok) {
|
|
449
|
+
return scopedOwnerFailureResponse(c, owner.failure);
|
|
450
|
+
}
|
|
451
|
+
if (session &&
|
|
452
|
+
!scopedOwnerCoversSnapshot(deps, session.subject_id, session.snapshot)) {
|
|
453
|
+
return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
|
|
454
|
+
}
|
|
455
|
+
const result = fetchReview({
|
|
456
|
+
sessions: deps.sessions,
|
|
457
|
+
tokens: deps.tokens,
|
|
458
|
+
sessionId,
|
|
459
|
+
ownerToken: bearer(c),
|
|
460
|
+
inventory: deps.inventoryFor(session?.subject_id ?? "", session?.snapshot.source_id ?? ""),
|
|
461
|
+
instanceChoices: parseInstanceChoices(c),
|
|
462
|
+
ownerChoices: parseOwnerChoices(c),
|
|
463
|
+
ownerConditions: parseOwnerConditions(c),
|
|
464
|
+
standingTerms: deps.standingTermsFor?.(session?.requester.client_id ?? "") ??
|
|
465
|
+
undefined,
|
|
466
|
+
store: deps.store,
|
|
467
|
+
existingGrantInstanceIds: session
|
|
468
|
+
? Array.from(new Set(session.snapshot.streams.flatMap((stream) => deps
|
|
469
|
+
.inventoryFor(session.subject_id, session.snapshot.source_id)
|
|
470
|
+
.eligibleFor(stream.name))))
|
|
471
|
+
: [],
|
|
472
|
+
});
|
|
473
|
+
if (!result.ok) {
|
|
474
|
+
return errorResponse(c, approvalStatus(result.failure.code), approvalOAuthError(result.failure.code), result.failure.message);
|
|
475
|
+
}
|
|
476
|
+
return c.json(result.result, 200, NO_STORE);
|
|
477
|
+
});
|
|
478
|
+
/**
|
|
479
|
+
* Approve. Requires an authenticated owner token AND the review digest the
|
|
480
|
+
* owner was shown — see `approval.ts` for why affiliation is not enough.
|
|
481
|
+
*/
|
|
482
|
+
app.post("/authorize/:session_id/approve", async (c) => {
|
|
483
|
+
const sessionId = c.req.param("session_id");
|
|
484
|
+
const session = deps.sessions.get(sessionId);
|
|
485
|
+
const owner = resolveScopedOwnerToken(deps, bearer(c), session?.subject_id, session?.snapshot.source_id);
|
|
486
|
+
if (!owner.ok) {
|
|
487
|
+
return scopedOwnerFailureResponse(c, owner.failure);
|
|
488
|
+
}
|
|
489
|
+
if (session &&
|
|
490
|
+
!scopedOwnerCoversSnapshot(deps, session.subject_id, session.snapshot)) {
|
|
491
|
+
return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
|
|
492
|
+
}
|
|
493
|
+
let body;
|
|
494
|
+
try {
|
|
495
|
+
body = await c.req.json();
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return errorResponse(c, 400, "invalid_request", "body must be JSON");
|
|
499
|
+
}
|
|
500
|
+
const result = approveAuthorization({
|
|
501
|
+
sessions: deps.sessions,
|
|
502
|
+
tokens: deps.tokens,
|
|
503
|
+
sessionId,
|
|
504
|
+
ownerToken: bearer(c),
|
|
505
|
+
reviewDigest: body.review_digest ?? "",
|
|
506
|
+
inventory: deps.inventoryFor(session?.subject_id ?? "", session?.snapshot.source_id ?? ""),
|
|
507
|
+
instanceChoices: body.instance_choices,
|
|
508
|
+
ownerChoices: body.owner_choices,
|
|
509
|
+
ownerConditions: body.owner_conditions,
|
|
510
|
+
standingTerms: deps.standingTermsFor?.(session?.requester.client_id ?? "") ??
|
|
511
|
+
undefined,
|
|
512
|
+
explicitAiTrainingConsent: body.explicit_ai_training_consent,
|
|
513
|
+
});
|
|
514
|
+
if (!result.ok) {
|
|
515
|
+
return errorResponse(c, approvalStatus(result.failure.code), approvalOAuthError(result.failure.code), result.failure.message);
|
|
516
|
+
}
|
|
517
|
+
// Persist the grant and its authorization code as one durable unit
|
|
518
|
+
// before marking this session complete. Only
|
|
519
|
+
// once this succeeds does the session move to "approved" — if the write
|
|
520
|
+
// fails, the session stays "pending" so the same reviewed approval can
|
|
521
|
+
// be retried rather than being stranded as already-decided with nothing
|
|
522
|
+
// durable behind it.
|
|
523
|
+
const code = `pdpp_code_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
524
|
+
deps.store.insertGrantWithAuthCode({
|
|
525
|
+
grant: result.grant,
|
|
526
|
+
subjectId: result.grant.subject.id,
|
|
527
|
+
reviewDigest: result.consentEvidence.review_digest,
|
|
528
|
+
consentEvidence: result.consentEvidence,
|
|
529
|
+
code,
|
|
530
|
+
authCode: {
|
|
531
|
+
grantId: result.grant.grant_id,
|
|
532
|
+
clientId: result.grant.client.client_id,
|
|
533
|
+
redirectUri: session.redirect_uri,
|
|
534
|
+
// Carried from the authorization request, so redemption can prove the
|
|
535
|
+
// redeemer is the client that asked (RFC 7636 §4.4).
|
|
536
|
+
codeChallenge: session.code_challenge ?? null,
|
|
537
|
+
codeChallengeMethod: session.code_challenge_method ?? null,
|
|
538
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
deps.sessions.setStatus(session.session_id, "approved");
|
|
542
|
+
const redirect = new URL(session.redirect_uri);
|
|
543
|
+
redirect.searchParams.set("code", code);
|
|
544
|
+
if (session.state_param) {
|
|
545
|
+
redirect.searchParams.set("state", session.state_param);
|
|
546
|
+
}
|
|
547
|
+
deps.logger.info({ grant_id: result.grant.grant_id }, "PDPP grant issued after authenticated owner approval");
|
|
548
|
+
return c.json({ redirect_uri: redirect.toString(), grant_id: result.grant.grant_id }, 200, NO_STORE);
|
|
549
|
+
});
|
|
550
|
+
/** Deny. Terminal; no grant and no consent evidence beyond the denial. */
|
|
551
|
+
app.post("/authorize/:session_id/deny", async (c) => {
|
|
552
|
+
const sessionId = c.req.param("session_id");
|
|
553
|
+
const session = deps.sessions.get(sessionId);
|
|
554
|
+
const owner = resolveScopedOwnerToken(deps, bearer(c), session?.subject_id, session?.snapshot.source_id);
|
|
555
|
+
if (!owner.ok) {
|
|
556
|
+
return scopedOwnerFailureResponse(c, owner.failure);
|
|
557
|
+
}
|
|
558
|
+
if (session &&
|
|
559
|
+
!scopedOwnerCoversSnapshot(deps, session.subject_id, session.snapshot)) {
|
|
560
|
+
return errorResponse(c, 403, "access_denied", "owner token is not scoped to the requested source");
|
|
561
|
+
}
|
|
562
|
+
// The owner's optional note. Read defensively: a denial must succeed even
|
|
563
|
+
// when the body is absent or malformed, because refusing is the safe
|
|
564
|
+
// outcome and must never be blocked by the shape of an optional field.
|
|
565
|
+
let reason;
|
|
566
|
+
try {
|
|
567
|
+
const body = (await c.req.json());
|
|
568
|
+
if (typeof body?.reason === "string")
|
|
569
|
+
reason = body.reason;
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
reason = undefined;
|
|
573
|
+
}
|
|
574
|
+
const result = denyAuthorization({
|
|
575
|
+
sessions: deps.sessions,
|
|
576
|
+
tokens: deps.tokens,
|
|
577
|
+
sessionId,
|
|
578
|
+
ownerToken: bearer(c),
|
|
579
|
+
...(reason !== undefined && { reason }),
|
|
580
|
+
});
|
|
581
|
+
if (!result.ok) {
|
|
582
|
+
return errorResponse(c, approvalStatus(result.failure.code), approvalOAuthError(result.failure.code), result.failure.message);
|
|
583
|
+
}
|
|
584
|
+
const redirect = new URL(session.redirect_uri);
|
|
585
|
+
redirect.searchParams.set("error", "access_denied");
|
|
586
|
+
if (session.state_param) {
|
|
587
|
+
redirect.searchParams.set("state", session.state_param);
|
|
588
|
+
}
|
|
589
|
+
return c.json({ redirect_uri: redirect.toString() }, 200, NO_STORE);
|
|
590
|
+
});
|
|
591
|
+
/**
|
|
592
|
+
* Token endpoint: authorization-code redemption and refresh rotation.
|
|
593
|
+
*
|
|
594
|
+
* Form-encoded per RFC 6749 §4.1.3 / §6, so a standard OAuth client library
|
|
595
|
+
* works against it unmodified.
|
|
596
|
+
*/
|
|
597
|
+
app.post("/token", async (c) => {
|
|
598
|
+
const contentType = c.req.header("content-type") ?? "";
|
|
599
|
+
if (!contentType.includes("application/x-www-form-urlencoded")) {
|
|
600
|
+
return errorResponse(c, 400, "invalid_request", "Content-Type must be application/x-www-form-urlencoded");
|
|
601
|
+
}
|
|
602
|
+
const body = await c.req.parseBody();
|
|
603
|
+
const grantType = asString(body.grant_type);
|
|
604
|
+
if (grantType === "authorization_code") {
|
|
605
|
+
const code = asString(body.code);
|
|
606
|
+
const clientId = asString(body.client_id);
|
|
607
|
+
const redirectUri = asString(body.redirect_uri);
|
|
608
|
+
if (!code || !clientId || !redirectUri) {
|
|
609
|
+
return errorResponse(c, 400, "invalid_request", "code, client_id, and redirect_uri are required");
|
|
610
|
+
}
|
|
611
|
+
const result = deps.tokens.redeemAuthorizationCode({
|
|
612
|
+
code,
|
|
613
|
+
clientId,
|
|
614
|
+
redirectUri,
|
|
615
|
+
codeVerifier: asString(body.code_verifier) ?? undefined,
|
|
616
|
+
});
|
|
617
|
+
if (!result.ok) {
|
|
618
|
+
return errorResponse(c, 400, result.failure.code, result.failure.message);
|
|
619
|
+
}
|
|
620
|
+
return c.json(result.issued, 200, NO_STORE);
|
|
621
|
+
}
|
|
622
|
+
if (grantType === "refresh_token") {
|
|
623
|
+
const refreshToken = asString(body.refresh_token);
|
|
624
|
+
if (!refreshToken) {
|
|
625
|
+
return errorResponse(c, 400, "invalid_request", "refresh_token is required");
|
|
626
|
+
}
|
|
627
|
+
const result = deps.tokens.refresh({ refreshToken });
|
|
628
|
+
if (!result.ok) {
|
|
629
|
+
return errorResponse(c, 400, result.failure.code, result.failure.message);
|
|
630
|
+
}
|
|
631
|
+
return c.json(result.issued, 200, NO_STORE);
|
|
632
|
+
}
|
|
633
|
+
return errorResponse(c, 400, "unsupported_grant_type", `grant_type '${grantType ?? ""}' is not supported by the PDPP token endpoint`);
|
|
634
|
+
});
|
|
635
|
+
/**
|
|
636
|
+
* RFC 7662 introspection.
|
|
637
|
+
*
|
|
638
|
+
* **The co-located deployment is the supported baseline**, and there the RS
|
|
639
|
+
* calls `resolveToken` directly — this endpoint is not on that path.
|
|
640
|
+
*
|
|
641
|
+
* **Known gap for the separated deployment (§9 AS item 18).** The only
|
|
642
|
+
* credential accepted here is a PDPP *owner* token: 15-minute TTL, mintable
|
|
643
|
+
* only from a wallet owner-proof. A standalone Resource Server cannot hold
|
|
644
|
+
* one, so the deployment topology this endpoint exists to serve cannot
|
|
645
|
+
* actually authenticate to it. Closing that needs a distinct RS client
|
|
646
|
+
* identity (client credentials, mTLS, or a registered RS principal) that is
|
|
647
|
+
* not owner scope, which is a deployment-model decision this build does not
|
|
648
|
+
* make.
|
|
649
|
+
*
|
|
650
|
+
* Stated plainly rather than papered over: this AS does **not** claim
|
|
651
|
+
* conformance for separated AS/RS introspection. It is conformant for the
|
|
652
|
+
* co-located equivalent §8 explicitly permits ("A co-located AS and RS MAY
|
|
653
|
+
* resolve the same context through a local equivalent").
|
|
654
|
+
*/
|
|
655
|
+
app.post("/introspect", async (c) => {
|
|
656
|
+
const callerToken = bearer(c);
|
|
657
|
+
if (!callerToken) {
|
|
658
|
+
return errorResponse(c, 401, "invalid_client", "the resource server must authenticate to introspect");
|
|
659
|
+
}
|
|
660
|
+
const caller = deps.tokens.resolveToken(callerToken);
|
|
661
|
+
if (!caller.active || caller.tokenKind !== "owner") {
|
|
662
|
+
return errorResponse(c, 401, "invalid_client", "introspection requires an authenticated resource server");
|
|
663
|
+
}
|
|
664
|
+
if (!caller.subjectId) {
|
|
665
|
+
return errorResponse(c, 403, "access_denied", "introspection requires an owner token");
|
|
666
|
+
}
|
|
667
|
+
const body = await c.req.parseBody();
|
|
668
|
+
const token = asString(body.token);
|
|
669
|
+
if (!token) {
|
|
670
|
+
// RFC 7662: a missing token is a request error, not an inactive answer.
|
|
671
|
+
return errorResponse(c, 400, "invalid_request", "token is required");
|
|
672
|
+
}
|
|
673
|
+
const target = deps.tokens.resolveToken(token);
|
|
674
|
+
if (target.active) {
|
|
675
|
+
if (target.grant) {
|
|
676
|
+
const stored = deps.store.getGrant(target.grant.grant_id);
|
|
677
|
+
if (!stored ||
|
|
678
|
+
stored.subjectId !== caller.subjectId ||
|
|
679
|
+
!grantContainedByOwnerScope(caller.subjectId, stored, caller.sourceId, caller.instanceIds)) {
|
|
680
|
+
return c.json({ active: false }, 200, NO_STORE);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else if (target.tokenKind !== "owner" ||
|
|
684
|
+
target.subjectId !== caller.subjectId ||
|
|
685
|
+
(caller.sourceId
|
|
686
|
+
? target.sourceId !== caller.sourceId
|
|
687
|
+
: !caller.instanceIds ||
|
|
688
|
+
!target.instanceIds ||
|
|
689
|
+
!target.instanceIds.some((id) => caller.instanceIds?.includes(id)))) {
|
|
690
|
+
return c.json({ active: false }, 200, NO_STORE);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return c.json(deps.tokens.introspect(token), 200, NO_STORE);
|
|
694
|
+
});
|
|
695
|
+
/**
|
|
696
|
+
* List the owner's PDPP grants, newest first, with each one's status.
|
|
697
|
+
*
|
|
698
|
+
* Sits at `/grants` rather than under `/owner` because `/owner/*` is the
|
|
699
|
+
* wallet-proof-guarded path that mints owner tokens, while this route
|
|
700
|
+
* authenticates the same way `/revoke` does — with the owner token that
|
|
701
|
+
* exchange produced. It is the read half of the same owner grant-lifecycle
|
|
702
|
+
* pair, so it belongs beside its writer, not beside the credential mint.
|
|
703
|
+
*
|
|
704
|
+
* The projection is exactly the review's `existing_grants` shape plus
|
|
705
|
+
* `status` and `client_id`, so a surface that renders one can render the
|
|
706
|
+
* other. What it deliberately omits is everything that is authority or
|
|
707
|
+
* evidence rather than description: tokens, authorization codes, consent
|
|
708
|
+
* evidence and review digests. A grant list is for showing an owner what
|
|
709
|
+
* they have agreed to; none of those fields serve that, and each would turn
|
|
710
|
+
* a read-only listing into a credential leak.
|
|
711
|
+
*
|
|
712
|
+
* `status` widens `grantStatus` by one value the store models as a separate
|
|
713
|
+
* column: a `single_use` grant that has been redeemed reads `consumed`, not
|
|
714
|
+
* `active`, because it can never issue another token.
|
|
715
|
+
*/
|
|
716
|
+
app.get("/grants", (c) => {
|
|
717
|
+
const caller = resolveOwner(c);
|
|
718
|
+
if (!caller.ok)
|
|
719
|
+
return caller.response;
|
|
720
|
+
const clientFilter = c.req.query("client_id");
|
|
721
|
+
const now = new Date();
|
|
722
|
+
const grants = deps.store
|
|
723
|
+
.listGrantsForSubject(caller.subjectId)
|
|
724
|
+
.filter((stored) => !clientFilter || stored.clientId === clientFilter)
|
|
725
|
+
.filter((stored) => grantContainedByOwnerScope(caller.subjectId, stored, caller.sourceId, caller.instanceIds))
|
|
726
|
+
.map((stored) => ({
|
|
727
|
+
grant_id: stored.grant.grant_id,
|
|
728
|
+
client_id: stored.clientId,
|
|
729
|
+
status: listedGrantStatus(deps.store, stored, now),
|
|
730
|
+
issued_at: stored.grant.issued_at,
|
|
731
|
+
...(stored.grant.expires_at && { expires_at: stored.grant.expires_at }),
|
|
732
|
+
access_mode: stored.grant.access_mode,
|
|
733
|
+
purpose_code: stored.grant.purpose_code,
|
|
734
|
+
streams: stored.grant.streams.map((s) => ({
|
|
735
|
+
name: s.name,
|
|
736
|
+
fields: s.fields,
|
|
737
|
+
})),
|
|
738
|
+
}));
|
|
739
|
+
return c.json({ grants }, 200, NO_STORE);
|
|
740
|
+
});
|
|
741
|
+
/**
|
|
742
|
+
* Revoke a grant. Owner-authenticated, and scoped to the owner's own grants
|
|
743
|
+
* — a valid owner token is not authority over another subject's grant.
|
|
744
|
+
*/
|
|
745
|
+
app.post("/revoke", async (c) => {
|
|
746
|
+
const caller = resolveOwner(c);
|
|
747
|
+
if (!caller.ok)
|
|
748
|
+
return caller.response;
|
|
749
|
+
const body = await c.req.parseBody();
|
|
750
|
+
const grantId = asString(body.grant_id);
|
|
751
|
+
if (!grantId) {
|
|
752
|
+
return errorResponse(c, 400, "invalid_request", "grant_id is required");
|
|
753
|
+
}
|
|
754
|
+
const stored = deps.store.getGrant(grantId);
|
|
755
|
+
// Not-found for another owner's grant as well, so a caller cannot probe
|
|
756
|
+
// which grant ids exist outside their own subject.
|
|
757
|
+
if (!stored ||
|
|
758
|
+
stored.subjectId !== caller.subjectId ||
|
|
759
|
+
!grantContainedByOwnerScope(caller.subjectId, stored, caller.sourceId, caller.instanceIds)) {
|
|
760
|
+
return errorResponse(c, 404, "not_found", "grant not found");
|
|
761
|
+
}
|
|
762
|
+
const revoked = deps.tokens.revokeGrant(grantId);
|
|
763
|
+
deps.logger.info({ grant_id: grantId, revoked }, "PDPP grant revocation");
|
|
764
|
+
// Idempotent: a second revoke is still "it is revoked".
|
|
765
|
+
return c.json({ grant_id: grantId, status: "revoked" }, 200, NO_STORE);
|
|
766
|
+
});
|
|
767
|
+
/**
|
|
768
|
+
* The single owner-token check the owner grant routes share.
|
|
769
|
+
*
|
|
770
|
+
* A client token is refused with the same 401 as no token at all: from the
|
|
771
|
+
* caller's side, holding the wrong kind of credential and holding none are
|
|
772
|
+
* the same answer, and distinguishing them would tell a client token holder
|
|
773
|
+
* that owner scope exists to be reached for.
|
|
774
|
+
*/
|
|
775
|
+
function resolveOwner(c) {
|
|
776
|
+
const ownerToken = bearer(c);
|
|
777
|
+
if (!ownerToken) {
|
|
778
|
+
return {
|
|
779
|
+
ok: false,
|
|
780
|
+
response: errorResponse(c, 401, "unauthorized", "an owner token is required"),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
const caller = deps.tokens.resolveToken(ownerToken);
|
|
784
|
+
if (!caller.active || caller.tokenKind !== "owner" || !caller.subjectId) {
|
|
785
|
+
return {
|
|
786
|
+
ok: false,
|
|
787
|
+
response: errorResponse(c, 401, "unauthorized", "an active owner token is required"),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
ok: true,
|
|
792
|
+
subjectId: caller.subjectId,
|
|
793
|
+
...(caller.sourceId && { sourceId: caller.sourceId }),
|
|
794
|
+
...(caller.instanceIds && { instanceIds: caller.instanceIds }),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
return app;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The lifecycle value an owner should see for one grant.
|
|
801
|
+
*
|
|
802
|
+
* `grantStatus` answers active/expired/revoked. A redeemed `single_use` grant
|
|
803
|
+
* is none of those in the store's terms — it has not expired and nobody
|
|
804
|
+
* revoked it — yet it can never issue another token, so reporting it as
|
|
805
|
+
* "active" would tell the owner they still have live access they do not have.
|
|
806
|
+
*/
|
|
807
|
+
function listedGrantStatus(store, stored, now) {
|
|
808
|
+
const status = store.grantStatus(stored, now);
|
|
809
|
+
if (status === "active" &&
|
|
810
|
+
stored.grant.access_mode === "single_use" &&
|
|
811
|
+
stored.consumedAt) {
|
|
812
|
+
return "consumed";
|
|
813
|
+
}
|
|
814
|
+
return status;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* The wire error for one Core approval-failure code.
|
|
818
|
+
*
|
|
819
|
+
* Core keeps `selection_refused` distinct so the two refusal shapes can carry
|
|
820
|
+
* different statuses and different UI handling, but PR #1 names
|
|
821
|
+
* `access_denied` as the OAuth error a client sees for a refused selection.
|
|
822
|
+
* This is the one place those two vocabularies meet, so a client reads
|
|
823
|
+
* standard OAuth while Core keeps its precision.
|
|
824
|
+
*/
|
|
825
|
+
function approvalOAuthError(code) {
|
|
826
|
+
if (code === "selection_refused")
|
|
827
|
+
return "access_denied";
|
|
828
|
+
// v0.2: an unsupported recipient term is reported as a structured
|
|
829
|
+
// authorization failure. `invalid_authorization_details` is the RFC 9396
|
|
830
|
+
// code for authorization details the AS will not honour, which is what an
|
|
831
|
+
// uncovered term makes them.
|
|
832
|
+
if (code === "recipient_terms_unsupported") {
|
|
833
|
+
return "invalid_authorization_details";
|
|
834
|
+
}
|
|
835
|
+
return code;
|
|
836
|
+
}
|
|
837
|
+
function approvalStatus(code) {
|
|
838
|
+
switch (code) {
|
|
839
|
+
case "unauthorized":
|
|
840
|
+
return 401;
|
|
841
|
+
case "session_not_found":
|
|
842
|
+
return 404;
|
|
843
|
+
case "stale_review":
|
|
844
|
+
return 409;
|
|
845
|
+
case "recipient_terms_unsupported":
|
|
846
|
+
// 409: the request and the owner's terms conflict, and neither side can
|
|
847
|
+
// fix it alone -- the recipient has to accept the term. Not 403, which
|
|
848
|
+
// would read as "the owner said no".
|
|
849
|
+
return 409;
|
|
850
|
+
case "selection_refused":
|
|
851
|
+
// A refusal, not a malformed request: the client asked for something
|
|
852
|
+
// well-formed and the owner's decision cannot satisfy it. 403 rather
|
|
853
|
+
// than 400 so a consent UI can tell "fix your request" from "this
|
|
854
|
+
// combination cannot be approved". `access_denied` keeps its existing
|
|
855
|
+
// 400 for an already-decided session.
|
|
856
|
+
return 403;
|
|
857
|
+
default:
|
|
858
|
+
return 400;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
function asString(value) {
|
|
862
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Read owner instance picks from repeated `instance[<stream>]=<handle>` query
|
|
866
|
+
* parameters, so a choice survives a plain GET re-fetch without the UI having
|
|
867
|
+
* to hold server state.
|
|
868
|
+
*/
|
|
869
|
+
function parseInstanceChoices(c) {
|
|
870
|
+
const choices = {};
|
|
871
|
+
const url = new URL(c.req.url);
|
|
872
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
873
|
+
const match = /^instance\[(.+)\]$/.exec(key);
|
|
874
|
+
if (!match || value.length === 0)
|
|
875
|
+
continue;
|
|
876
|
+
(choices[match[1]] ??= []).push(value);
|
|
877
|
+
}
|
|
878
|
+
return Object.keys(choices).length > 0 ? choices : undefined;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Read the owner's v0.2 narrowing from the review URL, so a re-fetch shows the
|
|
882
|
+
* current proposal without the UI having to hold server state — the same
|
|
883
|
+
* property `instance[...]` already has.
|
|
884
|
+
*
|
|
885
|
+
* decline[<stream>] — remove an optional stream (value ignored)
|
|
886
|
+
* field[<stream>]=<name> — repeated; the narrowed field list
|
|
887
|
+
* since[<stream>]=<instant> — narrowed window lower bound
|
|
888
|
+
* until[<stream>]=<instant> — narrowed window upper bound
|
|
889
|
+
*
|
|
890
|
+
* A repeated `field[...]` with no values cannot be expressed in a query
|
|
891
|
+
* string, which is why "share nothing from this stream" is `decline[...]`
|
|
892
|
+
* rather than an empty field list. The two are different decisions and the
|
|
893
|
+
* wire keeps them different: declining an optional stream succeeds, while
|
|
894
|
+
* emptying a stream's fields is a refusal.
|
|
895
|
+
*/
|
|
896
|
+
/**
|
|
897
|
+
* Read the owner's v0.2 conditions from the review URL.
|
|
898
|
+
*
|
|
899
|
+
* retention_max_duration / retention_on_expiry — proposed retention term
|
|
900
|
+
* condition_purpose_code — proposed purpose code
|
|
901
|
+
*
|
|
902
|
+
* Both retention members are required together: a duration with no expiry
|
|
903
|
+
* action, or the reverse, is half a term, and half a term cannot be matched
|
|
904
|
+
* against what the recipient accepted.
|
|
905
|
+
*/
|
|
906
|
+
function parseOwnerConditions(c) {
|
|
907
|
+
const url = new URL(c.req.url);
|
|
908
|
+
const maxDuration = url.searchParams.get("retention_max_duration");
|
|
909
|
+
const onExpiry = url.searchParams.get("retention_on_expiry");
|
|
910
|
+
const purposeCode = url.searchParams.get("condition_purpose_code");
|
|
911
|
+
const conditions = {
|
|
912
|
+
...(maxDuration &&
|
|
913
|
+
(onExpiry === "delete" || onExpiry === "anonymize") && {
|
|
914
|
+
retention: { max_duration: maxDuration, on_expiry: onExpiry },
|
|
915
|
+
}),
|
|
916
|
+
...(purposeCode && { purpose_code: purposeCode }),
|
|
917
|
+
};
|
|
918
|
+
return Object.keys(conditions).length > 0 ? conditions : undefined;
|
|
919
|
+
}
|
|
920
|
+
function parseOwnerChoices(c) {
|
|
921
|
+
const declined = [];
|
|
922
|
+
const fields = {};
|
|
923
|
+
const windows = {};
|
|
924
|
+
const url = new URL(c.req.url);
|
|
925
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
926
|
+
const decline = /^decline\[(.+)\]$/.exec(key);
|
|
927
|
+
if (decline) {
|
|
928
|
+
declined.push(decline[1]);
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const field = /^field\[(.+)\]$/.exec(key);
|
|
932
|
+
if (field && value.length > 0) {
|
|
933
|
+
(fields[field[1]] ??= []).push(value);
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
const since = /^since\[(.+)\]$/.exec(key);
|
|
937
|
+
if (since && value.length > 0) {
|
|
938
|
+
(windows[since[1]] ??= {}).since = value;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
const until = /^until\[(.+)\]$/.exec(key);
|
|
942
|
+
if (until && value.length > 0) {
|
|
943
|
+
(windows[until[1]] ??= {}).until = value;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
const choices = {
|
|
947
|
+
...(declined.length > 0 && { declined_streams: declined }),
|
|
948
|
+
...(Object.keys(fields).length > 0 && { fields }),
|
|
949
|
+
...(Object.keys(windows).length > 0 && { time_ranges: windows }),
|
|
950
|
+
};
|
|
951
|
+
return Object.keys(choices).length > 0 ? choices : undefined;
|
|
952
|
+
}
|
|
953
|
+
//# sourceMappingURL=pdpp-auth.js.map
|