@authms/core 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,85 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { TabSync } from '../sync';
3
+
4
+ class MockBroadcastChannel {
5
+ name: string;
6
+ onmessage: ((event: any) => void) | null = null;
7
+ private static channels: Map<string, MockBroadcastChannel> = new Map();
8
+ constructor(name: string) { this.name = name; MockBroadcastChannel.channels.set(name, this); }
9
+ postMessage(data: any) {
10
+ MockBroadcastChannel.channels.forEach((ch, key) => {
11
+ if (key === this.name && ch.onmessage) ch.onmessage({ data });
12
+ });
13
+ }
14
+ close() { MockBroadcastChannel.channels.delete(this.name); }
15
+ }
16
+
17
+ (globalThis as any).BroadcastChannel = MockBroadcastChannel;
18
+
19
+ describe('TabSync', () => {
20
+ beforeEach(() => {
21
+ MockBroadcastChannel['channels'].clear();
22
+ });
23
+
24
+ afterEach(() => {
25
+ MockBroadcastChannel['channels'].clear();
26
+ });
27
+
28
+ it('listen — creates BroadcastChannel and listens for LOGOUT', () => {
29
+ const onLogout = vi.fn();
30
+ const onTokenChange = vi.fn();
31
+ const sync = new TabSync(onLogout, onTokenChange);
32
+
33
+ sync.listen();
34
+
35
+ const channel = MockBroadcastChannel['channels'].get('authms:sync');
36
+ expect(channel).toBeDefined();
37
+ expect(channel!.name).toBe('authms:sync');
38
+ expect(channel!.onmessage).toBeDefined();
39
+
40
+ channel!.onmessage!({ data: { type: 'LOGOUT', timestamp: Date.now() } });
41
+ expect(onLogout).toHaveBeenCalledOnce();
42
+ expect(onTokenChange).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it('broadcast LOGOUT — calls onLogout callback on other listeners', () => {
46
+ const onLogoutA = vi.fn();
47
+ const syncA = new TabSync(onLogoutA, vi.fn());
48
+ syncA.listen();
49
+
50
+ const onLogoutB = vi.fn();
51
+ const onTokenChangeB = vi.fn();
52
+ const syncB = new TabSync(onLogoutB, onTokenChangeB);
53
+ syncB.listen();
54
+
55
+ syncA.broadcast('LOGOUT');
56
+
57
+ expect(onLogoutA).not.toHaveBeenCalled();
58
+ expect(onLogoutB).toHaveBeenCalledOnce();
59
+ expect(onTokenChangeB).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('close — cleans up the channel', () => {
63
+ const sync = new TabSync(vi.fn(), vi.fn());
64
+ sync.listen();
65
+
66
+ expect(MockBroadcastChannel['channels'].has('authms:sync')).toBe(true);
67
+
68
+ sync.close();
69
+
70
+ expect(MockBroadcastChannel['channels'].has('authms:sync')).toBe(false);
71
+ });
72
+
73
+ it('non-browser environment — handles missing BroadcastChannel gracefully', () => {
74
+ const origBC = (globalThis as any).BroadcastChannel;
75
+ (globalThis as any).BroadcastChannel = undefined;
76
+
77
+ const onLogout = vi.fn();
78
+ const sync = new TabSync(onLogout, vi.fn());
79
+
80
+ expect(() => sync.listen()).not.toThrow();
81
+ expect(onLogout).not.toHaveBeenCalled();
82
+
83
+ (globalThis as any).BroadcastChannel = origBC;
84
+ });
85
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { TokenManager } from '../token-manager';
3
+ import type { StorageAdapter } from '../platform/types';
4
+
5
+ class MemoryStorage implements StorageAdapter {
6
+ private store = new Map<string, string>();
7
+ getItem(key: string) { return this.store.get(key) ?? null; }
8
+ setItem(key: string, value: string) { this.store.set(key, value); }
9
+ removeItem(key: string) { this.store.delete(key); }
10
+ keys() { return Array.from(this.store.keys()); }
11
+ }
12
+
13
+ function createToken(overrides: Record<string, unknown> = {}): string {
14
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
15
+ const payload = btoa(JSON.stringify({
16
+ sub: 'user-1',
17
+ user_id: 'user-1',
18
+ tenant_id: 'tenant-1',
19
+ exp: Math.floor(Date.now() / 1000) + 900,
20
+ iat: Math.floor(Date.now() / 1000),
21
+ ...overrides,
22
+ }));
23
+ return `${header}.${payload}.signature`;
24
+ }
25
+
26
+ describe('TokenManager', () => {
27
+ let storage: MemoryStorage;
28
+ let tm: TokenManager;
29
+
30
+ beforeEach(() => {
31
+ storage = new MemoryStorage();
32
+ tm = new TokenManager(storage);
33
+ });
34
+
35
+ it('should start with no tokens', () => {
36
+ expect(tm.getAccessToken()).toBeNull();
37
+ expect(tm.getRefreshToken()).toBeNull();
38
+ expect(tm.isAuthenticated()).toBe(false);
39
+ });
40
+
41
+ it('should store and retrieve tokens', async () => {
42
+ const accessToken = createToken();
43
+ tm.setTokens(accessToken, 'refresh-1', 900);
44
+ await tm.persist();
45
+
46
+ expect(tm.getAccessToken()).toBe(accessToken);
47
+ expect(tm.getRefreshToken()).toBe('refresh-1');
48
+ expect(tm.isAuthenticated()).toBe(true);
49
+ });
50
+
51
+ it('should detect expired tokens', () => {
52
+ const expiredToken = createToken({ exp: Math.floor(Date.now() / 1000) - 60 });
53
+ tm.setTokens(expiredToken, 'refresh-1', 900);
54
+ expect(tm.getAccessToken()).toBeNull();
55
+ expect(tm.isAuthenticated()).toBe(false);
56
+ });
57
+
58
+ it('should clear all tokens', () => {
59
+ tm.setTokens(createToken(), 'refresh-1', 900);
60
+ tm.clear();
61
+ expect(tm.getAccessToken()).toBeNull();
62
+ expect(tm.getRefreshToken()).toBeNull();
63
+ expect(tm.isAuthenticated()).toBe(false);
64
+ });
65
+
66
+ it('should persist and load tokens', async () => {
67
+ const accessToken = createToken();
68
+ tm.setTokens(accessToken, 'refresh-1', 900);
69
+ tm.setUser({ id: 'user-1', name: 'Test' });
70
+ await tm.persist();
71
+
72
+ const tm2 = new TokenManager(storage);
73
+ await tm2.load();
74
+ expect(tm2.getAccessToken()).toBe(accessToken);
75
+ expect(tm2.getRefreshToken()).toBe('refresh-1');
76
+ expect(tm2.getUser()).toEqual({ id: 'user-1', name: 'Test' });
77
+ });
78
+
79
+ it('should notify listeners on token change', () => {
80
+ const calls: (string | null)[] = [];
81
+ const unsub = tm.onTokenChange((t) => calls.push(t));
82
+ tm.setTokens(createToken(), 'r1', 900);
83
+ tm.clear();
84
+ unsub();
85
+ tm.setTokens(createToken(), 'r2', 900);
86
+ expect(calls.length).toBe(2);
87
+ expect(calls[0]).toBeTruthy();
88
+ expect(calls[1]).toBeNull();
89
+ });
90
+
91
+ it('should decode JWT claims', () => {
92
+ const token = createToken({ sub: 'user-1', tenant_id: 'tenant-1' });
93
+ const claims = tm.decodeToken(token);
94
+ expect(claims?.sub).toBe('user-1');
95
+ expect(claims?.tenant_id).toBe('tenant-1');
96
+ });
97
+
98
+ it('should manage tenant id', () => {
99
+ tm.setTenantId('tenant-1');
100
+ expect(tm.getTenantId()).toBe('tenant-1');
101
+ tm.setTenantId(null);
102
+ expect(tm.getTenantId()).toBeNull();
103
+ });
104
+ });
@@ -0,0 +1,203 @@
1
+ import type { HttpAdapter } from './platform/types';
2
+ import type { TokenManager } from './token-manager';
3
+ import { AuthmsError, AuthmsApiError, AuthmsAuthError, AuthmsNetworkError } from './errors';
4
+
5
+ interface ApiClientConfig {
6
+ baseUrl: string;
7
+ tokenManager: TokenManager;
8
+ http: HttpAdapter;
9
+ refreshTokenFn: () => Promise<void>;
10
+ onForceLogout?: () => void;
11
+ }
12
+
13
+ function snakeToCamel(str: string): string {
14
+ return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
15
+ }
16
+
17
+ function camelToSnake(str: string): string {
18
+ return str.replace(/[A-Z]/g, c => '_' + c.toLowerCase());
19
+ }
20
+
21
+ function transformKeys(obj: unknown, transform: (k: string) => string): unknown {
22
+ if (obj === null || typeof obj !== 'object') return obj;
23
+ if (Array.isArray(obj)) return obj.map(v => transformKeys(v, transform));
24
+ const result: Record<string, unknown> = {};
25
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
26
+ result[transform(k)] = transformKeys(v, transform);
27
+ }
28
+ return result;
29
+ }
30
+
31
+ function unwrapResponse(data: Record<string, unknown>): unknown {
32
+ if ('data' in data && data.data !== undefined) {
33
+ return data.data;
34
+ }
35
+ if ('items' in data) {
36
+ return { items: data.items, total: data.total, pagination: data.pagination };
37
+ }
38
+ return data;
39
+ }
40
+
41
+ export class ApiClient {
42
+ private baseUrl: string;
43
+ private tokenManager: TokenManager;
44
+ private http: HttpAdapter;
45
+ private refreshTokenFn: () => Promise<void>;
46
+ private onForceLogout?: () => void;
47
+ private refreshPromise: Promise<void> | null = null;
48
+ private redirectingToLogin = false;
49
+
50
+ constructor(config: ApiClientConfig) {
51
+ this.baseUrl = config.baseUrl.replace(/\/$/, '');
52
+ this.tokenManager = config.tokenManager;
53
+ this.http = config.http;
54
+ this.refreshTokenFn = config.refreshTokenFn;
55
+ this.onForceLogout = config.onForceLogout;
56
+ }
57
+
58
+ async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
59
+ const url = this.buildUrl(path, params);
60
+ return this.request<T>(url, { method: 'GET' });
61
+ }
62
+
63
+ async post<T>(path: string, data?: unknown): Promise<T> {
64
+ const url = `${this.baseUrl}${path}`;
65
+ return this.request<T>(url, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: data ? JSON.stringify(transformKeys(data, camelToSnake)) : undefined,
69
+ });
70
+ }
71
+
72
+ async put<T>(path: string, data?: unknown): Promise<T> {
73
+ const url = `${this.baseUrl}${path}`;
74
+ return this.request<T>(url, {
75
+ method: 'PUT',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: data ? JSON.stringify(transformKeys(data, camelToSnake)) : undefined,
78
+ });
79
+ }
80
+
81
+ async delete<T>(path: string, params?: Record<string, unknown>): Promise<T> {
82
+ const url = this.buildUrl(path, params);
83
+ return this.request<T>(url, { method: 'DELETE' });
84
+ }
85
+
86
+ private buildUrl(path: string, params?: Record<string, unknown>): string {
87
+ let url = `${this.baseUrl}${path}`;
88
+ if (params && Object.keys(params).length > 0) {
89
+ const query = new URLSearchParams();
90
+ for (const [k, v] of Object.entries(params)) {
91
+ if (v !== undefined && v !== null) {
92
+ query.append(camelToSnake(k), String(v));
93
+ }
94
+ }
95
+ url += '?' + query.toString();
96
+ }
97
+ return url;
98
+ }
99
+
100
+ private async request<T>(url: string, init: RequestInit): Promise<T> {
101
+ const headers: Record<string, string> = {
102
+ ...(init.headers as Record<string, string> || {}),
103
+ };
104
+
105
+ const token = this.tokenManager.getAccessToken();
106
+ if (token) {
107
+ headers['Authorization'] = `Bearer ${token}`;
108
+ }
109
+
110
+ const tenantId = this.tokenManager.getTenantId();
111
+ if (tenantId) {
112
+ headers['X-Tenant-ID'] = tenantId;
113
+ }
114
+
115
+ const requestInit: RequestInit = {
116
+ ...init,
117
+ headers,
118
+ };
119
+
120
+ let response: Response;
121
+ try {
122
+ response = await this.http.request(url, requestInit);
123
+ } catch (err) {
124
+ throw new AuthmsNetworkError(err instanceof Error ? err.message : 'Network request failed');
125
+ }
126
+
127
+ if (response.status === 401 && !url.includes('/api/v1/auth/login') && !url.includes('/api/v1/auth/refresh')) {
128
+ return this.handle401<T>(url, requestInit);
129
+ }
130
+
131
+ return this.handleResponse<T>(response);
132
+ }
133
+
134
+ private async handle401<T>(url: string, requestInit: RequestInit): Promise<T> {
135
+ if (!this.refreshPromise) {
136
+ this.refreshPromise = (async () => {
137
+ try {
138
+ await this.refreshTokenFn();
139
+ } catch {
140
+ this.tokenManager.clear();
141
+ if (!this.redirectingToLogin) {
142
+ this.redirectingToLogin = true;
143
+ this.onForceLogout?.();
144
+ throw new AuthmsAuthError('SESSION_EXPIRED', 'Session expired, please login again', 401);
145
+ }
146
+ throw new AuthmsAuthError('REFRESH_FAILED', 'Token refresh failed', 401);
147
+ } finally {
148
+ this.refreshPromise = null;
149
+ }
150
+ })();
151
+ }
152
+
153
+ await this.refreshPromise;
154
+
155
+ const newToken = this.tokenManager.getAccessToken();
156
+ if (newToken) {
157
+ const headers = { ...(requestInit.headers as Record<string, string>) };
158
+ headers['Authorization'] = `Bearer ${newToken}`;
159
+ const response = await this.http.request(url, { ...requestInit, headers });
160
+ return this.handleResponse<T>(response);
161
+ }
162
+
163
+ throw new AuthmsAuthError('NOT_AUTHENTICATED', 'Not authenticated', 401);
164
+ }
165
+
166
+ private async handleResponse<T>(response: Response): Promise<T> {
167
+ if (!response.ok) {
168
+ await this.handleErrorResponse(response);
169
+ }
170
+
171
+ if (response.status === 204 || response.headers.get('content-length') === '0') {
172
+ return undefined as unknown as T;
173
+ }
174
+
175
+ const json: Record<string, unknown> = await response.json();
176
+
177
+ if (json && typeof json === 'object' && 'code' in json) {
178
+ const code = String(json.code);
179
+ if (json.code !== 0) {
180
+ throw new AuthmsApiError(
181
+ code,
182
+ (json.message as string) || 'API error',
183
+ response.status,
184
+ );
185
+ }
186
+ return transformKeys(unwrapResponse(json), snakeToCamel) as T;
187
+ }
188
+
189
+ return json as T;
190
+ }
191
+
192
+ private async handleErrorResponse(response: Response): Promise<never> {
193
+ try {
194
+ const json = await response.json() as Record<string, unknown>;
195
+ const code = String(json.code ?? response.status);
196
+ const message = (json.message as string) || `HTTP ${response.status}`;
197
+ throw new AuthmsApiError(code, message, response.status);
198
+ } catch (e) {
199
+ if (e instanceof AuthmsError) throw e;
200
+ throw new AuthmsNetworkError(`HTTP ${response.status}: ${response.statusText}`);
201
+ }
202
+ }
203
+ }