@oxyhq/core 21.0.2 → 21.2.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 +40 -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 +40 -0
- package/src/mixins/OxyServices.utility.ts +46 -3
- package/src/mixins/__tests__/accounts.test.ts +11 -4
- 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
|
@@ -180,6 +180,21 @@ export interface CreateAccountInput {
|
|
|
180
180
|
};
|
|
181
181
|
bio?: string;
|
|
182
182
|
avatar?: string;
|
|
183
|
+
/**
|
|
184
|
+
* Named color preset KEY — `'blue'`, `'mint'`, … — never a hex value. The
|
|
185
|
+
* account graph's half of `User.color`, which every account DTO already
|
|
186
|
+
* carries; this is how one gets WRITTEN for an account you administer.
|
|
187
|
+
*
|
|
188
|
+
* Set it HERE rather than after the fact. For a managed account the colour is
|
|
189
|
+
* a visual identity, and an account that is discoverable without one and
|
|
190
|
+
* acquires it on a second request is a face that changes by itself.
|
|
191
|
+
*
|
|
192
|
+
* Omitted is not "no colour": the platform assigns a random preset, exactly as
|
|
193
|
+
* it did before this field existed. A reserved preset is refused unless the
|
|
194
|
+
* account has a claim to it — the administrator's own entitlements are not the
|
|
195
|
+
* ones weighed.
|
|
196
|
+
*/
|
|
197
|
+
color?: string;
|
|
183
198
|
/**
|
|
184
199
|
* What the account is about. ORDERED — the FIRST element is the primary
|
|
185
200
|
* category, so a picker must submit them in the order the user arranged them
|
|
@@ -191,6 +206,22 @@ export interface CreateAccountInput {
|
|
|
191
206
|
* keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
|
|
192
207
|
*/
|
|
193
208
|
accountCategories?: AccountCategoryId[];
|
|
209
|
+
/**
|
|
210
|
+
* Create the account already opted OUT of discovery — kept out of people
|
|
211
|
+
* search, the follow-graph lists, `/similar` and the recommendation pools,
|
|
212
|
+
* with non-public media follower-gated.
|
|
213
|
+
*
|
|
214
|
+
* Pass `true` when the account is not something its owner has published yet:
|
|
215
|
+
* an agent, an unlaunched project, an organization for something unannounced.
|
|
216
|
+
* OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
|
|
217
|
+
* the platform default, which is discoverable, and that default is not
|
|
218
|
+
* changed by this option existing.
|
|
219
|
+
*
|
|
220
|
+
* Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
|
|
221
|
+
* own bearer. Passing it here is the only way to have the account never be
|
|
222
|
+
* discoverable at all, rather than discoverable until a second call lands.
|
|
223
|
+
*/
|
|
224
|
+
isPrivateAccount?: boolean;
|
|
194
225
|
}
|
|
195
226
|
/** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
|
|
196
227
|
export interface UpdateAccountInput {
|
|
@@ -208,6 +239,15 @@ export interface UpdateAccountInput {
|
|
|
208
239
|
};
|
|
209
240
|
bio?: string | null;
|
|
210
241
|
avatar?: string | null;
|
|
242
|
+
/**
|
|
243
|
+
* Named color preset KEY, same vocabulary as `CreateAccountInput['color']`.
|
|
244
|
+
*
|
|
245
|
+
* NOT nullable, unlike `bio` and `avatar`: the column is `NOT NULL` with a
|
|
246
|
+
* default, so an account always HAS a colour and there is no "clear" to
|
|
247
|
+
* express. Sending the value the account already carries is always accepted,
|
|
248
|
+
* so a client may PATCH back the object it was served.
|
|
249
|
+
*/
|
|
250
|
+
color?: string;
|
|
211
251
|
/**
|
|
212
252
|
* Replaces the WHOLE list, in the order given — there is no add/remove verb,
|
|
213
253
|
* because a partial edit cannot express a re-ordering and the order is what
|
|
@@ -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.2.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.32.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",
|
|
@@ -201,6 +201,21 @@ export interface CreateAccountInput {
|
|
|
201
201
|
name?: { first?: string; last?: string; displayName?: string };
|
|
202
202
|
bio?: string;
|
|
203
203
|
avatar?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Named color preset KEY — `'blue'`, `'mint'`, … — never a hex value. The
|
|
206
|
+
* account graph's half of `User.color`, which every account DTO already
|
|
207
|
+
* carries; this is how one gets WRITTEN for an account you administer.
|
|
208
|
+
*
|
|
209
|
+
* Set it HERE rather than after the fact. For a managed account the colour is
|
|
210
|
+
* a visual identity, and an account that is discoverable without one and
|
|
211
|
+
* acquires it on a second request is a face that changes by itself.
|
|
212
|
+
*
|
|
213
|
+
* Omitted is not "no colour": the platform assigns a random preset, exactly as
|
|
214
|
+
* it did before this field existed. A reserved preset is refused unless the
|
|
215
|
+
* account has a claim to it — the administrator's own entitlements are not the
|
|
216
|
+
* ones weighed.
|
|
217
|
+
*/
|
|
218
|
+
color?: string;
|
|
204
219
|
/**
|
|
205
220
|
* What the account is about. ORDERED — the FIRST element is the primary
|
|
206
221
|
* category, so a picker must submit them in the order the user arranged them
|
|
@@ -212,6 +227,22 @@ export interface CreateAccountInput {
|
|
|
212
227
|
* keep working. At most `MAX_ACCOUNT_CATEGORIES`, no duplicates.
|
|
213
228
|
*/
|
|
214
229
|
accountCategories?: AccountCategoryId[];
|
|
230
|
+
/**
|
|
231
|
+
* Create the account already opted OUT of discovery — kept out of people
|
|
232
|
+
* search, the follow-graph lists, `/similar` and the recommendation pools,
|
|
233
|
+
* with non-public media follower-gated.
|
|
234
|
+
*
|
|
235
|
+
* Pass `true` when the account is not something its owner has published yet:
|
|
236
|
+
* an agent, an unlaunched project, an organization for something unannounced.
|
|
237
|
+
* OMITTED IS NOT `false` IN MEANING, only in effect — saying nothing leaves
|
|
238
|
+
* the platform default, which is discoverable, and that default is not
|
|
239
|
+
* changed by this option existing.
|
|
240
|
+
*
|
|
241
|
+
* Setting it later is `PUT /users/:userId/privacy`, which needs the ACCOUNT's
|
|
242
|
+
* own bearer. Passing it here is the only way to have the account never be
|
|
243
|
+
* discoverable at all, rather than discoverable until a second call lands.
|
|
244
|
+
*/
|
|
245
|
+
isPrivateAccount?: boolean;
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
/** Input accepted by `updateAccount`. Tree placement changes go through `/move`. */
|
|
@@ -226,6 +257,15 @@ export interface UpdateAccountInput {
|
|
|
226
257
|
name?: { first?: string; last?: string; displayName?: string };
|
|
227
258
|
bio?: string | null;
|
|
228
259
|
avatar?: string | null;
|
|
260
|
+
/**
|
|
261
|
+
* Named color preset KEY, same vocabulary as `CreateAccountInput['color']`.
|
|
262
|
+
*
|
|
263
|
+
* NOT nullable, unlike `bio` and `avatar`: the column is `NOT NULL` with a
|
|
264
|
+
* default, so an account always HAS a colour and there is no "clear" to
|
|
265
|
+
* express. Sending the value the account already carries is always accepted,
|
|
266
|
+
* so a client may PATCH back the object it was served.
|
|
267
|
+
*/
|
|
268
|
+
color?: string;
|
|
229
269
|
/**
|
|
230
270
|
* Replaces the WHOLE list, in the order given — there is no add/remove verb,
|
|
231
271
|
* because a partial edit cannot express a re-ordering and the order is what
|
|
@@ -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 },
|
|
@@ -255,13 +255,20 @@ describe('OxyServices.accounts', () => {
|
|
|
255
255
|
it('posts the payload, unwraps `account`, and busts every list', async () => {
|
|
256
256
|
makeRequestSpy.mockResolvedValue({ account: accountNodeFixture });
|
|
257
257
|
|
|
258
|
-
const result = await oxy.createAccount({
|
|
258
|
+
const result = await oxy.createAccount({
|
|
259
|
+
kind: 'organization',
|
|
260
|
+
username: 'oxy-org',
|
|
261
|
+
color: 'purple',
|
|
262
|
+
});
|
|
259
263
|
|
|
260
264
|
expect(result).toEqual(accountNodeFixture);
|
|
265
|
+
// `color` rides the CREATE body, not a follow-up patch: an account that is
|
|
266
|
+
// discoverable without its colour and acquires one on a second request is
|
|
267
|
+
// a face that changes by itself.
|
|
261
268
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
262
269
|
'POST',
|
|
263
270
|
'/accounts',
|
|
264
|
-
{ kind: 'organization', username: 'oxy-org' },
|
|
271
|
+
{ kind: 'organization', username: 'oxy-org', color: 'purple' },
|
|
265
272
|
expect.objectContaining({ cache: false }),
|
|
266
273
|
);
|
|
267
274
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts');
|
|
@@ -273,13 +280,13 @@ describe('OxyServices.accounts', () => {
|
|
|
273
280
|
it('patches, unwraps `account`, and busts the detail + lists', async () => {
|
|
274
281
|
makeRequestSpy.mockResolvedValue({ account: accountNodeFixture });
|
|
275
282
|
|
|
276
|
-
const result = await oxy.updateAccount('acc1', { bio: 'hello' });
|
|
283
|
+
const result = await oxy.updateAccount('acc1', { bio: 'hello', color: 'mint' });
|
|
277
284
|
|
|
278
285
|
expect(result).toEqual(accountNodeFixture);
|
|
279
286
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
280
287
|
'PATCH',
|
|
281
288
|
'/accounts/acc1',
|
|
282
|
-
{ bio: 'hello' },
|
|
289
|
+
{ bio: 'hello', color: 'mint' },
|
|
283
290
|
expect.objectContaining({ cache: false }),
|
|
284
291
|
);
|
|
285
292
|
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/accounts/acc1');
|
|
@@ -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
|
+
});
|