@oxyhq/core 3.4.11 → 3.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -3
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/server/auth.js +88 -0
- package/dist/cjs/server/index.js +8 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/server/auth.js +80 -0
- package/dist/esm/server/index.js +1 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/server/auth.d.ts +52 -0
- package/dist/types/server/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/__tests__/httpServiceCsrf.test.ts +7 -7
- package/src/__tests__/userIdentity.test.ts +6 -8
- package/src/server/__tests__/auth.test.ts +78 -0
- package/src/server/auth.ts +155 -0
- package/src/server/index.ts +17 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
|
+
import type { OxyServices } from '../OxyServices';
|
|
3
|
+
export interface OxyRequestUser {
|
|
4
|
+
id: string;
|
|
5
|
+
_id?: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
avatar?: string;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface OxyActingAsContext {
|
|
12
|
+
userId: string;
|
|
13
|
+
role: 'owner' | 'admin' | 'editor';
|
|
14
|
+
}
|
|
15
|
+
export interface OxyServiceAppContext {
|
|
16
|
+
appId: string;
|
|
17
|
+
appName: string;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
credentialId: string;
|
|
20
|
+
}
|
|
21
|
+
export interface OxyServiceActingAsContext {
|
|
22
|
+
userId: string;
|
|
23
|
+
scopes: string[];
|
|
24
|
+
}
|
|
25
|
+
export interface OxyAuthRequest extends Request {
|
|
26
|
+
userId?: string | null;
|
|
27
|
+
user?: OxyRequestUser | null;
|
|
28
|
+
originalUser?: OxyRequestUser | null;
|
|
29
|
+
actingAs?: OxyActingAsContext;
|
|
30
|
+
accessToken?: string;
|
|
31
|
+
sessionId?: string | null;
|
|
32
|
+
serviceApp?: OxyServiceAppContext;
|
|
33
|
+
serviceActingAs?: OxyServiceActingAsContext;
|
|
34
|
+
}
|
|
35
|
+
export interface OxyAuthenticatedRequest extends OxyAuthRequest {
|
|
36
|
+
userId: string;
|
|
37
|
+
user: OxyRequestUser;
|
|
38
|
+
}
|
|
39
|
+
export interface OxyAuthMiddlewareOptions {
|
|
40
|
+
/**
|
|
41
|
+
* Options forwarded to `oxy.auth()`.
|
|
42
|
+
* `optional` is forced to `true` by the composed helpers so route guards can
|
|
43
|
+
* produce one consistent 401 shape.
|
|
44
|
+
*/
|
|
45
|
+
auth?: Parameters<OxyServices['auth']>[0];
|
|
46
|
+
}
|
|
47
|
+
export declare function getOxyUserId(req: Request): string | null;
|
|
48
|
+
export declare function isOxyAuthenticated(req: Request): req is OxyAuthenticatedRequest;
|
|
49
|
+
export declare function getRequiredOxyUserId(req: Request): string;
|
|
50
|
+
export declare function requireOxyAuth(req: Request, res: Response, next: NextFunction): void;
|
|
51
|
+
export declare function createOptionalOxyAuth(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
|
|
52
|
+
export declare function createOxyAuthMiddleware(oxy: OxyServices, options?: OxyAuthMiddlewareOptions): RequestHandler;
|
|
@@ -14,5 +14,7 @@
|
|
|
14
14
|
* app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
+
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, } from './auth';
|
|
18
|
+
export type { OxyActingAsContext, OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, } from './auth';
|
|
17
19
|
export { createOxyRateLimit } from './rateLimit';
|
|
18
20
|
export type { OxyRateLimitOptions } from './rateLimit';
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import { HttpService } from '../HttpService';
|
|
|
2
2
|
|
|
3
3
|
interface FetchCall {
|
|
4
4
|
url: string;
|
|
5
|
-
init: RequestInit;
|
|
5
|
+
init: RequestInit | undefined;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
function createJwt(payload: Record<string, unknown>): string {
|
|
@@ -41,11 +41,11 @@ describe('HttpService CSRF behavior', () => {
|
|
|
41
41
|
|
|
42
42
|
it('does not fetch csrf-token before bearer-authenticated writes', async () => {
|
|
43
43
|
const calls: FetchCall[] = [];
|
|
44
|
-
|
|
44
|
+
globalThis.fetch = async (input, init) => {
|
|
45
|
+
const url = String(input);
|
|
45
46
|
calls.push({ url, init });
|
|
46
47
|
return jsonResponse({ ok: true });
|
|
47
|
-
}
|
|
48
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
48
|
+
};
|
|
49
49
|
|
|
50
50
|
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
51
51
|
const accessToken = createJwt({
|
|
@@ -65,7 +65,8 @@ describe('HttpService CSRF behavior', () => {
|
|
|
65
65
|
|
|
66
66
|
it('still fetches csrf-token for cookie-authenticated writes without bearer', async () => {
|
|
67
67
|
const calls: FetchCall[] = [];
|
|
68
|
-
|
|
68
|
+
globalThis.fetch = async (input, init) => {
|
|
69
|
+
const url = String(input);
|
|
69
70
|
calls.push({ url, init });
|
|
70
71
|
if (url.endsWith('/csrf-token')) {
|
|
71
72
|
return new Response(JSON.stringify({ csrfToken: 'csrf_1' }), {
|
|
@@ -74,8 +75,7 @@ describe('HttpService CSRF behavior', () => {
|
|
|
74
75
|
});
|
|
75
76
|
}
|
|
76
77
|
return jsonResponse({ ok: true });
|
|
77
|
-
}
|
|
78
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
78
|
+
};
|
|
79
79
|
|
|
80
80
|
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
81
81
|
|
|
@@ -3,7 +3,7 @@ import { getNormalizedUserId, normalizeUserIdentity } from '../utils/userIdentit
|
|
|
3
3
|
|
|
4
4
|
interface FetchCall {
|
|
5
5
|
url: string;
|
|
6
|
-
init: RequestInit;
|
|
6
|
+
init: RequestInit | undefined;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function jsonResponse(data: unknown): Response {
|
|
@@ -37,11 +37,11 @@ describe('user identity normalization', () => {
|
|
|
37
37
|
|
|
38
38
|
it('normalizes getCurrentUser responses before exposing them to apps', async () => {
|
|
39
39
|
const calls: FetchCall[] = [];
|
|
40
|
-
|
|
40
|
+
globalThis.fetch = async (input, init) => {
|
|
41
|
+
const url = String(input);
|
|
41
42
|
calls.push({ url, init });
|
|
42
43
|
return jsonResponse({ _id: 'user_1', username: 'nate', publicKey: 'pub_1' });
|
|
43
|
-
}
|
|
44
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
44
|
+
};
|
|
45
45
|
|
|
46
46
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
47
47
|
oxy.setTokens(createJwt({
|
|
@@ -56,15 +56,13 @@ describe('user identity normalization', () => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
it('normalizes validateSession users before services stores them', async () => {
|
|
59
|
-
|
|
59
|
+
globalThis.fetch = async () =>
|
|
60
60
|
jsonResponse({
|
|
61
61
|
valid: true,
|
|
62
62
|
expiresAt: '2099-01-01T00:00:00.000Z',
|
|
63
63
|
lastActivity: '2026-06-18T00:00:00.000Z',
|
|
64
64
|
user: { _id: 'user_1', username: 'nate', publicKey: 'pub_1' },
|
|
65
|
-
})
|
|
66
|
-
);
|
|
67
|
-
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
65
|
+
});
|
|
68
66
|
|
|
69
67
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
70
68
|
const validation = await oxy.validateSession('session_1', { useHeaderValidation: true });
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import type { OxyServices } from '../../OxyServices';
|
|
3
|
+
import {
|
|
4
|
+
createOxyAuthMiddleware,
|
|
5
|
+
getOxyUserId,
|
|
6
|
+
getRequiredOxyUserId,
|
|
7
|
+
requireOxyAuth,
|
|
8
|
+
type OxyAuthRequest,
|
|
9
|
+
} from '../auth';
|
|
10
|
+
|
|
11
|
+
function makeResponse(): Response {
|
|
12
|
+
return {
|
|
13
|
+
status: jest.fn().mockReturnThis(),
|
|
14
|
+
json: jest.fn().mockReturnThis(),
|
|
15
|
+
} as unknown as Response;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeNext(): NextFunction {
|
|
19
|
+
return jest.fn() as unknown as NextFunction;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('@oxyhq/core/server auth helpers', () => {
|
|
23
|
+
it('reads the current user id from userId, user.id, or user._id', () => {
|
|
24
|
+
expect(getOxyUserId({ userId: 'user-from-request' } as OxyAuthRequest)).toBe('user-from-request');
|
|
25
|
+
expect(getOxyUserId({ user: { id: 'user-from-id' } } as OxyAuthRequest)).toBe('user-from-id');
|
|
26
|
+
expect(getOxyUserId({ user: { id: '', _id: 'user-from-mongo-id' } } as OxyAuthRequest)).toBe('user-from-mongo-id');
|
|
27
|
+
expect(getOxyUserId({ user: { id: '' } } as OxyAuthRequest)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws when a required user id is missing', () => {
|
|
31
|
+
expect(() => getRequiredOxyUserId({} as Request)).toThrow('User not authenticated');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns a consistent 401 when auth is missing', () => {
|
|
35
|
+
const req = {} as Request;
|
|
36
|
+
const res = makeResponse();
|
|
37
|
+
const next = makeNext();
|
|
38
|
+
|
|
39
|
+
requireOxyAuth(req, res, next);
|
|
40
|
+
|
|
41
|
+
expect(res.status).toHaveBeenCalledWith(401);
|
|
42
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
43
|
+
error: 'Unauthorized',
|
|
44
|
+
message: 'Authentication required',
|
|
45
|
+
});
|
|
46
|
+
expect(next).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('normalizes an authenticated request before continuing', () => {
|
|
50
|
+
const req = { user: { id: '', _id: 'mongo-user-id' } } as OxyAuthRequest;
|
|
51
|
+
const res = makeResponse();
|
|
52
|
+
const next = makeNext();
|
|
53
|
+
|
|
54
|
+
requireOxyAuth(req, res, next);
|
|
55
|
+
|
|
56
|
+
expect(req.userId).toBe('mongo-user-id');
|
|
57
|
+
expect(req.user?.id).toBe('mongo-user-id');
|
|
58
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('can resolve and require auth as one middleware', () => {
|
|
62
|
+
const oxy = {
|
|
63
|
+
auth: jest.fn(() => (req: OxyAuthRequest, _res: Response, next: NextFunction) => {
|
|
64
|
+
req.user = { id: 'resolved-user' };
|
|
65
|
+
next();
|
|
66
|
+
}),
|
|
67
|
+
} as unknown as OxyServices;
|
|
68
|
+
const req = {} as OxyAuthRequest;
|
|
69
|
+
const res = makeResponse();
|
|
70
|
+
const next = makeNext();
|
|
71
|
+
|
|
72
|
+
createOxyAuthMiddleware(oxy)(req, res, next);
|
|
73
|
+
|
|
74
|
+
expect(oxy.auth).toHaveBeenCalledWith({ optional: true });
|
|
75
|
+
expect(req.userId).toBe('resolved-user');
|
|
76
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
|
+
import type { OxyServices } from '../OxyServices';
|
|
3
|
+
|
|
4
|
+
export interface OxyRequestUser {
|
|
5
|
+
id: string;
|
|
6
|
+
_id?: string;
|
|
7
|
+
username?: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
avatar?: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OxyActingAsContext {
|
|
14
|
+
userId: string;
|
|
15
|
+
role: 'owner' | 'admin' | 'editor';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OxyServiceAppContext {
|
|
19
|
+
appId: string;
|
|
20
|
+
appName: string;
|
|
21
|
+
scopes: string[];
|
|
22
|
+
credentialId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OxyServiceActingAsContext {
|
|
26
|
+
userId: string;
|
|
27
|
+
scopes: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OxyAuthRequest extends Request {
|
|
31
|
+
userId?: string | null;
|
|
32
|
+
user?: OxyRequestUser | null;
|
|
33
|
+
originalUser?: OxyRequestUser | null;
|
|
34
|
+
actingAs?: OxyActingAsContext;
|
|
35
|
+
accessToken?: string;
|
|
36
|
+
sessionId?: string | null;
|
|
37
|
+
serviceApp?: OxyServiceAppContext;
|
|
38
|
+
serviceActingAs?: OxyServiceActingAsContext;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface OxyAuthenticatedRequest extends OxyAuthRequest {
|
|
42
|
+
userId: string;
|
|
43
|
+
user: OxyRequestUser;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface OxyAuthMiddlewareOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Options forwarded to `oxy.auth()`.
|
|
49
|
+
* `optional` is forced to `true` by the composed helpers so route guards can
|
|
50
|
+
* produce one consistent 401 shape.
|
|
51
|
+
*/
|
|
52
|
+
auth?: Parameters<OxyServices['auth']>[0];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeId(value: string | null | undefined): string | null {
|
|
56
|
+
const normalized = value?.trim();
|
|
57
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureUser(req: OxyAuthRequest, userId: string): OxyRequestUser {
|
|
61
|
+
const existing = req.user;
|
|
62
|
+
if (existing) {
|
|
63
|
+
const user = {
|
|
64
|
+
...existing,
|
|
65
|
+
id: normalizeId(existing.id) ?? normalizeId(existing._id) ?? userId,
|
|
66
|
+
};
|
|
67
|
+
req.user = user;
|
|
68
|
+
return user;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const user = { id: userId };
|
|
72
|
+
req.user = user;
|
|
73
|
+
return user;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function getOxyUserId(req: Request): string | null {
|
|
77
|
+
const authReq = req as OxyAuthRequest;
|
|
78
|
+
return (
|
|
79
|
+
normalizeId(authReq.userId) ??
|
|
80
|
+
normalizeId(authReq.user?.id) ??
|
|
81
|
+
normalizeId(authReq.user?._id)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isOxyAuthenticated(req: Request): req is OxyAuthenticatedRequest {
|
|
86
|
+
return getOxyUserId(req) !== null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getRequiredOxyUserId(req: Request): string {
|
|
90
|
+
const userId = getOxyUserId(req);
|
|
91
|
+
if (!userId) {
|
|
92
|
+
throw new Error('User not authenticated');
|
|
93
|
+
}
|
|
94
|
+
return userId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function requireOxyAuth(req: Request, res: Response, next: NextFunction): void {
|
|
98
|
+
const userId = getOxyUserId(req);
|
|
99
|
+
if (!userId) {
|
|
100
|
+
res.status(401).json({
|
|
101
|
+
error: 'Unauthorized',
|
|
102
|
+
message: 'Authentication required',
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const authReq = req as OxyAuthRequest;
|
|
108
|
+
authReq.userId = userId;
|
|
109
|
+
ensureUser(authReq, userId);
|
|
110
|
+
next();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createOptionalOxyAuth(
|
|
114
|
+
oxy: OxyServices,
|
|
115
|
+
options: OxyAuthMiddlewareOptions = {},
|
|
116
|
+
): RequestHandler {
|
|
117
|
+
const resolveSession = oxy.auth({ ...options.auth, optional: true });
|
|
118
|
+
|
|
119
|
+
return (req, res, next) => {
|
|
120
|
+
if (getOxyUserId(req)) {
|
|
121
|
+
next();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
resolveSession(req, res, (error?: unknown) => {
|
|
126
|
+
if (error) {
|
|
127
|
+
next(error);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
next();
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createOxyAuthMiddleware(
|
|
136
|
+
oxy: OxyServices,
|
|
137
|
+
options: OxyAuthMiddlewareOptions = {},
|
|
138
|
+
): RequestHandler {
|
|
139
|
+
const resolveSession = createOptionalOxyAuth(oxy, options);
|
|
140
|
+
|
|
141
|
+
return (req, res, next) => {
|
|
142
|
+
if (getOxyUserId(req)) {
|
|
143
|
+
requireOxyAuth(req, res, next);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
resolveSession(req, res, (error?: unknown) => {
|
|
148
|
+
if (error) {
|
|
149
|
+
next(error);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
requireOxyAuth(req, res, next);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -15,5 +15,22 @@
|
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
export {
|
|
19
|
+
createOptionalOxyAuth,
|
|
20
|
+
createOxyAuthMiddleware,
|
|
21
|
+
getOxyUserId,
|
|
22
|
+
getRequiredOxyUserId,
|
|
23
|
+
isOxyAuthenticated,
|
|
24
|
+
requireOxyAuth,
|
|
25
|
+
} from './auth';
|
|
26
|
+
export type {
|
|
27
|
+
OxyActingAsContext,
|
|
28
|
+
OxyAuthenticatedRequest,
|
|
29
|
+
OxyAuthMiddlewareOptions,
|
|
30
|
+
OxyAuthRequest,
|
|
31
|
+
OxyRequestUser,
|
|
32
|
+
OxyServiceActingAsContext,
|
|
33
|
+
OxyServiceAppContext,
|
|
34
|
+
} from './auth';
|
|
18
35
|
export { createOxyRateLimit } from './rateLimit';
|
|
19
36
|
export type { OxyRateLimitOptions } from './rateLimit';
|