@oxy.so/core 1.4.0 → 1.5.1
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 +173 -6
- package/dist/cjs/server/auth.js +29 -0
- package/dist/cjs/server/index.js +3 -2
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +173 -6
- package/dist/esm/server/auth.js +28 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +3 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +51 -0
- package/dist/types/server/auth.d.ts +19 -1
- package/dist/types/server/index.d.ts +2 -2
- package/package.json +1 -1
- package/src/OxyServices.ts +9 -0
- package/src/__tests__/publicInterfaceParity.test.ts +75 -0
- package/src/index.ts +1 -1
- package/src/mixins/OxyServices.utility.ts +228 -5
- package/src/mixins/__tests__/authRefusalObservability.test.ts +396 -0
- package/src/server/auth.ts +37 -1
- package/src/server/index.ts +2 -0
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
import { type LinkedHttpClient, type OxyConfig } from './OxyServices.base';
|
|
60
60
|
import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeoutError, ServiceAssetMetadataError } from './OxyServices.errors';
|
|
61
61
|
import { composeOxyServices } from './mixins';
|
|
62
|
+
import type { OxyAuthRefusal } from './mixins/OxyServices.utility';
|
|
62
63
|
/**
|
|
63
64
|
* OxyServices - Unified client library for interacting with the Oxy API
|
|
64
65
|
*
|
|
@@ -105,6 +106,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
105
106
|
onError?: (error: unknown) => unknown;
|
|
106
107
|
loadUser?: boolean;
|
|
107
108
|
optional?: boolean;
|
|
109
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
108
110
|
serviceTokenJwksUrl?: string;
|
|
109
111
|
jwtSecret?: string;
|
|
110
112
|
expectedIssuer?: string;
|
|
@@ -115,6 +117,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
115
117
|
}): (socket: unknown, next: (err?: Error) => void) => Promise<void>;
|
|
116
118
|
serviceAuth(options?: {
|
|
117
119
|
debug?: boolean;
|
|
120
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
118
121
|
serviceTokenJwksUrl?: string;
|
|
119
122
|
jwtSecret?: string;
|
|
120
123
|
expectedIssuer?: string;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type { CommonsDeliveryFacts, CommonsDeliveryPlatform, CommonsDeliveryRout
|
|
|
28
28
|
export type { ServiceTokenResponse, OAuthUserInfoResponse, OAuthTokenExchangeResult, } from './mixins/OxyServices.auth';
|
|
29
29
|
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsSignInPurpose, CommonsOAuthContext, CommonsApprovalInfo, CommonsApprovalSubjectAccount, CommonsSignInActionResult, CommonsOAuthFinalizeResult, CommonsDeliveryResult, } from './mixins/OxyServices.auth';
|
|
30
30
|
export type { PushTokenPlatform, RegisterPushTokenInput, } from './mixins/OxyServices.notifications';
|
|
31
|
-
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
31
|
+
export type { ServiceApp, ServiceActingAsVerification, OxyAuthRefusal } from './mixins/OxyServices.utility';
|
|
32
32
|
export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
|
|
33
33
|
export type { InitDeviceTransferResult, DeviceTransferOutcome, } from './mixins/OxyServices.deviceTransfer';
|
|
34
34
|
export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, FollowMutationResult, ViewerGraph, } from './mixins/OxyServices.user';
|
|
@@ -58,6 +58,43 @@ export interface ServiceApp {
|
|
|
58
58
|
/** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
|
|
59
59
|
environment: OxyServiceEnvironment;
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Why a request's credential was refused, recorded on the request itself.
|
|
63
|
+
*
|
|
64
|
+
* The middleware answers a fixed, deliberately uninformative body to the
|
|
65
|
+
* client; this is the other half of that trade. A refusal is ALWAYS observable
|
|
66
|
+
* to the HOST: it is logged at `warn` with a stable `code`, handed to
|
|
67
|
+
* `onRefusal`, and left on `req.oxyAuthRefusal` for the host's own logs.
|
|
68
|
+
*
|
|
69
|
+
* It exists because the opposite cost was measured: Oxy served
|
|
70
|
+
* `{"keys":[]}` from `/.well-known/jwks.json` with no Ed25519 signing key
|
|
71
|
+
* bound, every service token failed `Oxy service-token key set is unavailable`,
|
|
72
|
+
* and on the OPTIONAL path that failure fell straight through to `next()`.
|
|
73
|
+
* The host then answered its own generic 401 and logged nothing at all, so the
|
|
74
|
+
* one fact that named the fault — an empty key set — existed nowhere. Hours.
|
|
75
|
+
*
|
|
76
|
+
* **Nothing here is ever sent to a client, and `reason` never contains the
|
|
77
|
+
* token, a signature, a secret or a session id.** It carries the SDK's own
|
|
78
|
+
* failure message (`kid` unknown, audience mismatch, key set unavailable) plus
|
|
79
|
+
* the ids already considered safe to log elsewhere in this middleware.
|
|
80
|
+
*/
|
|
81
|
+
export interface OxyAuthRefusal {
|
|
82
|
+
/** Stable, greppable code. Matches the `code` a non-optional refusal answers. */
|
|
83
|
+
code: string;
|
|
84
|
+
/** Which credential lane refused. */
|
|
85
|
+
stage: 'token' | 'service-token' | 'session';
|
|
86
|
+
/** Human-readable cause. Never a token, signature, secret or session id. */
|
|
87
|
+
reason: string;
|
|
88
|
+
/** The status a NON-optional mount would have answered with. */
|
|
89
|
+
status: number;
|
|
90
|
+
/**
|
|
91
|
+
* `true` when the middleware was mounted with `optional: true`, so this
|
|
92
|
+
* refusal did not produce a response — the request continued unauthenticated
|
|
93
|
+
* and whatever the host does next (typically its own generic 401) is the
|
|
94
|
+
* only thing the client sees.
|
|
95
|
+
*/
|
|
96
|
+
optional: boolean;
|
|
97
|
+
}
|
|
61
98
|
/**
|
|
62
99
|
* Options for oxyClient.auth() middleware
|
|
63
100
|
*/
|
|
@@ -70,6 +107,17 @@ interface AuthMiddlewareOptions {
|
|
|
70
107
|
loadUser?: boolean;
|
|
71
108
|
/** Optional auth - attach user if token present but don't block (default: false) */
|
|
72
109
|
optional?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Called whenever a PRESENTED credential is refused, on the optional path as
|
|
112
|
+
* well as the blocking one. Runs before the response (if any) and after
|
|
113
|
+
* `req.oxyAuthRefusal` is set, so a host can raise its own alert or carry the
|
|
114
|
+
* code into its request log. Throwing from it is swallowed — an observer must
|
|
115
|
+
* not be able to turn a refusal into a 500.
|
|
116
|
+
*
|
|
117
|
+
* Refusal is about a credential that was offered and rejected: a request with
|
|
118
|
+
* NO `Authorization` header is not refused and does not reach this.
|
|
119
|
+
*/
|
|
120
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
73
121
|
/**
|
|
74
122
|
* Public JWKS endpoint used for Ed25519 service-token verification. Defaults
|
|
75
123
|
* to `/.well-known/jwks.json` on this Oxy client's configured API origin.
|
|
@@ -242,6 +290,8 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
242
290
|
*/
|
|
243
291
|
serviceAuth(options?: {
|
|
244
292
|
debug?: boolean;
|
|
293
|
+
/** Observe refusals on the service lane; forwarded straight to `auth()`. */
|
|
294
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
245
295
|
/** @deprecated Oxy-API-only HS256 transition; external verifiers use JWKS. */
|
|
246
296
|
jwtSecret?: string;
|
|
247
297
|
serviceTokenJwksUrl?: string;
|
|
@@ -362,6 +412,7 @@ interface AuthReq {
|
|
|
362
412
|
userId: string;
|
|
363
413
|
scopes: string[];
|
|
364
414
|
};
|
|
415
|
+
oxyAuthRefusal?: OxyAuthRefusal;
|
|
365
416
|
}
|
|
366
417
|
interface AuthRes {
|
|
367
418
|
status(code: number): AuthRes;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
2
|
import type { OxyServices } from '../OxyServices';
|
|
3
|
+
import type { OxyAuthRefusal } from '../mixins/OxyServices.utility';
|
|
3
4
|
import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
|
|
4
5
|
export { OXY_SERVICE_ENVIRONMENTS };
|
|
5
|
-
export type { OxyServiceEnvironment };
|
|
6
|
+
export type { OxyServiceEnvironment, OxyAuthRefusal };
|
|
6
7
|
export interface OxyRequestUser {
|
|
7
8
|
id: string;
|
|
8
9
|
_id?: string;
|
|
@@ -35,6 +36,12 @@ export interface OxyAuthRequest extends Request {
|
|
|
35
36
|
sessionId?: string | null;
|
|
36
37
|
serviceApp?: OxyServiceAppContext;
|
|
37
38
|
serviceActingAs?: OxyServiceActingAsContext;
|
|
39
|
+
/**
|
|
40
|
+
* Why the presented credential was refused, when one was and the request
|
|
41
|
+
* still reached the handler (the optional path). Read it with
|
|
42
|
+
* {@link getOxyAuthRefusal} — never put it in a response body.
|
|
43
|
+
*/
|
|
44
|
+
oxyAuthRefusal?: OxyAuthRefusal;
|
|
38
45
|
}
|
|
39
46
|
export interface OxyAuthenticatedRequest extends OxyAuthRequest {
|
|
40
47
|
userId: string;
|
|
@@ -125,6 +132,17 @@ export declare function getOxyDelegatedUserId(req: Request): string | null;
|
|
|
125
132
|
*/
|
|
126
133
|
export declare function getOxyRequestAttribution(req: Request): OxyRequestAttribution | null;
|
|
127
134
|
export declare function getRequiredOxyUserId(req: Request): string;
|
|
135
|
+
/**
|
|
136
|
+
* Why the credential on this request was refused, or `null` when none was
|
|
137
|
+
* presented (or it was accepted).
|
|
138
|
+
*
|
|
139
|
+
* Set by `oxy.auth()` — including the `optional: true` mount the composed
|
|
140
|
+
* helpers use, where a refusal otherwise leaves no trace: the request simply
|
|
141
|
+
* arrives unauthenticated and the host answers its own generic 401. Hosts log
|
|
142
|
+
* this beside their own 401; it is diagnostic and **must not be put into a
|
|
143
|
+
* response body**, which is exactly what it is kept out of.
|
|
144
|
+
*/
|
|
145
|
+
export declare function getOxyAuthRefusal(req: Request): OxyAuthRefusal | null;
|
|
128
146
|
export declare function requireOxyAuth(req: Request, res: Response, next: NextFunction): void;
|
|
129
147
|
export declare function createOptionalOxyAuth(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
|
|
130
148
|
export declare function createOxyAuthMiddleware(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
|
|
18
|
-
export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyBillingPrincipal, OxyRequestAttribution, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
|
|
17
|
+
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
|
|
18
|
+
export type { OxyAuthenticatedRequest, OxyAuthRefusal, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyBillingPrincipal, OxyRequestAttribution, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
|
|
19
19
|
export { createOxyRateLimit } from './rateLimit';
|
|
20
20
|
export type { OxyRateLimitOptions } from './rateLimit';
|
|
21
21
|
export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
|
package/package.json
CHANGED
package/src/OxyServices.ts
CHANGED
|
@@ -61,6 +61,7 @@ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeo
|
|
|
61
61
|
|
|
62
62
|
// Import mixin composition helper
|
|
63
63
|
import { composeOxyServices } from './mixins';
|
|
64
|
+
import type { OxyAuthRefusal } from './mixins/OxyServices.utility';
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
67
|
* OxyServices - Unified client library for interacting with the Oxy API
|
|
@@ -120,11 +121,18 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
120
121
|
createLinkedClient(config: OxyConfig): LinkedHttpClient;
|
|
121
122
|
|
|
122
123
|
// Express.js middleware
|
|
124
|
+
//
|
|
125
|
+
// This list is the one CONSUMERS see: `OxyAuthMiddlewareOptions` in
|
|
126
|
+
// `server/auth.ts` is `Parameters<OxyServices['auth']>[0]`, so an option that
|
|
127
|
+
// exists on the mixin and not here does not exist for anybody outside this
|
|
128
|
+
// package. `onRefusal` shipped in 1.5.0 exactly that way — present in the
|
|
129
|
+
// implementation, invisible to every host that tried to pass it.
|
|
123
130
|
auth(options?: {
|
|
124
131
|
debug?: boolean;
|
|
125
132
|
onError?: (error: unknown) => unknown;
|
|
126
133
|
loadUser?: boolean;
|
|
127
134
|
optional?: boolean;
|
|
135
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
128
136
|
serviceTokenJwksUrl?: string;
|
|
129
137
|
jwtSecret?: string;
|
|
130
138
|
expectedIssuer?: string;
|
|
@@ -139,6 +147,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
139
147
|
// Service-token-only middleware (delegates to auth() internally)
|
|
140
148
|
serviceAuth(options?: {
|
|
141
149
|
debug?: boolean;
|
|
150
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
142
151
|
serviceTokenJwksUrl?: string;
|
|
143
152
|
jwtSecret?: string;
|
|
144
153
|
expectedIssuer?: string;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The middleware options a CONSUMER can pass must be the ones the middleware
|
|
3
|
+
* actually takes.
|
|
4
|
+
*
|
|
5
|
+
* `src/OxyServices.ts` re-declares `auth()` and `serviceAuth()` by hand, with
|
|
6
|
+
* their option objects written out inline, because declaration emit for the
|
|
7
|
+
* mixin pipeline does not carry them. That hand-written interface is what
|
|
8
|
+
* downstream packages see — `OxyAuthMiddlewareOptions` in `server/auth.ts` is
|
|
9
|
+
* literally `Parameters<OxyServices['auth']>[0]` — so an option that exists on
|
|
10
|
+
* the mixin and not there exists for this package and for nobody else.
|
|
11
|
+
*
|
|
12
|
+
* `onRefusal` shipped in 1.5.0 exactly that way: implemented, tested, exported,
|
|
13
|
+
* and rejected by the first host that tried to pass it
|
|
14
|
+
* (`TS2345 … has no properties in common with`). Nothing in this package could
|
|
15
|
+
* see it, because everything in this package resolves the mixin's own
|
|
16
|
+
* declaration, where the option is present. Only the published `.d.ts` loses it.
|
|
17
|
+
*
|
|
18
|
+
* So the check is on the SOURCE TEXT of the two declarations, which is the
|
|
19
|
+
* thing that diverges. It is not a type test: a type test here reads the merged
|
|
20
|
+
* interface and passes either way — measured, by deleting the option and
|
|
21
|
+
* watching `tsc --noEmit` stay green.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const SRC = join(__dirname, '..');
|
|
28
|
+
|
|
29
|
+
/** Every `name?:` key declared directly inside one brace-balanced block. */
|
|
30
|
+
function optionKeys(source: string, startPattern: RegExp): string[] {
|
|
31
|
+
const match = startPattern.exec(source);
|
|
32
|
+
if (!match) throw new Error(`declaration not found: ${String(startPattern)}`);
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let index = source.indexOf('{', match.index);
|
|
35
|
+
const open = index;
|
|
36
|
+
do {
|
|
37
|
+
const char = source[index];
|
|
38
|
+
if (char === '{') depth += 1;
|
|
39
|
+
else if (char === '}') depth -= 1;
|
|
40
|
+
index += 1;
|
|
41
|
+
} while (depth > 0 && index < source.length);
|
|
42
|
+
const body = source.slice(open + 1, index - 1);
|
|
43
|
+
// Only keys at THIS level: a nested object literal would otherwise contribute
|
|
44
|
+
// its own keys. None exist today; the guard keeps that from becoming silent.
|
|
45
|
+
const withoutNested = body.replace(/\{[^{}]*\}/g, '');
|
|
46
|
+
return [...withoutNested.matchAll(/^\s*(\w+)\??\s*:/gm)].map((m) => m[1] as string).sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('the public OxyServices interface declares the options the mixin takes', () => {
|
|
50
|
+
const mixin = readFileSync(join(SRC, 'mixins/OxyServices.utility.ts'), 'utf8');
|
|
51
|
+
const publicInterface = readFileSync(join(SRC, 'OxyServices.ts'), 'utf8');
|
|
52
|
+
|
|
53
|
+
it('auth() — same option names on both declarations', () => {
|
|
54
|
+
const declared = optionKeys(mixin, /interface AuthMiddlewareOptions/);
|
|
55
|
+
const published = optionKeys(publicInterface, /^ {2}auth\(options\?: \{/m);
|
|
56
|
+
|
|
57
|
+
expect(declared).toContain('onRefusal');
|
|
58
|
+
expect(published).toEqual(declared);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('serviceAuth() — same option names on both declarations', () => {
|
|
62
|
+
const declared = optionKeys(mixin, /^ {4}serviceAuth\(options: \{/m);
|
|
63
|
+
const published = optionKeys(publicInterface, /^ {2}serviceAuth\(options\?: \{/m);
|
|
64
|
+
|
|
65
|
+
expect(declared).toContain('onRefusal');
|
|
66
|
+
expect(published).toEqual(declared);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reads real declarations rather than matching nothing', () => {
|
|
70
|
+
// The floor. A regex that stopped matching would make both cases above
|
|
71
|
+
// compare two empty lists and pass.
|
|
72
|
+
expect(optionKeys(mixin, /interface AuthMiddlewareOptions/).length).toBeGreaterThan(5);
|
|
73
|
+
expect(optionKeys(publicInterface, /^ {2}auth\(options\?: \{/m).length).toBeGreaterThan(5);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -81,7 +81,7 @@ export type {
|
|
|
81
81
|
PushTokenPlatform,
|
|
82
82
|
RegisterPushTokenInput,
|
|
83
83
|
} from './mixins/OxyServices.notifications';
|
|
84
|
-
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
84
|
+
export type { ServiceApp, ServiceActingAsVerification, OxyAuthRefusal } from './mixins/OxyServices.utility';
|
|
85
85
|
export type {
|
|
86
86
|
ContactDiscoveryMatch,
|
|
87
87
|
ContactDiscoveryResponse,
|