@reactionary/ucp 0.10.33 → 0.10.34
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 +144 -0
- package/index.js +1 -0
- package/lib/reactionary-ucp-identity-state.js +152 -0
- package/lib/reactionary-ucp-identity.js +466 -0
- package/lib/reactionary-ucp-profile.js +15 -2
- package/lib/reactionary-ucp-server.js +39 -8
- package/package.json +2 -2
- package/src/index.d.ts +1 -0
- package/src/lib/reactionary-ucp-common.d.ts +2 -0
- package/src/lib/reactionary-ucp-identity-state.d.ts +136 -0
- package/src/lib/reactionary-ucp-identity.d.ts +73 -0
- package/src/lib/reactionary-ucp-profile.d.ts +1 -1
- package/src/lib/reactionary-ucp-server.d.ts +3 -0
package/README.md
CHANGED
|
@@ -145,6 +145,150 @@ REST requests may include:
|
|
|
145
145
|
- `Request-Id`: echoed in the response header.
|
|
146
146
|
- `Idempotency-Key`: supported for mutating REST endpoints and stored in the UCP session cache. Reusing a key with a different mutation payload returns `409`.
|
|
147
147
|
|
|
148
|
+
## Identity linking (OAuth 2.0)
|
|
149
|
+
|
|
150
|
+
Setting the optional `identity` server option turns the UCP server into a small OAuth 2.0 authorization server implementing UCP's `dev.ucp.common.identity_linking`, so an agent can act for a logged-in shopper. The agent never sees credentials; the shopper logs in on the storefront's existing login page, and the resulting Reactionary `Session` is stored behind opaque, hashed bearer tokens.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const server = new ReactionaryUCPServer(createClient, {
|
|
154
|
+
sessionCache: redisCache, // optional enhancer, see below
|
|
155
|
+
profile: { /* ... */ },
|
|
156
|
+
identity: {
|
|
157
|
+
issuer: 'https://shop.example.com',
|
|
158
|
+
loginUrl: 'https://shop.example.com/account/login',
|
|
159
|
+
stateSecret: process.env.UCP_OAUTH_STATE_SECRET, // 32+ chars; must be identical on all instances
|
|
160
|
+
internalApiKey: process.env.UCP_OAUTH_INTERNAL_KEY, // only for split deployments
|
|
161
|
+
clients: [
|
|
162
|
+
{
|
|
163
|
+
clientId: 'openai-shopping',
|
|
164
|
+
clientSecret: process.env.OPENAI_CLIENT_SECRET, // omit for public clients (PKCE-only)
|
|
165
|
+
redirectUris: ['https://agents.example-platform.com/oauth/callback'],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
What this serves:
|
|
173
|
+
|
|
174
|
+
- `GET /.well-known/oauth-authorization-server` (RFC 8414 metadata)
|
|
175
|
+
- `GET {endpoint}/oauth/authorize` — validates client, exact `redirect_uri`, and PKCE S256 (mandatory for all clients), then redirects to `loginUrl` with `?ucp_request_id=...`
|
|
176
|
+
- `GET|POST {endpoint}/oauth/consent` — consent page (override with `renderConsentPage`) that mints the single-use authorization code
|
|
177
|
+
- `POST {endpoint}/oauth/token` — `authorization_code` and `refresh_token` grants
|
|
178
|
+
- `POST {endpoint}/oauth/revoke` — RFC 7009; revoking a refresh token kills its access tokens
|
|
179
|
+
- `POST {endpoint}/oauth/complete` — internal server-to-server completion (requires `internalApiKey`)
|
|
180
|
+
|
|
181
|
+
All OAuth state (authorization requests, codes, access and refresh tokens) is sealed into self-contained AES-256-GCM blobs encrypted with `stateSecret` — nothing needs to be stored for the flow to be correct, tokens survive restarts, and multiple instances only need the same `stateSecret`. The `sessionCache` is a pure enhancer; when present it adds:
|
|
182
|
+
|
|
183
|
+
- **single-use enforcement** of authorization codes and consent grants (without it, replay is bounded only by the 60s code TTL plus PKCE and client auth)
|
|
184
|
+
- **instant revocation** (`/oauth/revoke` keeps a deny-list; without a shared durable cache, revocation is best-effort until the token expires)
|
|
185
|
+
- **session freshness** (backend-session changes during bearer requests are written to a cache overlay; without it, requests fall back to the session sealed at link time)
|
|
186
|
+
|
|
187
|
+
Gated routes (default: `GET /orders/*` requires `dev.ucp.shopping.order:read`, configurable via `scopes`) answer `401` with `WWW-Authenticate: Bearer error="identity_required"` or `403 insufficient_scope`. When the stored backend session has expired, the agent receives `401` and must re-link — there is no silent re-login and no password is ever stored.
|
|
188
|
+
|
|
189
|
+
The profile at `/.well-known/ucp` automatically advertises `dev.ucp.common.identity_linking` with `config.scopes`.
|
|
190
|
+
|
|
191
|
+
### Storefront integration
|
|
192
|
+
|
|
193
|
+
The storefront owns the login UI; the library owns everything else. After its normal login succeeds, the storefront tells the UCP server "this request id belongs to this customer and session" and sends the browser to the returned `continueUrl`. There are two transports for that hand-off:
|
|
194
|
+
|
|
195
|
+
- **Embedded** (storefront mounts the UCP handler in-process): call `server.identity.completeAuthorization(...)` directly.
|
|
196
|
+
- **Split deployment** (UCP runs as its own Express server): `POST {endpoint}/oauth/complete` with the `x-ucp-internal-key` header, server-to-server. No shared storage is needed — state travels in sealed blobs — and the browser never sees the internal key.
|
|
197
|
+
|
|
198
|
+
The login page must honour the `ucp_request_id` query parameter: keep it through the login flow and hand it to the continue route below.
|
|
199
|
+
|
|
200
|
+
#### Next.js (App Router)
|
|
201
|
+
|
|
202
|
+
`app/ucp/oauth/continue/route.ts` — the storefront's only new code:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
206
|
+
import { getReactionarySession } from '../../../lib/session'; // however the storefront restores its Session
|
|
207
|
+
|
|
208
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
209
|
+
const requestId = request.nextUrl.searchParams.get('ucp_request_id');
|
|
210
|
+
const { customerId, session } = await getReactionarySession(request);
|
|
211
|
+
|
|
212
|
+
if (!requestId || !customerId) {
|
|
213
|
+
return NextResponse.redirect(new URL('/account/login', request.url));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Split deployment: hand the session to the standalone UCP server.
|
|
217
|
+
const response = await fetch(`${process.env.UCP_BASE_URL}/oauth/complete`, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers: {
|
|
220
|
+
'content-type': 'application/json',
|
|
221
|
+
'x-ucp-internal-key': process.env.UCP_OAUTH_INTERNAL_KEY ?? '',
|
|
222
|
+
},
|
|
223
|
+
body: JSON.stringify({ request_id: requestId, customer_id: customerId, session }),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const { continue_url: continueUrl } = (await response.json()) as { continue_url: string };
|
|
227
|
+
return NextResponse.redirect(continueUrl);
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Embedded variant (UCP server mounted in the same Next.js app): replace the `fetch` with
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const { continueUrl } = await ucpServer.identity.completeAuthorization({
|
|
235
|
+
requestId,
|
|
236
|
+
customerId,
|
|
237
|
+
session,
|
|
238
|
+
});
|
|
239
|
+
return NextResponse.redirect(continueUrl);
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
And the login page forwards the parameter, e.g. `app/account/login/page.tsx` redirects to `/ucp/oauth/continue?ucp_request_id=...` after a successful sign-in when the parameter is present.
|
|
243
|
+
|
|
244
|
+
#### SvelteKit
|
|
245
|
+
|
|
246
|
+
`src/routes/ucp/oauth/continue/+server.ts`:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { redirect } from '@sveltejs/kit';
|
|
250
|
+
import { UCP_BASE_URL, UCP_OAUTH_INTERNAL_KEY } from '$env/static/private';
|
|
251
|
+
import type { RequestHandler } from './$types';
|
|
252
|
+
|
|
253
|
+
export const GET: RequestHandler = async ({ url, locals }) => {
|
|
254
|
+
const requestId = url.searchParams.get('ucp_request_id');
|
|
255
|
+
const { customerId, session } = locals; // however the storefront restores its Session
|
|
256
|
+
|
|
257
|
+
if (!requestId || !customerId) {
|
|
258
|
+
redirect(302, '/account/login');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const response = await fetch(`${UCP_BASE_URL}/oauth/complete`, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: {
|
|
264
|
+
'content-type': 'application/json',
|
|
265
|
+
'x-ucp-internal-key': UCP_OAUTH_INTERNAL_KEY,
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify({ request_id: requestId, customer_id: customerId, session }),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const { continue_url: continueUrl } = (await response.json()) as { continue_url: string };
|
|
271
|
+
redirect(302, continueUrl);
|
|
272
|
+
};
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
#### Standalone Express UCP server
|
|
276
|
+
|
|
277
|
+
Redirect hops in a split deployment:
|
|
278
|
+
|
|
279
|
+
```text
|
|
280
|
+
agent -> UCP /oauth/authorize -> storefront /account/login?ucp_request_id=...
|
|
281
|
+
-> storefront /ucp/oauth/continue (reads own session, POSTs /oauth/complete with internal key)
|
|
282
|
+
-> UCP /oauth/consent (approve/deny) -> agent redirect_uri?code=...&iss=...
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Requirements:
|
|
286
|
+
|
|
287
|
+
- No shared storage is required between the storefront and the UCP server: state travels inside sealed blobs. Give the UCP server a Redis-backed `sessionCache` to get single-use code enforcement and instant revocation across instances.
|
|
288
|
+
- All UCP instances must share the same `stateSecret`; rotating it invalidates every outstanding token and link-in-progress.
|
|
289
|
+
- Set `internalApiKey` (e.g. from `UCP_OAUTH_INTERNAL_KEY`) on the UCP server and in the storefront's environment. It is a server-to-server secret; never expose it to the browser.
|
|
290
|
+
- `issuer` is the public origin; `baseUrl` defaults to `{issuer}/ucp` and must match where the UCP endpoint is actually served.
|
|
291
|
+
|
|
148
292
|
## Request IDs and idempotency
|
|
149
293
|
|
|
150
294
|
`Request-Id` is optional and echoed in the response header so agents can correlate calls and responses.
|
package/index.js
CHANGED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { SessionSchema } from "@reactionary/core";
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
const USED_STATE_CACHE_KEY_PREFIX = "reactionary:ucp:identity:used";
|
|
6
|
+
const REVOKED_TOKEN_CACHE_KEY_PREFIX = "reactionary:ucp:identity:revoked";
|
|
7
|
+
const SESSION_OVERLAY_CACHE_KEY_PREFIX = "reactionary:ucp:identity:session";
|
|
8
|
+
const UCPAuthorizationRequestStateSchema = z.looseObject({
|
|
9
|
+
clientId: z.string(),
|
|
10
|
+
redirectUri: z.string(),
|
|
11
|
+
scope: z.string(),
|
|
12
|
+
state: z.string().optional(),
|
|
13
|
+
codeChallenge: z.string()
|
|
14
|
+
});
|
|
15
|
+
const UCPConsentStateSchema = UCPAuthorizationRequestStateSchema.extend({
|
|
16
|
+
customerId: z.string(),
|
|
17
|
+
session: SessionSchema
|
|
18
|
+
});
|
|
19
|
+
const UCPAuthorizationCodeStateSchema = UCPConsentStateSchema;
|
|
20
|
+
const UCPTokenStateSchema = z.looseObject({
|
|
21
|
+
kind: z.enum(["access", "refresh"]),
|
|
22
|
+
clientId: z.string(),
|
|
23
|
+
customerId: z.string(),
|
|
24
|
+
scope: z.string(),
|
|
25
|
+
session: SessionSchema,
|
|
26
|
+
refreshTokenHash: z.string().optional()
|
|
27
|
+
});
|
|
28
|
+
const SealedEnvelopeSchema = z.object({
|
|
29
|
+
kind: z.string(),
|
|
30
|
+
expiresAt: z.number(),
|
|
31
|
+
payload: z.unknown()
|
|
32
|
+
});
|
|
33
|
+
function hashIdentityToken(token) {
|
|
34
|
+
return createHash("sha256").update(token).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
class ReactionaryUCPIdentityState {
|
|
37
|
+
constructor(stateSecret, cache) {
|
|
38
|
+
this.cache = cache;
|
|
39
|
+
this.key = createHash("sha256").update(stateSecret).digest();
|
|
40
|
+
}
|
|
41
|
+
cache;
|
|
42
|
+
key;
|
|
43
|
+
seal(kind, payload, ttlSeconds) {
|
|
44
|
+
const envelope = JSON.stringify({
|
|
45
|
+
kind,
|
|
46
|
+
expiresAt: Date.now() + ttlSeconds * 1e3,
|
|
47
|
+
payload
|
|
48
|
+
});
|
|
49
|
+
const iv = randomBytes(12);
|
|
50
|
+
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
|
51
|
+
const ciphertext = Buffer.concat([
|
|
52
|
+
cipher.update(deflateRawSync(Buffer.from(envelope, "utf-8"))),
|
|
53
|
+
cipher.final()
|
|
54
|
+
]);
|
|
55
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64url");
|
|
56
|
+
}
|
|
57
|
+
open(kind, sealed, schema) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = Buffer.from(sealed, "base64url");
|
|
60
|
+
const decipher = createDecipheriv("aes-256-gcm", this.key, raw.subarray(0, 12));
|
|
61
|
+
decipher.setAuthTag(raw.subarray(12, 28));
|
|
62
|
+
const plaintext = inflateRawSync(
|
|
63
|
+
Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()])
|
|
64
|
+
).toString("utf-8");
|
|
65
|
+
const envelope = SealedEnvelopeSchema.parse(JSON.parse(plaintext));
|
|
66
|
+
if (envelope.kind !== kind || envelope.expiresAt <= Date.now()) {
|
|
67
|
+
return void 0;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
state: schema.parse(envelope.payload),
|
|
71
|
+
expiresAt: envelope.expiresAt
|
|
72
|
+
};
|
|
73
|
+
} catch {
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async markUsed(sealed, ttlSeconds) {
|
|
78
|
+
if (!this.cache) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const key = `${USED_STATE_CACHE_KEY_PREFIX}:${hashIdentityToken(sealed)}`;
|
|
82
|
+
const existing = await this.cache.get(key, UsedMarkerSchema);
|
|
83
|
+
if (existing) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
await this.cache.put(key, { usedAt: Date.now() }, {
|
|
87
|
+
ttlSeconds,
|
|
88
|
+
dependencyIds: [key]
|
|
89
|
+
});
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
async revokeToken(token, ttlSeconds) {
|
|
93
|
+
if (!this.cache) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const key = `${REVOKED_TOKEN_CACHE_KEY_PREFIX}:${hashIdentityToken(token)}`;
|
|
97
|
+
await this.cache.invalidate([key]);
|
|
98
|
+
await this.cache.put(key, { revokedAt: Date.now() }, {
|
|
99
|
+
ttlSeconds,
|
|
100
|
+
dependencyIds: [key]
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async isTokenRevoked(tokenHashes) {
|
|
104
|
+
if (!this.cache) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
for (const tokenHash of tokenHashes) {
|
|
108
|
+
const revoked = await this.cache.get(
|
|
109
|
+
`${REVOKED_TOKEN_CACHE_KEY_PREFIX}:${tokenHash}`,
|
|
110
|
+
RevokedMarkerSchema
|
|
111
|
+
);
|
|
112
|
+
if (revoked) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
async putSessionOverlay(tokenHash, session, ttlSeconds) {
|
|
119
|
+
if (!this.cache) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const key = `${SESSION_OVERLAY_CACHE_KEY_PREFIX}:${tokenHash}`;
|
|
123
|
+
await this.cache.invalidate([key]);
|
|
124
|
+
await this.cache.put(key, session, {
|
|
125
|
+
ttlSeconds,
|
|
126
|
+
dependencyIds: [key]
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async getSessionOverlay(tokenHash) {
|
|
130
|
+
if (!this.cache) {
|
|
131
|
+
return void 0;
|
|
132
|
+
}
|
|
133
|
+
return await this.cache.get(
|
|
134
|
+
`${SESSION_OVERLAY_CACHE_KEY_PREFIX}:${tokenHash}`,
|
|
135
|
+
SessionSchema
|
|
136
|
+
) ?? void 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const UsedMarkerSchema = z.looseObject({
|
|
140
|
+
usedAt: z.number()
|
|
141
|
+
});
|
|
142
|
+
const RevokedMarkerSchema = z.looseObject({
|
|
143
|
+
revokedAt: z.number()
|
|
144
|
+
});
|
|
145
|
+
export {
|
|
146
|
+
ReactionaryUCPIdentityState,
|
|
147
|
+
UCPAuthorizationCodeStateSchema,
|
|
148
|
+
UCPAuthorizationRequestStateSchema,
|
|
149
|
+
UCPConsentStateSchema,
|
|
150
|
+
UCPTokenStateSchema,
|
|
151
|
+
hashIdentityToken
|
|
152
|
+
};
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { SessionSchema } from "@reactionary/core";
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { jsonResponse } from "./reactionary-ucp-http.js";
|
|
5
|
+
import {
|
|
6
|
+
hashIdentityToken,
|
|
7
|
+
ReactionaryUCPIdentityState,
|
|
8
|
+
UCPAuthorizationCodeStateSchema,
|
|
9
|
+
UCPAuthorizationRequestStateSchema,
|
|
10
|
+
UCPConsentStateSchema,
|
|
11
|
+
UCPTokenStateSchema
|
|
12
|
+
} from "./reactionary-ucp-identity-state.js";
|
|
13
|
+
const DEFAULT_UCP_IDENTITY_SCOPES = [
|
|
14
|
+
{
|
|
15
|
+
scope: "dev.ucp.shopping.order:read",
|
|
16
|
+
description: "Read your orders",
|
|
17
|
+
gates: [{ method: "GET", pathPattern: /^\/orders\// }]
|
|
18
|
+
}
|
|
19
|
+
];
|
|
20
|
+
const PENDING_TTL_SECONDS = 10 * 60;
|
|
21
|
+
const CODE_TTL_SECONDS = 60;
|
|
22
|
+
const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
23
|
+
const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
24
|
+
const MIN_STATE_SECRET_LENGTH = 32;
|
|
25
|
+
class ReactionaryUCPIdentity {
|
|
26
|
+
constructor(options, cache) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
if (options.stateSecret.length < MIN_STATE_SECRET_LENGTH) {
|
|
29
|
+
throw new Error(`identity.stateSecret must be at least ${MIN_STATE_SECRET_LENGTH} characters long.`);
|
|
30
|
+
}
|
|
31
|
+
this.state = new ReactionaryUCPIdentityState(options.stateSecret, cache);
|
|
32
|
+
this.scopes = options.scopes ?? DEFAULT_UCP_IDENTITY_SCOPES;
|
|
33
|
+
this.baseUrl = (options.baseUrl ?? `${options.issuer}/ucp`).replace(/\/$/, "");
|
|
34
|
+
this.accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
|
|
35
|
+
this.refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS;
|
|
36
|
+
}
|
|
37
|
+
options;
|
|
38
|
+
state;
|
|
39
|
+
scopes;
|
|
40
|
+
baseUrl;
|
|
41
|
+
accessTokenTtlSeconds;
|
|
42
|
+
refreshTokenTtlSeconds;
|
|
43
|
+
getScopeNames() {
|
|
44
|
+
return this.scopes.map((scope) => scope.scope);
|
|
45
|
+
}
|
|
46
|
+
async handleHttp(request, path) {
|
|
47
|
+
const rawPathname = new URL(request.url).pathname;
|
|
48
|
+
if (rawPathname === "/.well-known/oauth-authorization-server" && request.method === "GET") {
|
|
49
|
+
return jsonResponse(this.getMetadata());
|
|
50
|
+
}
|
|
51
|
+
if (path === "/oauth/authorize" && request.method === "GET") {
|
|
52
|
+
return this.handleAuthorize(request);
|
|
53
|
+
}
|
|
54
|
+
if (path === "/oauth/complete" && request.method === "POST") {
|
|
55
|
+
return this.handleComplete(request);
|
|
56
|
+
}
|
|
57
|
+
if (path === "/oauth/consent" && request.method === "GET") {
|
|
58
|
+
return this.handleConsentPage(request);
|
|
59
|
+
}
|
|
60
|
+
if (path === "/oauth/consent" && request.method === "POST") {
|
|
61
|
+
return this.handleConsentDecision(request);
|
|
62
|
+
}
|
|
63
|
+
if (path === "/oauth/token" && request.method === "POST") {
|
|
64
|
+
return this.handleToken(request);
|
|
65
|
+
}
|
|
66
|
+
if (path === "/oauth/revoke" && request.method === "POST") {
|
|
67
|
+
return this.handleRevoke(request);
|
|
68
|
+
}
|
|
69
|
+
return void 0;
|
|
70
|
+
}
|
|
71
|
+
async completeAuthorization(payload) {
|
|
72
|
+
const opened = this.state.open(
|
|
73
|
+
"authorization_request",
|
|
74
|
+
payload.requestId,
|
|
75
|
+
UCPAuthorizationRequestStateSchema
|
|
76
|
+
);
|
|
77
|
+
if (!opened) {
|
|
78
|
+
throw new Error("Unknown or expired authorization request.");
|
|
79
|
+
}
|
|
80
|
+
const grant = this.state.seal(
|
|
81
|
+
"consent",
|
|
82
|
+
{
|
|
83
|
+
...opened.state,
|
|
84
|
+
customerId: payload.customerId,
|
|
85
|
+
session: payload.session
|
|
86
|
+
},
|
|
87
|
+
PENDING_TTL_SECONDS
|
|
88
|
+
);
|
|
89
|
+
const continueUrl = new URL(`${this.baseUrl}/oauth/consent`);
|
|
90
|
+
continueUrl.searchParams.set("grant", grant);
|
|
91
|
+
return { continueUrl: continueUrl.toString() };
|
|
92
|
+
}
|
|
93
|
+
async resolveBearer(request) {
|
|
94
|
+
const authorization = request.headers.get("authorization");
|
|
95
|
+
if (!authorization?.toLowerCase().startsWith("bearer ")) {
|
|
96
|
+
return void 0;
|
|
97
|
+
}
|
|
98
|
+
const token = authorization.slice("bearer ".length).trim();
|
|
99
|
+
const opened = this.state.open("token", token, UCPTokenStateSchema);
|
|
100
|
+
if (!opened || opened.state.kind !== "access") {
|
|
101
|
+
return bearerChallengeResponse(401, "invalid_token", "The access token is invalid or expired.");
|
|
102
|
+
}
|
|
103
|
+
const tokenHash = hashIdentityToken(token);
|
|
104
|
+
const revokedHashes = [tokenHash];
|
|
105
|
+
if (opened.state.refreshTokenHash) {
|
|
106
|
+
revokedHashes.push(opened.state.refreshTokenHash);
|
|
107
|
+
}
|
|
108
|
+
if (await this.state.isTokenRevoked(revokedHashes)) {
|
|
109
|
+
return bearerChallengeResponse(401, "invalid_token", "The access token has been revoked.");
|
|
110
|
+
}
|
|
111
|
+
const overlaySession = await this.state.getSessionOverlay(tokenHash);
|
|
112
|
+
return {
|
|
113
|
+
tokenHash,
|
|
114
|
+
expiresAt: opened.expiresAt,
|
|
115
|
+
scope: opened.state.scope,
|
|
116
|
+
session: overlaySession ?? opened.state.session
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
checkAccess(method, path, bearer) {
|
|
120
|
+
const requiredScopes = this.scopes.filter(
|
|
121
|
+
(scope) => scope.gates?.some(
|
|
122
|
+
(gate) => (!gate.method || gate.method === method) && gate.pathPattern.test(path)
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
if (requiredScopes.length === 0) {
|
|
126
|
+
return void 0;
|
|
127
|
+
}
|
|
128
|
+
const scopeNames = requiredScopes.map((scope) => scope.scope);
|
|
129
|
+
if (!bearer) {
|
|
130
|
+
return bearerChallengeResponse(
|
|
131
|
+
401,
|
|
132
|
+
"identity_required",
|
|
133
|
+
"This operation requires a linked user identity.",
|
|
134
|
+
scopeNames
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const grantedScopes = bearer.scope.split(" ");
|
|
138
|
+
const missing = scopeNames.filter((scope) => !grantedScopes.includes(scope));
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
return bearerChallengeResponse(
|
|
141
|
+
403,
|
|
142
|
+
"insufficient_scope",
|
|
143
|
+
"The access token does not grant the required scope.",
|
|
144
|
+
missing
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
async persistBearerSession(bearer, session) {
|
|
150
|
+
const remainingTtlSeconds = Math.max(1, Math.ceil((bearer.expiresAt - Date.now()) / 1e3));
|
|
151
|
+
await this.state.putSessionOverlay(bearer.tokenHash, session, remainingTtlSeconds);
|
|
152
|
+
}
|
|
153
|
+
getMetadata() {
|
|
154
|
+
return {
|
|
155
|
+
issuer: this.options.issuer,
|
|
156
|
+
authorization_endpoint: `${this.baseUrl}/oauth/authorize`,
|
|
157
|
+
token_endpoint: `${this.baseUrl}/oauth/token`,
|
|
158
|
+
revocation_endpoint: `${this.baseUrl}/oauth/revoke`,
|
|
159
|
+
response_types_supported: ["code"],
|
|
160
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
161
|
+
code_challenge_methods_supported: ["S256"],
|
|
162
|
+
scopes_supported: this.getScopeNames(),
|
|
163
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"]
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async handleAuthorize(request) {
|
|
167
|
+
const url = new URL(request.url);
|
|
168
|
+
const clientId = url.searchParams.get("client_id") ?? "";
|
|
169
|
+
const redirectUri = url.searchParams.get("redirect_uri") ?? "";
|
|
170
|
+
const client = this.options.clients.find((candidate) => candidate.clientId === clientId);
|
|
171
|
+
if (!client || !client.redirectUris.includes(redirectUri)) {
|
|
172
|
+
return jsonResponse(
|
|
173
|
+
{ error: "invalid_request", error_description: "Unknown client_id or unregistered redirect_uri." },
|
|
174
|
+
{ status: 400 }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const state = url.searchParams.get("state") ?? void 0;
|
|
178
|
+
const responseType = url.searchParams.get("response_type");
|
|
179
|
+
const scope = url.searchParams.get("scope") ?? "";
|
|
180
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
181
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
182
|
+
if (responseType !== "code") {
|
|
183
|
+
return authorizeErrorRedirect(redirectUri, "unsupported_response_type", state);
|
|
184
|
+
}
|
|
185
|
+
if (!codeChallenge || codeChallengeMethod !== "S256") {
|
|
186
|
+
return authorizeErrorRedirect(redirectUri, "invalid_request", state, "PKCE with S256 is required.");
|
|
187
|
+
}
|
|
188
|
+
const knownScopes = this.getScopeNames();
|
|
189
|
+
const requestedScopes = scope.split(" ").filter((entry) => entry.length > 0);
|
|
190
|
+
if (requestedScopes.some((entry) => !knownScopes.includes(entry))) {
|
|
191
|
+
return authorizeErrorRedirect(redirectUri, "invalid_scope", state);
|
|
192
|
+
}
|
|
193
|
+
const requestId = this.state.seal(
|
|
194
|
+
"authorization_request",
|
|
195
|
+
{
|
|
196
|
+
clientId,
|
|
197
|
+
redirectUri,
|
|
198
|
+
scope: requestedScopes.join(" "),
|
|
199
|
+
...state !== void 0 ? { state } : {},
|
|
200
|
+
codeChallenge
|
|
201
|
+
},
|
|
202
|
+
PENDING_TTL_SECONDS
|
|
203
|
+
);
|
|
204
|
+
const loginUrl = new URL(this.options.loginUrl);
|
|
205
|
+
loginUrl.searchParams.set("ucp_request_id", requestId);
|
|
206
|
+
return redirectResponse(loginUrl.toString());
|
|
207
|
+
}
|
|
208
|
+
async handleComplete(request) {
|
|
209
|
+
if (!this.options.internalApiKey) {
|
|
210
|
+
return jsonResponse(
|
|
211
|
+
{ error: "invalid_request", error_description: "The completion endpoint is not enabled; configure internalApiKey." },
|
|
212
|
+
{ status: 404 }
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const providedKey = request.headers.get("x-ucp-internal-key") ?? "";
|
|
216
|
+
if (!secureEquals(providedKey, this.options.internalApiKey)) {
|
|
217
|
+
return jsonResponse({ error: "invalid_client" }, { status: 401 });
|
|
218
|
+
}
|
|
219
|
+
const body = await request.json().catch(() => void 0);
|
|
220
|
+
const parsed = CompleteAuthorizationBodySchema.safeParse(body);
|
|
221
|
+
if (!parsed.success) {
|
|
222
|
+
return jsonResponse(
|
|
223
|
+
{ error: "invalid_request", error_description: "Body must contain request_id, customer_id and session." },
|
|
224
|
+
{ status: 400 }
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const { continueUrl } = await this.completeAuthorization({
|
|
229
|
+
requestId: parsed.data.request_id,
|
|
230
|
+
customerId: parsed.data.customer_id,
|
|
231
|
+
session: parsed.data.session
|
|
232
|
+
});
|
|
233
|
+
return jsonResponse({ continue_url: continueUrl });
|
|
234
|
+
} catch {
|
|
235
|
+
return jsonResponse(
|
|
236
|
+
{ error: "invalid_request", error_description: "Unknown or expired authorization request." },
|
|
237
|
+
{ status: 400 }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async handleConsentPage(request) {
|
|
242
|
+
const grant = new URL(request.url).searchParams.get("grant") ?? "";
|
|
243
|
+
const opened = this.state.open("consent", grant, UCPConsentStateSchema);
|
|
244
|
+
if (!opened) {
|
|
245
|
+
return htmlResponse("<p>This authorization request is unknown or has expired.</p>", 400);
|
|
246
|
+
}
|
|
247
|
+
const context = {
|
|
248
|
+
clientId: opened.state.clientId,
|
|
249
|
+
customerId: opened.state.customerId,
|
|
250
|
+
scopes: opened.state.scope.split(" ").filter((entry) => entry.length > 0),
|
|
251
|
+
approveUrl: `${this.baseUrl}/oauth/consent`,
|
|
252
|
+
grant
|
|
253
|
+
};
|
|
254
|
+
const render = this.options.renderConsentPage ?? renderDefaultConsentPage;
|
|
255
|
+
return htmlResponse(render(context));
|
|
256
|
+
}
|
|
257
|
+
async handleConsentDecision(request) {
|
|
258
|
+
const form = await request.formData().catch(() => void 0);
|
|
259
|
+
const grant = readFormValue(form, "grant");
|
|
260
|
+
const decision = readFormValue(form, "decision");
|
|
261
|
+
const opened = this.state.open("consent", grant, UCPConsentStateSchema);
|
|
262
|
+
if (!opened || !await this.state.markUsed(grant, PENDING_TTL_SECONDS)) {
|
|
263
|
+
return htmlResponse("<p>This authorization request is unknown or has expired.</p>", 400);
|
|
264
|
+
}
|
|
265
|
+
if (decision !== "approve") {
|
|
266
|
+
return authorizeErrorRedirect(opened.state.redirectUri, "access_denied", opened.state.state);
|
|
267
|
+
}
|
|
268
|
+
const code = this.state.seal("code", opened.state, CODE_TTL_SECONDS);
|
|
269
|
+
const redirectUrl = new URL(opened.state.redirectUri);
|
|
270
|
+
redirectUrl.searchParams.set("code", code);
|
|
271
|
+
redirectUrl.searchParams.set("iss", this.options.issuer);
|
|
272
|
+
if (opened.state.state !== void 0) {
|
|
273
|
+
redirectUrl.searchParams.set("state", opened.state.state);
|
|
274
|
+
}
|
|
275
|
+
return redirectResponse(redirectUrl.toString());
|
|
276
|
+
}
|
|
277
|
+
async handleToken(request) {
|
|
278
|
+
const form = await request.formData().catch(() => void 0);
|
|
279
|
+
const client = this.authenticateClient(request, form);
|
|
280
|
+
if (!client) {
|
|
281
|
+
return jsonResponse({ error: "invalid_client" }, {
|
|
282
|
+
status: 401,
|
|
283
|
+
headers: { "www-authenticate": 'Basic realm="ucp"' }
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const grantType = readFormValue(form, "grant_type");
|
|
287
|
+
if (grantType === "authorization_code") {
|
|
288
|
+
return this.handleAuthorizationCodeGrant(client, form);
|
|
289
|
+
}
|
|
290
|
+
if (grantType === "refresh_token") {
|
|
291
|
+
return this.handleRefreshTokenGrant(client, form);
|
|
292
|
+
}
|
|
293
|
+
return jsonResponse({ error: "unsupported_grant_type" }, { status: 400 });
|
|
294
|
+
}
|
|
295
|
+
async handleAuthorizationCodeGrant(client, form) {
|
|
296
|
+
const code = readFormValue(form, "code");
|
|
297
|
+
const codeVerifier = readFormValue(form, "code_verifier");
|
|
298
|
+
const redirectUri = readFormValue(form, "redirect_uri");
|
|
299
|
+
const opened = code ? this.state.open("code", code, UCPAuthorizationCodeStateSchema) : void 0;
|
|
300
|
+
if (!opened || !await this.state.markUsed(code, CODE_TTL_SECONDS) || opened.state.clientId !== client.clientId || opened.state.redirectUri !== redirectUri || !verifyPkce(codeVerifier, opened.state.codeChallenge)) {
|
|
301
|
+
return jsonResponse({ error: "invalid_grant" }, { status: 400 });
|
|
302
|
+
}
|
|
303
|
+
const refreshToken = this.state.seal(
|
|
304
|
+
"token",
|
|
305
|
+
{
|
|
306
|
+
kind: "refresh",
|
|
307
|
+
clientId: opened.state.clientId,
|
|
308
|
+
customerId: opened.state.customerId,
|
|
309
|
+
scope: opened.state.scope,
|
|
310
|
+
session: opened.state.session
|
|
311
|
+
},
|
|
312
|
+
this.refreshTokenTtlSeconds
|
|
313
|
+
);
|
|
314
|
+
const accessToken = this.mintAccessToken({
|
|
315
|
+
kind: "access",
|
|
316
|
+
clientId: opened.state.clientId,
|
|
317
|
+
customerId: opened.state.customerId,
|
|
318
|
+
scope: opened.state.scope,
|
|
319
|
+
session: opened.state.session,
|
|
320
|
+
refreshTokenHash: hashIdentityToken(refreshToken)
|
|
321
|
+
});
|
|
322
|
+
return jsonResponse({
|
|
323
|
+
access_token: accessToken,
|
|
324
|
+
token_type: "Bearer",
|
|
325
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
326
|
+
refresh_token: refreshToken,
|
|
327
|
+
scope: opened.state.scope
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
async handleRefreshTokenGrant(client, form) {
|
|
331
|
+
const refreshToken = readFormValue(form, "refresh_token");
|
|
332
|
+
const opened = refreshToken ? this.state.open("token", refreshToken, UCPTokenStateSchema) : void 0;
|
|
333
|
+
const refreshTokenHash = hashIdentityToken(refreshToken);
|
|
334
|
+
if (!opened || opened.state.kind !== "refresh" || opened.state.clientId !== client.clientId || await this.state.isTokenRevoked([refreshTokenHash])) {
|
|
335
|
+
return jsonResponse({ error: "invalid_grant" }, { status: 400 });
|
|
336
|
+
}
|
|
337
|
+
const accessToken = this.mintAccessToken({
|
|
338
|
+
kind: "access",
|
|
339
|
+
clientId: opened.state.clientId,
|
|
340
|
+
customerId: opened.state.customerId,
|
|
341
|
+
scope: opened.state.scope,
|
|
342
|
+
session: opened.state.session,
|
|
343
|
+
refreshTokenHash
|
|
344
|
+
});
|
|
345
|
+
return jsonResponse({
|
|
346
|
+
access_token: accessToken,
|
|
347
|
+
token_type: "Bearer",
|
|
348
|
+
expires_in: this.accessTokenTtlSeconds,
|
|
349
|
+
scope: opened.state.scope
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
mintAccessToken(state) {
|
|
353
|
+
return this.state.seal("token", state, this.accessTokenTtlSeconds);
|
|
354
|
+
}
|
|
355
|
+
async handleRevoke(request) {
|
|
356
|
+
const form = await request.formData().catch(() => void 0);
|
|
357
|
+
const client = this.authenticateClient(request, form);
|
|
358
|
+
if (!client) {
|
|
359
|
+
return jsonResponse({ error: "invalid_client" }, { status: 401 });
|
|
360
|
+
}
|
|
361
|
+
const token = readFormValue(form, "token");
|
|
362
|
+
const opened = token ? this.state.open("token", token, UCPTokenStateSchema) : void 0;
|
|
363
|
+
if (opened && opened.state.clientId === client.clientId) {
|
|
364
|
+
const remainingTtlSeconds = Math.max(1, Math.ceil((opened.expiresAt - Date.now()) / 1e3));
|
|
365
|
+
await this.state.revokeToken(token, remainingTtlSeconds);
|
|
366
|
+
}
|
|
367
|
+
return new Response(null, { status: 200 });
|
|
368
|
+
}
|
|
369
|
+
authenticateClient(request, form) {
|
|
370
|
+
const authorization = request.headers.get("authorization");
|
|
371
|
+
let clientId = readFormValue(form, "client_id");
|
|
372
|
+
let clientSecret = readFormValue(form, "client_secret");
|
|
373
|
+
if (authorization?.toLowerCase().startsWith("basic ")) {
|
|
374
|
+
const decoded = Buffer.from(authorization.slice("basic ".length), "base64").toString("utf-8");
|
|
375
|
+
const separatorIndex = decoded.indexOf(":");
|
|
376
|
+
if (separatorIndex >= 0) {
|
|
377
|
+
clientId = decodeURIComponent(decoded.slice(0, separatorIndex));
|
|
378
|
+
clientSecret = decodeURIComponent(decoded.slice(separatorIndex + 1));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const client = this.options.clients.find((candidate) => candidate.clientId === clientId);
|
|
382
|
+
if (!client) {
|
|
383
|
+
return void 0;
|
|
384
|
+
}
|
|
385
|
+
if (client.clientSecret) {
|
|
386
|
+
return clientSecret && secureEquals(clientSecret, client.clientSecret) ? client : void 0;
|
|
387
|
+
}
|
|
388
|
+
return client;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const CompleteAuthorizationBodySchema = z.object({
|
|
392
|
+
request_id: z.string(),
|
|
393
|
+
customer_id: z.string(),
|
|
394
|
+
session: SessionSchema
|
|
395
|
+
});
|
|
396
|
+
function readFormValue(form, key) {
|
|
397
|
+
const value = form?.get(key);
|
|
398
|
+
return typeof value === "string" ? value : "";
|
|
399
|
+
}
|
|
400
|
+
function verifyPkce(codeVerifier, codeChallenge) {
|
|
401
|
+
if (!codeVerifier) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
405
|
+
return secureEquals(computed, codeChallenge);
|
|
406
|
+
}
|
|
407
|
+
function secureEquals(left, right) {
|
|
408
|
+
const leftDigest = createHash("sha256").update(left).digest();
|
|
409
|
+
const rightDigest = createHash("sha256").update(right).digest();
|
|
410
|
+
return timingSafeEqual(leftDigest, rightDigest);
|
|
411
|
+
}
|
|
412
|
+
function redirectResponse(location) {
|
|
413
|
+
return new Response(null, {
|
|
414
|
+
status: 302,
|
|
415
|
+
headers: { location }
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function authorizeErrorRedirect(redirectUri, error, state, description) {
|
|
419
|
+
const url = new URL(redirectUri);
|
|
420
|
+
url.searchParams.set("error", error);
|
|
421
|
+
if (description) {
|
|
422
|
+
url.searchParams.set("error_description", description);
|
|
423
|
+
}
|
|
424
|
+
if (state !== void 0) {
|
|
425
|
+
url.searchParams.set("state", state);
|
|
426
|
+
}
|
|
427
|
+
return redirectResponse(url.toString());
|
|
428
|
+
}
|
|
429
|
+
function bearerChallengeResponse(status, error, description, scopes) {
|
|
430
|
+
const scopePart = scopes && scopes.length > 0 ? `, scope="${scopes.join(" ")}"` : "";
|
|
431
|
+
return jsonResponse(
|
|
432
|
+
{ error, error_description: description },
|
|
433
|
+
{
|
|
434
|
+
status,
|
|
435
|
+
headers: {
|
|
436
|
+
"www-authenticate": `Bearer realm="ucp", error="${error}", error_description="${description}"${scopePart}`
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
function htmlResponse(body, status = 200) {
|
|
442
|
+
return new Response(`<!doctype html><html><body>${body}</body></html>`, {
|
|
443
|
+
status,
|
|
444
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
function renderDefaultConsentPage(context) {
|
|
448
|
+
const scopeList = context.scopes.length > 0 ? `<ul>${context.scopes.map((scope) => `<li>${escapeHtml(scope)}</li>`).join("")}</ul>` : "<p>No additional permissions requested.</p>";
|
|
449
|
+
return [
|
|
450
|
+
`<h1>Authorize ${escapeHtml(context.clientId)}</h1>`,
|
|
451
|
+
`<p>The agent <strong>${escapeHtml(context.clientId)}</strong> wants to act on your behalf with these permissions:</p>`,
|
|
452
|
+
scopeList,
|
|
453
|
+
`<form method="post" action="${escapeHtml(context.approveUrl)}">`,
|
|
454
|
+
`<input type="hidden" name="grant" value="${escapeHtml(context.grant)}">`,
|
|
455
|
+
'<button name="decision" value="approve" type="submit">Approve</button>',
|
|
456
|
+
'<button name="decision" value="deny" type="submit">Deny</button>',
|
|
457
|
+
"</form>"
|
|
458
|
+
].join("\n");
|
|
459
|
+
}
|
|
460
|
+
function escapeHtml(value) {
|
|
461
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
462
|
+
}
|
|
463
|
+
export {
|
|
464
|
+
DEFAULT_UCP_IDENTITY_SCOPES,
|
|
465
|
+
ReactionaryUCPIdentity
|
|
466
|
+
};
|
|
@@ -12,8 +12,21 @@ function getRequestRoute(request, profile) {
|
|
|
12
12
|
path: url.pathname
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
-
function createUCPProfile(client, profile) {
|
|
15
|
+
function createUCPProfile(client, profile, identityScopes) {
|
|
16
16
|
const endpoint = profile?.endpoint ?? "http://localhost/ucp";
|
|
17
|
+
const capabilities = profile?.capabilities ?? createUcpCapabilities(client);
|
|
18
|
+
if (identityScopes) {
|
|
19
|
+
capabilities["dev.ucp.common.identity_linking"] = [
|
|
20
|
+
{
|
|
21
|
+
version: "2026-08-25",
|
|
22
|
+
spec: `${UCP_DOCS_BASE_URL}/specification/common/identity-linking/`,
|
|
23
|
+
schema: `${UCP_DOCS_BASE_URL}/schemas/common/identity_linking.json`,
|
|
24
|
+
config: {
|
|
25
|
+
scopes: identityScopes
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
];
|
|
29
|
+
}
|
|
17
30
|
return {
|
|
18
31
|
ucp: {
|
|
19
32
|
version: "2026-08-25",
|
|
@@ -28,7 +41,7 @@ function createUCPProfile(client, profile) {
|
|
|
28
41
|
}
|
|
29
42
|
]
|
|
30
43
|
},
|
|
31
|
-
capabilities
|
|
44
|
+
capabilities,
|
|
32
45
|
...profile?.paymentHandlers ? { payment_handlers: profile.paymentHandlers } : {}
|
|
33
46
|
},
|
|
34
47
|
keys: profile?.keys ?? [],
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
import {
|
|
6
6
|
} from "./reactionary-ucp-common.js";
|
|
7
7
|
import { getOrCreateSessionId, jsonResponse, sendWebResponse, toWebRequest, UCP_SESSION_ID_HEADER } from "./reactionary-ucp-http.js";
|
|
8
|
+
import { ReactionaryUCPIdentity } from "./reactionary-ucp-identity.js";
|
|
8
9
|
import { createUCPProfile, getRequestRoute } from "./reactionary-ucp-profile.js";
|
|
9
10
|
import { handleRestRequest, UCPHttpError } from "./reactionary-ucp-rest.js";
|
|
10
11
|
import { ReactionaryUCPSessionStore } from "./reactionary-ucp-session-store.js";
|
|
@@ -12,20 +13,36 @@ class ReactionaryUCPServer {
|
|
|
12
13
|
constructor(clientFactory, options = {}) {
|
|
13
14
|
this.clientFactory = clientFactory;
|
|
14
15
|
this.options = options;
|
|
16
|
+
const cache = this.options.sessionCache ?? new MemoryCache();
|
|
15
17
|
this.sessionStore = new ReactionaryUCPSessionStore(
|
|
16
|
-
|
|
18
|
+
cache,
|
|
17
19
|
this.options.sessionTtlSeconds ?? 60 * 60 * 24
|
|
18
20
|
);
|
|
21
|
+
if (this.options.identity) {
|
|
22
|
+
this.identity = new ReactionaryUCPIdentity(this.options.identity, cache);
|
|
23
|
+
}
|
|
19
24
|
}
|
|
20
25
|
clientFactory;
|
|
21
26
|
options;
|
|
22
27
|
sessionStore;
|
|
28
|
+
identity;
|
|
23
29
|
async fetch(request) {
|
|
30
|
+
return this.handleFetch(request);
|
|
31
|
+
}
|
|
32
|
+
async handleFetch(request) {
|
|
24
33
|
const sessionId = getOrCreateSessionId(request);
|
|
25
|
-
const
|
|
34
|
+
const bearerResolution = await this.identity?.resolveBearer(request);
|
|
35
|
+
if (bearerResolution instanceof Response) {
|
|
36
|
+
return bearerResolution;
|
|
37
|
+
}
|
|
38
|
+
const requestContext = await this.createRequestContext(sessionId, bearerResolution);
|
|
26
39
|
const client = this.clientFactory(requestContext);
|
|
27
|
-
const response = await this.handleRequest(request, client, sessionId);
|
|
28
|
-
|
|
40
|
+
const response = await this.handleRequest(request, client, sessionId, bearerResolution);
|
|
41
|
+
if (bearerResolution) {
|
|
42
|
+
await this.identity?.persistBearerSession(bearerResolution, requestContext.session);
|
|
43
|
+
} else {
|
|
44
|
+
await this.sessionStore.put(sessionId, requestContext.session);
|
|
45
|
+
}
|
|
29
46
|
response.headers.set(UCP_SESSION_ID_HEADER, sessionId);
|
|
30
47
|
const requestId = request.headers.get("Request-Id");
|
|
31
48
|
if (requestId) {
|
|
@@ -48,15 +65,19 @@ class ReactionaryUCPServer {
|
|
|
48
65
|
close() {
|
|
49
66
|
return Promise.resolve();
|
|
50
67
|
}
|
|
51
|
-
async createRequestContext(sessionId) {
|
|
52
|
-
const restoredSession = await this.sessionStore.get(sessionId);
|
|
68
|
+
async createRequestContext(sessionId, bearerResolution) {
|
|
53
69
|
const requestContext = createInitialRequestContext();
|
|
70
|
+
if (bearerResolution) {
|
|
71
|
+
requestContext.session = bearerResolution.session;
|
|
72
|
+
return requestContext;
|
|
73
|
+
}
|
|
74
|
+
const restoredSession = await this.sessionStore.get(sessionId);
|
|
54
75
|
if (restoredSession) {
|
|
55
76
|
requestContext.session = restoredSession;
|
|
56
77
|
}
|
|
57
78
|
return requestContext;
|
|
58
79
|
}
|
|
59
|
-
async handleRequest(request, client, sessionId) {
|
|
80
|
+
async handleRequest(request, client, sessionId, bearerResolution) {
|
|
60
81
|
const route = getRequestRoute(request, this.options.profile);
|
|
61
82
|
if (request.method === "OPTIONS") {
|
|
62
83
|
return new Response(null, {
|
|
@@ -67,7 +88,17 @@ class ReactionaryUCPServer {
|
|
|
67
88
|
});
|
|
68
89
|
}
|
|
69
90
|
if (route.path === "/.well-known/ucp" && request.method === "GET") {
|
|
70
|
-
return jsonResponse(createUCPProfile(client, this.options.profile));
|
|
91
|
+
return jsonResponse(createUCPProfile(client, this.options.profile, this.identity?.getScopeNames()));
|
|
92
|
+
}
|
|
93
|
+
if (this.identity) {
|
|
94
|
+
const identityResponse = await this.identity.handleHttp(request, route.path);
|
|
95
|
+
if (identityResponse) {
|
|
96
|
+
return identityResponse;
|
|
97
|
+
}
|
|
98
|
+
const accessResponse = this.identity.checkAccess(request.method, route.path, bearerResolution);
|
|
99
|
+
if (accessResponse) {
|
|
100
|
+
return accessResponse;
|
|
101
|
+
}
|
|
71
102
|
}
|
|
72
103
|
try {
|
|
73
104
|
const restResponse = await handleRestRequest(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactionary/ucp",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"url": "git+https://github.com/Solteq/reactionary.git"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@reactionary/core": "0.10.
|
|
12
|
+
"@reactionary/core": "0.10.34",
|
|
13
13
|
"zod": "4.1.9"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
package/src/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Cache, Cart, Checkout, Client, FacetValueIdentifier, Order, Product, ProductSearchResult, RequestContext, Result } from '@reactionary/core';
|
|
2
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
3
|
import type { components } from './ucp-shopping.openapi.js';
|
|
4
|
+
import type { ReactionaryUCPIdentityOptions } from './reactionary-ucp-identity.js';
|
|
4
5
|
type UCPService = components['schemas']['base'];
|
|
5
6
|
type UCPCapability = components['schemas']['$defs-base'];
|
|
6
7
|
export type UCPPaymentHandler = components['schemas']['entity'] & {
|
|
@@ -57,6 +58,7 @@ export interface ReactionaryUCPServerOptions {
|
|
|
57
58
|
sessionCache?: Cache;
|
|
58
59
|
sessionTtlSeconds?: number;
|
|
59
60
|
profile?: ReactionaryUCPProfileOptions;
|
|
61
|
+
identity?: ReactionaryUCPIdentityOptions;
|
|
60
62
|
}
|
|
61
63
|
export interface ReactionaryUCPProfileOptions {
|
|
62
64
|
endpoint: string;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { type Cache } from '@reactionary/core';
|
|
2
|
+
import * as z from 'zod';
|
|
3
|
+
export declare const UCPAuthorizationRequestStateSchema: z.ZodObject<{
|
|
4
|
+
clientId: z.ZodString;
|
|
5
|
+
redirectUri: z.ZodString;
|
|
6
|
+
scope: z.ZodString;
|
|
7
|
+
state: z.ZodOptional<z.ZodString>;
|
|
8
|
+
codeChallenge: z.ZodString;
|
|
9
|
+
}, z.core.$loose>;
|
|
10
|
+
export declare const UCPConsentStateSchema: z.ZodObject<{
|
|
11
|
+
clientId: z.ZodString;
|
|
12
|
+
redirectUri: z.ZodString;
|
|
13
|
+
scope: z.ZodString;
|
|
14
|
+
state: z.ZodOptional<z.ZodString>;
|
|
15
|
+
codeChallenge: z.ZodString;
|
|
16
|
+
customerId: z.ZodString;
|
|
17
|
+
session: z.ZodObject<{
|
|
18
|
+
identityContext: z.ZodObject<{
|
|
19
|
+
identity: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
20
|
+
type: z.ZodLiteral<"Anonymous">;
|
|
21
|
+
}, z.core.$loose>, z.ZodObject<{
|
|
22
|
+
id: z.ZodObject<{
|
|
23
|
+
userId: z.ZodString;
|
|
24
|
+
}, z.core.$loose>;
|
|
25
|
+
type: z.ZodLiteral<"Guest">;
|
|
26
|
+
}, z.core.$loose>, z.ZodObject<{
|
|
27
|
+
id: z.ZodObject<{
|
|
28
|
+
userId: z.ZodString;
|
|
29
|
+
}, z.core.$loose>;
|
|
30
|
+
type: z.ZodLiteral<"Registered">;
|
|
31
|
+
}, z.core.$loose>], "type">;
|
|
32
|
+
lastUpdated: z.ZodCoercedDate<unknown>;
|
|
33
|
+
}, z.core.$loose>;
|
|
34
|
+
marketingContext: z.ZodDefault<z.ZodObject<{
|
|
35
|
+
identifier: z.ZodObject<{
|
|
36
|
+
key: z.ZodString;
|
|
37
|
+
}, z.core.$loose>;
|
|
38
|
+
segments: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
39
|
+
blurb: z.ZodDefault<z.ZodString>;
|
|
40
|
+
}, z.core.$loose>>;
|
|
41
|
+
}, z.core.$loose>;
|
|
42
|
+
}, z.core.$loose>;
|
|
43
|
+
export declare const UCPAuthorizationCodeStateSchema: z.ZodObject<{
|
|
44
|
+
clientId: z.ZodString;
|
|
45
|
+
redirectUri: z.ZodString;
|
|
46
|
+
scope: z.ZodString;
|
|
47
|
+
state: z.ZodOptional<z.ZodString>;
|
|
48
|
+
codeChallenge: z.ZodString;
|
|
49
|
+
customerId: z.ZodString;
|
|
50
|
+
session: z.ZodObject<{
|
|
51
|
+
identityContext: z.ZodObject<{
|
|
52
|
+
identity: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
53
|
+
type: z.ZodLiteral<"Anonymous">;
|
|
54
|
+
}, z.core.$loose>, z.ZodObject<{
|
|
55
|
+
id: z.ZodObject<{
|
|
56
|
+
userId: z.ZodString;
|
|
57
|
+
}, z.core.$loose>;
|
|
58
|
+
type: z.ZodLiteral<"Guest">;
|
|
59
|
+
}, z.core.$loose>, z.ZodObject<{
|
|
60
|
+
id: z.ZodObject<{
|
|
61
|
+
userId: z.ZodString;
|
|
62
|
+
}, z.core.$loose>;
|
|
63
|
+
type: z.ZodLiteral<"Registered">;
|
|
64
|
+
}, z.core.$loose>], "type">;
|
|
65
|
+
lastUpdated: z.ZodCoercedDate<unknown>;
|
|
66
|
+
}, z.core.$loose>;
|
|
67
|
+
marketingContext: z.ZodDefault<z.ZodObject<{
|
|
68
|
+
identifier: z.ZodObject<{
|
|
69
|
+
key: z.ZodString;
|
|
70
|
+
}, z.core.$loose>;
|
|
71
|
+
segments: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
72
|
+
blurb: z.ZodDefault<z.ZodString>;
|
|
73
|
+
}, z.core.$loose>>;
|
|
74
|
+
}, z.core.$loose>;
|
|
75
|
+
}, z.core.$loose>;
|
|
76
|
+
export declare const UCPTokenStateSchema: z.ZodObject<{
|
|
77
|
+
kind: z.ZodEnum<{
|
|
78
|
+
access: "access";
|
|
79
|
+
refresh: "refresh";
|
|
80
|
+
}>;
|
|
81
|
+
clientId: z.ZodString;
|
|
82
|
+
customerId: z.ZodString;
|
|
83
|
+
scope: z.ZodString;
|
|
84
|
+
session: z.ZodObject<{
|
|
85
|
+
identityContext: z.ZodObject<{
|
|
86
|
+
identity: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
87
|
+
type: z.ZodLiteral<"Anonymous">;
|
|
88
|
+
}, z.core.$loose>, z.ZodObject<{
|
|
89
|
+
id: z.ZodObject<{
|
|
90
|
+
userId: z.ZodString;
|
|
91
|
+
}, z.core.$loose>;
|
|
92
|
+
type: z.ZodLiteral<"Guest">;
|
|
93
|
+
}, z.core.$loose>, z.ZodObject<{
|
|
94
|
+
id: z.ZodObject<{
|
|
95
|
+
userId: z.ZodString;
|
|
96
|
+
}, z.core.$loose>;
|
|
97
|
+
type: z.ZodLiteral<"Registered">;
|
|
98
|
+
}, z.core.$loose>], "type">;
|
|
99
|
+
lastUpdated: z.ZodCoercedDate<unknown>;
|
|
100
|
+
}, z.core.$loose>;
|
|
101
|
+
marketingContext: z.ZodDefault<z.ZodObject<{
|
|
102
|
+
identifier: z.ZodObject<{
|
|
103
|
+
key: z.ZodString;
|
|
104
|
+
}, z.core.$loose>;
|
|
105
|
+
segments: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
106
|
+
blurb: z.ZodDefault<z.ZodString>;
|
|
107
|
+
}, z.core.$loose>>;
|
|
108
|
+
}, z.core.$loose>;
|
|
109
|
+
refreshTokenHash: z.ZodOptional<z.ZodString>;
|
|
110
|
+
}, z.core.$loose>;
|
|
111
|
+
export type UCPAuthorizationRequestState = z.infer<typeof UCPAuthorizationRequestStateSchema>;
|
|
112
|
+
export type UCPConsentState = z.infer<typeof UCPConsentStateSchema>;
|
|
113
|
+
export type UCPAuthorizationCodeState = z.infer<typeof UCPAuthorizationCodeStateSchema>;
|
|
114
|
+
export type UCPTokenState = z.infer<typeof UCPTokenStateSchema>;
|
|
115
|
+
export type UCPSealedStateKind = 'authorization_request' | 'consent' | 'code' | 'token';
|
|
116
|
+
export declare function hashIdentityToken(token: string): string;
|
|
117
|
+
/**
|
|
118
|
+
* Seals identity state into self-contained AES-256-GCM blobs so no shared
|
|
119
|
+
* storage is required for correctness. The optional cache only enhances the
|
|
120
|
+
* flow: single-use enforcement, instant revocation, and session freshness.
|
|
121
|
+
*/
|
|
122
|
+
export declare class ReactionaryUCPIdentityState {
|
|
123
|
+
private readonly cache;
|
|
124
|
+
private readonly key;
|
|
125
|
+
constructor(stateSecret: string, cache: Cache | undefined);
|
|
126
|
+
seal(kind: UCPSealedStateKind, payload: unknown, ttlSeconds: number): string;
|
|
127
|
+
open<TState>(kind: UCPSealedStateKind, sealed: string, schema: z.ZodType<TState>): {
|
|
128
|
+
state: TState;
|
|
129
|
+
expiresAt: number;
|
|
130
|
+
} | undefined;
|
|
131
|
+
markUsed(sealed: string, ttlSeconds: number): Promise<boolean>;
|
|
132
|
+
revokeToken(token: string, ttlSeconds: number): Promise<void>;
|
|
133
|
+
isTokenRevoked(tokenHashes: string[]): Promise<boolean>;
|
|
134
|
+
putSessionOverlay(tokenHash: string, session: UCPTokenState['session'], ttlSeconds: number): Promise<void>;
|
|
135
|
+
getSessionOverlay(tokenHash: string): Promise<UCPTokenState['session'] | undefined>;
|
|
136
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type Cache, type Session } from '@reactionary/core';
|
|
2
|
+
export interface ReactionaryUCPIdentityClient {
|
|
3
|
+
clientId: string;
|
|
4
|
+
redirectUris: string[];
|
|
5
|
+
clientSecret?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ReactionaryUCPIdentityScope {
|
|
8
|
+
scope: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
gates?: Array<{
|
|
11
|
+
method?: string;
|
|
12
|
+
pathPattern: RegExp;
|
|
13
|
+
}>;
|
|
14
|
+
}
|
|
15
|
+
export interface ReactionaryUCPIdentityConsentContext {
|
|
16
|
+
clientId: string;
|
|
17
|
+
customerId: string;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
approveUrl: string;
|
|
20
|
+
grant: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ReactionaryUCPIdentityOptions {
|
|
23
|
+
issuer: string;
|
|
24
|
+
loginUrl: string;
|
|
25
|
+
stateSecret: string;
|
|
26
|
+
clients: ReactionaryUCPIdentityClient[];
|
|
27
|
+
scopes?: ReactionaryUCPIdentityScope[];
|
|
28
|
+
baseUrl?: string;
|
|
29
|
+
internalApiKey?: string;
|
|
30
|
+
accessTokenTtlSeconds?: number;
|
|
31
|
+
refreshTokenTtlSeconds?: number;
|
|
32
|
+
renderConsentPage?: (context: ReactionaryUCPIdentityConsentContext) => string;
|
|
33
|
+
}
|
|
34
|
+
export interface UCPCompleteAuthorizationPayload {
|
|
35
|
+
requestId: string;
|
|
36
|
+
customerId: string;
|
|
37
|
+
session: Session;
|
|
38
|
+
}
|
|
39
|
+
export interface UCPBearerResolution {
|
|
40
|
+
tokenHash: string;
|
|
41
|
+
expiresAt: number;
|
|
42
|
+
scope: string;
|
|
43
|
+
session: Session;
|
|
44
|
+
}
|
|
45
|
+
export declare const DEFAULT_UCP_IDENTITY_SCOPES: ReactionaryUCPIdentityScope[];
|
|
46
|
+
export declare class ReactionaryUCPIdentity {
|
|
47
|
+
private readonly options;
|
|
48
|
+
private readonly state;
|
|
49
|
+
private readonly scopes;
|
|
50
|
+
private readonly baseUrl;
|
|
51
|
+
private readonly accessTokenTtlSeconds;
|
|
52
|
+
private readonly refreshTokenTtlSeconds;
|
|
53
|
+
constructor(options: ReactionaryUCPIdentityOptions, cache?: Cache);
|
|
54
|
+
getScopeNames(): string[];
|
|
55
|
+
handleHttp(request: Request, path: string): Promise<Response | undefined>;
|
|
56
|
+
completeAuthorization(payload: UCPCompleteAuthorizationPayload): Promise<{
|
|
57
|
+
continueUrl: string;
|
|
58
|
+
}>;
|
|
59
|
+
resolveBearer(request: Request): Promise<UCPBearerResolution | Response | undefined>;
|
|
60
|
+
checkAccess(method: string, path: string, bearer: UCPBearerResolution | undefined): Response | undefined;
|
|
61
|
+
persistBearerSession(bearer: UCPBearerResolution, session: Session): Promise<void>;
|
|
62
|
+
private getMetadata;
|
|
63
|
+
private handleAuthorize;
|
|
64
|
+
private handleComplete;
|
|
65
|
+
private handleConsentPage;
|
|
66
|
+
private handleConsentDecision;
|
|
67
|
+
private handleToken;
|
|
68
|
+
private handleAuthorizationCodeGrant;
|
|
69
|
+
private handleRefreshTokenGrant;
|
|
70
|
+
private mintAccessToken;
|
|
71
|
+
private handleRevoke;
|
|
72
|
+
private authenticateClient;
|
|
73
|
+
}
|
|
@@ -2,4 +2,4 @@ import type { ReactionaryUCPClient, ReactionaryUCPProfile, ReactionaryUCPProfile
|
|
|
2
2
|
export declare function getRequestRoute(request: Request, profile: ReactionaryUCPProfileOptions | undefined): {
|
|
3
3
|
path: string;
|
|
4
4
|
};
|
|
5
|
-
export declare function createUCPProfile(client: ReactionaryUCPClient, profile: ReactionaryUCPProfileOptions | undefined): ReactionaryUCPProfile;
|
|
5
|
+
export declare function createUCPProfile(client: ReactionaryUCPClient, profile: ReactionaryUCPProfileOptions | undefined, identityScopes?: string[]): ReactionaryUCPProfile;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { type ReactionaryUCPClient, type ReactionaryUCPClientFactory, type ReactionaryUCPHttpHandler, type ReactionaryUCPNodeRequestHandler, type ReactionaryUCPProfile, type ReactionaryUCPProfileOptions, type ReactionaryUCPServerOptions } from './reactionary-ucp-common.js';
|
|
2
|
+
import { ReactionaryUCPIdentity } from './reactionary-ucp-identity.js';
|
|
2
3
|
export type { ReactionaryUCPClient, ReactionaryUCPClientFactory, ReactionaryUCPHttpHandler, ReactionaryUCPNodeRequestHandler, ReactionaryUCPProfile, ReactionaryUCPProfileOptions, ReactionaryUCPServerOptions, };
|
|
3
4
|
export declare class ReactionaryUCPServer<TClient extends ReactionaryUCPClient = ReactionaryUCPClient> {
|
|
4
5
|
private readonly clientFactory;
|
|
5
6
|
private readonly options;
|
|
6
7
|
private readonly sessionStore;
|
|
8
|
+
readonly identity?: ReactionaryUCPIdentity;
|
|
7
9
|
constructor(clientFactory: ReactionaryUCPClientFactory<TClient>, options?: ReactionaryUCPServerOptions);
|
|
8
10
|
fetch(request: Request): Promise<Response>;
|
|
11
|
+
private handleFetch;
|
|
9
12
|
getHandler(): ReactionaryUCPHttpHandler;
|
|
10
13
|
toNodeHandler(): ReactionaryUCPNodeRequestHandler;
|
|
11
14
|
close(): Promise<void>;
|