@bhooai/nexus-auth 0.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.
@@ -0,0 +1,230 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Router, NexusServer, bodyParser } from '@bhooai/nexus-core/http';
3
+ import { request } from 'node:http';
4
+ import type { AddressInfo } from 'node:net';
5
+ import {
6
+ hashPassword,
7
+ verifyPassword,
8
+ signAccessToken,
9
+ verifyToken,
10
+ MemorySessionStore,
11
+ AuthService,
12
+ RoleRegistry,
13
+ requireAuth,
14
+ requireRole,
15
+ requirePermission,
16
+ authToken,
17
+ setAuthCookies,
18
+ buildGoogleAuthUrl,
19
+ buildFacebookAuthUrl,
20
+ computePkceChallenge,
21
+ generatePkceVerifier,
22
+ MemoryOAuthStateStore,
23
+ type GoogleOAuthConfig,
24
+ type FacebookOAuthConfig,
25
+ } from '../src/index.js';
26
+
27
+ const JWT = { secret: 'test-secret-key-very-long-for-hs256-testing', issuer: 'nexus-test', accessTtl: 60, refreshTtl: 3600 };
28
+
29
+ function call(port: number, opts: { method?: string; path?: string; headers?: Record<string, string>; body?: string } = {}): Promise<{ status: number; body: string; headers: Record<string, string | string[] | undefined> }> {
30
+ return new Promise((resolve, reject) => {
31
+ const req = request(
32
+ { hostname: '127.0.0.1', port, path: opts.path ?? '/', method: opts.method ?? 'GET', headers: opts.headers },
33
+ (res) => {
34
+ let body = '';
35
+ res.on('data', (c) => (body += c));
36
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body, headers: res.headers }));
37
+ },
38
+ );
39
+ req.on('error', reject);
40
+ if (opts.body) req.write(opts.body);
41
+ req.end();
42
+ });
43
+ }
44
+
45
+ describe('auth: passwords', () => {
46
+ it('hashes and verifies a password', async () => {
47
+ const hash = await hashPassword('hunter2');
48
+ expect(hash).not.toBe('hunter2');
49
+ expect(await verifyPassword('hunter2', hash)).toBe(true);
50
+ expect(await verifyPassword('wrong', hash)).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe('auth: jwt', () => {
55
+ it('signs and verifies an access token with roles', async () => {
56
+ const token = await signAccessToken('user-1', ['admin'], JWT);
57
+ const payload = await verifyToken(token, JWT);
58
+ expect(payload.sub).toBe('user-1');
59
+ expect(payload.roles).toEqual(['admin']);
60
+ expect(payload.kind).toBe('access');
61
+ });
62
+
63
+ it('rejects a token signed with a different secret', async () => {
64
+ const token = await signAccessToken('user-1', [], JWT);
65
+ await expect(verifyToken(token, { ...JWT, secret: 'other-secret-also-long-enough-for-hs' })).rejects.toThrow();
66
+ });
67
+ });
68
+
69
+ describe('auth: session store', () => {
70
+ it('creates, retrieves, and destroys sessions', async () => {
71
+ const store = new MemorySessionStore();
72
+ const s = await store.create('u1', ['user']);
73
+ expect(await store.get(s.id)).not.toBeNull();
74
+ await store.destroy(s.id);
75
+ expect(await store.get(s.id)).toBeNull();
76
+ });
77
+
78
+ it('destroyAllForUser removes every session for that user', async () => {
79
+ const store = new MemorySessionStore();
80
+ const a = await store.create('u1', ['user']);
81
+ const b = await store.create('u1', ['user']);
82
+ await store.destroyAllForUser('u1');
83
+ expect(await store.get(a.id)).toBeNull();
84
+ expect(await store.get(b.id)).toBeNull();
85
+ });
86
+ });
87
+
88
+ describe('auth: AuthService login / refresh / reuse detection', () => {
89
+ it('issues a token pair on login and refreshes with rotation', async () => {
90
+ const service = new AuthService(JWT, new MemorySessionStore());
91
+ const pair = await service.login({ userId: 'u1', roles: ['user'] });
92
+ expect(pair.accessToken).toBeTruthy();
93
+ expect(pair.refreshToken).toBeTruthy();
94
+ expect(pair.sessionId).toBeTruthy();
95
+
96
+ const refreshed = await service.refresh(pair.refreshToken);
97
+ expect(refreshed.refreshToken).not.toBe(pair.refreshToken); // rotated
98
+
99
+ // The old refresh token is now invalid (jti mismatch → reuse → family revoked)
100
+ await expect(service.refresh(pair.refreshToken)).rejects.toThrow(/reuse|expired|Session/);
101
+ });
102
+
103
+ it('verifyAccessToken accepts the access token', async () => {
104
+ const service = new AuthService(JWT, new MemorySessionStore());
105
+ const pair = await service.login({ userId: 'u1', roles: ['user'] });
106
+ const payload = await service.verifyAccessToken(pair.accessToken);
107
+ expect(payload.sub).toBe('u1');
108
+ });
109
+
110
+ it('logout ends the session so refresh fails', async () => {
111
+ const service = new AuthService(JWT, new MemorySessionStore());
112
+ const pair = await service.login({ userId: 'u1', roles: ['user'] });
113
+ await service.logout(pair.sessionId);
114
+ await expect(service.refresh(pair.refreshToken)).rejects.toThrow();
115
+ });
116
+ });
117
+
118
+ describe('auth: RBAC', () => {
119
+ it('resolves inherited permissions and powers requirePermission middleware', async () => {
120
+ const registry = new RoleRegistry().defineAll({
121
+ viewer: { permissions: ['read:posts'] },
122
+ editor: { permissions: ['write:posts'], inherits: ['viewer'] },
123
+ admin: { permissions: ['delete:posts'], inherits: ['editor'] },
124
+ });
125
+ expect(registry.can('editor', 'read:posts')).toBe(true);
126
+ expect(registry.can('editor', 'delete:posts')).toBe(false);
127
+ expect(registry.can('admin', 'delete:posts')).toBe(true);
128
+ expect(registry.canAny(['editor'], 'write:posts')).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe('auth: authToken + requireRole middleware (live server)', () => {
133
+ let server: NexusServer;
134
+ let port: number;
135
+ let service: AuthService;
136
+ afterEach(async () => server && (await server.close()));
137
+
138
+ async function boot(roles: string[]) {
139
+ service = new AuthService(JWT, new MemorySessionStore());
140
+ const registry = new RoleRegistry().define('admin', { permissions: ['billing:read'] });
141
+ const router = new Router();
142
+ router.get('/me', (ctx) => ctx.json({ id: (ctx.state.user as { id: string }).id }), [authToken(service), requireAuth()]);
143
+ router.get('/admin', (ctx) => ctx.json({ ok: true }), [authToken(service), requireRole('admin')]);
144
+ router.get('/perm', (ctx) => ctx.json({ ok: true }), [authToken(service), requirePermission(registry, 'billing:read')]);
145
+ server = new NexusServer({ router, middleware: [bodyParser()] });
146
+ await server.listen(0, '127.0.0.1');
147
+ port = (server.address as AddressInfo).port;
148
+ return service.login({ userId: 'u1', roles });
149
+ }
150
+
151
+ it('sets ctx.state.user from a bearer token and allows requireAuth', async () => {
152
+ const pair = await boot(['user']);
153
+ const res = await call(port, { path: '/me', headers: { authorization: `Bearer ${pair.accessToken}` } });
154
+ expect(res.status).toBe(200);
155
+ expect(JSON.parse(res.body).id).toBe('u1');
156
+ });
157
+
158
+ it('rejects requests with no token (401)', async () => {
159
+ await boot(['user']);
160
+ const res = await call(port, { path: '/me' });
161
+ expect(res.status).toBe(401);
162
+ });
163
+
164
+ it('requireRole allows admins and forbids plain users (403)', async () => {
165
+ const adminPair = await boot(['admin']);
166
+ const ok = await call(port, { path: '/admin', headers: { authorization: `Bearer ${adminPair.accessToken}` } });
167
+ expect(ok.status).toBe(200);
168
+
169
+ const userPair = await service.login({ userId: 'u2', roles: ['user'] });
170
+ const forbidden = await call(port, { path: '/admin', headers: { authorization: `Bearer ${userPair.accessToken}` } });
171
+ expect(forbidden.status).toBe(403);
172
+ });
173
+
174
+ it('requirePermission checks the registry', async () => {
175
+ const adminPair = await boot(['admin']);
176
+ const ok = await call(port, { path: '/perm', headers: { authorization: `Bearer ${adminPair.accessToken}` } });
177
+ expect(ok.status).toBe(200);
178
+ });
179
+ });
180
+
181
+ describe('auth: OAuth helpers (no network)', () => {
182
+ let server: NexusServer;
183
+ let port: number;
184
+ afterEach(async () => server && (await server.close()));
185
+
186
+ const google: GoogleOAuthConfig = { clientId: 'g-id', clientSecret: 'g-secret', redirectUri: 'https://app.test/cb' };
187
+ const facebook: FacebookOAuthConfig = { clientId: 'fb-id', clientSecret: 'fb-secret', redirectUri: 'https://app.test/cb' };
188
+
189
+ it('builds a Google auth URL with PKCE challenge and state', () => {
190
+ const verifier = generatePkceVerifier();
191
+ const url = buildGoogleAuthUrl(google, { state: 'st', verifier });
192
+ expect(url).toContain('https://accounts.google.com/o/oauth2/v2/auth');
193
+ expect(url).toContain('code_challenge_method=S256');
194
+ expect(url).toContain('state=st');
195
+ expect(url).toContain(`code_challenge=${computePkceChallenge(verifier)}`);
196
+ });
197
+
198
+ it('builds a Facebook auth URL with state and scope', () => {
199
+ const url = buildFacebookAuthUrl(facebook, 'st');
200
+ expect(url).toContain('facebook.com/v19.0/dialog/oauth');
201
+ expect(url).toContain('state=st');
202
+ expect(url).toContain('client_id=fb-id');
203
+ });
204
+
205
+ it('state store consumes a value exactly once and rejects expired/unknown', async () => {
206
+ const store = new MemoryOAuthStateStore();
207
+ await store.set('s1', { verifier: 'v' }, 1000);
208
+ expect((await store.consume('s1'))?.verifier).toBe('v');
209
+ expect(await store.consume('s1')).toBeNull(); // single-use
210
+ expect(await store.consume('unknown')).toBeNull();
211
+ });
212
+
213
+ it('setAuthCookies writes two httpOnly cookies', async () => {
214
+ const service = new AuthService(JWT, new MemorySessionStore());
215
+ const pair = await service.login({ userId: 'u1', roles: ['user'] });
216
+ const router = new Router();
217
+ router.get('/c', (ctx) => setAuthCookies(ctx, pair));
218
+ server = new NexusServer({ router });
219
+ await server.listen(0, '127.0.0.1');
220
+ port = (server.address as AddressInfo).port;
221
+ const res = await call(port, { path: '/c' });
222
+ const setCookie = res.headers['set-cookie'] as string[] | undefined;
223
+ expect(setCookie).toBeDefined();
224
+ expect(setCookie!.some((c) => c.startsWith('nexus_at='))).toBe(true);
225
+ expect(setCookie!.some((c) => c.startsWith('nexus_rt='))).toBe(true);
226
+ expect(setCookie!.every((c) => c.includes('HttpOnly'))).toBe(true);
227
+ await server.close();
228
+ server = undefined as unknown as NexusServer;
229
+ });
230
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { Router, NexusServer, bodyParser } from '@bhooai/nexus-core/http';
3
+ import { cors, csrf, securityHeaders, rateLimit } from '../src/index.js';
4
+ import { request } from 'node:http';
5
+ import type { AddressInfo } from 'node:net';
6
+
7
+ function call(port: number, opts: { method?: string; path?: string; headers?: Record<string, string>; body?: string } = {}): Promise<{ status: number; body: string; headers: Record<string, string | string[] | undefined> }> {
8
+ return new Promise((resolve, reject) => {
9
+ const req = request(
10
+ { hostname: '127.0.0.1', port, path: opts.path ?? '/', method: opts.method ?? 'GET', headers: opts.headers },
11
+ (res) => {
12
+ let body = '';
13
+ res.on('data', (c) => (body += c));
14
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body, headers: res.headers }));
15
+ },
16
+ );
17
+ req.on('error', reject);
18
+ if (opts.body) req.write(opts.body);
19
+ req.end();
20
+ });
21
+ }
22
+
23
+ function extractCookie(setCookie: string | string[] | undefined, name: string): string | undefined {
24
+ const header = Array.isArray(setCookie) ? setCookie[0] : setCookie;
25
+ if (!header) return undefined;
26
+ const match = new RegExp(`${name}=([^;]+)`).exec(header);
27
+ return match?.[1];
28
+ }
29
+
30
+ describe('security: CORS', () => {
31
+ let server: NexusServer;
32
+ let port: number;
33
+ afterEach(async () => server && (await server.close()));
34
+
35
+ it('reflects origin and handles preflight with credentials', async () => {
36
+ const router = new Router();
37
+ router.get('/x', (ctx) => ctx.json({ ok: true }));
38
+ server = new NexusServer({
39
+ router,
40
+ middleware: [cors({ origin: ['http://app.test'], credentials: true })],
41
+ });
42
+ await server.listen(0, '127.0.0.1');
43
+ port = (server.address as AddressInfo).port;
44
+
45
+ const preflight = await call(port, {
46
+ method: 'OPTIONS',
47
+ path: '/x',
48
+ headers: { origin: 'http://app.test', 'access-control-request-method': 'GET' },
49
+ });
50
+ expect(preflight.status).toBe(204);
51
+ expect(preflight.headers['access-control-allow-origin']).toBe('http://app.test');
52
+ expect(preflight.headers['access-control-allow-credentials']).toBe('true');
53
+ expect(String(preflight.headers['vary'])).toContain('Origin');
54
+
55
+ const blocked = await call(port, { method: 'OPTIONS', path: '/x', headers: { origin: 'http://evil.test', 'access-control-request-method': 'GET' } });
56
+ expect(blocked.headers['access-control-allow-origin']).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe('security: CSRF', () => {
61
+ let server: NexusServer;
62
+ let port: number;
63
+ afterEach(async () => server && (await server.close()));
64
+
65
+ it('blocks unsafe requests without a matching token and allows them with one', async () => {
66
+ const router = new Router();
67
+ router.get('/csrf', (ctx) => ctx.json({ token: ctx.state.csrfToken }));
68
+ router.post('/unsafe', (ctx) => ctx.json({ done: true }));
69
+ server = new NexusServer({ router, middleware: [bodyParser(), csrf()] });
70
+ await server.listen(0, '127.0.0.1');
71
+ port = (server.address as AddressInfo).port;
72
+
73
+ // 1. get a token (and its cookie)
74
+ const tokenRes = await call(port, { path: '/csrf' });
75
+ const token = JSON.parse(tokenRes.body).token;
76
+ const cookie = extractCookie(tokenRes.headers['set-cookie'], 'nexus_csrf');
77
+ expect(token).toBeDefined();
78
+ expect(cookie).toBe(token);
79
+
80
+ // 2. POST without token -> 401
81
+ const blocked = await call(port, { method: 'POST', path: '/unsafe', headers: { 'content-type': 'application/json' }, body: '{}' });
82
+ expect(blocked.status).toBe(401);
83
+
84
+ // 3. POST with matching token + cookie -> 200
85
+ const allowed = await call(port, {
86
+ method: 'POST',
87
+ path: '/unsafe',
88
+ headers: { 'content-type': 'application/json', cookie: `nexus_csrf=${cookie}`, 'x-csrf-token': token },
89
+ body: '{}',
90
+ });
91
+ expect(allowed.status).toBe(200);
92
+ expect(JSON.parse(allowed.body)).toEqual({ done: true });
93
+ });
94
+ });
95
+
96
+ describe('security: headers', () => {
97
+ let server: NexusServer;
98
+ let port: number;
99
+ afterEach(async () => server && (await server.close()));
100
+
101
+ it('applies helmet-equivalent headers', async () => {
102
+ const router = new Router();
103
+ router.get('/', (ctx) => ctx.text('ok'));
104
+ server = new NexusServer({ router, middleware: [securityHeaders()] });
105
+ await server.listen(0, '127.0.0.1');
106
+ port = (server.address as AddressInfo).port;
107
+ const res = await call(port, { path: '/' });
108
+ expect(res.headers['x-content-type-options']).toBe('nosniff');
109
+ expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
110
+ expect(String(res.headers['content-security-policy'])).toContain("default-src 'self'");
111
+ });
112
+ });
113
+
114
+ describe('security: rateLimit', () => {
115
+ let server: NexusServer;
116
+ let port: number;
117
+ afterEach(async () => server && (await server.close()));
118
+
119
+ it('limits requests beyond max within the window', async () => {
120
+ const router = new Router();
121
+ router.get('/limited', (ctx) => ctx.text('ok'));
122
+ server = new NexusServer({ router, middleware: [rateLimit({ windowMs: 10_000, max: 2, keyGenerator: () => 'k' })] });
123
+ await server.listen(0, '127.0.0.1');
124
+ port = (server.address as AddressInfo).port;
125
+ const a = await call(port, { path: '/limited' });
126
+ const b = await call(port, { path: '/limited' });
127
+ const c = await call(port, { path: '/limited' });
128
+ expect(a.status).toBe(200);
129
+ expect(b.status).toBe(200);
130
+ expect(c.status).toBe(401);
131
+ expect(Number(c.headers['rate-limit-remaining'])).toBe(0);
132
+ });
133
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [{ "path": "../nexus-core" }]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineProject } from 'vitest/config';
2
+
3
+ export default defineProject({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['tests/**/*.test.ts'],
7
+ globals: false,
8
+ },
9
+ });