@oxy.so/core 7.16.0 → 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 +1 -1
- 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,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The person behind a request, in an Oxy account and over an Oxy application:
|
|
3
|
+
* `OxyServer.accountAccessFor` and `OxyServer.applicationAccessFor`.
|
|
4
|
+
*
|
|
5
|
+
* Both read Oxy with the request's OWN verified bearer (an `OxyUserClient`),
|
|
6
|
+
* so Oxy resolves the HUMAN operator from the session — a person acting as
|
|
7
|
+
* their organization is answered for themselves — with inheritance, per-member
|
|
8
|
+
* grants and revokes applied. Nothing else the client sends is consulted.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // Can the caller update this store (an Oxy account) in my app?
|
|
12
|
+
* const access = await oxy.accountAccessFor(req, storeAccountId, { fresh: true });
|
|
13
|
+
* if (!access.has('account:update')) throw new OxyAccessError('ACCESS_DENIED', '…');
|
|
14
|
+
*
|
|
15
|
+
* // Can the caller manage webhooks of application A (developer RBAC)?
|
|
16
|
+
* const app = await oxy.applicationAccessFor(req, applicationId);
|
|
17
|
+
* if (!app.has('webhooks:update')) …
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Which reader answers which question is in `./callerAuthority`; the short
|
|
21
|
+
* version: `appPermissionsFor` is about THIS backend's own declared operator
|
|
22
|
+
* keys, `applicationAccessFor` about any application through the account that
|
|
23
|
+
* owns it, `accountAccessFor` about an account. For work with no person
|
|
24
|
+
* present (a background import), `oxy.accounts.accessFor(accountId, userId)`
|
|
25
|
+
* asks with the service's own token instead.
|
|
26
|
+
*
|
|
27
|
+
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
28
|
+
*/
|
|
29
|
+
import { accountAccessGrantSchema, } from '@oxy.so/contracts';
|
|
30
|
+
import { accessFailure, CallerAuthorityCache, OxyAccessError, permissionSet, readCallerSession, } from './callerAuthority.js';
|
|
31
|
+
function callerSession(req) {
|
|
32
|
+
const caller = readCallerSession(req);
|
|
33
|
+
if (caller.ok)
|
|
34
|
+
return caller.session;
|
|
35
|
+
throw new OxyAccessError('ACCESS_SESSION_REQUIRED', caller.reason === 'service'
|
|
36
|
+
? 'This request is authenticated by a service token; account and application access belong to a person in a session'
|
|
37
|
+
: 'No verified user session on this request; mount middleware.auth() first');
|
|
38
|
+
}
|
|
39
|
+
function requireId(id, what) {
|
|
40
|
+
if (typeof id !== 'string' || id.trim() === '') {
|
|
41
|
+
throw new TypeError(`${what} must be a non-empty string`);
|
|
42
|
+
}
|
|
43
|
+
return id;
|
|
44
|
+
}
|
|
45
|
+
function record(value) {
|
|
46
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
47
|
+
? value
|
|
48
|
+
: null;
|
|
49
|
+
}
|
|
50
|
+
function stringArray(value) {
|
|
51
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
52
|
+
? value
|
|
53
|
+
: null;
|
|
54
|
+
}
|
|
55
|
+
const RELATIONSHIPS = ['self', 'owner', 'member'];
|
|
56
|
+
/** `GET /accounts/:id` → the caller's effective access, or `null` for a malformed answer. */
|
|
57
|
+
export function parseAccountAccess(accountId, answer) {
|
|
58
|
+
const node = record(answer);
|
|
59
|
+
if (!node || node.accountId !== accountId)
|
|
60
|
+
return null;
|
|
61
|
+
const relationship = node.relationship;
|
|
62
|
+
if (!RELATIONSHIPS.includes(relationship) || typeof node.kind !== 'string')
|
|
63
|
+
return null;
|
|
64
|
+
const grant = accountAccessGrantSchema.safeParse(node.callerAccess);
|
|
65
|
+
if (!grant.success)
|
|
66
|
+
return null;
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
accountId,
|
|
69
|
+
kind: node.kind,
|
|
70
|
+
relationship,
|
|
71
|
+
role: grant.data.role,
|
|
72
|
+
source: grant.data.source,
|
|
73
|
+
...permissionSet(grant.data.permissions),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** `GET /applications/:id` → the caller's access over it, or `null` for a malformed answer. */
|
|
77
|
+
export function parseApplicationAccess(applicationId, answer) {
|
|
78
|
+
const application = record(answer);
|
|
79
|
+
if (!application || application._id !== applicationId)
|
|
80
|
+
return null;
|
|
81
|
+
const ownerAccountId = application.ownerAccountId;
|
|
82
|
+
const membership = record(application.callerMembership);
|
|
83
|
+
const permissions = stringArray(membership?.permissions);
|
|
84
|
+
if (typeof ownerAccountId !== 'string' ||
|
|
85
|
+
ownerAccountId === '' ||
|
|
86
|
+
typeof application.isOfficial !== 'boolean' ||
|
|
87
|
+
!membership ||
|
|
88
|
+
typeof membership.role !== 'string' ||
|
|
89
|
+
membership.role === '' ||
|
|
90
|
+
membership.ownerAccountId !== ownerAccountId ||
|
|
91
|
+
!permissions) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze({
|
|
95
|
+
applicationId,
|
|
96
|
+
ownerAccountId,
|
|
97
|
+
official: application.isOfficial,
|
|
98
|
+
role: membership.role,
|
|
99
|
+
...permissionSet(permissions),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** The caches and lookups behind `accountAccessFor` / `applicationAccessFor`. One per server. */
|
|
103
|
+
export class OxyCallerAccessResolver {
|
|
104
|
+
constructor(host) {
|
|
105
|
+
this.host = host;
|
|
106
|
+
this.accounts = new CallerAuthorityCache(host.ttlMs);
|
|
107
|
+
this.applications = new CallerAuthorityCache(host.ttlMs);
|
|
108
|
+
}
|
|
109
|
+
async account(req, accountId, options = {}) {
|
|
110
|
+
const id = requireId(accountId, 'accountId');
|
|
111
|
+
const session = callerSession(req);
|
|
112
|
+
return this.accounts.read([id], session, options.fresh === true, async () => {
|
|
113
|
+
const answer = await this.host
|
|
114
|
+
.readAccount(id, session.accessToken)
|
|
115
|
+
.catch((error) => {
|
|
116
|
+
throw accessFailure(error, `account ${id}`);
|
|
117
|
+
});
|
|
118
|
+
const access = parseAccountAccess(id, answer);
|
|
119
|
+
if (!access) {
|
|
120
|
+
throw new OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered account ${id} without the caller's access`);
|
|
121
|
+
}
|
|
122
|
+
return access;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async application(req, applicationId, options = {}) {
|
|
126
|
+
const id = requireId(applicationId, 'applicationId');
|
|
127
|
+
const session = callerSession(req);
|
|
128
|
+
return this.applications.read([id], session, options.fresh === true, async () => {
|
|
129
|
+
const answer = await this.host
|
|
130
|
+
.readApplication(id, session.accessToken)
|
|
131
|
+
.catch((error) => {
|
|
132
|
+
throw accessFailure(error, `application ${id}`);
|
|
133
|
+
});
|
|
134
|
+
const access = parseApplicationAccess(id, answer);
|
|
135
|
+
if (!access) {
|
|
136
|
+
throw new OxyAccessError('ACCESS_UNAVAILABLE', `Oxy answered application ${id} without the caller's access`);
|
|
137
|
+
}
|
|
138
|
+
return access;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
invalidate(filter = {}) {
|
|
142
|
+
this.accounts.invalidate(filter);
|
|
143
|
+
this.applications.invalidate(filter);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
/** Entries kept per reader; the oldest goes first. */
|
|
26
|
+
const CALLER_AUTHORITY_CACHE_MAX = 1000;
|
|
27
|
+
/**
|
|
28
|
+
* The person behind `req`, or why there is none: `service` for a request
|
|
29
|
+
* authenticated by a service token (delegated or not — its bearer is the
|
|
30
|
+
* service's, never the person's), `missing` when `middleware.auth()` resolved
|
|
31
|
+
* no user session.
|
|
32
|
+
*/
|
|
33
|
+
export function readCallerSession(req) {
|
|
34
|
+
if (req.serviceApp)
|
|
35
|
+
return { ok: false, reason: 'service' };
|
|
36
|
+
const { userId, sessionId, accessToken } = req;
|
|
37
|
+
if (typeof userId !== 'string' ||
|
|
38
|
+
userId.length === 0 ||
|
|
39
|
+
typeof sessionId !== 'string' ||
|
|
40
|
+
sessionId.length === 0 ||
|
|
41
|
+
typeof accessToken !== 'string' ||
|
|
42
|
+
!isBearerToken(accessToken)) {
|
|
43
|
+
return { ok: false, reason: 'missing' };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, session: { userId, sessionId, accessToken } };
|
|
46
|
+
}
|
|
47
|
+
/** A bearer that can be put in an `Authorization` header unchanged. */
|
|
48
|
+
export function isBearerToken(token) {
|
|
49
|
+
return token.length > 0 && !/\s/.test(token);
|
|
50
|
+
}
|
|
51
|
+
/** The typed failure of `asUser`, `accountAccessFor`, `applicationAccessFor` and their middleware. */
|
|
52
|
+
export class OxyAccessError extends Error {
|
|
53
|
+
constructor(code, message, options = {}) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = 'OxyAccessError';
|
|
56
|
+
this.code = code;
|
|
57
|
+
this.permission = options.permission;
|
|
58
|
+
this.cause = options.cause;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The status for an {@link OxyAccessError}: `deny` (default `403`) for a
|
|
63
|
+
* refusal; `401` for no session (`404` when `deny` is `404`, so a hidden
|
|
64
|
+
* surface stays hidden from the signed-out too); `503` when Oxy could not say.
|
|
65
|
+
*/
|
|
66
|
+
export function accessErrorStatus(error, deny = 403) {
|
|
67
|
+
switch (error.code) {
|
|
68
|
+
case 'ACCESS_DENIED':
|
|
69
|
+
return deny;
|
|
70
|
+
case 'ACCESS_SESSION_REQUIRED':
|
|
71
|
+
return deny === 404 ? 404 : 401;
|
|
72
|
+
case 'ACCESS_UNAVAILABLE':
|
|
73
|
+
return 503;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
|
|
77
|
+
export function authorityErrorBody(status) {
|
|
78
|
+
switch (status) {
|
|
79
|
+
case 401:
|
|
80
|
+
return { error: { code: 'unauthorized', message: 'Authentication required' } };
|
|
81
|
+
case 403:
|
|
82
|
+
return { error: { code: 'forbidden', message: 'Not permitted' } };
|
|
83
|
+
case 404:
|
|
84
|
+
return { error: { code: 'not_found', message: 'Not found' } };
|
|
85
|
+
case 503:
|
|
86
|
+
return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
|
|
87
|
+
default:
|
|
88
|
+
return { error: { code: 'internal_error', message: 'Internal error' } };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The HTTP status an `OxyApiError` (or anything shaped like one) carries. */
|
|
92
|
+
export function errorStatus(error) {
|
|
93
|
+
const status = error?.status;
|
|
94
|
+
return typeof status === 'number' ? status : undefined;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Map a failed caller-bearer read of Oxy to its outcome: 401 → session
|
|
98
|
+
* required, 403/404 → denied, everything else → unavailable.
|
|
99
|
+
*/
|
|
100
|
+
export function accessFailure(error, what) {
|
|
101
|
+
const status = errorStatus(error);
|
|
102
|
+
if (status === 401) {
|
|
103
|
+
return new OxyAccessError('ACCESS_SESSION_REQUIRED', `Oxy refused the session reading ${what}`, {
|
|
104
|
+
cause: error,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (status === 403 || status === 404) {
|
|
108
|
+
return new OxyAccessError('ACCESS_DENIED', `Oxy refused ${what} to this session`, {
|
|
109
|
+
cause: error,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return new OxyAccessError('ACCESS_UNAVAILABLE', `Could not read ${what} from Oxy`, {
|
|
113
|
+
cause: error,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The positive-only answer cache one caller-authority reader keeps, keyed by
|
|
118
|
+
* (resource, session, person). Concurrent identical reads share one lookup; a
|
|
119
|
+
* `fresh` read skips the cache and refreshes it; a slower, OLDER answer never
|
|
120
|
+
* overwrites a newer one (the newer may be the fresh read that saw a
|
|
121
|
+
* revocation); a failure is never stored.
|
|
122
|
+
*/
|
|
123
|
+
export class CallerAuthorityCache {
|
|
124
|
+
/**
|
|
125
|
+
* @param ttlMs How long an answer is reused; `0` disables the cache.
|
|
126
|
+
* @param isAbout Whether a cached answer concerns `userId` beyond the
|
|
127
|
+
* session's own user (an application-permissions answer names the
|
|
128
|
+
* resolved operator), for {@link invalidate}.
|
|
129
|
+
*/
|
|
130
|
+
constructor(ttlMs, isAbout = () => false) {
|
|
131
|
+
this.ttlMs = ttlMs;
|
|
132
|
+
this.isAbout = isAbout;
|
|
133
|
+
this.entries = new Map();
|
|
134
|
+
this.pending = new Map();
|
|
135
|
+
}
|
|
136
|
+
async read(resourceKey, session, fresh, lookup) {
|
|
137
|
+
const key = JSON.stringify([...resourceKey, session.sessionId, session.userId]);
|
|
138
|
+
const ttl = this.ttlMs;
|
|
139
|
+
if (!fresh && ttl > 0) {
|
|
140
|
+
const cached = this.entries.get(key);
|
|
141
|
+
if (cached && cached.expiresAt > Date.now())
|
|
142
|
+
return cached.value;
|
|
143
|
+
const inFlight = this.pending.get(key);
|
|
144
|
+
if (inFlight)
|
|
145
|
+
return inFlight;
|
|
146
|
+
}
|
|
147
|
+
const startedAt = Date.now();
|
|
148
|
+
const answer = lookup().then((value) => {
|
|
149
|
+
if (ttl > 0)
|
|
150
|
+
this.remember(key, session.userId, value, startedAt, ttl);
|
|
151
|
+
return value;
|
|
152
|
+
});
|
|
153
|
+
if (fresh || ttl <= 0)
|
|
154
|
+
return answer;
|
|
155
|
+
this.pending.set(key, answer);
|
|
156
|
+
try {
|
|
157
|
+
return await answer;
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
if (this.pending.get(key) === answer)
|
|
161
|
+
this.pending.delete(key);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Forget every answer, or those about one person. */
|
|
165
|
+
invalidate(filter = {}) {
|
|
166
|
+
const { userId } = filter;
|
|
167
|
+
if (!userId) {
|
|
168
|
+
this.entries.clear();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const [key, entry] of this.entries) {
|
|
172
|
+
if (entry.userId === userId || this.isAbout(entry.value, userId))
|
|
173
|
+
this.entries.delete(key);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
remember(key, userId, value, startedAt, ttl) {
|
|
177
|
+
const existing = this.entries.get(key);
|
|
178
|
+
if (existing && existing.startedAt > startedAt)
|
|
179
|
+
return;
|
|
180
|
+
this.entries.delete(key);
|
|
181
|
+
this.entries.set(key, { value, userId, startedAt, expiresAt: startedAt + ttl });
|
|
182
|
+
if (this.entries.size > CALLER_AUTHORITY_CACHE_MAX) {
|
|
183
|
+
const oldest = this.entries.keys().next().value;
|
|
184
|
+
if (oldest !== undefined)
|
|
185
|
+
this.entries.delete(oldest);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** A frozen, sorted permission set with `has`. */
|
|
190
|
+
export function permissionSet(permissions) {
|
|
191
|
+
const sorted = Object.freeze([...new Set(permissions)].sort());
|
|
192
|
+
const held = new Set(sorted);
|
|
193
|
+
return { permissions: sorted, has: (permission) => held.has(permission) };
|
|
194
|
+
}
|
|
@@ -41,7 +41,7 @@ export function issueCapabilityTicket(claims, options) {
|
|
|
41
41
|
const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
|
|
42
42
|
return `${signingInput}.${base64UrlEncode(signBytes(null, Buffer.from(signingInput), options.privateKey))}`;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
function parseCapabilityTicket(token) {
|
|
45
45
|
const segments = token.split('.');
|
|
46
46
|
if (segments.length !== 3) {
|
|
47
47
|
throw new CapabilityTicketError('malformed', 'Capability ticket must have three segments');
|
|
@@ -62,21 +62,29 @@ export function verifyCapabilityTicket(token, options) {
|
|
|
62
62
|
headerRecord.kid.length === 0) {
|
|
63
63
|
throw new CapabilityTicketError('malformed', 'Capability ticket header is not supported');
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
return {
|
|
66
|
+
keyId: headerRecord.kid,
|
|
67
|
+
signingInput: `${encodedHeader}.${encodedPayload}`,
|
|
68
|
+
encodedPayload,
|
|
69
|
+
encodedSignature,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Signature and claims of a parsed ticket against the key its `kid` resolved to. */
|
|
73
|
+
function checkCapabilityTicket(ticket, publicKey, options) {
|
|
66
74
|
if (!publicKey || publicKey.asymmetricKeyType !== 'ed25519') {
|
|
67
75
|
throw new CapabilityTicketError('unknown_key', 'Capability ticket signing key is not trusted');
|
|
68
76
|
}
|
|
69
77
|
let providedSignature;
|
|
70
78
|
try {
|
|
71
|
-
providedSignature = Buffer.from(encodedSignature, 'base64url');
|
|
79
|
+
providedSignature = Buffer.from(ticket.encodedSignature, 'base64url');
|
|
72
80
|
}
|
|
73
81
|
catch {
|
|
74
82
|
throw new CapabilityTicketError('malformed', 'Capability ticket signature is malformed');
|
|
75
83
|
}
|
|
76
|
-
if (!verifyBytes(null, Buffer.from(
|
|
84
|
+
if (!verifyBytes(null, Buffer.from(ticket.signingInput), publicKey, providedSignature)) {
|
|
77
85
|
throw new CapabilityTicketError('invalid_signature', 'Capability ticket signature is invalid');
|
|
78
86
|
}
|
|
79
|
-
const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(encodedPayload));
|
|
87
|
+
const parsed = capabilityTicketClaimsSchema.safeParse(decodeJsonSegment(ticket.encodedPayload));
|
|
80
88
|
if (!parsed.success) {
|
|
81
89
|
throw new CapabilityTicketError('invalid_claims', 'Capability ticket claims are invalid');
|
|
82
90
|
}
|
|
@@ -99,6 +107,30 @@ export function verifyCapabilityTicket(token, options) {
|
|
|
99
107
|
}
|
|
100
108
|
return claims;
|
|
101
109
|
}
|
|
110
|
+
export function verifyCapabilityTicket(token, options) {
|
|
111
|
+
const ticket = parseCapabilityTicket(token);
|
|
112
|
+
return checkCapabilityTicket(ticket, options.resolvePublicKey(ticket.keyId), options);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A verifier for one ticket whose key lookup may be asynchronous: the key is
|
|
116
|
+
* resolved once, and the returned `check` re-verifies signature and claims
|
|
117
|
+
* against it (the live verifier checks again after introspection, so a slow
|
|
118
|
+
* authority answer cannot outlive the ticket).
|
|
119
|
+
*
|
|
120
|
+
* @internal Shared by the middleware and `createLiveCapabilityTicketVerifier`.
|
|
121
|
+
*/
|
|
122
|
+
export async function resolveCapabilityTicketKey(token, options) {
|
|
123
|
+
const ticket = parseCapabilityTicket(token);
|
|
124
|
+
let publicKey;
|
|
125
|
+
try {
|
|
126
|
+
publicKey = await options.resolvePublicKey(ticket.keyId);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// A key set that cannot be read trusts no key; never a different error class.
|
|
130
|
+
publicKey = undefined;
|
|
131
|
+
}
|
|
132
|
+
return { check: () => checkCapabilityTicket(ticket, publicKey, options) };
|
|
133
|
+
}
|
|
102
134
|
export function readCapabilityAuthorization(authorization) {
|
|
103
135
|
if (!authorization)
|
|
104
136
|
return null;
|
|
@@ -121,7 +153,7 @@ export function createCapabilityTicketMiddleware(options) {
|
|
|
121
153
|
}
|
|
122
154
|
let claims;
|
|
123
155
|
try {
|
|
124
|
-
claims =
|
|
156
|
+
claims = (await resolveCapabilityTicketKey(token, options)).check();
|
|
125
157
|
}
|
|
126
158
|
catch (error) {
|
|
127
159
|
const code = error instanceof CapabilityTicketError ? error.code : 'invalid_claims';
|
package/dist/esm/server/cors.js
CHANGED
|
@@ -158,6 +158,43 @@ export function matchesAllowedOrigin(explicit, origin) {
|
|
|
158
158
|
return true;
|
|
159
159
|
return isOxyFamilyOrigin(normalized);
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The origin predicate `createOxyCors` applies, for any other transport that
|
|
163
|
+
* must admit the same browsers: the built-in HTTPS Oxy apex family plus the
|
|
164
|
+
* exact `appOrigins`, never an opaque origin (`exp://`, `file:`, …).
|
|
165
|
+
*
|
|
166
|
+
* ```ts
|
|
167
|
+
* const allowed = createOxyOriginCheck(['https://app.example.com']);
|
|
168
|
+
* allowed('https://mention.oxy.so'); // true
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function createOxyOriginCheck(appOrigins = []) {
|
|
172
|
+
const explicit = normalizeAppOrigins(appOrigins);
|
|
173
|
+
return (origin) => matchesAllowedOrigin(explicit, origin);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The Socket.IO server options that admit exactly the origins `createOxyCors`
|
|
177
|
+
* admits — spread them into `new Server(httpServer, { ...createOxySocketCors(…) })`.
|
|
178
|
+
*
|
|
179
|
+
* Two halves, because Socket.IO has two transports: `cors` answers the
|
|
180
|
+
* long-polling requests (which browsers check), and `allowRequest` refuses a
|
|
181
|
+
* handshake from any other browser origin, which is the only check a WebSocket
|
|
182
|
+
* upgrade gets — browsers apply no CORS to it. A request with no `Origin`
|
|
183
|
+
* header (a native app, a server) is not a browser cross-origin request and is
|
|
184
|
+
* admitted; authenticate it with `oxy.middleware.socket()` as every socket.
|
|
185
|
+
*/
|
|
186
|
+
export function createOxySocketCors(options = {}) {
|
|
187
|
+
const allowed = createOxyOriginCheck(options.appOrigins);
|
|
188
|
+
const admits = (origin) => origin === undefined || (typeof origin === 'string' && (origin === '' || allowed(origin)));
|
|
189
|
+
return {
|
|
190
|
+
cors: {
|
|
191
|
+
origin: (origin, callback) => callback(null, admits(origin)),
|
|
192
|
+
credentials: options.allowCredentials ?? true,
|
|
193
|
+
methods: ['GET', 'POST'],
|
|
194
|
+
},
|
|
195
|
+
allowRequest: (req, callback) => callback(null, admits(req.headers.origin)),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
161
198
|
/**
|
|
162
199
|
* Create a strict Oxy CORS middleware. See module docs.
|
|
163
200
|
*
|
|
@@ -168,7 +205,7 @@ export function matchesAllowedOrigin(explicit, origin) {
|
|
|
168
205
|
*/
|
|
169
206
|
export function createOxyCors(options = {}) {
|
|
170
207
|
const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
|
|
171
|
-
const
|
|
208
|
+
const allowed = createOxyOriginCheck(appOrigins);
|
|
172
209
|
const methodsHeader = methods.join(', ');
|
|
173
210
|
const allowedHeadersHeader = allowedHeaders.join(', ');
|
|
174
211
|
const exposedHeadersHeader = exposedHeaders.join(', ');
|
|
@@ -186,7 +223,7 @@ export function createOxyCors(options = {}) {
|
|
|
186
223
|
}
|
|
187
224
|
// Origin is present. Caching correctness: this response varies by Origin.
|
|
188
225
|
res.setHeader('Vary', 'Origin');
|
|
189
|
-
if (!
|
|
226
|
+
if (!allowed(origin)) {
|
|
190
227
|
// DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
|
|
191
228
|
// will block the cross-origin read. Preflights for denied origins get a
|
|
192
229
|
// 204 with no CORS headers (the actual request then fails CORS).
|
package/dist/esm/server/index.js
CHANGED
|
@@ -4,19 +4,42 @@
|
|
|
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
|
// The backend client: OxyServices plus the service-token lane, middleware,
|
|
18
37
|
// account events and the service-only namespace methods.
|
|
19
38
|
export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, DEFAULT_SERVICE_TOKEN_AUDIENCE, } from './OxyServer.js';
|
|
39
|
+
// The requesting person: an isolated client bound to their bearer, and what
|
|
40
|
+
// they may do in an account or over an application (with typed outcomes).
|
|
41
|
+
export { OxyUserClient } from './userClient.js';
|
|
42
|
+
export { OxyAccessError, accessErrorStatus } from './callerAuthority.js';
|
|
20
43
|
export { ServerAccountsApi, ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
|
|
21
44
|
export { ServerSafetyApi } from './safety.js';
|
|
22
45
|
export { ServerSessionApi } from './session.js';
|
|
@@ -41,7 +64,7 @@ export { FILE_EVENT_DEFAULT_TOLERANCE_SECONDS, FILE_EVENT_SIGNATURE_HEADER, FILE
|
|
|
41
64
|
export { STREAM_KEY_AUTHORIZATION_EVENT_TYPE, StreamKeyAuthorizationVerificationError, verifyStreamKeyAuthorization, } from './streamKeyAuthorization.js';
|
|
42
65
|
export { LargeUploadFailedError } from './largeUpload.js';
|
|
43
66
|
// Strict CORS allowlist (Oxy apex family + explicit app origins).
|
|
44
|
-
export { createOxyCors } from './cors.js';
|
|
67
|
+
export { createOxyCors, createOxyOriginCheck, createOxySocketCors } from './cors.js';
|
|
45
68
|
// Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
|
|
46
69
|
// Oxy API/CDN origins) with additive, per-app extensions.
|
|
47
70
|
//
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canonicalCapabilityJson, capabilityTicketClaimsSchema, } from '@oxy.so/contracts';
|
|
2
|
-
import {
|
|
2
|
+
import { resolveCapabilityTicketKey, } from './capabilityTicket.js';
|
|
3
3
|
/** Local signature + live receiving-service introspection; no cache or retry. */
|
|
4
4
|
export function createLiveCapabilityTicketVerifier(options) {
|
|
5
5
|
if (typeof options.issuer !== 'string' || options.issuer.trim() === '')
|
|
@@ -10,7 +10,9 @@ export function createLiveCapabilityTicketVerifier(options) {
|
|
|
10
10
|
}
|
|
11
11
|
return async (ticket, input = {}) => {
|
|
12
12
|
input.signal?.throwIfAborted();
|
|
13
|
-
const
|
|
13
|
+
const verifier = await resolveCapabilityTicketKey(ticket, options);
|
|
14
|
+
input.signal?.throwIfAborted();
|
|
15
|
+
const signed = verifier.check();
|
|
14
16
|
if (!signed.catalog)
|
|
15
17
|
throw new Error('Internal MCP requires a signed catalogue binding');
|
|
16
18
|
const controller = new AbortController();
|
|
@@ -36,7 +38,7 @@ export function createLiveCapabilityTicketVerifier(options) {
|
|
|
36
38
|
if (canonicalCapabilityJson(live) !== canonicalCapabilityJson(signed))
|
|
37
39
|
throw new Error('Capability introspection claims mismatch');
|
|
38
40
|
// An authority response delayed past expiry cannot extend the signed grant.
|
|
39
|
-
return
|
|
41
|
+
return verifier.check();
|
|
40
42
|
}
|
|
41
43
|
finally {
|
|
42
44
|
clearTimeout(timer);
|