@brika/auth 0.1.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/README.md +207 -0
- package/package.json +50 -0
- package/src/__tests__/AuthClient.test.ts +736 -0
- package/src/__tests__/AuthService.test.ts +140 -0
- package/src/__tests__/ScopeService.test.ts +156 -0
- package/src/__tests__/SessionService.test.ts +311 -0
- package/src/__tests__/UserService-avatar.test.ts +277 -0
- package/src/__tests__/UserService.test.ts +223 -0
- package/src/__tests__/canAccess.test.ts +166 -0
- package/src/__tests__/disabledScopes.test.ts +101 -0
- package/src/__tests__/middleware.test.ts +190 -0
- package/src/__tests__/plugin.test.ts +78 -0
- package/src/__tests__/requireSession.test.ts +78 -0
- package/src/__tests__/routes-auth.test.ts +248 -0
- package/src/__tests__/routes-profile.test.ts +403 -0
- package/src/__tests__/routes-scopes.test.ts +64 -0
- package/src/__tests__/routes-sessions.test.ts +235 -0
- package/src/__tests__/routes-users.test.ts +477 -0
- package/src/__tests__/serveImage.test.ts +277 -0
- package/src/__tests__/setup.test.ts +270 -0
- package/src/__tests__/verifyToken.test.ts +219 -0
- package/src/client/AuthClient.ts +312 -0
- package/src/client/http-client.ts +84 -0
- package/src/client/index.ts +19 -0
- package/src/config.ts +82 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +16 -0
- package/src/lib/define-roles.ts +35 -0
- package/src/lib/define-scopes.ts +48 -0
- package/src/middleware/canAccess.ts +126 -0
- package/src/middleware/index.ts +13 -0
- package/src/middleware/requireAuth.ts +35 -0
- package/src/middleware/requireScope.ts +46 -0
- package/src/middleware/verifyToken.ts +52 -0
- package/src/plugin.ts +86 -0
- package/src/react/AuthProvider.tsx +105 -0
- package/src/react/hooks.ts +128 -0
- package/src/react/index.ts +51 -0
- package/src/react/withScopeGuard.tsx +73 -0
- package/src/roles.ts +40 -0
- package/src/schemas.ts +112 -0
- package/src/scopes.ts +60 -0
- package/src/server/index.ts +44 -0
- package/src/server/requireSession.ts +44 -0
- package/src/server/routes/auth.ts +102 -0
- package/src/server/routes/cookie.ts +7 -0
- package/src/server/routes/index.ts +32 -0
- package/src/server/routes/profile.ts +162 -0
- package/src/server/routes/scopes.ts +22 -0
- package/src/server/routes/sessions.ts +68 -0
- package/src/server/routes/setup.ts +50 -0
- package/src/server/routes/users.ts +175 -0
- package/src/server/serveImage.ts +91 -0
- package/src/services/AuthService.ts +80 -0
- package/src/services/ScopeService.ts +94 -0
- package/src/services/SessionService.ts +245 -0
- package/src/services/UserService.ts +245 -0
- package/src/setup.ts +99 -0
- package/src/tanstack/index.ts +15 -0
- package/src/tanstack/routeBuilder.ts +311 -0
- package/src/types.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - verifyToken Middleware Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for token extraction (cookie + Authorization header),
|
|
5
|
+
* IP forwarding, and session attachment to context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'bun:test';
|
|
9
|
+
import { container } from '@brika/di';
|
|
10
|
+
import { initAuthConfig } from '../config';
|
|
11
|
+
import { verifyToken } from '../middleware/verifyToken';
|
|
12
|
+
import { SessionService } from '../services/SessionService';
|
|
13
|
+
import type { Session } from '../types';
|
|
14
|
+
import { Role, Scope } from '../types';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
function mockContext(
|
|
21
|
+
url: string,
|
|
22
|
+
options?: {
|
|
23
|
+
cookie?: string;
|
|
24
|
+
auth?: string;
|
|
25
|
+
ip?: string;
|
|
26
|
+
realIp?: string;
|
|
27
|
+
}
|
|
28
|
+
) {
|
|
29
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
30
|
+
const ctx = {
|
|
31
|
+
req: {
|
|
32
|
+
url,
|
|
33
|
+
header: vi.fn((name: string) => {
|
|
34
|
+
if (name === 'Cookie') {
|
|
35
|
+
return options?.cookie;
|
|
36
|
+
}
|
|
37
|
+
if (name === 'Authorization') {
|
|
38
|
+
return options?.auth;
|
|
39
|
+
}
|
|
40
|
+
if (name === 'x-forwarded-for') {
|
|
41
|
+
return options?.ip;
|
|
42
|
+
}
|
|
43
|
+
if (name === 'x-real-ip') {
|
|
44
|
+
return options?.realIp;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}),
|
|
48
|
+
},
|
|
49
|
+
get: vi.fn(),
|
|
50
|
+
set: vi.fn(),
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
ctx,
|
|
54
|
+
next,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fakeSession: Session = {
|
|
59
|
+
id: 'sess-abc',
|
|
60
|
+
userId: 'user-1',
|
|
61
|
+
userEmail: 'user@test.com',
|
|
62
|
+
userName: 'Test User',
|
|
63
|
+
userRole: Role.USER,
|
|
64
|
+
scopes: [Scope.WORKFLOW_READ],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Setup
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const mockSessionService = {
|
|
72
|
+
validateSession: vi.fn(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
container.clearInstances();
|
|
77
|
+
container.register(SessionService, {
|
|
78
|
+
useValue: mockSessionService as never,
|
|
79
|
+
});
|
|
80
|
+
mockSessionService.validateSession.mockReset();
|
|
81
|
+
initAuthConfig(); // ensures cookieName defaults to 'brika_session'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
container.clearInstances();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// verifyToken
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe('verifyToken', () => {
|
|
93
|
+
it('sets null session and calls next when no cookie or auth header', async () => {
|
|
94
|
+
const middleware = verifyToken();
|
|
95
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test');
|
|
96
|
+
|
|
97
|
+
await middleware(ctx as never, next);
|
|
98
|
+
|
|
99
|
+
expect(ctx.set).toHaveBeenCalledWith('session', null);
|
|
100
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(mockSessionService.validateSession).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('extracts token from cookie and calls validateSession', async () => {
|
|
105
|
+
mockSessionService.validateSession.mockReturnValue(fakeSession);
|
|
106
|
+
const middleware = verifyToken();
|
|
107
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
108
|
+
cookie: 'brika_session=my-token-value',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await middleware(ctx as never, next);
|
|
112
|
+
|
|
113
|
+
expect(mockSessionService.validateSession).toHaveBeenCalledWith('my-token-value', undefined);
|
|
114
|
+
expect(ctx.set).toHaveBeenCalledWith('session', fakeSession);
|
|
115
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('falls back to Authorization Bearer header when no cookie', async () => {
|
|
119
|
+
mockSessionService.validateSession.mockReturnValue(fakeSession);
|
|
120
|
+
const middleware = verifyToken();
|
|
121
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
122
|
+
auth: 'Bearer bearer-token-value',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await middleware(ctx as never, next);
|
|
126
|
+
|
|
127
|
+
expect(mockSessionService.validateSession).toHaveBeenCalledWith(
|
|
128
|
+
'bearer-token-value',
|
|
129
|
+
undefined
|
|
130
|
+
);
|
|
131
|
+
expect(ctx.set).toHaveBeenCalledWith('session', fakeSession);
|
|
132
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('passes x-forwarded-for IP to validateSession', async () => {
|
|
136
|
+
mockSessionService.validateSession.mockReturnValue(fakeSession);
|
|
137
|
+
const middleware = verifyToken();
|
|
138
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
139
|
+
cookie: 'brika_session=token-xyz',
|
|
140
|
+
ip: '203.0.113.42',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await middleware(ctx as never, next);
|
|
144
|
+
|
|
145
|
+
expect(mockSessionService.validateSession).toHaveBeenCalledWith('token-xyz', '203.0.113.42');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('falls back to x-real-ip when x-forwarded-for is absent', async () => {
|
|
149
|
+
mockSessionService.validateSession.mockReturnValue(fakeSession);
|
|
150
|
+
const middleware = verifyToken();
|
|
151
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
152
|
+
cookie: 'brika_session=token-xyz',
|
|
153
|
+
realIp: '198.51.100.7',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await middleware(ctx as never, next);
|
|
157
|
+
|
|
158
|
+
expect(mockSessionService.validateSession).toHaveBeenCalledWith('token-xyz', '198.51.100.7');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('sets session from validateSession return value on context', async () => {
|
|
162
|
+
mockSessionService.validateSession.mockReturnValue(fakeSession);
|
|
163
|
+
const middleware = verifyToken();
|
|
164
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
165
|
+
cookie: 'brika_session=valid-token',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await middleware(ctx as never, next);
|
|
169
|
+
|
|
170
|
+
expect(ctx.set).toHaveBeenCalledWith('session', fakeSession);
|
|
171
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('sets null session when validateSession returns null (expired/revoked)', async () => {
|
|
175
|
+
mockSessionService.validateSession.mockReturnValue(null);
|
|
176
|
+
const middleware = verifyToken();
|
|
177
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
178
|
+
cookie: 'brika_session=expired-token',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await middleware(ctx as never, next);
|
|
182
|
+
|
|
183
|
+
expect(ctx.set).toHaveBeenCalledWith('session', null);
|
|
184
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// getCookieValue (via cookie parsing behaviour of the middleware)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
describe('verifyToken cookie parsing', () => {
|
|
193
|
+
it('handles multiple cookies and extracts the correct one', async () => {
|
|
194
|
+
mockSessionService.validateSession.mockReturnValue(fakeSession);
|
|
195
|
+
const middleware = verifyToken();
|
|
196
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
197
|
+
// brika_session is the second cookie in the header string
|
|
198
|
+
cookie: 'other_cookie=irrelevant; brika_session=correct-token; another=value',
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await middleware(ctx as never, next);
|
|
202
|
+
|
|
203
|
+
expect(mockSessionService.validateSession).toHaveBeenCalledWith('correct-token', undefined);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('sets null session when the cookie name is not present in the header', async () => {
|
|
207
|
+
const middleware = verifyToken();
|
|
208
|
+
const { ctx, next } = mockContext('http://localhost:3001/api/test', {
|
|
209
|
+
// Cookie header exists but does not contain brika_session
|
|
210
|
+
cookie: 'some_other_cookie=value',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await middleware(ctx as never, next);
|
|
214
|
+
|
|
215
|
+
expect(mockSessionService.validateSession).not.toHaveBeenCalled();
|
|
216
|
+
expect(ctx.set).toHaveBeenCalledWith('session', null);
|
|
217
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Client — Cookie-based session authentication
|
|
3
|
+
*
|
|
4
|
+
* The server sets an HttpOnly cookie on login.
|
|
5
|
+
* All requests include `credentials: 'include'` so the browser sends it automatically.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const auth = new AuthClient()
|
|
9
|
+
* const session = await auth.login(email, password)
|
|
10
|
+
* await auth.logout()
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class AuthError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
message: string,
|
|
16
|
+
readonly status: number
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AuthClientConfig {
|
|
23
|
+
apiUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SessionInfo {
|
|
27
|
+
id: string;
|
|
28
|
+
ip: string | null;
|
|
29
|
+
userAgent: string | null;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
lastSeenAt: number;
|
|
32
|
+
current: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LoginResponse {
|
|
36
|
+
user: Session['user'];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Session {
|
|
40
|
+
user: {
|
|
41
|
+
id: string;
|
|
42
|
+
email: string;
|
|
43
|
+
name: string;
|
|
44
|
+
role: string;
|
|
45
|
+
avatarHash: string | null;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
};
|
|
49
|
+
scopes?: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class AuthClient {
|
|
53
|
+
private readonly apiUrl: string;
|
|
54
|
+
|
|
55
|
+
constructor(config: AuthClientConfig = {}) {
|
|
56
|
+
this.apiUrl =
|
|
57
|
+
config.apiUrl ||
|
|
58
|
+
(globalThis.window === undefined
|
|
59
|
+
? 'http://localhost:3001'
|
|
60
|
+
: globalThis.window.location.origin);
|
|
61
|
+
|
|
62
|
+
// Clean up legacy localStorage keys from pre-cookie auth
|
|
63
|
+
if (globalThis.window !== undefined) {
|
|
64
|
+
localStorage.removeItem('brika_token');
|
|
65
|
+
localStorage.removeItem('brika_session');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Login with email and password.
|
|
71
|
+
* Server sets HttpOnly cookie — no token stored client-side.
|
|
72
|
+
* After the cookie is set, fetches the full session (with scopes).
|
|
73
|
+
*/
|
|
74
|
+
async login(email: string, password: string): Promise<Session> {
|
|
75
|
+
await this.request('/api/auth/login', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
},
|
|
80
|
+
body: JSON.stringify({
|
|
81
|
+
email,
|
|
82
|
+
password,
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Cookie is now set — fetch full session including scopes
|
|
87
|
+
const session = await this.getSession();
|
|
88
|
+
if (!session) {
|
|
89
|
+
throw new Error('Login failed');
|
|
90
|
+
}
|
|
91
|
+
return session;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Logout — server revokes session and clears cookie.
|
|
96
|
+
*/
|
|
97
|
+
async logout(): Promise<void> {
|
|
98
|
+
try {
|
|
99
|
+
await fetch(`${this.apiUrl}/api/auth/logout`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
credentials: 'include',
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
// Silent fail — cookie will expire anyway
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get current session from server (validates cookie).
|
|
110
|
+
*/
|
|
111
|
+
async getSession(): Promise<Session | null> {
|
|
112
|
+
try {
|
|
113
|
+
const data = await this.request<{
|
|
114
|
+
user: Session['user'];
|
|
115
|
+
scopes: string[];
|
|
116
|
+
}>('/api/auth/session');
|
|
117
|
+
return {
|
|
118
|
+
user: data.user,
|
|
119
|
+
scopes: data.scopes,
|
|
120
|
+
};
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Update own profile (name).
|
|
128
|
+
*/
|
|
129
|
+
async updateProfile(updates: { name?: string }): Promise<Session> {
|
|
130
|
+
return await this.request<Session>('/api/auth/profile', {
|
|
131
|
+
method: 'PUT',
|
|
132
|
+
headers: {
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify(updates),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Upload avatar image. Processed to webp on server.
|
|
141
|
+
* Returns the new avatar content hash for cache busting.
|
|
142
|
+
*/
|
|
143
|
+
async uploadAvatar(file: Blob): Promise<string> {
|
|
144
|
+
const data = await this.request<{
|
|
145
|
+
ok: boolean;
|
|
146
|
+
avatarHash: string;
|
|
147
|
+
}>('/api/auth/profile/avatar', {
|
|
148
|
+
method: 'PUT',
|
|
149
|
+
body: file,
|
|
150
|
+
});
|
|
151
|
+
return data.avatarHash;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Remove avatar.
|
|
156
|
+
*/
|
|
157
|
+
async removeAvatar(): Promise<void> {
|
|
158
|
+
await this.request('/api/auth/profile/avatar', {
|
|
159
|
+
method: 'DELETE',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get avatar URL for a user.
|
|
165
|
+
* Accepts a user-like object `{ id, avatarHash }` — the hash makes the URL
|
|
166
|
+
* content-addressed so the browser fetches a new image when the avatar changes.
|
|
167
|
+
*/
|
|
168
|
+
avatarUrl(
|
|
169
|
+
user: {
|
|
170
|
+
id: string;
|
|
171
|
+
avatarHash?: string | null;
|
|
172
|
+
},
|
|
173
|
+
options?: {
|
|
174
|
+
size?: number;
|
|
175
|
+
dpr?: number;
|
|
176
|
+
}
|
|
177
|
+
): string {
|
|
178
|
+
const params = new URLSearchParams();
|
|
179
|
+
|
|
180
|
+
const dpr = options?.dpr ?? globalThis.devicePixelRatio ?? 1;
|
|
181
|
+
const size = options?.size ? Math.round(options.size * dpr) : undefined;
|
|
182
|
+
if (size) {
|
|
183
|
+
params.set('s', String(size));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (user.avatarHash) {
|
|
187
|
+
params.set('v', user.avatarHash);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const qs = params.toString();
|
|
191
|
+
const suffix = qs ? `?${qs}` : '';
|
|
192
|
+
return `${this.apiUrl}/api/auth/avatar/${user.id}${suffix}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* List all active sessions for the current user.
|
|
197
|
+
*/
|
|
198
|
+
async listSessions(): Promise<SessionInfo[]> {
|
|
199
|
+
const data = await this.request<{
|
|
200
|
+
sessions: SessionInfo[];
|
|
201
|
+
}>('/api/auth/sessions');
|
|
202
|
+
return data.sessions;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Revoke a specific session by ID.
|
|
207
|
+
*/
|
|
208
|
+
async revokeSession(sessionId: string): Promise<void> {
|
|
209
|
+
await this.request<{
|
|
210
|
+
ok: boolean;
|
|
211
|
+
}>(`/api/auth/sessions/${sessionId}`, {
|
|
212
|
+
method: 'DELETE',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Change own password. Requires current password for verification.
|
|
218
|
+
*/
|
|
219
|
+
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
|
220
|
+
await this.request('/api/auth/profile/password', {
|
|
221
|
+
method: 'PUT',
|
|
222
|
+
headers: {
|
|
223
|
+
'Content-Type': 'application/json',
|
|
224
|
+
},
|
|
225
|
+
body: JSON.stringify({
|
|
226
|
+
currentPassword,
|
|
227
|
+
newPassword,
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Revoke all sessions for the current user (signs out everywhere).
|
|
234
|
+
*/
|
|
235
|
+
async revokeAllSessions(): Promise<void> {
|
|
236
|
+
await this.request<{
|
|
237
|
+
ok: boolean;
|
|
238
|
+
}>('/api/auth/sessions', {
|
|
239
|
+
method: 'DELETE',
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Check if initial setup is needed (no admin or setup not completed).
|
|
245
|
+
* Uses the hub-level endpoint that combines auth + hub state checks.
|
|
246
|
+
*/
|
|
247
|
+
async checkSetupStatus(): Promise<{ needsSetup: boolean }> {
|
|
248
|
+
try {
|
|
249
|
+
return await this.request<{ needsSetup: boolean }>('/api/setup/status');
|
|
250
|
+
} catch {
|
|
251
|
+
return { needsSetup: false };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Mark the onboarding wizard as fully completed.
|
|
257
|
+
* Called at the final step after all setup screens are done.
|
|
258
|
+
*/
|
|
259
|
+
async completeSetup(): Promise<void> {
|
|
260
|
+
await this.request('/api/setup/complete', { method: 'POST' });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create the first admin user during initial setup.
|
|
265
|
+
* Server sets session cookie automatically.
|
|
266
|
+
*/
|
|
267
|
+
async setup(data: { email: string; name: string; password: string }): Promise<Session> {
|
|
268
|
+
await this.request('/api/auth/setup', {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: { 'Content-Type': 'application/json' },
|
|
271
|
+
body: JSON.stringify(data),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const session = await this.getSession();
|
|
275
|
+
if (!session) {
|
|
276
|
+
throw new Error('Setup failed');
|
|
277
|
+
}
|
|
278
|
+
return session;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Make authenticated request (cookie sent automatically).
|
|
283
|
+
*/
|
|
284
|
+
async request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|
285
|
+
const response = await fetch(`${this.apiUrl}${url}`, {
|
|
286
|
+
...options,
|
|
287
|
+
credentials: 'include',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
if (!response.ok) {
|
|
291
|
+
const body = await response.json().catch(() => null);
|
|
292
|
+
throw new AuthError(
|
|
293
|
+
body?.error ?? (response.status === 401 ? 'Unauthorized' : 'Request failed'),
|
|
294
|
+
response.status
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return await response.json();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Singleton instance */
|
|
303
|
+
let authClient: AuthClient | null = null;
|
|
304
|
+
|
|
305
|
+
export function getAuthClient(config?: AuthClientConfig): AuthClient {
|
|
306
|
+
authClient ??= new AuthClient(config);
|
|
307
|
+
return authClient;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function createAuthClient(config?: AuthClientConfig): AuthClient {
|
|
311
|
+
return new AuthClient(config);
|
|
312
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/client - HTTP Client
|
|
3
|
+
*
|
|
4
|
+
* Simple HTTP client for making auth API calls.
|
|
5
|
+
* Uses credentials: 'include' for cookie-based auth.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LoginRequest } from '../types';
|
|
9
|
+
import type { LoginResponse } from './AuthClient';
|
|
10
|
+
|
|
11
|
+
export interface HttpClientOptions {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* HTTP client for auth API calls
|
|
18
|
+
*/
|
|
19
|
+
export class AuthHttpClient {
|
|
20
|
+
private readonly baseUrl: string;
|
|
21
|
+
private readonly fetch: typeof fetch;
|
|
22
|
+
|
|
23
|
+
constructor(options: HttpClientOptions) {
|
|
24
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
25
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Login with email and password
|
|
30
|
+
*/
|
|
31
|
+
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
|
32
|
+
const response = await this.fetch(`${this.baseUrl}/api/auth/login`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify(credentials),
|
|
38
|
+
credentials: 'include',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error('Login failed');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return response.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Logout — server clears the session cookie
|
|
50
|
+
*/
|
|
51
|
+
async logout(): Promise<void> {
|
|
52
|
+
await this.fetch(`${this.baseUrl}/api/auth/logout`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
credentials: 'include',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Verify current session (cookie sent automatically)
|
|
60
|
+
*/
|
|
61
|
+
async verify(): Promise<boolean> {
|
|
62
|
+
const response = await this.fetch(`${this.baseUrl}/api/auth/session`, {
|
|
63
|
+
credentials: 'include',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return response.ok;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Make authenticated request (cookie sent automatically)
|
|
71
|
+
*/
|
|
72
|
+
async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
73
|
+
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
74
|
+
...options,
|
|
75
|
+
credentials: 'include',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error(`Request failed: ${response.statusText}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return response.json();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/client
|
|
3
|
+
*
|
|
4
|
+
* Client-side authentication module.
|
|
5
|
+
* Use this to make HTTP calls to your auth API.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { AuthHttpClient } from '@brika/auth/client';
|
|
10
|
+
*
|
|
11
|
+
* const client = new AuthHttpClient({ baseUrl: 'http://localhost:3001' });
|
|
12
|
+
* const { user } = await client.login({
|
|
13
|
+
* email: 'user@example.com',
|
|
14
|
+
* password: 'password123',
|
|
15
|
+
* });
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { AuthHttpClient, type HttpClientOptions } from './http-client';
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - Configuration
|
|
3
|
+
*
|
|
4
|
+
* Runtime-configurable auth settings. The hub passes config via the
|
|
5
|
+
* auth plugin; defaults apply when no config is provided.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* auth({ dataDir, server, config: { session: { ttl: 86400 } } })
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface AuthConfig {
|
|
12
|
+
session?: {
|
|
13
|
+
/** Session TTL in seconds (default: 604800 = 7 days) */
|
|
14
|
+
ttl?: number;
|
|
15
|
+
/** Cookie name (default: 'brika_session') */
|
|
16
|
+
cookieName?: string;
|
|
17
|
+
/** Max active sessions per user (default: 10). Oldest sessions are revoked when exceeded. */
|
|
18
|
+
maxPerUser?: number;
|
|
19
|
+
};
|
|
20
|
+
password?: {
|
|
21
|
+
/** Minimum length (default: 8) */
|
|
22
|
+
minLength?: number;
|
|
23
|
+
/** Require uppercase letter (default: true) */
|
|
24
|
+
requireUppercase?: boolean;
|
|
25
|
+
/** Require digit (default: true) */
|
|
26
|
+
requireNumbers?: boolean;
|
|
27
|
+
/** Require special character (default: true) */
|
|
28
|
+
requireSpecial?: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ResolvedAuthConfig {
|
|
33
|
+
session: {
|
|
34
|
+
ttl: number;
|
|
35
|
+
cookieName: string;
|
|
36
|
+
maxPerUser: number;
|
|
37
|
+
};
|
|
38
|
+
password: {
|
|
39
|
+
minLength: number;
|
|
40
|
+
requireUppercase: boolean;
|
|
41
|
+
requireNumbers: boolean;
|
|
42
|
+
requireSpecial: boolean;
|
|
43
|
+
specialChars: RegExp;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const AUTH_DEFAULTS: ResolvedAuthConfig = {
|
|
48
|
+
session: {
|
|
49
|
+
ttl: 604800, // 7 days
|
|
50
|
+
cookieName: 'brika_session',
|
|
51
|
+
maxPerUser: 10,
|
|
52
|
+
},
|
|
53
|
+
password: {
|
|
54
|
+
minLength: 8,
|
|
55
|
+
requireUppercase: true,
|
|
56
|
+
requireNumbers: true,
|
|
57
|
+
requireSpecial: true,
|
|
58
|
+
specialChars: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let _config: ResolvedAuthConfig = AUTH_DEFAULTS;
|
|
63
|
+
|
|
64
|
+
/** Initialize auth config. Call once during bootstrap. */
|
|
65
|
+
export function initAuthConfig(config?: AuthConfig): ResolvedAuthConfig {
|
|
66
|
+
_config = {
|
|
67
|
+
session: {
|
|
68
|
+
...AUTH_DEFAULTS.session,
|
|
69
|
+
...config?.session,
|
|
70
|
+
},
|
|
71
|
+
password: {
|
|
72
|
+
...AUTH_DEFAULTS.password,
|
|
73
|
+
...config?.password,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return _config;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Get the resolved auth config. */
|
|
80
|
+
export function getAuthConfig(): ResolvedAuthConfig {
|
|
81
|
+
return _config;
|
|
82
|
+
}
|