@oxy.so/core 7.15.1 → 7.17.0
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 +24 -13
- package/dist/cjs/server/OxyServer.js +85 -3
- package/dist/cjs/server/appPermissions.js +14 -92
- package/dist/cjs/server/callerAccess.js +151 -0
- package/dist/cjs/server/callerAuthority.js +206 -0
- package/dist/cjs/server/capabilityTicket.js +39 -6
- package/dist/cjs/server/cors.js +41 -2
- package/dist/cjs/server/index.js +33 -5
- package/dist/cjs/server/liveCapabilityTicket.js +4 -2
- package/dist/cjs/server/middleware.js +104 -2
- package/dist/cjs/server/namespaces.js +15 -4
- package/dist/cjs/server/safeFetch.js +29 -1
- package/dist/cjs/server/userClient.js +127 -0
- package/dist/cjs/utils/cache.js +58 -22
- package/dist/esm/server/OxyServer.js +85 -3
- package/dist/esm/server/appPermissions.js +12 -89
- package/dist/esm/server/callerAccess.js +145 -0
- package/dist/esm/server/callerAuthority.js +194 -0
- package/dist/esm/server/capabilityTicket.js +38 -6
- package/dist/esm/server/cors.js +39 -2
- package/dist/esm/server/index.js +26 -3
- package/dist/esm/server/liveCapabilityTicket.js +5 -3
- package/dist/esm/server/middleware.js +104 -2
- package/dist/esm/server/namespaces.js +15 -4
- package/dist/esm/server/safeFetch.js +29 -1
- package/dist/esm/server/userClient.js +121 -0
- package/dist/esm/utils/cache.js +57 -22
- package/dist/types/api/accounts.d.ts +8 -1
- package/dist/types/api/apps.d.ts +22 -4
- package/dist/types/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +74 -7
- package/dist/types/server/appPermissions.d.ts +3 -13
- package/dist/types/server/auth.d.ts +5 -0
- package/dist/types/server/callerAccess.d.ts +86 -0
- package/dist/types/server/callerAuthority.d.ts +127 -0
- package/dist/types/server/capabilityTicket.d.ts +23 -1
- package/dist/types/server/cors.d.ts +39 -0
- package/dist/types/server/index.d.ts +31 -7
- package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
- package/dist/types/server/middleware.d.ts +34 -2
- package/dist/types/server/namespaces.d.ts +18 -2
- package/dist/types/server/safeFetch.d.ts +10 -0
- package/dist/types/server/userClient.d.ts +71 -0
- package/dist/types/utils/cache.d.ts +13 -0
- package/package.json +5 -5
- package/src/api/accounts.ts +8 -1
- package/src/api/apps.ts +23 -4
- package/src/index.ts +1 -0
- package/src/server/OxyServer.ts +126 -7
- package/src/server/appPermissions.ts +28 -105
- package/src/server/auth.ts +5 -0
- package/src/server/callerAccess.ts +232 -0
- package/src/server/callerAuthority.ts +267 -0
- package/src/server/capabilityTicket.ts +76 -17
- package/src/server/cors.ts +63 -2
- package/src/server/index.ts +38 -4
- package/src/server/liveCapabilityTicket.ts +12 -5
- package/src/server/middleware.ts +171 -4
- package/src/server/namespaces.ts +36 -5
- package/src/server/safeFetch.ts +31 -1
- package/src/server/userClient.ts +147 -0
- package/src/utils/cache.ts +56 -18
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every "may the person behind this request …?" read on `OxyServer`
|
|
3
|
+
* shares: which requests carry a person at all, the one failure type and its
|
|
4
|
+
* status mapping, the response envelope, and the positive-only answer cache.
|
|
5
|
+
*
|
|
6
|
+
* The readers built on it answer different questions — keep them apart:
|
|
7
|
+
*
|
|
8
|
+
* | Question | Reader |
|
|
9
|
+
* |---|---|
|
|
10
|
+
* | Does this person hold one of MY application's declared operator keys (`payments.operate`)? | `appPermissionsFor` (`./appPermissions`) |
|
|
11
|
+
* | What may this person do in Oxy account X (an organization, store, channel)? | `accountAccessFor` (`./callerAccess`) |
|
|
12
|
+
* | What may this person do to application A, through the account that owns it (`app:read`, `app:update`)? | `applicationAccessFor` (`./callerAccess`) |
|
|
13
|
+
*
|
|
14
|
+
* ## The outcome mapping, and why an outage is never a "no"
|
|
15
|
+
*
|
|
16
|
+
* Every reader asks Oxy with the request's OWN verified bearer and maps the
|
|
17
|
+
* answer the same way: a 401 means the session is gone (sign in again); a 403
|
|
18
|
+
* or 404 is Oxy's definitive refusal; anything else — a 5xx, a timeout, a
|
|
19
|
+
* malformed body — is UNAVAILABLE, answered 503. Only a positive answer is
|
|
20
|
+
* cached, so a blip can never be remembered as a refusal, and a refusal is
|
|
21
|
+
* never remembered at all (the next request asks again).
|
|
22
|
+
*
|
|
23
|
+
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Entries kept per reader; the oldest goes first. */
|
|
27
|
+
const CALLER_AUTHORITY_CACHE_MAX = 1000;
|
|
28
|
+
|
|
29
|
+
/** The request fields a caller-authority read uses — all set by `middleware.auth()`. */
|
|
30
|
+
export interface OxyCallerRequest {
|
|
31
|
+
userId?: string | null;
|
|
32
|
+
sessionId?: string | null;
|
|
33
|
+
accessToken?: string;
|
|
34
|
+
/** Set when the request is authenticated by a SERVICE token; such a request carries no person. */
|
|
35
|
+
serviceApp?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The verified person behind a request. */
|
|
39
|
+
export interface OxyCallerSession {
|
|
40
|
+
readonly userId: string;
|
|
41
|
+
readonly sessionId: string;
|
|
42
|
+
readonly accessToken: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The person behind `req`, or why there is none: `service` for a request
|
|
47
|
+
* authenticated by a service token (delegated or not — its bearer is the
|
|
48
|
+
* service's, never the person's), `missing` when `middleware.auth()` resolved
|
|
49
|
+
* no user session.
|
|
50
|
+
*/
|
|
51
|
+
export function readCallerSession(
|
|
52
|
+
req: OxyCallerRequest,
|
|
53
|
+
): { ok: true; session: OxyCallerSession } | { ok: false; reason: 'service' | 'missing' } {
|
|
54
|
+
if (req.serviceApp) return { ok: false, reason: 'service' };
|
|
55
|
+
const { userId, sessionId, accessToken } = req;
|
|
56
|
+
if (
|
|
57
|
+
typeof userId !== 'string' ||
|
|
58
|
+
userId.length === 0 ||
|
|
59
|
+
typeof sessionId !== 'string' ||
|
|
60
|
+
sessionId.length === 0 ||
|
|
61
|
+
typeof accessToken !== 'string' ||
|
|
62
|
+
!isBearerToken(accessToken)
|
|
63
|
+
) {
|
|
64
|
+
return { ok: false, reason: 'missing' };
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, session: { userId, sessionId, accessToken } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A bearer that can be put in an `Authorization` header unchanged. */
|
|
70
|
+
export function isBearerToken(token: string): boolean {
|
|
71
|
+
return token.length > 0 && !/\s/.test(token);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* - `ACCESS_SESSION_REQUIRED` — no person on the request (anonymous, or a
|
|
76
|
+
* service token), or Oxy refused the session (401). Ask them to sign in.
|
|
77
|
+
* - `ACCESS_DENIED` — Oxy answered "no" (403, 404), or the answer lacks the
|
|
78
|
+
* permission a gate required. Definitive.
|
|
79
|
+
* - `ACCESS_UNAVAILABLE` — Oxy could not be asked or gave no usable answer.
|
|
80
|
+
* Fails closed, answered 503, never cached, never a "no".
|
|
81
|
+
*/
|
|
82
|
+
export type OxyAccessErrorCode = 'ACCESS_SESSION_REQUIRED' | 'ACCESS_DENIED' | 'ACCESS_UNAVAILABLE';
|
|
83
|
+
|
|
84
|
+
/** The typed failure of `asUser`, `accountAccessFor`, `applicationAccessFor` and their middleware. */
|
|
85
|
+
export class OxyAccessError extends Error {
|
|
86
|
+
readonly code: OxyAccessErrorCode;
|
|
87
|
+
/** The permission a gate required, when a gate refused. */
|
|
88
|
+
readonly permission: string | undefined;
|
|
89
|
+
/** The underlying failure (an `OxyApiError`), if any. Never sent to a client. */
|
|
90
|
+
readonly cause: unknown;
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
code: OxyAccessErrorCode,
|
|
94
|
+
message: string,
|
|
95
|
+
options: { permission?: string; cause?: unknown } = {},
|
|
96
|
+
) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = 'OxyAccessError';
|
|
99
|
+
this.code = code;
|
|
100
|
+
this.permission = options.permission;
|
|
101
|
+
this.cause = options.cause;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What a refused request is answered with. */
|
|
106
|
+
export type OxyAccessDenyStatus = 403 | 404;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The status for an {@link OxyAccessError}: `deny` (default `403`) for a
|
|
110
|
+
* refusal; `401` for no session (`404` when `deny` is `404`, so a hidden
|
|
111
|
+
* surface stays hidden from the signed-out too); `503` when Oxy could not say.
|
|
112
|
+
*/
|
|
113
|
+
export function accessErrorStatus(
|
|
114
|
+
error: OxyAccessError,
|
|
115
|
+
deny: OxyAccessDenyStatus = 403,
|
|
116
|
+
): 401 | 403 | 404 | 503 {
|
|
117
|
+
switch (error.code) {
|
|
118
|
+
case 'ACCESS_DENIED':
|
|
119
|
+
return deny;
|
|
120
|
+
case 'ACCESS_SESSION_REQUIRED':
|
|
121
|
+
return deny === 404 ? 404 : 401;
|
|
122
|
+
case 'ACCESS_UNAVAILABLE':
|
|
123
|
+
return 503;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
|
|
128
|
+
export function authorityErrorBody(status: number): {
|
|
129
|
+
error: { code: string; message: string };
|
|
130
|
+
} {
|
|
131
|
+
switch (status) {
|
|
132
|
+
case 401:
|
|
133
|
+
return { error: { code: 'unauthorized', message: 'Authentication required' } };
|
|
134
|
+
case 403:
|
|
135
|
+
return { error: { code: 'forbidden', message: 'Not permitted' } };
|
|
136
|
+
case 404:
|
|
137
|
+
return { error: { code: 'not_found', message: 'Not found' } };
|
|
138
|
+
case 503:
|
|
139
|
+
return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
|
|
140
|
+
default:
|
|
141
|
+
return { error: { code: 'internal_error', message: 'Internal error' } };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The HTTP status an `OxyApiError` (or anything shaped like one) carries. */
|
|
146
|
+
export function errorStatus(error: unknown): number | undefined {
|
|
147
|
+
const status = (error as { status?: unknown } | null)?.status;
|
|
148
|
+
return typeof status === 'number' ? status : undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Map a failed caller-bearer read of Oxy to its outcome: 401 → session
|
|
153
|
+
* required, 403/404 → denied, everything else → unavailable.
|
|
154
|
+
*/
|
|
155
|
+
export function accessFailure(error: unknown, what: string): OxyAccessError {
|
|
156
|
+
const status = errorStatus(error);
|
|
157
|
+
if (status === 401) {
|
|
158
|
+
return new OxyAccessError(
|
|
159
|
+
'ACCESS_SESSION_REQUIRED',
|
|
160
|
+
`Oxy refused the session reading ${what}`,
|
|
161
|
+
{
|
|
162
|
+
cause: error,
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (status === 403 || status === 404) {
|
|
167
|
+
return new OxyAccessError('ACCESS_DENIED', `Oxy refused ${what} to this session`, {
|
|
168
|
+
cause: error,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return new OxyAccessError('ACCESS_UNAVAILABLE', `Could not read ${what} from Oxy`, {
|
|
172
|
+
cause: error,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface CacheEntry<T> {
|
|
177
|
+
value: T;
|
|
178
|
+
userId: string;
|
|
179
|
+
startedAt: number;
|
|
180
|
+
expiresAt: number;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The positive-only answer cache one caller-authority reader keeps, keyed by
|
|
185
|
+
* (resource, session, person). Concurrent identical reads share one lookup; a
|
|
186
|
+
* `fresh` read skips the cache and refreshes it; a slower, OLDER answer never
|
|
187
|
+
* overwrites a newer one (the newer may be the fresh read that saw a
|
|
188
|
+
* revocation); a failure is never stored.
|
|
189
|
+
*/
|
|
190
|
+
export class CallerAuthorityCache<T> {
|
|
191
|
+
private readonly entries = new Map<string, CacheEntry<T>>();
|
|
192
|
+
private readonly pending = new Map<string, Promise<T>>();
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @param ttlMs How long an answer is reused; `0` disables the cache.
|
|
196
|
+
* @param isAbout Whether a cached answer concerns `userId` beyond the
|
|
197
|
+
* session's own user (an application-permissions answer names the
|
|
198
|
+
* resolved operator), for {@link invalidate}.
|
|
199
|
+
*/
|
|
200
|
+
constructor(
|
|
201
|
+
private readonly ttlMs: number,
|
|
202
|
+
private readonly isAbout: (value: T, userId: string) => boolean = () => false,
|
|
203
|
+
) {}
|
|
204
|
+
|
|
205
|
+
async read(
|
|
206
|
+
resourceKey: readonly string[],
|
|
207
|
+
session: OxyCallerSession,
|
|
208
|
+
fresh: boolean,
|
|
209
|
+
lookup: () => Promise<T>,
|
|
210
|
+
): Promise<T> {
|
|
211
|
+
const key = JSON.stringify([...resourceKey, session.sessionId, session.userId]);
|
|
212
|
+
const ttl = this.ttlMs;
|
|
213
|
+
if (!fresh && ttl > 0) {
|
|
214
|
+
const cached = this.entries.get(key);
|
|
215
|
+
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
|
216
|
+
const inFlight = this.pending.get(key);
|
|
217
|
+
if (inFlight) return inFlight;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const startedAt = Date.now();
|
|
221
|
+
const answer = lookup().then((value) => {
|
|
222
|
+
if (ttl > 0) this.remember(key, session.userId, value, startedAt, ttl);
|
|
223
|
+
return value;
|
|
224
|
+
});
|
|
225
|
+
if (fresh || ttl <= 0) return answer;
|
|
226
|
+
|
|
227
|
+
this.pending.set(key, answer);
|
|
228
|
+
try {
|
|
229
|
+
return await answer;
|
|
230
|
+
} finally {
|
|
231
|
+
if (this.pending.get(key) === answer) this.pending.delete(key);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Forget every answer, or those about one person. */
|
|
236
|
+
invalidate(filter: { userId?: string } = {}): void {
|
|
237
|
+
const { userId } = filter;
|
|
238
|
+
if (!userId) {
|
|
239
|
+
this.entries.clear();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
for (const [key, entry] of this.entries) {
|
|
243
|
+
if (entry.userId === userId || this.isAbout(entry.value, userId)) this.entries.delete(key);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private remember(key: string, userId: string, value: T, startedAt: number, ttl: number): void {
|
|
248
|
+
const existing = this.entries.get(key);
|
|
249
|
+
if (existing && existing.startedAt > startedAt) return;
|
|
250
|
+
this.entries.delete(key);
|
|
251
|
+
this.entries.set(key, { value, userId, startedAt, expiresAt: startedAt + ttl });
|
|
252
|
+
if (this.entries.size > CALLER_AUTHORITY_CACHE_MAX) {
|
|
253
|
+
const oldest = this.entries.keys().next().value;
|
|
254
|
+
if (oldest !== undefined) this.entries.delete(oldest);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A frozen, sorted permission set with `has`. */
|
|
260
|
+
export function permissionSet(permissions: readonly string[]): {
|
|
261
|
+
readonly permissions: readonly string[];
|
|
262
|
+
has(permission: string): boolean;
|
|
263
|
+
} {
|
|
264
|
+
const sorted = Object.freeze([...new Set(permissions)].sort());
|
|
265
|
+
const held = new Set(sorted);
|
|
266
|
+
return { permissions: sorted, has: (permission: string) => held.has(permission) };
|
|
267
|
+
}
|
|
@@ -29,11 +29,28 @@ export interface CapabilityTicketVerificationOptions {
|
|
|
29
29
|
maximumTtlSeconds?: number;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A signing-key lookup that may be asynchronous — `createOxyJwksKeyResolver`
|
|
34
|
+
* is — for the verifiers that already run asynchronously (the middleware and
|
|
35
|
+
* `createLiveCapabilityTicketVerifier`). The ticket's own `kid` is passed; no
|
|
36
|
+
* caller needs to decode the header first.
|
|
37
|
+
*/
|
|
38
|
+
export type CapabilityTicketKeyResolver = (
|
|
39
|
+
keyId: string,
|
|
40
|
+
) => KeyObject | undefined | Promise<KeyObject | undefined>;
|
|
41
|
+
|
|
42
|
+
/** {@link CapabilityTicketVerificationOptions} with a key lookup that may be asynchronous. */
|
|
43
|
+
export interface AsyncCapabilityTicketVerificationOptions
|
|
44
|
+
extends Omit<CapabilityTicketVerificationOptions, 'resolvePublicKey'> {
|
|
45
|
+
resolvePublicKey: CapabilityTicketKeyResolver;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
export interface CapabilityTicketRequest extends Request {
|
|
33
49
|
capabilityTicket?: CapabilityTicketClaims;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
|
-
export interface CapabilityTicketMiddlewareOptions
|
|
52
|
+
export interface CapabilityTicketMiddlewareOptions
|
|
53
|
+
extends AsyncCapabilityTicketVerificationOptions {
|
|
37
54
|
authorize: (claims: CapabilityTicketClaims) => Promise<PolicyDecision>;
|
|
38
55
|
}
|
|
39
56
|
|
|
@@ -99,10 +116,15 @@ export function issueCapabilityTicket(
|
|
|
99
116
|
return `${signingInput}.${base64UrlEncode(signBytes(null, Buffer.from(signingInput), options.privateKey))}`;
|
|
100
117
|
}
|
|
101
118
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
119
|
+
/** A ticket split and its header checked, before any key is looked up. */
|
|
120
|
+
interface ParsedCapabilityTicket {
|
|
121
|
+
readonly keyId: string;
|
|
122
|
+
readonly signingInput: string;
|
|
123
|
+
readonly encodedPayload: string;
|
|
124
|
+
readonly encodedSignature: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseCapabilityTicket(token: string): ParsedCapabilityTicket {
|
|
106
128
|
const segments = token.split('.');
|
|
107
129
|
if (segments.length !== 3) {
|
|
108
130
|
throw new CapabilityTicketError('malformed', 'Capability ticket must have three segments');
|
|
@@ -126,28 +148,34 @@ export function verifyCapabilityTicket(
|
|
|
126
148
|
) {
|
|
127
149
|
throw new CapabilityTicketError('malformed', 'Capability ticket header is not supported');
|
|
128
150
|
}
|
|
129
|
-
|
|
151
|
+
return {
|
|
152
|
+
keyId: headerRecord.kid,
|
|
153
|
+
signingInput: `${encodedHeader}.${encodedPayload}`,
|
|
154
|
+
encodedPayload,
|
|
155
|
+
encodedSignature,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Signature and claims of a parsed ticket against the key its `kid` resolved to. */
|
|
160
|
+
function checkCapabilityTicket(
|
|
161
|
+
ticket: ParsedCapabilityTicket,
|
|
162
|
+
publicKey: KeyObject | undefined,
|
|
163
|
+
options: Omit<CapabilityTicketVerificationOptions, 'resolvePublicKey'>,
|
|
164
|
+
): CapabilityTicketClaims {
|
|
130
165
|
if (!publicKey || publicKey.asymmetricKeyType !== 'ed25519') {
|
|
131
166
|
throw new CapabilityTicketError('unknown_key', 'Capability ticket signing key is not trusted');
|
|
132
167
|
}
|
|
133
168
|
let providedSignature: Buffer;
|
|
134
169
|
try {
|
|
135
|
-
providedSignature = Buffer.from(encodedSignature, 'base64url');
|
|
170
|
+
providedSignature = Buffer.from(ticket.encodedSignature, 'base64url');
|
|
136
171
|
} catch {
|
|
137
172
|
throw new CapabilityTicketError('malformed', 'Capability ticket signature is malformed');
|
|
138
173
|
}
|
|
139
|
-
if (
|
|
140
|
-
!verifyBytes(
|
|
141
|
-
null,
|
|
142
|
-
Buffer.from(`${encodedHeader}.${encodedPayload}`),
|
|
143
|
-
publicKey,
|
|
144
|
-
providedSignature,
|
|
145
|
-
)
|
|
146
|
-
) {
|
|
174
|
+
if (!verifyBytes(null, Buffer.from(ticket.signingInput), publicKey, providedSignature)) {
|
|
147
175
|
throw new CapabilityTicketError('invalid_signature', 'Capability ticket signature is invalid');
|
|
148
176
|
}
|
|
149
177
|
|
|
150
|
-
const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(encodedPayload));
|
|
178
|
+
const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(ticket.encodedPayload));
|
|
151
179
|
if (!parsed.success) {
|
|
152
180
|
throw new CapabilityTicketError('invalid_claims', 'Capability ticket claims are invalid');
|
|
153
181
|
}
|
|
@@ -179,6 +207,37 @@ export function verifyCapabilityTicket(
|
|
|
179
207
|
return claims;
|
|
180
208
|
}
|
|
181
209
|
|
|
210
|
+
export function verifyCapabilityTicket(
|
|
211
|
+
token: string,
|
|
212
|
+
options: CapabilityTicketVerificationOptions,
|
|
213
|
+
): CapabilityTicketClaims {
|
|
214
|
+
const ticket = parseCapabilityTicket(token);
|
|
215
|
+
return checkCapabilityTicket(ticket, options.resolvePublicKey(ticket.keyId), options);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A verifier for one ticket whose key lookup may be asynchronous: the key is
|
|
220
|
+
* resolved once, and the returned `check` re-verifies signature and claims
|
|
221
|
+
* against it (the live verifier checks again after introspection, so a slow
|
|
222
|
+
* authority answer cannot outlive the ticket).
|
|
223
|
+
*
|
|
224
|
+
* @internal Shared by the middleware and `createLiveCapabilityTicketVerifier`.
|
|
225
|
+
*/
|
|
226
|
+
export async function resolveCapabilityTicketKey(
|
|
227
|
+
token: string,
|
|
228
|
+
options: AsyncCapabilityTicketVerificationOptions,
|
|
229
|
+
): Promise<{ check: () => CapabilityTicketClaims }> {
|
|
230
|
+
const ticket = parseCapabilityTicket(token);
|
|
231
|
+
let publicKey: KeyObject | undefined;
|
|
232
|
+
try {
|
|
233
|
+
publicKey = await options.resolvePublicKey(ticket.keyId);
|
|
234
|
+
} catch {
|
|
235
|
+
// A key set that cannot be read trusts no key; never a different error class.
|
|
236
|
+
publicKey = undefined;
|
|
237
|
+
}
|
|
238
|
+
return { check: () => checkCapabilityTicket(ticket, publicKey, options) };
|
|
239
|
+
}
|
|
240
|
+
|
|
182
241
|
export function readCapabilityAuthorization(authorization: string | undefined): string | null {
|
|
183
242
|
if (!authorization) return null;
|
|
184
243
|
const [scheme, token, extra] = authorization.trim().split(/\s+/);
|
|
@@ -203,7 +262,7 @@ export function createCapabilityTicketMiddleware(
|
|
|
203
262
|
|
|
204
263
|
let claims: CapabilityTicketClaims;
|
|
205
264
|
try {
|
|
206
|
-
claims =
|
|
265
|
+
claims = (await resolveCapabilityTicketKey(token, options)).check();
|
|
207
266
|
} catch (error) {
|
|
208
267
|
const code = error instanceof CapabilityTicketError ? error.code : 'invalid_claims';
|
|
209
268
|
response.status(401).json({ error: 'invalid_capability_ticket', code });
|
package/src/server/cors.ts
CHANGED
|
@@ -197,6 +197,67 @@ export function matchesAllowedOrigin(explicit: ReadonlySet<string>, origin: stri
|
|
|
197
197
|
return isOxyFamilyOrigin(normalized);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* The origin predicate `createOxyCors` applies, for any other transport that
|
|
202
|
+
* must admit the same browsers: the built-in HTTPS Oxy apex family plus the
|
|
203
|
+
* exact `appOrigins`, never an opaque origin (`exp://`, `file:`, …).
|
|
204
|
+
*
|
|
205
|
+
* ```ts
|
|
206
|
+
* const allowed = createOxyOriginCheck(['https://app.example.com']);
|
|
207
|
+
* allowed('https://mention.oxy.so'); // true
|
|
208
|
+
* ```
|
|
209
|
+
*/
|
|
210
|
+
export function createOxyOriginCheck(appOrigins: string[] = []): (origin: string) => boolean {
|
|
211
|
+
const explicit = normalizeAppOrigins(appOrigins);
|
|
212
|
+
return (origin: string) => matchesAllowedOrigin(explicit, origin);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Options of {@link createOxySocketCors}. */
|
|
216
|
+
export interface OxySocketCorsOptions {
|
|
217
|
+
/** Explicit additional allowed origins, as {@link OxyCorsOptions.appOrigins}. */
|
|
218
|
+
appOrigins?: string[];
|
|
219
|
+
/** Whether the polling transport sends `Access-Control-Allow-Credentials: true`. Default `true`. */
|
|
220
|
+
allowCredentials?: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The Socket.IO server options that admit exactly the origins `createOxyCors`
|
|
225
|
+
* admits — spread them into `new Server(httpServer, { ...createOxySocketCors(…) })`.
|
|
226
|
+
*
|
|
227
|
+
* Two halves, because Socket.IO has two transports: `cors` answers the
|
|
228
|
+
* long-polling requests (which browsers check), and `allowRequest` refuses a
|
|
229
|
+
* handshake from any other browser origin, which is the only check a WebSocket
|
|
230
|
+
* upgrade gets — browsers apply no CORS to it. A request with no `Origin`
|
|
231
|
+
* header (a native app, a server) is not a browser cross-origin request and is
|
|
232
|
+
* admitted; authenticate it with `oxy.middleware.socket()` as every socket.
|
|
233
|
+
*/
|
|
234
|
+
export function createOxySocketCors(options: OxySocketCorsOptions = {}): {
|
|
235
|
+
cors: {
|
|
236
|
+
origin: (
|
|
237
|
+
origin: string | undefined,
|
|
238
|
+
callback: (error: Error | null, allow?: boolean) => void,
|
|
239
|
+
) => void;
|
|
240
|
+
credentials: boolean;
|
|
241
|
+
methods: string[];
|
|
242
|
+
};
|
|
243
|
+
allowRequest: (
|
|
244
|
+
req: { headers: Record<string, string | string[] | undefined> },
|
|
245
|
+
callback: (error: string | null | undefined, success: boolean) => void,
|
|
246
|
+
) => void;
|
|
247
|
+
} {
|
|
248
|
+
const allowed = createOxyOriginCheck(options.appOrigins);
|
|
249
|
+
const admits = (origin: string | string[] | undefined): boolean =>
|
|
250
|
+
origin === undefined || (typeof origin === 'string' && (origin === '' || allowed(origin)));
|
|
251
|
+
return {
|
|
252
|
+
cors: {
|
|
253
|
+
origin: (origin, callback) => callback(null, admits(origin)),
|
|
254
|
+
credentials: options.allowCredentials ?? true,
|
|
255
|
+
methods: ['GET', 'POST'],
|
|
256
|
+
},
|
|
257
|
+
allowRequest: (req, callback) => callback(null, admits(req.headers.origin)),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
200
261
|
/**
|
|
201
262
|
* Create a strict Oxy CORS middleware. See module docs.
|
|
202
263
|
*
|
|
@@ -215,7 +276,7 @@ export function createOxyCors(options: OxyCorsOptions = {}): RequestHandler {
|
|
|
215
276
|
maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS,
|
|
216
277
|
} = options;
|
|
217
278
|
|
|
218
|
-
const
|
|
279
|
+
const allowed = createOxyOriginCheck(appOrigins);
|
|
219
280
|
const methodsHeader = methods.join(', ');
|
|
220
281
|
const allowedHeadersHeader = allowedHeaders.join(', ');
|
|
221
282
|
const exposedHeadersHeader = exposedHeaders.join(', ');
|
|
@@ -237,7 +298,7 @@ export function createOxyCors(options: OxyCorsOptions = {}): RequestHandler {
|
|
|
237
298
|
// Origin is present. Caching correctness: this response varies by Origin.
|
|
238
299
|
res.setHeader('Vary', 'Origin');
|
|
239
300
|
|
|
240
|
-
if (!
|
|
301
|
+
if (!allowed(origin)) {
|
|
241
302
|
// DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
|
|
242
303
|
// will block the cross-origin read. Preflights for denied origins get a
|
|
243
304
|
// 204 with no CORS headers (the actual request then fails CORS).
|
package/src/server/index.ts
CHANGED
|
@@ -4,14 +4,33 @@
|
|
|
4
4
|
* This subpath export provides Express middleware and Node.js-specific
|
|
5
5
|
* utilities that are not available in React Native or browser environments.
|
|
6
6
|
*
|
|
7
|
+
* One `OxyServer` per process, authenticated as the backend's own workload
|
|
8
|
+
* (ADR 0026/0049 — no api key, no client secret):
|
|
9
|
+
*
|
|
7
10
|
* @example
|
|
8
11
|
* ```ts
|
|
9
|
-
* import {
|
|
12
|
+
* import {
|
|
13
|
+
* OxyServer,
|
|
14
|
+
* createOxyAuthMiddleware,
|
|
15
|
+
* createOxyCors,
|
|
16
|
+
* createOxyRateLimit,
|
|
17
|
+
* createOxySecurityHeaders,
|
|
18
|
+
* oxyTrustProxy,
|
|
19
|
+
* } from '@oxy.so/core/server';
|
|
10
20
|
*
|
|
11
|
-
* const oxy = new OxyServer({
|
|
21
|
+
* export const oxy = new OxyServer({
|
|
22
|
+
* baseURL: process.env.OXY_API_URL ?? 'https://api.oxy.so',
|
|
23
|
+
* applicationId: process.env.OXY_APPLICATION_ID,
|
|
24
|
+
* serviceIdentity: 'when-anonymous',
|
|
25
|
+
* });
|
|
12
26
|
*
|
|
27
|
+
* app.set('trust proxy', oxyTrustProxy);
|
|
28
|
+
* app.use(createOxySecurityHeaders(), createOxyCors({ appOrigins }));
|
|
13
29
|
* app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
14
30
|
* app.use('/api', createOxyAuthMiddleware(oxy));
|
|
31
|
+
*
|
|
32
|
+
* // As the person behind the request, never on the shared client:
|
|
33
|
+
* const node = await oxy.withUser(req, (user) => user.accounts.get(accountId));
|
|
15
34
|
* ```
|
|
16
35
|
*/
|
|
17
36
|
|
|
@@ -24,6 +43,17 @@ export {
|
|
|
24
43
|
DEFAULT_SERVICE_TOKEN_AUDIENCE,
|
|
25
44
|
} from './OxyServer';
|
|
26
45
|
export type { OxyServerConfig, ServiceTokenAudience, ServiceTokenResponse } from './OxyServer';
|
|
46
|
+
// The requesting person: an isolated client bound to their bearer, and what
|
|
47
|
+
// they may do in an account or over an application (with typed outcomes).
|
|
48
|
+
export { OxyUserClient } from './userClient';
|
|
49
|
+
export type { OxyUserSource } from './userClient';
|
|
50
|
+
export { OxyAccessError, accessErrorStatus } from './callerAuthority';
|
|
51
|
+
export type { OxyAccessDenyStatus, OxyAccessErrorCode, OxyCallerRequest } from './callerAuthority';
|
|
52
|
+
export type {
|
|
53
|
+
CallerAccessOptions,
|
|
54
|
+
OxyAccountAccess,
|
|
55
|
+
OxyApplicationAccess,
|
|
56
|
+
} from './callerAccess';
|
|
27
57
|
export {
|
|
28
58
|
ServerAccountsApi,
|
|
29
59
|
ServerAppsApi,
|
|
@@ -53,6 +83,7 @@ export type {
|
|
|
53
83
|
CapabilityTicketGrant,
|
|
54
84
|
CapabilityTicketIntrospection,
|
|
55
85
|
CreateExecutionAuthorizationInput,
|
|
86
|
+
ServiceCallOptions,
|
|
56
87
|
ServiceCapabilityCatalog,
|
|
57
88
|
ServiceUploadBody,
|
|
58
89
|
ServiceUploadDescription,
|
|
@@ -77,6 +108,7 @@ export type {
|
|
|
77
108
|
OxyAccountEventFeedItem,
|
|
78
109
|
OxyAccountEventFeedPage,
|
|
79
110
|
OxyMiddleware,
|
|
111
|
+
RequireAccessOptions,
|
|
80
112
|
ServiceActingAsVerification,
|
|
81
113
|
ServiceApp,
|
|
82
114
|
VerifyAccountEventOptions,
|
|
@@ -186,8 +218,8 @@ export type {
|
|
|
186
218
|
} from './largeUpload';
|
|
187
219
|
|
|
188
220
|
// Strict CORS allowlist (Oxy apex family + explicit app origins).
|
|
189
|
-
export { createOxyCors } from './cors';
|
|
190
|
-
export type { OxyCorsOptions } from './cors';
|
|
221
|
+
export { createOxyCors, createOxyOriginCheck, createOxySocketCors } from './cors';
|
|
222
|
+
export type { OxyCorsOptions, OxySocketCorsOptions } from './cors';
|
|
191
223
|
|
|
192
224
|
// Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
|
|
193
225
|
// Oxy API/CDN origins) with additive, per-app extensions.
|
|
@@ -224,6 +256,8 @@ export {
|
|
|
224
256
|
verifyCapabilityTicket,
|
|
225
257
|
} from './capabilityTicket';
|
|
226
258
|
export type {
|
|
259
|
+
AsyncCapabilityTicketVerificationOptions,
|
|
260
|
+
CapabilityTicketKeyResolver,
|
|
227
261
|
CapabilityTicketMiddlewareOptions,
|
|
228
262
|
CapabilityTicketRequest,
|
|
229
263
|
CapabilityTicketSigningOptions,
|
|
@@ -4,12 +4,17 @@ import {
|
|
|
4
4
|
type CapabilityTicketClaims,
|
|
5
5
|
} from '@oxy.so/contracts';
|
|
6
6
|
import {
|
|
7
|
-
|
|
8
|
-
type
|
|
7
|
+
resolveCapabilityTicketKey,
|
|
8
|
+
type AsyncCapabilityTicketVerificationOptions,
|
|
9
9
|
} from './capabilityTicket';
|
|
10
10
|
import type { CapabilityTicketIntrospection } from './namespaces';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* `resolvePublicKey` may be asynchronous — pass `createOxyJwksKeyResolver({ jwksUrl })`
|
|
14
|
+
* as it is; the ticket's `kid` is looked up once per verification.
|
|
15
|
+
*/
|
|
16
|
+
export interface LiveCapabilityTicketVerifierOptions
|
|
17
|
+
extends AsyncCapabilityTicketVerificationOptions {
|
|
13
18
|
issuer: string;
|
|
14
19
|
introspect: (
|
|
15
20
|
ticket: string,
|
|
@@ -32,7 +37,9 @@ export function createLiveCapabilityTicketVerifier(options: LiveCapabilityTicket
|
|
|
32
37
|
input: { signal?: AbortSignal } = {},
|
|
33
38
|
): Promise<CapabilityTicketClaims> => {
|
|
34
39
|
input.signal?.throwIfAborted();
|
|
35
|
-
const
|
|
40
|
+
const verifier = await resolveCapabilityTicketKey(ticket, options);
|
|
41
|
+
input.signal?.throwIfAborted();
|
|
42
|
+
const signed = verifier.check();
|
|
36
43
|
if (!signed.catalog) throw new Error('Internal MCP requires a signed catalogue binding');
|
|
37
44
|
const controller = new AbortController();
|
|
38
45
|
const abort = () => controller.abort(input.signal?.reason);
|
|
@@ -59,7 +66,7 @@ export function createLiveCapabilityTicketVerifier(options: LiveCapabilityTicket
|
|
|
59
66
|
if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed))
|
|
60
67
|
throw new Error('Capability introspection claims mismatch');
|
|
61
68
|
// An authority response delayed past expiry cannot extend the signed grant.
|
|
62
|
-
return
|
|
69
|
+
return verifier.check();
|
|
63
70
|
} finally {
|
|
64
71
|
clearTimeout(timer);
|
|
65
72
|
input.signal?.removeEventListener('abort', abort);
|