@oxyhq/core 21.0.1 → 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/cors.js +84 -15
- 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/cors.js +82 -15
- 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/cors.d.ts +41 -0
- 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__/cors.socket.test.ts +225 -0
- package/src/server/__tests__/securityHeaders.test.ts +116 -12
- package/src/server/cors.ts +87 -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
|
*
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
|
|
16
16
|
* `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
|
|
17
17
|
* - allows the caller's explicit `appOrigins`,
|
|
18
|
+
* - REFUSES the opaque origin on both sides (see `OPAQUE_ORIGIN`),
|
|
18
19
|
* - DENIES everything else (no reflection, never a wildcard with credentials),
|
|
19
20
|
* - echoes back the EXACT matched origin (so credentialed requests work) and
|
|
20
21
|
* sets `Vary: Origin` for correct caching,
|
|
@@ -29,6 +30,11 @@ export interface OxyCorsOptions {
|
|
|
29
30
|
* `https://app.example.com`, `http://localhost:3000`). These are allowed IN
|
|
30
31
|
* ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
|
|
31
32
|
* via `new URL().origin`.
|
|
33
|
+
*
|
|
34
|
+
* An entry that is not a URL, or whose origin is the opaque origin
|
|
35
|
+
* (`exp://…`, `capacitor://…`, `chrome-extension://…`, `file:`, `data:`), is
|
|
36
|
+
* DROPPED with an error log rather than admitted — see `OPAQUE_ORIGIN` for
|
|
37
|
+
* why one such entry would otherwise admit every other one.
|
|
32
38
|
*/
|
|
33
39
|
appOrigins?: string[];
|
|
34
40
|
/**
|
|
@@ -46,6 +52,41 @@ export interface OxyCorsOptions {
|
|
|
46
52
|
/** Preflight cache lifetime in seconds. Default 86400 (24h). */
|
|
47
53
|
maxAgeSeconds?: number;
|
|
48
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Normalize the configured `appOrigins` into the exact-match set — the
|
|
57
|
+
* CONFIGURE-SIDE half of the opaque-origin guard.
|
|
58
|
+
*
|
|
59
|
+
* An entry that is not a URL, or whose origin is opaque, is dropped and named
|
|
60
|
+
* in an error log. Dropped rather than thrown on because `appOrigins` is
|
|
61
|
+
* deployment configuration — at least one Oxy backend reads it from the
|
|
62
|
+
* environment — and a typo there must cost that one origin its CORS headers,
|
|
63
|
+
* never the whole service its boot. Both failure modes are equally SAFE (the
|
|
64
|
+
* entry is absent from the set either way), so the choice is purely about
|
|
65
|
+
* blast radius, and dropping keeps it to one origin whose requests then fail
|
|
66
|
+
* visibly in the browser.
|
|
67
|
+
*
|
|
68
|
+
* Exported for `__tests__/cors.socket.test.ts` and NOT re-exported from
|
|
69
|
+
* `server/index.ts`, so it is not part of the package's public surface. The
|
|
70
|
+
* two halves of the guard are separately exported because they are separately
|
|
71
|
+
* testable only that way: with this half in place the match-side half is
|
|
72
|
+
* unreachable through `createOxyCors`, so a test driving the public API alone
|
|
73
|
+
* would measure this function twice and the other one never.
|
|
74
|
+
*/
|
|
75
|
+
export declare function normalizeAppOrigins(appOrigins: string[]): Set<string>;
|
|
76
|
+
/**
|
|
77
|
+
* Whether `origin` may be echoed back: it is in the built-in HTTPS Oxy apex
|
|
78
|
+
* family, or it exactly matches one of the configured app origins.
|
|
79
|
+
*
|
|
80
|
+
* The opaque-origin refusal here is the MATCH-SIDE half of the guard, and it
|
|
81
|
+
* is what makes the property hold regardless of how `explicit` was built — a
|
|
82
|
+
* set that somehow contains `"null"` still matches nothing, because no
|
|
83
|
+
* incoming origin ever normalizes past this line. `normalizeAppOrigins` is
|
|
84
|
+
* what stops such a set existing today; this is what stops it mattering.
|
|
85
|
+
*
|
|
86
|
+
* Exported for the same reason as `normalizeAppOrigins`, and likewise absent
|
|
87
|
+
* from `server/index.ts`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function matchesAllowedOrigin(explicit: ReadonlySet<string>, origin: string): boolean;
|
|
49
90
|
/**
|
|
50
91
|
* Create a strict Oxy CORS middleware. See module docs.
|
|
51
92
|
*
|
|
@@ -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
|
+
});
|