@workfort/auth 0.0.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/client.d.ts +24 -0
- package/dist/client.js +97 -0
- package/dist/client.test.d.ts +1 -0
- package/dist/client.test.js +149 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +16 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.js +10 -0
- package/package.json +25 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { User, Session, AuthEventMap } from './types.js';
|
|
2
|
+
type Listener<T> = (data: T) => void;
|
|
3
|
+
export declare class AuthClient {
|
|
4
|
+
private _user;
|
|
5
|
+
private _session;
|
|
6
|
+
private _listeners;
|
|
7
|
+
private _lastVisible;
|
|
8
|
+
private _visHandler;
|
|
9
|
+
getUser(): User | null;
|
|
10
|
+
getSession(): Session | null;
|
|
11
|
+
get isAuthenticated(): boolean;
|
|
12
|
+
init(): Promise<void>;
|
|
13
|
+
refresh(): Promise<void>;
|
|
14
|
+
/** Clears session and emits events. Redirect to login is the shell's responsibility
|
|
15
|
+
* (it listens for the 'logout' event and navigates accordingly). */
|
|
16
|
+
logout(): Promise<void>;
|
|
17
|
+
on<K extends keyof AuthEventMap>(event: K, listener: Listener<AuthEventMap[K]>): void;
|
|
18
|
+
off<K extends keyof AuthEventMap>(event: K, listener: Listener<AuthEventMap[K]>): void;
|
|
19
|
+
destroy(): void;
|
|
20
|
+
private _fetchSession;
|
|
21
|
+
private _setupVisibilityListener;
|
|
22
|
+
private _emit;
|
|
23
|
+
}
|
|
24
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { AuthInitError } from './types.js';
|
|
2
|
+
const SESSION_ENDPOINT = '/api/auth/v1/session';
|
|
3
|
+
const SIGNOUT_ENDPOINT = '/api/auth/v1/sign-out';
|
|
4
|
+
const VISIBILITY_THRESHOLD_MS = 5 * 60 * 1000;
|
|
5
|
+
export class AuthClient {
|
|
6
|
+
_user = null;
|
|
7
|
+
_session = null;
|
|
8
|
+
_listeners = new Map();
|
|
9
|
+
_lastVisible = Date.now();
|
|
10
|
+
_visHandler = null;
|
|
11
|
+
getUser() { return this._user; }
|
|
12
|
+
getSession() { return this._session; }
|
|
13
|
+
get isAuthenticated() { return this._user !== null; }
|
|
14
|
+
async init() {
|
|
15
|
+
await this._fetchSession();
|
|
16
|
+
this._setupVisibilityListener();
|
|
17
|
+
}
|
|
18
|
+
async refresh() {
|
|
19
|
+
const wasAuth = this.isAuthenticated;
|
|
20
|
+
await this._fetchSession();
|
|
21
|
+
if (wasAuth && !this.isAuthenticated) {
|
|
22
|
+
this._emit('logout', undefined);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Clears session and emits events. Redirect to login is the shell's responsibility
|
|
26
|
+
* (it listens for the 'logout' event and navigates accordingly). */
|
|
27
|
+
async logout() {
|
|
28
|
+
try {
|
|
29
|
+
await fetch(SIGNOUT_ENDPOINT, { method: 'POST', credentials: 'include' });
|
|
30
|
+
}
|
|
31
|
+
catch { /* best-effort */ }
|
|
32
|
+
this._user = null;
|
|
33
|
+
this._session = null;
|
|
34
|
+
this._emit('logout', undefined);
|
|
35
|
+
this._emit('change', null);
|
|
36
|
+
}
|
|
37
|
+
on(event, listener) {
|
|
38
|
+
if (!this._listeners.has(event))
|
|
39
|
+
this._listeners.set(event, new Set());
|
|
40
|
+
this._listeners.get(event).add(listener);
|
|
41
|
+
}
|
|
42
|
+
off(event, listener) {
|
|
43
|
+
this._listeners.get(event)?.delete(listener);
|
|
44
|
+
}
|
|
45
|
+
destroy() {
|
|
46
|
+
if (this._visHandler) {
|
|
47
|
+
document.removeEventListener('visibilitychange', this._visHandler);
|
|
48
|
+
this._visHandler = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async _fetchSession() {
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await fetch(SESSION_ENDPOINT, { credentials: 'include' });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new AuthInitError('Failed to reach auth service', { cause: err });
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 401) {
|
|
60
|
+
this._user = null;
|
|
61
|
+
this._session = null;
|
|
62
|
+
this._emit('change', null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
throw new AuthInitError(`Auth service returned ${res.status}`);
|
|
67
|
+
}
|
|
68
|
+
let data;
|
|
69
|
+
try {
|
|
70
|
+
data = await res.json();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new AuthInitError('Invalid JSON from auth service', { cause: err });
|
|
74
|
+
}
|
|
75
|
+
this._user = data.user;
|
|
76
|
+
this._session = data.session;
|
|
77
|
+
this._emit('change', this._user);
|
|
78
|
+
}
|
|
79
|
+
_setupVisibilityListener() {
|
|
80
|
+
if (typeof document === 'undefined')
|
|
81
|
+
return;
|
|
82
|
+
this._visHandler = () => {
|
|
83
|
+
if (document.visibilityState === 'visible') {
|
|
84
|
+
if (Date.now() - this._lastVisible > VISIBILITY_THRESHOLD_MS) {
|
|
85
|
+
this.refresh().catch(() => { });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
this._lastVisible = Date.now();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
document.addEventListener('visibilitychange', this._visHandler);
|
|
93
|
+
}
|
|
94
|
+
_emit(event, data) {
|
|
95
|
+
this._listeners.get(event)?.forEach((fn) => fn(data));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { AuthClient } from './client.js';
|
|
3
|
+
import { AuthInitError } from './types.js';
|
|
4
|
+
const mockUser = {
|
|
5
|
+
id: 'user-1',
|
|
6
|
+
username: 'kazw',
|
|
7
|
+
name: 'Kaz Walker',
|
|
8
|
+
displayName: 'Kaz',
|
|
9
|
+
type: 'user',
|
|
10
|
+
};
|
|
11
|
+
const mockSession = {
|
|
12
|
+
id: 'session-1',
|
|
13
|
+
expiresAt: '2026-12-31T00:00:00Z',
|
|
14
|
+
refreshedAt: '2026-03-12T00:00:00Z',
|
|
15
|
+
};
|
|
16
|
+
function mockFetchResponse(status, body) {
|
|
17
|
+
return {
|
|
18
|
+
ok: status >= 200 && status < 300,
|
|
19
|
+
status,
|
|
20
|
+
json: () => Promise.resolve(body),
|
|
21
|
+
headers: new Headers(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
describe('AuthClient', () => {
|
|
25
|
+
let client;
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
client = new AuthClient();
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
client.destroy();
|
|
32
|
+
});
|
|
33
|
+
describe('init', () => {
|
|
34
|
+
it('fetches session and populates user', async () => {
|
|
35
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, { user: mockUser, session: mockSession }));
|
|
36
|
+
await client.init();
|
|
37
|
+
expect(client.getUser()).toEqual(mockUser);
|
|
38
|
+
expect(client.getSession()).toEqual(mockSession);
|
|
39
|
+
expect(client.isAuthenticated).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('sets user to null on 401', async () => {
|
|
42
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(401));
|
|
43
|
+
await client.init();
|
|
44
|
+
expect(client.getUser()).toBeNull();
|
|
45
|
+
expect(client.getSession()).toBeNull();
|
|
46
|
+
expect(client.isAuthenticated).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
it('throws AuthInitError on network failure', async () => {
|
|
49
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down'));
|
|
50
|
+
await expect(client.init()).rejects.toThrow(AuthInitError);
|
|
51
|
+
await expect(client.init()).rejects.toThrow('Failed to reach auth service');
|
|
52
|
+
});
|
|
53
|
+
it('throws AuthInitError on non-401 error status', async () => {
|
|
54
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockFetchResponse(500));
|
|
55
|
+
await expect(client.init()).rejects.toThrow(AuthInitError);
|
|
56
|
+
await expect(client.init()).rejects.toThrow('Auth service returned 500');
|
|
57
|
+
});
|
|
58
|
+
it('throws AuthInitError on invalid JSON', async () => {
|
|
59
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
|
60
|
+
ok: true,
|
|
61
|
+
status: 200,
|
|
62
|
+
json: () => Promise.reject(new SyntaxError('Unexpected token')),
|
|
63
|
+
headers: new Headers(),
|
|
64
|
+
});
|
|
65
|
+
await expect(client.init()).rejects.toThrow('Invalid JSON from auth service');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('events', () => {
|
|
69
|
+
it('emits change event with user on successful init', async () => {
|
|
70
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, { user: mockUser, session: mockSession }));
|
|
71
|
+
const changes = [];
|
|
72
|
+
client.on('change', (u) => changes.push(u));
|
|
73
|
+
await client.init();
|
|
74
|
+
expect(changes).toEqual([mockUser]);
|
|
75
|
+
});
|
|
76
|
+
it('emits change event with null on 401', async () => {
|
|
77
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(401));
|
|
78
|
+
const changes = [];
|
|
79
|
+
client.on('change', (u) => changes.push(u));
|
|
80
|
+
await client.init();
|
|
81
|
+
expect(changes).toEqual([null]);
|
|
82
|
+
});
|
|
83
|
+
it('off removes listener', async () => {
|
|
84
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockFetchResponse(200, { user: mockUser, session: mockSession }));
|
|
85
|
+
const changes = [];
|
|
86
|
+
const listener = (u) => changes.push(u);
|
|
87
|
+
client.on('change', listener);
|
|
88
|
+
client.off('change', listener);
|
|
89
|
+
await client.init();
|
|
90
|
+
expect(changes).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('logout', () => {
|
|
94
|
+
it('clears user and session', async () => {
|
|
95
|
+
vi.spyOn(globalThis, 'fetch')
|
|
96
|
+
.mockResolvedValueOnce(mockFetchResponse(200, { user: mockUser, session: mockSession }))
|
|
97
|
+
.mockResolvedValueOnce(mockFetchResponse(200)); // sign-out call
|
|
98
|
+
await client.init();
|
|
99
|
+
expect(client.isAuthenticated).toBe(true);
|
|
100
|
+
await client.logout();
|
|
101
|
+
expect(client.getUser()).toBeNull();
|
|
102
|
+
expect(client.getSession()).toBeNull();
|
|
103
|
+
expect(client.isAuthenticated).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
it('emits logout and change events', async () => {
|
|
106
|
+
vi.spyOn(globalThis, 'fetch')
|
|
107
|
+
.mockResolvedValueOnce(mockFetchResponse(200, { user: mockUser, session: mockSession }))
|
|
108
|
+
.mockResolvedValueOnce(mockFetchResponse(200));
|
|
109
|
+
await client.init();
|
|
110
|
+
const events = [];
|
|
111
|
+
client.on('logout', () => events.push('logout'));
|
|
112
|
+
client.on('change', () => events.push('change'));
|
|
113
|
+
await client.logout();
|
|
114
|
+
expect(events).toEqual(['logout', 'change']);
|
|
115
|
+
});
|
|
116
|
+
it('still clears state if sign-out request fails', async () => {
|
|
117
|
+
vi.spyOn(globalThis, 'fetch')
|
|
118
|
+
.mockResolvedValueOnce(mockFetchResponse(200, { user: mockUser, session: mockSession }))
|
|
119
|
+
.mockRejectedValueOnce(new Error('network down'));
|
|
120
|
+
await client.init();
|
|
121
|
+
await client.logout();
|
|
122
|
+
expect(client.isAuthenticated).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('refresh', () => {
|
|
126
|
+
it('emits logout when session expires between refreshes', async () => {
|
|
127
|
+
vi.spyOn(globalThis, 'fetch')
|
|
128
|
+
.mockResolvedValueOnce(mockFetchResponse(200, { user: mockUser, session: mockSession }))
|
|
129
|
+
.mockResolvedValueOnce(mockFetchResponse(401));
|
|
130
|
+
await client.init();
|
|
131
|
+
expect(client.isAuthenticated).toBe(true);
|
|
132
|
+
const events = [];
|
|
133
|
+
client.on('logout', () => events.push('logout'));
|
|
134
|
+
await client.refresh();
|
|
135
|
+
expect(client.isAuthenticated).toBe(false);
|
|
136
|
+
expect(events).toEqual(['logout']);
|
|
137
|
+
});
|
|
138
|
+
it('does not emit logout if was already unauthenticated', async () => {
|
|
139
|
+
vi.spyOn(globalThis, 'fetch')
|
|
140
|
+
.mockResolvedValueOnce(mockFetchResponse(401))
|
|
141
|
+
.mockResolvedValueOnce(mockFetchResponse(401));
|
|
142
|
+
await client.init();
|
|
143
|
+
const events = [];
|
|
144
|
+
client.on('logout', () => events.push('logout'));
|
|
145
|
+
await client.refresh();
|
|
146
|
+
expect(events).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AuthClient } from './client.js';
|
|
2
|
+
export { AuthClient } from './client.js';
|
|
3
|
+
export { AuthInitError } from './types.js';
|
|
4
|
+
export type { User, Session, AuthEventMap } from './types.js';
|
|
5
|
+
/** Returns the singleton AuthClient. All adapters use this internally. */
|
|
6
|
+
export declare function getAuthClient(): AuthClient;
|
|
7
|
+
/** @internal Reset singleton for testing only. */
|
|
8
|
+
export declare function _resetAuthClient(): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AuthClient } from './client.js';
|
|
2
|
+
export { AuthClient } from './client.js';
|
|
3
|
+
export { AuthInitError } from './types.js';
|
|
4
|
+
let instance = null;
|
|
5
|
+
/** Returns the singleton AuthClient. All adapters use this internally. */
|
|
6
|
+
export function getAuthClient() {
|
|
7
|
+
if (!instance)
|
|
8
|
+
instance = new AuthClient();
|
|
9
|
+
return instance;
|
|
10
|
+
}
|
|
11
|
+
/** @internal Reset singleton for testing only. */
|
|
12
|
+
export function _resetAuthClient() {
|
|
13
|
+
if (instance)
|
|
14
|
+
instance.destroy();
|
|
15
|
+
instance = null;
|
|
16
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface User {
|
|
2
|
+
id: string;
|
|
3
|
+
username: string;
|
|
4
|
+
name: string;
|
|
5
|
+
displayName: string;
|
|
6
|
+
type: 'user' | 'agent' | 'service';
|
|
7
|
+
}
|
|
8
|
+
export interface Session {
|
|
9
|
+
id: string;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
refreshedAt: string;
|
|
12
|
+
}
|
|
13
|
+
export type AuthEventMap = {
|
|
14
|
+
change: User | null;
|
|
15
|
+
logout: void;
|
|
16
|
+
};
|
|
17
|
+
export declare class AuthInitError extends Error {
|
|
18
|
+
cause?: unknown;
|
|
19
|
+
constructor(message: string, options?: {
|
|
20
|
+
cause?: unknown;
|
|
21
|
+
});
|
|
22
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@workfort/auth",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"prepublishOnly": "tsc"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.8.2",
|
|
23
|
+
"vitest": "^4.1.0"
|
|
24
|
+
}
|
|
25
|
+
}
|