@oxyhq/core 21.0.2 → 21.1.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.utility.js +34 -1
- package/dist/cjs/server/index.js +10 -1
- package/dist/cjs/server/securityHeaders.js +144 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +34 -1
- package/dist/esm/server/index.js +7 -1
- package/dist/esm/server/securityHeaders.js +141 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +16 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +24 -2
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/server/securityHeaders.d.ts +67 -0
- package/package.json +3 -3
- package/src/mixins/OxyServices.accounts.ts +16 -0
- package/src/mixins/OxyServices.utility.ts +46 -3
- package/src/mixins/__tests__/verifyServiceActingAs.test.ts +167 -0
- package/src/server/__tests__/securityHeaders.test.ts +116 -12
- package/src/server/index.ts +9 -0
- package/src/server/securityHeaders.ts +164 -1
|
@@ -191,6 +191,22 @@ export interface CreateAccountInput {
|
|
|
191
191
|
* keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
|
|
192
192
|
*/
|
|
193
193
|
accountCategories?: AccountCategoryId[];
|
|
194
|
+
/**
|
|
195
|
+
* Create the account already opted OUT of discovery — kept out of people
|
|
196
|
+
* search, the follow-graph lists, `/similar` and the recommendation pools,
|
|
197
|
+
* with non-public media follower-gated.
|
|
198
|
+
*
|
|
199
|
+
* Pass `true` when the account is not something its owner has published yet:
|
|
200
|
+
* an agent, an unlaunched project, an organization for something unannounced.
|
|
201
|
+
* OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
|
|
202
|
+
* the platform default, which is discoverable, and that default is not
|
|
203
|
+
* changed by this option existing.
|
|
204
|
+
*
|
|
205
|
+
* Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
|
|
206
|
+
* own bearer. Passing it here is the only way to have the account never be
|
|
207
|
+
* discoverable at all, rather than discoverable until a second call lands.
|
|
208
|
+
*/
|
|
209
|
+
isPrivateAccount?: boolean;
|
|
194
210
|
}
|
|
195
211
|
/** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
|
|
196
212
|
export interface UpdateAccountInput {
|
|
@@ -6,8 +6,16 @@ import { type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
|
|
|
6
6
|
* Confirms that a given service app holds an active delegation grant for
|
|
7
7
|
* the supplied user, along with the explicit scope list the grant covers.
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
9
|
+
* The API side stores this as an ordinary `app_grants` row — the SAME revocable
|
|
10
|
+
* record the OAuth consent screen writes and the "Connected apps" UI lists and
|
|
11
|
+
* deletes — whose `scopes` name `acting-as:offline`. There is deliberately no
|
|
12
|
+
* separate delegation table: a second store would be a second revocation
|
|
13
|
+
* surface, and a user who disconnects an application in "Connected apps" means
|
|
14
|
+
* it, so one revoke has to end everything.
|
|
15
|
+
*
|
|
16
|
+
* `scopes` is what THAT USER consented to, not what the application may do in
|
|
17
|
+
* general. `requireScope` intersects it with the token's own app-wide scopes for
|
|
18
|
+
* a delegated request, and the intersection is the effective authority.
|
|
11
19
|
*
|
|
12
20
|
* The SDK never inspects the grant directly — it round-trips through
|
|
13
21
|
* `GET /internal/service-acting-as/verify?appId=...&userId=...` so the
|
|
@@ -266,6 +274,20 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
266
274
|
* service requests require the app scope. Delegated user requests require
|
|
267
275
|
* BOTH the app scope and the per-user delegation scope.
|
|
268
276
|
*
|
|
277
|
+
* The intersection is the point, not a redundancy, because the two scope
|
|
278
|
+
* lists answer different questions and neither implies the other:
|
|
279
|
+
*
|
|
280
|
+
* `serviceApp.scopes` what the PLATFORM allows this application to do
|
|
281
|
+
* (credential ∩ application ceiling, at mint time)
|
|
282
|
+
* `serviceActingAs.scopes` what THIS USER allowed it to do (`app_grants`)
|
|
283
|
+
*
|
|
284
|
+
* Requiring only the app scope would let an application do to a user
|
|
285
|
+
* something that user never consented to; requiring only the grant would let
|
|
286
|
+
* a user hand an application authority staff never gave it, so a revoked
|
|
287
|
+
* platform scope would keep working for every user who had already
|
|
288
|
+
* consented. Effective authority is the intersection, and this is where it
|
|
289
|
+
* is taken.
|
|
290
|
+
*
|
|
269
291
|
* Requests authenticated as a regular user (no service token) are rejected
|
|
270
292
|
* with 403 — scope-protected endpoints are service-to-service by design.
|
|
271
293
|
*
|
|
@@ -22,7 +22,7 @@ export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamErr
|
|
|
22
22
|
export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, SsrfCheckResult, } from './safeFetch';
|
|
23
23
|
export { createOxyCors } from './cors';
|
|
24
24
|
export type { OxyCorsOptions } from './cors';
|
|
25
|
-
export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders';
|
|
25
|
+
export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, cspSourcesFor, extractInlineScripts, formatOxyCspPolicy, inlineScriptCspHash, OXY_CSP_BASELINE, } from './securityHeaders';
|
|
26
26
|
export type { OxyCspDirective, OxyCspExtensions, OxyPagesHeadersOptions, OxySecurityHeadersOptions, } from './securityHeaders';
|
|
27
27
|
export { verifySecret } from './verifySecret';
|
|
28
28
|
export { createOxyUserInvalidationHandler, publishOxyUserInvalidation, } from './userInvalidation';
|
|
@@ -33,6 +33,19 @@
|
|
|
33
33
|
* cannot pass their own `contentSecurityPolicy` through to Helmet at all
|
|
34
34
|
* (the option is typed `never`).
|
|
35
35
|
*
|
|
36
|
+
* 3. A STATIC EXPO EXPORT SHIPS AN INLINE SCRIPT THE BASELINE FORBIDS.
|
|
37
|
+
* `web.output: 'static'` makes Expo Router emit
|
|
38
|
+
* `<script type="module">globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>`,
|
|
39
|
+
* which is what tells the client entry to call `hydrateRoot` instead of
|
|
40
|
+
* `createRoot().render()`. Nothing in app code puts it there, so — like the
|
|
41
|
+
* Cloudflare beacon above — an app cannot allowlist it from the app side.
|
|
42
|
+
* Measured on `accounts.oxy.so` 2026-08-21: blocked, so every visit threw
|
|
43
|
+
* away the server-rendered markup and re-rendered from scratch, with only a
|
|
44
|
+
* console error to show for it. The hashes are therefore DERIVED from the
|
|
45
|
+
* built output rather than hand-written (see {@link extractInlineScripts}):
|
|
46
|
+
* a hash pasted into config is correct exactly until the build changes one
|
|
47
|
+
* byte, and then it fails the same silent way.
|
|
48
|
+
*
|
|
36
49
|
* WHAT IT PROVIDES
|
|
37
50
|
* ----------------
|
|
38
51
|
* `createOxySecurityHeaders(options)` returns the Helmet middleware with the
|
|
@@ -102,6 +115,43 @@ export declare function buildOxyCspDirectives(extensions?: OxyCspExtensions): Re
|
|
|
102
115
|
* `upgrade-insecure-requests`) emit the name alone.
|
|
103
116
|
*/
|
|
104
117
|
export declare function formatOxyCspPolicy(directives: Record<string, string[]>): string;
|
|
118
|
+
/**
|
|
119
|
+
* The source list one directive carries in a serialized policy, or `[]` when
|
|
120
|
+
* the policy does not name that directive. The inverse of
|
|
121
|
+
* {@link formatOxyCspPolicy}, and the reason it lives here rather than beside
|
|
122
|
+
* either caller: the post-deploy gate parses the policy the ORIGIN serves while
|
|
123
|
+
* the unit test parses the one the middleware renders, so a copy in each would
|
|
124
|
+
* let the header shape change with the test still green and the gate reading
|
|
125
|
+
* `[]` — reporting every script blocked, which reads as a broken app rather
|
|
126
|
+
* than as a broken parser.
|
|
127
|
+
*
|
|
128
|
+
* A directive present with no sources (`upgrade-insecure-requests`) and a
|
|
129
|
+
* directive absent entirely both answer `[]`. Callers that need to tell those
|
|
130
|
+
* apart are asking a different question than "what is allowed here".
|
|
131
|
+
*/
|
|
132
|
+
export declare function cspSourcesFor(policy: string, directive: string): string[];
|
|
133
|
+
/**
|
|
134
|
+
* Every inline `<script>` body in an HTML document, in document order. A
|
|
135
|
+
* `<script src=…>` is a URL the source list already governs and is skipped.
|
|
136
|
+
*
|
|
137
|
+
* Scanned rather than matched with one regex because the two failure modes are
|
|
138
|
+
* not symmetric: an EXTRA body costs a redundant hash nobody notices, while a
|
|
139
|
+
* MISSED body silently reinstates the exact breakage this exists to prevent.
|
|
140
|
+
* So the scan errs toward finding them — it walks the open tag quote-aware
|
|
141
|
+
* instead of letting a `>` inside an attribute value truncate it.
|
|
142
|
+
*
|
|
143
|
+
* The type attribute is deliberately not consulted. Whether a given `type`
|
|
144
|
+
* executes is a browser decision (and it changes: `importmap` and
|
|
145
|
+
* `speculationrules` were both once inert), and pinning the exact bytes of a
|
|
146
|
+
* data block we ship ourselves weakens nothing.
|
|
147
|
+
*/
|
|
148
|
+
export declare function extractInlineScripts(html: string): string[];
|
|
149
|
+
/**
|
|
150
|
+
* The `'sha256-…'` source that allows one inline script, hashed over its exact
|
|
151
|
+
* bytes as CSP specifies — no trimming, no normalization. One byte of
|
|
152
|
+
* whitespace either way is a different hash and the script stays blocked.
|
|
153
|
+
*/
|
|
154
|
+
export declare function inlineScriptCspHash(source: string): string;
|
|
105
155
|
export interface OxyPagesHeadersOptions {
|
|
106
156
|
/** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
|
|
107
157
|
csp?: OxyCspExtensions;
|
|
@@ -110,11 +160,28 @@ export interface OxyPagesHeadersOptions {
|
|
|
110
160
|
* HTTPS only, so static deploys should keep this on.
|
|
111
161
|
*/
|
|
112
162
|
hsts?: boolean;
|
|
163
|
+
/**
|
|
164
|
+
* The BUILT HTML documents this `_headers` will be served alongside. Every
|
|
165
|
+
* inline script found in them is allowed by hash, added to `script-src`.
|
|
166
|
+
*
|
|
167
|
+
* Passing the built output — rather than hand-writing a hash into
|
|
168
|
+
* `oxy.pages-headers.json` — is the whole point: a pasted hash is correct
|
|
169
|
+
* until the generator changes one byte of that script, and then the script is
|
|
170
|
+
* blocked again with nothing but a console error to show for it.
|
|
171
|
+
*/
|
|
172
|
+
html?: readonly string[];
|
|
113
173
|
}
|
|
114
174
|
/**
|
|
115
175
|
* Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
|
|
116
176
|
* same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
|
|
117
177
|
* hardening headers Helmet would add on an Express HTML backend.
|
|
178
|
+
*
|
|
179
|
+
* Adding a hash to `script-src` does not narrow it: per CSP Level 3 a hash is
|
|
180
|
+
* an additional source, so `'self'` and the beacon host keep matching external
|
|
181
|
+
* scripts. (It WOULD neutralize `'unsafe-inline'` in the same directive — which
|
|
182
|
+
* is why this hashes scripts only. `style-src` keeps `'unsafe-inline'` for
|
|
183
|
+
* react-native-web's runtime stylesheet, and a style hash would silently switch
|
|
184
|
+
* that off and render every Oxy web app unstyled.)
|
|
118
185
|
*/
|
|
119
186
|
export declare function buildOxyPagesHeaders(options?: OxyPagesHeadersOptions): string;
|
|
120
187
|
export interface OxySecurityHeadersOptions {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "21.0
|
|
3
|
+
"version": "21.1.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"dependencies": {
|
|
117
117
|
"@noble/ciphers": "^1.3.0",
|
|
118
118
|
"@noble/hashes": "^1.8.0",
|
|
119
|
-
"@oxyhq/contracts": "^0.
|
|
119
|
+
"@oxyhq/contracts": "^0.31.0",
|
|
120
120
|
"@oxyhq/protocol": "^0.2.0",
|
|
121
121
|
"@scure/bip39": "^1.6.0",
|
|
122
122
|
"@types/elliptic": "^6.4.18",
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"devDependencies": {
|
|
154
154
|
"@biomejs/biome": "^1.9.4",
|
|
155
155
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
156
|
-
"@types/express": "^4.17.
|
|
156
|
+
"@types/express": "^4.17.25",
|
|
157
157
|
"@types/invariant": "^2.2.34",
|
|
158
158
|
"@types/node": "^20.19.43",
|
|
159
159
|
"expo-crypto": "~56.0.3",
|
|
@@ -212,6 +212,22 @@ export interface CreateAccountInput {
|
|
|
212
212
|
* keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
|
|
213
213
|
*/
|
|
214
214
|
accountCategories?: AccountCategoryId[];
|
|
215
|
+
/**
|
|
216
|
+
* Create the account already opted OUT of discovery — kept out of people
|
|
217
|
+
* search, the follow-graph lists, `/similar` and the recommendation pools,
|
|
218
|
+
* with non-public media follower-gated.
|
|
219
|
+
*
|
|
220
|
+
* Pass `true` when the account is not something its owner has published yet:
|
|
221
|
+
* an agent, an unlaunched project, an organization for something unannounced.
|
|
222
|
+
* OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
|
|
223
|
+
* the platform default, which is discoverable, and that default is not
|
|
224
|
+
* changed by this option existing.
|
|
225
|
+
*
|
|
226
|
+
* Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
|
|
227
|
+
* own bearer. Passing it here is the only way to have the account never be
|
|
228
|
+
* discoverable at all, rather than discoverable until a second call lands.
|
|
229
|
+
*/
|
|
230
|
+
isPrivateAccount?: boolean;
|
|
215
231
|
}
|
|
216
232
|
|
|
217
233
|
/** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
|
|
@@ -36,8 +36,16 @@ interface JwtPayload {
|
|
|
36
36
|
* Confirms that a given service app holds an active delegation grant for
|
|
37
37
|
* the supplied user, along with the explicit scope list the grant covers.
|
|
38
38
|
*
|
|
39
|
-
* The
|
|
40
|
-
*
|
|
39
|
+
* The API side stores this as an ordinary `app_grants` row — the SAME revocable
|
|
40
|
+
* record the OAuth consent screen writes and the "Connected apps" UI lists and
|
|
41
|
+
* deletes — whose `scopes` name `acting-as:offline`. There is deliberately no
|
|
42
|
+
* separate delegation table: a second store would be a second revocation
|
|
43
|
+
* surface, and a user who disconnects an application in "Connected apps" means
|
|
44
|
+
* it, so one revoke has to end everything.
|
|
45
|
+
*
|
|
46
|
+
* `scopes` is what THAT USER consented to, not what the application may do in
|
|
47
|
+
* general. `requireScope` intersects it with the token's own app-wide scopes for
|
|
48
|
+
* a delegated request, and the intersection is the effective authority.
|
|
41
49
|
*
|
|
42
50
|
* The SDK never inspects the grant directly — it round-trips through
|
|
43
51
|
* `GET /internal/service-acting-as/verify?appId=...&userId=...` so the
|
|
@@ -217,11 +225,31 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
217
225
|
}
|
|
218
226
|
|
|
219
227
|
try {
|
|
228
|
+
// The verify endpoint is service-to-service and admits only a
|
|
229
|
+
// platform-TRUSTED calling application, so this call must carry the
|
|
230
|
+
// VERIFIER's own service token. Sent explicitly rather than through
|
|
231
|
+
// `makeServiceRequest`, which would drop `retry: false` and the timeout
|
|
232
|
+
// — and those two are not incidental: this runs inside request-handling
|
|
233
|
+
// middleware, so an inner retry loop multiplies the latency of every
|
|
234
|
+
// delegated request by the number of attempts.
|
|
235
|
+
//
|
|
236
|
+
// A verifier with no service credentials configured throws here and
|
|
237
|
+
// lands in the catch below, which is the correct outcome. A host that
|
|
238
|
+
// cannot prove who it is has no business being told which users have
|
|
239
|
+
// delegated to which applications, and the 60s negative cache stops a
|
|
240
|
+
// misconfigured deployment from turning every request into a round trip.
|
|
241
|
+
const serviceToken = await (this as unknown as OxyAuthInstance).getServiceToken();
|
|
242
|
+
|
|
220
243
|
const result = await this.makeRequest<ServiceActingAsVerification>(
|
|
221
244
|
'GET',
|
|
222
245
|
'/internal/service-acting-as/verify',
|
|
223
246
|
{ appId, userId },
|
|
224
|
-
{
|
|
247
|
+
{
|
|
248
|
+
cache: false,
|
|
249
|
+
retry: false,
|
|
250
|
+
timeout: 5000,
|
|
251
|
+
headers: { Authorization: `Bearer ${serviceToken}` },
|
|
252
|
+
},
|
|
225
253
|
);
|
|
226
254
|
|
|
227
255
|
const authorized = Boolean(result && result.authorized);
|
|
@@ -973,6 +1001,20 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
973
1001
|
* service requests require the app scope. Delegated user requests require
|
|
974
1002
|
* BOTH the app scope and the per-user delegation scope.
|
|
975
1003
|
*
|
|
1004
|
+
* The intersection is the point, not a redundancy, because the two scope
|
|
1005
|
+
* lists answer different questions and neither implies the other:
|
|
1006
|
+
*
|
|
1007
|
+
* `serviceApp.scopes` what the PLATFORM allows this application to do
|
|
1008
|
+
* (credential ∩ application ceiling, at mint time)
|
|
1009
|
+
* `serviceActingAs.scopes` what THIS USER allowed it to do (`app_grants`)
|
|
1010
|
+
*
|
|
1011
|
+
* Requiring only the app scope would let an application do to a user
|
|
1012
|
+
* something that user never consented to; requiring only the grant would let
|
|
1013
|
+
* a user hand an application authority staff never gave it, so a revoked
|
|
1014
|
+
* platform scope would keep working for every user who had already
|
|
1015
|
+
* consented. Effective authority is the intersection, and this is where it
|
|
1016
|
+
* is taken.
|
|
1017
|
+
*
|
|
976
1018
|
* Requests authenticated as a regular user (no service token) are rejected
|
|
977
1019
|
* with 403 — scope-protected endpoints are service-to-service by design.
|
|
978
1020
|
*
|
|
@@ -1155,6 +1197,7 @@ interface SocketLike {
|
|
|
1155
1197
|
|
|
1156
1198
|
interface OxyAuthInstance {
|
|
1157
1199
|
verifyServiceActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null>;
|
|
1200
|
+
getServiceToken(apiKey?: string, apiSecret?: string): Promise<string>;
|
|
1158
1201
|
validateSession(
|
|
1159
1202
|
sessionId: string,
|
|
1160
1203
|
options?: { deviceFingerprint?: string; useHeaderValidation?: boolean },
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `verifyServiceActingAs` — the SDK half of the delegation check.
|
|
3
|
+
*
|
|
4
|
+
* `serviceAuth.test.ts` covers what the MIDDLEWARE does with this method's
|
|
5
|
+
* answer, and it does so by stubbing the method out. So nothing there exercises
|
|
6
|
+
* the method itself: how it authenticates, what it sends, and what it does when
|
|
7
|
+
* the answer is no or never arrives. That is this file.
|
|
8
|
+
*
|
|
9
|
+
* The property under test throughout is fail-closed. `null` is the only value
|
|
10
|
+
* this method may return when it is not certain, because the middleware turns
|
|
11
|
+
* `null` into a 403 and anything else into an attached `req.userId`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { OxyServices } from '../../OxyServices';
|
|
15
|
+
import type { RequestOptions } from '../../types';
|
|
16
|
+
|
|
17
|
+
const APP = 'delegating-app';
|
|
18
|
+
const USER = 'subject-user';
|
|
19
|
+
|
|
20
|
+
interface CapturedCall {
|
|
21
|
+
method: string;
|
|
22
|
+
url: string;
|
|
23
|
+
data: unknown;
|
|
24
|
+
options: RequestOptions | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stub `makeRequest` and record what it was handed.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately not a network mock: the assertion that matters is the exact
|
|
31
|
+
* request the SDK composes — an unauthenticated one now gets a 403 from the API
|
|
32
|
+
* rather than an answer, so the Authorization header is part of the contract and
|
|
33
|
+
* not an implementation detail.
|
|
34
|
+
*/
|
|
35
|
+
function captureRequests(oxy: OxyServices, result: unknown) {
|
|
36
|
+
const calls: CapturedCall[] = [];
|
|
37
|
+
jest
|
|
38
|
+
.spyOn(oxy, 'makeRequest')
|
|
39
|
+
.mockImplementation(async (method, url, data, options) => {
|
|
40
|
+
calls.push({ method, url, data, options });
|
|
41
|
+
if (result instanceof Error) throw result;
|
|
42
|
+
return result as never;
|
|
43
|
+
});
|
|
44
|
+
return calls;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('verifyServiceActingAs', () => {
|
|
48
|
+
let oxy: OxyServices;
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
52
|
+
jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('verifier-service-token');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
jest.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("authenticates with the VERIFIER's own service token", async () => {
|
|
60
|
+
const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
|
|
61
|
+
|
|
62
|
+
await oxy.verifyServiceActingAs(APP, USER);
|
|
63
|
+
|
|
64
|
+
expect(calls).toHaveLength(1);
|
|
65
|
+
expect(calls[0].options?.headers).toEqual({
|
|
66
|
+
Authorization: 'Bearer verifier-service-token',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('sends the pair as query params, with retries off and a bounded timeout', async () => {
|
|
71
|
+
// This runs inside request-handling middleware. A retry loop here multiplies
|
|
72
|
+
// the latency of every delegated request by the number of attempts, and a
|
|
73
|
+
// cached GET would serve a revoked grant.
|
|
74
|
+
const calls = captureRequests(oxy, { authorized: true, scopes: [] });
|
|
75
|
+
|
|
76
|
+
await oxy.verifyServiceActingAs(APP, USER);
|
|
77
|
+
|
|
78
|
+
expect(calls[0].method).toBe('GET');
|
|
79
|
+
expect(calls[0].url).toBe('/internal/service-acting-as/verify');
|
|
80
|
+
expect(calls[0].data).toEqual({ appId: APP, userId: USER });
|
|
81
|
+
expect(calls[0].options).toMatchObject({ cache: false, retry: false, timeout: 5000 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns the grant when the API authorizes, carrying the scopes through', async () => {
|
|
85
|
+
captureRequests(oxy, { authorized: true, scopes: ['acting-as:offline', 'podcasts:write'] });
|
|
86
|
+
|
|
87
|
+
const grant = await oxy.verifyServiceActingAs(APP, USER);
|
|
88
|
+
|
|
89
|
+
expect(grant).toEqual({
|
|
90
|
+
authorized: true,
|
|
91
|
+
scopes: ['acting-as:offline', 'podcasts:write'],
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns null when the API answers authorized:false', async () => {
|
|
96
|
+
captureRequests(oxy, { authorized: false, scopes: [] });
|
|
97
|
+
|
|
98
|
+
await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns null when the API answers with scopes but NO authorized flag', async () => {
|
|
102
|
+
// A truthy `scopes` array must never stand in for authorization: a caller
|
|
103
|
+
// reading the array and skipping the boolean is the mistake, and the SDK
|
|
104
|
+
// refuses to produce a value that would reward it.
|
|
105
|
+
captureRequests(oxy, { scopes: ['podcasts:write'] });
|
|
106
|
+
|
|
107
|
+
await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns null when the endpoint is UNREACHABLE — there is no fail-open path', async () => {
|
|
111
|
+
captureRequests(oxy, new Error('ECONNREFUSED'));
|
|
112
|
+
|
|
113
|
+
await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns null, and never calls the endpoint, when the verifier has no service credentials', async () => {
|
|
117
|
+
// A host that cannot prove who it is has no business being told which users
|
|
118
|
+
// delegated to which applications. `getServiceToken()` throws, and that is
|
|
119
|
+
// the whole outcome.
|
|
120
|
+
jest
|
|
121
|
+
.spyOn(oxy, 'getServiceToken')
|
|
122
|
+
.mockRejectedValue(new Error('Service credentials not provided.'));
|
|
123
|
+
const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
|
|
124
|
+
|
|
125
|
+
await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
|
|
126
|
+
expect(calls).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('caching', () => {
|
|
130
|
+
it('serves a positive grant from cache rather than re-asking', async () => {
|
|
131
|
+
const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
|
|
132
|
+
|
|
133
|
+
await oxy.verifyServiceActingAs(APP, USER);
|
|
134
|
+
await oxy.verifyServiceActingAs(APP, USER);
|
|
135
|
+
|
|
136
|
+
expect(calls).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('caches a REFUSAL too, so a misconfigured caller cannot hammer the endpoint', async () => {
|
|
140
|
+
const calls = captureRequests(oxy, { authorized: false, scopes: [] });
|
|
141
|
+
|
|
142
|
+
await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
|
|
143
|
+
await expect(oxy.verifyServiceActingAs(APP, USER)).resolves.toBeNull();
|
|
144
|
+
|
|
145
|
+
expect(calls).toHaveLength(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keys the cache on BOTH app and user — one grant never answers for another', async () => {
|
|
149
|
+
// The cache key is the whole security boundary of this method. Keyed on
|
|
150
|
+
// the user alone, one application's grant would authorize every other
|
|
151
|
+
// application for that user; keyed on the app alone, one user's grant
|
|
152
|
+
// would authorize acting as everybody.
|
|
153
|
+
const calls = captureRequests(oxy, { authorized: true, scopes: ['podcasts:write'] });
|
|
154
|
+
|
|
155
|
+
await oxy.verifyServiceActingAs(APP, USER);
|
|
156
|
+
await oxy.verifyServiceActingAs('other-app', USER);
|
|
157
|
+
await oxy.verifyServiceActingAs(APP, 'other-user');
|
|
158
|
+
|
|
159
|
+
expect(calls).toHaveLength(3);
|
|
160
|
+
expect(calls.map((c) => c.data)).toEqual([
|
|
161
|
+
{ appId: APP, userId: USER },
|
|
162
|
+
{ appId: 'other-app', userId: USER },
|
|
163
|
+
{ appId: APP, userId: 'other-user' },
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
buildOxyCspDirectives,
|
|
4
4
|
buildOxyPagesHeaders,
|
|
5
5
|
createOxySecurityHeaders,
|
|
6
|
+
cspSourcesFor,
|
|
7
|
+
extractInlineScripts,
|
|
6
8
|
formatOxyCspPolicy,
|
|
9
|
+
inlineScriptCspHash,
|
|
7
10
|
OXY_CSP_BASELINE,
|
|
8
11
|
type OxyCspExtensions,
|
|
9
12
|
} from '../securityHeaders';
|
|
@@ -27,14 +30,11 @@ function renderPolicy(options: Parameters<typeof createOxySecurityHeaders>[0]):
|
|
|
27
30
|
return headers['Content-Security-Policy'] ?? '';
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
/** The
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
.find((segment) => segment === directive || segment.startsWith(`${directive} `));
|
|
36
|
-
if (found === undefined) return [];
|
|
37
|
-
return found.split(/\s+/).slice(1);
|
|
33
|
+
/** The CSP value parsed back out of a Cloudflare Pages `_headers` block. */
|
|
34
|
+
function cspOf(block: string): string {
|
|
35
|
+
const line = block.split('\n').find((entry) => entry.trim().startsWith('Content-Security-Policy:'));
|
|
36
|
+
if (line === undefined) throw new Error('no Content-Security-Policy line in _headers block');
|
|
37
|
+
return line.trim().slice('Content-Security-Policy:'.length).trim();
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
describe('@oxyhq/core/server buildOxyCspDirectives', () => {
|
|
@@ -179,12 +179,116 @@ describe('@oxyhq/core/server buildOxyPagesHeaders', () => {
|
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The inline script Expo Router's static export emits, verbatim, and the hash
|
|
184
|
+
* `accounts.oxy.so` was measured rejecting on 2026-08-21. Pinned as a LITERAL
|
|
185
|
+
* rather than recomputed: a test that derives the expected value the same way
|
|
186
|
+
* the code does would pass against any hashing bug they share.
|
|
187
|
+
*/
|
|
188
|
+
const EXPO_HYDRATE_SCRIPT = 'globalThis.__EXPO_ROUTER_HYDRATE__=true;';
|
|
189
|
+
const EXPO_HYDRATE_SHA256 = "'sha256-67fhrP0+BkBqmgGGXTtgiVO/9EQs3QruYNU/7fnRkI8='";
|
|
190
|
+
|
|
191
|
+
/** A static Expo export's HTML, reduced to the parts that decide the policy. */
|
|
192
|
+
function expoExportHtml(body = EXPO_HYDRATE_SCRIPT): string {
|
|
193
|
+
return [
|
|
194
|
+
'<!DOCTYPE html><html><head>',
|
|
195
|
+
'<script src="/_expo/static/js/web/entry-abc.js" defer></script>',
|
|
196
|
+
`<script type="module">${body}</script>`,
|
|
197
|
+
'</head><body><div id="root"></div></body></html>',
|
|
198
|
+
].join('');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe('@oxyhq/core/server extractInlineScripts', () => {
|
|
202
|
+
it('returns inline bodies and skips external scripts', () => {
|
|
203
|
+
expect(extractInlineScripts(expoExportHtml())).toEqual([EXPO_HYDRATE_SCRIPT]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('does not let a ">" inside an attribute value truncate an inline tag', () => {
|
|
207
|
+
// Truncating here does not throw: the body window shifts right and the hash
|
|
208
|
+
// is taken over `b'>globalThis…`, which allows nothing. Note the attribute
|
|
209
|
+
// must precede the body for this to discriminate — an EXTERNAL script whose
|
|
210
|
+
// `src` comes first is skipped either way, which is why the beacon shape is
|
|
211
|
+
// not the case under test here.
|
|
212
|
+
const html = `<script type="module" data-x='a>b'>${EXPO_HYDRATE_SCRIPT}</script>`;
|
|
213
|
+
expect(extractInlineScripts(html)).toEqual([EXPO_HYDRATE_SCRIPT]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('still recognizes a src that follows a ">"-bearing attribute', () => {
|
|
217
|
+
// The mirror failure: truncation hides `src` from the attribute slice, so
|
|
218
|
+
// an external script is mistaken for an inline one and contributes a hash
|
|
219
|
+
// over a fragment of its own tag.
|
|
220
|
+
expect(extractInlineScripts(`<script data-x='a>b' src="/entry.js"></script>`)).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('hashes the exact bytes, so whitespace changes the source', () => {
|
|
224
|
+
expect(inlineScriptCspHash(EXPO_HYDRATE_SCRIPT)).toBe(EXPO_HYDRATE_SHA256);
|
|
225
|
+
expect(inlineScriptCspHash(` ${EXPO_HYDRATE_SCRIPT}`)).not.toBe(EXPO_HYDRATE_SHA256);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('@oxyhq/core/server buildOxyPagesHeaders inline-script hashes', () => {
|
|
230
|
+
it('allows the built HTML\'s inline script by hash', () => {
|
|
231
|
+
const block = buildOxyPagesHeaders({ html: [expoExportHtml()] });
|
|
232
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toEqual([
|
|
233
|
+
"'self'",
|
|
234
|
+
CLOUDFLARE_SCRIPT_HOST,
|
|
235
|
+
EXPO_HYDRATE_SHA256,
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('emits no hash at all when the build has no inline script', () => {
|
|
240
|
+
// Negative control for the assertion above: without it, "contains a
|
|
241
|
+
// sha256-" would be satisfied by a builder that hashed unconditionally.
|
|
242
|
+
const block = buildOxyPagesHeaders({ html: ['<html><body>nothing inline</body></html>'] });
|
|
243
|
+
expect(cspOf(block)).not.toContain('sha256-');
|
|
244
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toEqual(["'self'", CLOUDFLARE_SCRIPT_HOST]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('dedupes one route-per-file export down to a single hash', () => {
|
|
248
|
+
const block = buildOxyPagesHeaders({
|
|
249
|
+
html: [expoExportHtml(), expoExportHtml(), expoExportHtml()],
|
|
250
|
+
});
|
|
251
|
+
expect(cspOf(block).match(/sha256-/g)).toHaveLength(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('keeps per-app extensions and the baseline alongside the hash', () => {
|
|
255
|
+
const block = buildOxyPagesHeaders({
|
|
256
|
+
csp: { imgSrc: ['blob:'], connectSrc: ['blob:'] },
|
|
257
|
+
html: [expoExportHtml()],
|
|
258
|
+
});
|
|
259
|
+
expect(cspSourcesFor(cspOf(block), 'img-src')).toContain('blob:');
|
|
260
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toContain(EXPO_HYDRATE_SHA256);
|
|
261
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toContain("'self'");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('never hashes styles, which would disable style-src unsafe-inline', () => {
|
|
265
|
+
// A style hash would neutralize 'unsafe-inline' and render every
|
|
266
|
+
// react-native-web app unstyled — the one directive that must stay open.
|
|
267
|
+
const block = buildOxyPagesHeaders({
|
|
268
|
+
html: ['<html><head><style>.a{color:red}</style></head></html>'],
|
|
269
|
+
});
|
|
270
|
+
expect(cspSourcesFor(cspOf(block), 'style-src')).toEqual(["'self'", "'unsafe-inline'"]);
|
|
271
|
+
expect(cspOf(block)).not.toContain('sha256-');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('refuses a build whose inline scripts vary per route', () => {
|
|
275
|
+
// An Expo route loader emits `__EXPO_ROUTER_LOADER_DATA__` with different
|
|
276
|
+
// bytes per route. The hashes would still be correct; the policy would grow
|
|
277
|
+
// with the route count. That has to be a decision, so it fails loudly.
|
|
278
|
+
const perRoute = Array.from({ length: 9 }, (_, index) =>
|
|
279
|
+
expoExportHtml(`globalThis.__EXPO_ROUTER_LOADER_DATA__={"r":${index}};`),
|
|
280
|
+
);
|
|
281
|
+
expect(() => buildOxyPagesHeaders({ html: perRoute })).toThrow(RangeError);
|
|
282
|
+
expect(() => buildOxyPagesHeaders({ html: perRoute.slice(0, 8) })).not.toThrow();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
182
286
|
describe('@oxyhq/core/server createOxySecurityHeaders', () => {
|
|
183
287
|
it('sends the resolved baseline as a real Content-Security-Policy header', () => {
|
|
184
288
|
const policy = renderPolicy({});
|
|
185
289
|
|
|
186
|
-
expect(
|
|
187
|
-
expect(
|
|
290
|
+
expect(cspSourcesFor(policy, 'script-src')).toEqual(["'self'", CLOUDFLARE_SCRIPT_HOST]);
|
|
291
|
+
expect(cspSourcesFor(policy, 'connect-src')).toEqual([
|
|
188
292
|
"'self'",
|
|
189
293
|
CLOUDFLARE_REPORT_HOST,
|
|
190
294
|
'https://api.oxy.so',
|
|
@@ -202,7 +306,7 @@ describe('@oxyhq/core/server createOxySecurityHeaders', () => {
|
|
|
202
306
|
},
|
|
203
307
|
});
|
|
204
308
|
|
|
205
|
-
expect(
|
|
309
|
+
expect(cspSourcesFor(policy, 'connect-src')).toEqual([
|
|
206
310
|
"'self'",
|
|
207
311
|
CLOUDFLARE_REPORT_HOST,
|
|
208
312
|
'https://api.oxy.so',
|
|
@@ -211,7 +315,7 @@ describe('@oxyhq/core/server createOxySecurityHeaders', () => {
|
|
|
211
315
|
'https://api.mention.earth',
|
|
212
316
|
'wss://api.mention.earth',
|
|
213
317
|
]);
|
|
214
|
-
expect(
|
|
318
|
+
expect(cspSourcesFor(policy, 'frame-src')).toEqual([
|
|
215
319
|
"'self'",
|
|
216
320
|
'https://www.youtube-nocookie.com',
|
|
217
321
|
]);
|
package/src/server/index.ts
CHANGED
|
@@ -71,11 +71,20 @@ export type { OxyCorsOptions } from './cors';
|
|
|
71
71
|
|
|
72
72
|
// Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
|
|
73
73
|
// Oxy API/CDN origins) with additive, per-app extensions.
|
|
74
|
+
//
|
|
75
|
+
// `extractInlineScripts` / `inlineScriptCspHash` / `cspSourcesFor` are exported
|
|
76
|
+
// so a post-deploy gate can ask the SERVED document and the SERVED policy the
|
|
77
|
+
// same questions `buildOxyPagesHeaders` asked the built ones. A gate that
|
|
78
|
+
// re-implemented the scan or the parse would be testing its own copy, and would
|
|
79
|
+
// agree with a broken original.
|
|
74
80
|
export {
|
|
75
81
|
buildOxyCspDirectives,
|
|
76
82
|
buildOxyPagesHeaders,
|
|
77
83
|
createOxySecurityHeaders,
|
|
84
|
+
cspSourcesFor,
|
|
85
|
+
extractInlineScripts,
|
|
78
86
|
formatOxyCspPolicy,
|
|
87
|
+
inlineScriptCspHash,
|
|
79
88
|
OXY_CSP_BASELINE,
|
|
80
89
|
} from './securityHeaders';
|
|
81
90
|
export type {
|