@startsimpli/auth 0.4.14 → 0.4.16
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 +191 -377
- package/package.json +12 -2
- package/src/__tests__/auth-backend-contract.test.ts +84 -0
- package/src/__tests__/session-user-groups.test.ts +45 -0
- package/src/client/__tests__/mock-backend.test.ts +144 -0
- package/src/client/__tests__/secure-session-storage.test.ts +75 -0
- package/src/client/__tests__/secure-token-storage.test.ts +69 -0
- package/src/client/__tests__/session-storage.test.ts +118 -0
- package/src/client/__tests__/token-auth-core.test.ts +190 -0
- package/src/client/auth-client.ts +12 -9
- package/src/client/auth-context.tsx +48 -19
- package/src/client/backend.ts +58 -0
- package/src/client/functions.ts +7 -52
- package/src/client/index.ts +15 -0
- package/src/client/mock-backend.ts +258 -0
- package/src/client/optional-secure-store.ts +21 -0
- package/src/client/secure-session-storage.native.ts +53 -0
- package/src/client/secure-session-storage.ts +20 -0
- package/src/client/secure-token-storage.native.ts +55 -0
- package/src/client/secure-token-storage.ts +32 -0
- package/src/client/session-storage.ts +142 -0
- package/src/client/token-auth-core.ts +190 -0
- package/src/client/token.ts +18 -0
- package/src/index.ts +18 -1
- package/src/types/index.ts +5 -0
- package/src/utils/api-error.ts +54 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
TokenAuthClient,
|
|
4
|
+
InMemoryTokenStorage,
|
|
5
|
+
type TokenStorage,
|
|
6
|
+
} from '../token-auth-core';
|
|
7
|
+
|
|
8
|
+
// Build a fake (unsigned) JWT with a real `exp` so getTokenExpiresAt works.
|
|
9
|
+
function makeToken(secondsFromNow = 3600): string {
|
|
10
|
+
const exp = Math.floor(Date.now() / 1000) + secondsFromNow;
|
|
11
|
+
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
12
|
+
const body = btoa(JSON.stringify({ tokenType: 'access', exp, iat: exp - 3600, jti: 'x', userId: '1' }));
|
|
13
|
+
return `${header}.${body}.sig`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ACCESS = makeToken();
|
|
17
|
+
const ACCESS2 = makeToken(7200);
|
|
18
|
+
|
|
19
|
+
// Minimal fetch-mock helper returning a Response-like object.
|
|
20
|
+
function jsonResponse(body: unknown, ok = true, status = 200) {
|
|
21
|
+
return { ok, status, json: async () => body } as Response;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeClient(storage: TokenStorage = new InMemoryTokenStorage()) {
|
|
25
|
+
const fetchMock = vi.fn<typeof fetch>();
|
|
26
|
+
const client = new TokenAuthClient({
|
|
27
|
+
apiBaseUrl: 'http://localhost:8001',
|
|
28
|
+
storage,
|
|
29
|
+
fetch: fetchMock,
|
|
30
|
+
});
|
|
31
|
+
return { client, fetchMock, storage };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pull [url, init] out of the Nth fetch call for assertions.
|
|
35
|
+
function call(fetchMock: ReturnType<typeof vi.fn>, n = 0): [string, RequestInit] {
|
|
36
|
+
const [url, init] = fetchMock.mock.calls[n];
|
|
37
|
+
return [String(url), (init ?? {}) as RequestInit];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('TokenAuthClient.login', () => {
|
|
41
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
42
|
+
|
|
43
|
+
it('opts into token mode (X-Auth-Mode), stores the refresh token, returns a session', async () => {
|
|
44
|
+
const { client, fetchMock, storage } = makeClient();
|
|
45
|
+
fetchMock.mockResolvedValueOnce(
|
|
46
|
+
jsonResponse({ access: ACCESS, refresh: 'refresh-1', user: { id: 'u1', email: 'a@b.co' } })
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const session = await client.login('a@b.co', 'pw');
|
|
50
|
+
|
|
51
|
+
const [url, init] = call(fetchMock);
|
|
52
|
+
expect(url).toBe('http://localhost:8001/api/v1/auth/token/');
|
|
53
|
+
expect(init.method).toBe('POST');
|
|
54
|
+
expect((init.headers as Record<string, string>)['X-Auth-Mode']).toBe('token');
|
|
55
|
+
expect(JSON.parse(init.body as string)).toEqual({ email: 'a@b.co', password: 'pw' });
|
|
56
|
+
|
|
57
|
+
expect(session.accessToken).toBe(ACCESS);
|
|
58
|
+
expect(session.expiresAt).toBeGreaterThan(Date.now());
|
|
59
|
+
expect(session.user.id).toBe('u1');
|
|
60
|
+
expect(client.getAccessToken()).toBe(ACCESS);
|
|
61
|
+
expect(await storage.getRefreshToken()).toBe('refresh-1');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('fetches /me/ when the login response omits the user', async () => {
|
|
65
|
+
const { client, fetchMock } = makeClient();
|
|
66
|
+
fetchMock
|
|
67
|
+
.mockResolvedValueOnce(jsonResponse({ access: ACCESS, refresh: 'r' })) // login, no user
|
|
68
|
+
.mockResolvedValueOnce(
|
|
69
|
+
jsonResponse({ user: { id: 'u9', email: 'me@x.co', first_name: 'Me', last_name: 'X' } })
|
|
70
|
+
); // /me/
|
|
71
|
+
|
|
72
|
+
const session = await client.login('me@x.co', 'pw');
|
|
73
|
+
|
|
74
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
75
|
+
expect(call(fetchMock, 1)[0]).toBe('http://localhost:8001/api/v1/auth/me/');
|
|
76
|
+
expect(session.user.id).toBe('u9');
|
|
77
|
+
expect(session.user.firstName).toBe('Me');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws the extracted API error on failure and stores nothing', async () => {
|
|
81
|
+
const { client, fetchMock, storage } = makeClient();
|
|
82
|
+
fetchMock.mockResolvedValueOnce(
|
|
83
|
+
jsonResponse({ error: 'No active account found' }, false, 401)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await expect(client.login('a@b.co', 'bad')).rejects.toThrow('No active account found');
|
|
87
|
+
expect(await storage.getRefreshToken()).toBeNull();
|
|
88
|
+
expect(client.getAccessToken()).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('TokenAuthClient.refreshToken', () => {
|
|
93
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
94
|
+
|
|
95
|
+
it('sends the stored refresh token in the body and rotates it', async () => {
|
|
96
|
+
const storage = new InMemoryTokenStorage();
|
|
97
|
+
await storage.setRefreshToken('refresh-old');
|
|
98
|
+
const { client, fetchMock } = makeClient(storage);
|
|
99
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ access: ACCESS2, refresh: 'refresh-new' }));
|
|
100
|
+
|
|
101
|
+
const access = await client.refreshToken();
|
|
102
|
+
|
|
103
|
+
const [url, init] = call(fetchMock);
|
|
104
|
+
expect(url).toBe('http://localhost:8001/api/v1/auth/token/refresh/');
|
|
105
|
+
expect(JSON.parse(init.body as string)).toEqual({ refresh: 'refresh-old' });
|
|
106
|
+
expect(access).toBe(ACCESS2);
|
|
107
|
+
expect(client.getAccessToken()).toBe(ACCESS2);
|
|
108
|
+
expect(await storage.getRefreshToken()).toBe('refresh-new'); // rotated
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws when there is no stored refresh token', async () => {
|
|
112
|
+
const { client, fetchMock } = makeClient();
|
|
113
|
+
await expect(client.refreshToken()).rejects.toThrow();
|
|
114
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('clears storage when the refresh is rejected', async () => {
|
|
118
|
+
const storage = new InMemoryTokenStorage();
|
|
119
|
+
await storage.setRefreshToken('refresh-old');
|
|
120
|
+
const { client, fetchMock } = makeClient(storage);
|
|
121
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ detail: 'expired' }, false, 401));
|
|
122
|
+
|
|
123
|
+
await expect(client.refreshToken()).rejects.toThrow();
|
|
124
|
+
expect(await storage.getRefreshToken()).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('TokenAuthClient.logout', () => {
|
|
129
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
130
|
+
|
|
131
|
+
it('sends Bearer access + refresh in the body and clears storage', async () => {
|
|
132
|
+
const storage = new InMemoryTokenStorage();
|
|
133
|
+
await storage.setRefreshToken('refresh-1');
|
|
134
|
+
const { client, fetchMock } = makeClient(storage);
|
|
135
|
+
// seed an access token via a login
|
|
136
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ access: ACCESS, refresh: 'refresh-1', user: { id: 'u', email: 'e@e.co' } }));
|
|
137
|
+
await client.login('e@e.co', 'pw');
|
|
138
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({}, true, 205));
|
|
139
|
+
|
|
140
|
+
await client.logout();
|
|
141
|
+
|
|
142
|
+
const [url, init] = call(fetchMock, 1);
|
|
143
|
+
expect(url).toBe('http://localhost:8001/api/v1/auth/logout/');
|
|
144
|
+
expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${ACCESS}`);
|
|
145
|
+
expect(JSON.parse(init.body as string)).toEqual({ refresh: 'refresh-1' });
|
|
146
|
+
expect(await storage.getRefreshToken()).toBeNull();
|
|
147
|
+
expect(client.getAccessToken()).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('clears storage even if the network call fails', async () => {
|
|
151
|
+
const storage = new InMemoryTokenStorage();
|
|
152
|
+
await storage.setRefreshToken('refresh-1');
|
|
153
|
+
const { client, fetchMock } = makeClient(storage);
|
|
154
|
+
fetchMock.mockRejectedValueOnce(new Error('network down'));
|
|
155
|
+
|
|
156
|
+
await client.logout();
|
|
157
|
+
expect(await storage.getRefreshToken()).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('TokenAuthClient.getCurrentUser', () => {
|
|
162
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
163
|
+
|
|
164
|
+
it('sends Bearer and normalizes the snake_case {user} envelope', async () => {
|
|
165
|
+
const { client, fetchMock } = makeClient();
|
|
166
|
+
fetchMock.mockResolvedValueOnce(
|
|
167
|
+
jsonResponse({
|
|
168
|
+
user: {
|
|
169
|
+
id: 'abc',
|
|
170
|
+
email: 't@x.co',
|
|
171
|
+
first_name: 'Test',
|
|
172
|
+
last_name: 'User',
|
|
173
|
+
is_email_verified: true,
|
|
174
|
+
created_at: '2026-01-01T00:00:00Z',
|
|
175
|
+
updated_at: '2026-01-02T00:00:00Z',
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const user = await client.getCurrentUser(ACCESS);
|
|
181
|
+
|
|
182
|
+
const [url, init] = call(fetchMock);
|
|
183
|
+
expect(url).toBe('http://localhost:8001/api/v1/auth/me/');
|
|
184
|
+
expect((init.headers as Record<string, string>)['Authorization']).toBe(`Bearer ${ACCESS}`);
|
|
185
|
+
expect(user.id).toBe('abc');
|
|
186
|
+
expect(user.firstName).toBe('Test');
|
|
187
|
+
expect(user.lastName).toBe('User');
|
|
188
|
+
expect(user.isEmailVerified).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
import { isTokenExpired, getTokenExpiresAt, shouldRefreshToken } from '../utils';
|
|
14
14
|
import { extractApiError, setAccessToken as setModuleAccessToken } from './functions';
|
|
15
15
|
import { deleteCookie } from '../utils/cookies';
|
|
16
|
+
import type { AuthBackend, RegisterPayload } from './backend';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* sessionStorage key set by logout to suppress any subsequent
|
|
@@ -41,7 +42,7 @@ function hasLoggedOutFlag(): boolean {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export class AuthClient {
|
|
45
|
+
export class AuthClient implements AuthBackend {
|
|
45
46
|
private config: Required<AuthConfig>;
|
|
46
47
|
private session: Session | null = null;
|
|
47
48
|
private refreshTimer: NodeJS.Timeout | null = null;
|
|
@@ -118,14 +119,7 @@ export class AuthClient {
|
|
|
118
119
|
* backend returns a user in the response, use it directly; otherwise fall
|
|
119
120
|
* back to /me/ (same pattern as login).
|
|
120
121
|
*/
|
|
121
|
-
async register(payload: {
|
|
122
|
-
email: string;
|
|
123
|
-
password: string;
|
|
124
|
-
passwordConfirm: string;
|
|
125
|
-
name?: string;
|
|
126
|
-
firstName?: string;
|
|
127
|
-
lastName?: string;
|
|
128
|
-
}): Promise<Session> {
|
|
122
|
+
async register(payload: RegisterPayload): Promise<Session> {
|
|
129
123
|
// Derive first/last from `name` if the caller used it.
|
|
130
124
|
const rawName = payload.name?.trim() ?? '';
|
|
131
125
|
const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
|
|
@@ -508,6 +502,15 @@ export class AuthClient {
|
|
|
508
502
|
}
|
|
509
503
|
}
|
|
510
504
|
|
|
505
|
+
/**
|
|
506
|
+
* AuthBackend contract: restore a persisted session on mount. For the
|
|
507
|
+
* Django client this is the refresh-token-cookie bootstrap; aliased so the
|
|
508
|
+
* provider can call a backend-neutral name.
|
|
509
|
+
*/
|
|
510
|
+
restoreSession(): Promise<Session | null> {
|
|
511
|
+
return this.bootstrapFromCookies();
|
|
512
|
+
}
|
|
513
|
+
|
|
511
514
|
/**
|
|
512
515
|
* Get current session
|
|
513
516
|
*/
|
|
@@ -8,12 +8,14 @@ import {
|
|
|
8
8
|
createContext,
|
|
9
9
|
useContext,
|
|
10
10
|
useEffect,
|
|
11
|
+
useRef,
|
|
11
12
|
useState,
|
|
12
13
|
useCallback,
|
|
13
14
|
type ReactNode,
|
|
14
15
|
} from 'react';
|
|
15
16
|
import { AuthClient } from './auth-client';
|
|
16
17
|
import { setOnSessionExpired } from './functions';
|
|
18
|
+
import type { AuthBackend } from './backend';
|
|
17
19
|
import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
|
|
18
20
|
|
|
19
21
|
interface AuthContextValue extends AuthState {
|
|
@@ -44,16 +46,33 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
|
|
44
46
|
|
|
45
47
|
interface AuthProviderProps {
|
|
46
48
|
children: ReactNode;
|
|
47
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Django/JWT configuration. Used to construct the default {@link AuthClient}.
|
|
51
|
+
* Optional when an explicit `backend` is supplied instead.
|
|
52
|
+
*/
|
|
53
|
+
config?: AuthConfig;
|
|
54
|
+
/**
|
|
55
|
+
* An explicit {@link AuthBackend} to drive auth state. When provided, the
|
|
56
|
+
* provider uses it directly and ignores `config` (no AuthClient is created).
|
|
57
|
+
* This is how backendless apps inject a mock/offline backend.
|
|
58
|
+
*/
|
|
59
|
+
backend?: AuthBackend;
|
|
48
60
|
initialSession?: Session | null;
|
|
49
61
|
}
|
|
50
62
|
|
|
51
63
|
export function AuthProvider({
|
|
52
64
|
children,
|
|
53
65
|
config,
|
|
66
|
+
backend,
|
|
54
67
|
initialSession,
|
|
55
68
|
}: AuthProviderProps) {
|
|
56
|
-
const [authClient] = useState(() =>
|
|
69
|
+
const [authClient] = useState<AuthBackend>(() => {
|
|
70
|
+
if (backend) return backend;
|
|
71
|
+
if (!config) {
|
|
72
|
+
throw new Error('AuthProvider requires either a `config` or a `backend` prop');
|
|
73
|
+
}
|
|
74
|
+
return new AuthClient(config);
|
|
75
|
+
});
|
|
57
76
|
const [state, setState] = useState<AuthState>(() => ({
|
|
58
77
|
session: initialSession || null,
|
|
59
78
|
isLoading: !initialSession,
|
|
@@ -89,7 +108,7 @@ export function AuthProvider({
|
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
authClient
|
|
92
|
-
.
|
|
111
|
+
.restoreSession()
|
|
93
112
|
.then((session) => {
|
|
94
113
|
if (cancelled) return;
|
|
95
114
|
setState({
|
|
@@ -108,43 +127,53 @@ export function AuthProvider({
|
|
|
108
127
|
};
|
|
109
128
|
}, [authClient, initialSession]);
|
|
110
129
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
130
|
+
// Keep refs current on every render so the stable handleExpired closure
|
|
131
|
+
// below always reads the latest config values without being in deps.
|
|
132
|
+
const loginPathRef = useRef(config?.loginPath);
|
|
133
|
+
const callbackParamRef = useRef(config?.callbackParam ?? 'callbackUrl');
|
|
134
|
+
const onSessionExpiredRef = useRef(config?.onSessionExpired);
|
|
135
|
+
loginPathRef.current = config?.loginPath;
|
|
136
|
+
callbackParamRef.current = config?.callbackParam ?? 'callbackUrl';
|
|
137
|
+
onSessionExpiredRef.current = config?.onSessionExpired;
|
|
117
138
|
|
|
139
|
+
// Session expiration handler — covers both AuthClient timer and authFetch 401.
|
|
140
|
+
// Depends only on authClient (a useState singleton that never changes after
|
|
141
|
+
// mount). config intentionally omitted: it's a new object literal on every
|
|
142
|
+
// render from the caller, so including it caused destroy() to fire and wipe
|
|
143
|
+
// the session on every re-render.
|
|
144
|
+
useEffect(() => {
|
|
118
145
|
const handleExpired = () => {
|
|
119
146
|
setState({
|
|
120
147
|
session: null,
|
|
121
148
|
isLoading: false,
|
|
122
149
|
isAuthenticated: false,
|
|
123
150
|
});
|
|
124
|
-
|
|
151
|
+
onSessionExpiredRef.current?.();
|
|
125
152
|
|
|
126
|
-
|
|
127
|
-
// callback so any cleanup runs first. window.location avoids pulling
|
|
128
|
-
// a router dep into the shared package — works in any framework.
|
|
129
|
-
if (loginPath && typeof window !== 'undefined') {
|
|
153
|
+
if (loginPathRef.current && typeof window !== 'undefined') {
|
|
130
154
|
const here = window.location.pathname + window.location.search;
|
|
131
|
-
const isOnLogin = window.location.pathname.startsWith(
|
|
155
|
+
const isOnLogin = window.location.pathname.startsWith(loginPathRef.current);
|
|
132
156
|
if (!isOnLogin) {
|
|
133
157
|
const callback = encodeURIComponent(here);
|
|
134
|
-
window.location.href = `${
|
|
158
|
+
window.location.href = `${loginPathRef.current}?${callbackParamRef.current}=${callback}`;
|
|
135
159
|
}
|
|
136
160
|
}
|
|
137
161
|
};
|
|
138
162
|
|
|
139
|
-
|
|
140
|
-
//
|
|
163
|
+
// Wire AuthClient's internal expiry calls and the functional API (FetchWrapper).
|
|
164
|
+
// The Django client reads expiry via config; non-config backends (e.g. mock)
|
|
165
|
+
// accept the handler through the optional setOnSessionExpired method.
|
|
166
|
+
if (config) config.onSessionExpired = handleExpired;
|
|
167
|
+
authClient.setOnSessionExpired?.(handleExpired);
|
|
141
168
|
setOnSessionExpired(handleExpired);
|
|
142
169
|
|
|
143
170
|
return () => {
|
|
144
171
|
setOnSessionExpired(null);
|
|
172
|
+
authClient.setOnSessionExpired?.(null);
|
|
145
173
|
authClient.destroy();
|
|
146
174
|
};
|
|
147
|
-
|
|
175
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
176
|
+
}, [authClient]);
|
|
148
177
|
|
|
149
178
|
const login = useCallback(
|
|
150
179
|
async (email: string, password: string) => {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-agnostic authentication contract.
|
|
3
|
+
*
|
|
4
|
+
* AuthProvider drives all of its state from one object satisfying this
|
|
5
|
+
* interface. The Django/JWT implementation is {@link AuthClient}; backendless
|
|
6
|
+
* apps (demos, offline-first, Storybook) can inject any other implementation
|
|
7
|
+
* — e.g. `createMockAuthBackend` — without the provider knowing the difference.
|
|
8
|
+
*
|
|
9
|
+
* The method set is exactly what AuthProvider needs; nothing here is
|
|
10
|
+
* Django-specific.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Session, AuthUser } from '../types';
|
|
14
|
+
|
|
15
|
+
export interface RegisterPayload {
|
|
16
|
+
email: string;
|
|
17
|
+
password: string;
|
|
18
|
+
passwordConfirm: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
firstName?: string;
|
|
21
|
+
lastName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuthBackend {
|
|
25
|
+
/** Authenticate and open a session. Rejects on bad credentials. */
|
|
26
|
+
login(email: string, password: string): Promise<Session>;
|
|
27
|
+
/** End the session and clear any persisted state. */
|
|
28
|
+
logout(): Promise<void>;
|
|
29
|
+
/** Create an account and open a session. */
|
|
30
|
+
register(payload: RegisterPayload): Promise<Session>;
|
|
31
|
+
/** Re-fetch the current user (e.g. after a profile change). */
|
|
32
|
+
getCurrentUser(): Promise<AuthUser>;
|
|
33
|
+
/** Return a valid access token, refreshing if needed; null if unauthenticated. */
|
|
34
|
+
getAccessToken(): Promise<string | null>;
|
|
35
|
+
/** Synchronous current session, or null. May clear+return null if expired. */
|
|
36
|
+
getSession(): Session | null;
|
|
37
|
+
/** Adopt an externally-acquired session (SSR/hydration/OAuth callback). */
|
|
38
|
+
setSession(session: Session): void;
|
|
39
|
+
/**
|
|
40
|
+
* Restore a persisted session on mount. Web/Django reads the refresh-token
|
|
41
|
+
* cookie; native/mock backends read secure storage. Returns null when there
|
|
42
|
+
* is nothing to restore.
|
|
43
|
+
*/
|
|
44
|
+
restoreSession(): Promise<Session | null>;
|
|
45
|
+
/** Begin an OAuth flow; returns the authorization URL to redirect to. */
|
|
46
|
+
signInWithGoogle(redirectTo?: string): Promise<string>;
|
|
47
|
+
/** Complete an OAuth flow by exchanging the code+state for a session. */
|
|
48
|
+
completeGoogleCallback(code: string, state: string): Promise<Session>;
|
|
49
|
+
/**
|
|
50
|
+
* Optional. Register the provider's session-expiry handler. The Django
|
|
51
|
+
* AuthClient wires this through AuthConfig.onSessionExpired instead, so it
|
|
52
|
+
* is optional — backends that don't take an AuthConfig (e.g. the mock) use
|
|
53
|
+
* this to notify the provider when a session goes away out-of-band.
|
|
54
|
+
*/
|
|
55
|
+
setOnSessionExpired?(cb: (() => void) | null): void;
|
|
56
|
+
/** Release timers/listeners. Called on AuthProvider unmount. */
|
|
57
|
+
destroy(): void;
|
|
58
|
+
}
|
package/src/client/functions.ts
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { deleteCookie } from '../utils/cookies';
|
|
15
|
+
// Local binding for internal use; also re-exported below to preserve this
|
|
16
|
+
// module's public surface.
|
|
17
|
+
import { extractApiError } from '../utils/api-error';
|
|
15
18
|
import { decodeToken } from '../utils/token';
|
|
16
19
|
|
|
17
20
|
// --- Types ---
|
|
@@ -210,58 +213,10 @@ function _syncAuthCookie(token: string | null): void {
|
|
|
210
213
|
|
|
211
214
|
const AUTH_TIMEOUT_MS = 15_000;
|
|
212
215
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
* { detail: "..." } → the string
|
|
218
|
-
* { detail: ["...", "..."] } → first string
|
|
219
|
-
* { detail: { token: ["Invalid..."] } } → first nested string
|
|
220
|
-
* { email: ["already exists"] } → first field-level string
|
|
221
|
-
* { non_field_errors: ["..."] } → first field-level string
|
|
222
|
-
* { error: "CODE", detail: { field: ["..."] } } → first nested string
|
|
223
|
-
*
|
|
224
|
-
* @internal Shared with AuthClient; still considered implementation detail
|
|
225
|
-
* of the auth package. Do not rely on from outside `@startsimpli/auth`.
|
|
226
|
-
*/
|
|
227
|
-
export function extractApiError(d: Record<string, unknown>, fallback: string): string {
|
|
228
|
-
const pluck = (val: unknown): string | null => {
|
|
229
|
-
if (typeof val === 'string') return val
|
|
230
|
-
if (Array.isArray(val) && val.length > 0) {
|
|
231
|
-
for (const item of val) {
|
|
232
|
-
const s = pluck(item)
|
|
233
|
-
if (s) return s
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
if (val && typeof val === 'object') {
|
|
237
|
-
for (const v of Object.values(val as Record<string, unknown>)) {
|
|
238
|
-
const s = pluck(v)
|
|
239
|
-
if (s) return s
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
return null
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Standard DRF: { detail: "..." }
|
|
246
|
-
const fromDetail = pluck(d.detail)
|
|
247
|
-
if (fromDetail) return fromDetail
|
|
248
|
-
// Some backend shapes use `error` as the human-readable message (e.g.
|
|
249
|
-
// our Django auth errors: { "error": "No active account...", "code": "unauthorized" }).
|
|
250
|
-
// Prefer this over field-level probing so we don't accidentally return a
|
|
251
|
-
// code like "unauthorized" from a sibling field.
|
|
252
|
-
const fromError = pluck(d.error)
|
|
253
|
-
if (fromError) return fromError
|
|
254
|
-
// Field-level: { email: ["already exists"] } or { non_field_errors: ["..."] }
|
|
255
|
-
// Skip known meta/code fields so `{ code: "unauthorized" }` doesn't leak
|
|
256
|
-
// an internal identifier as a user-facing message.
|
|
257
|
-
const META_KEYS = new Set(['detail', 'error', 'code', 'statusCode', 'status', 'timestamp'])
|
|
258
|
-
for (const [key, val] of Object.entries(d)) {
|
|
259
|
-
if (META_KEYS.has(key)) continue
|
|
260
|
-
const s = pluck(val)
|
|
261
|
-
if (s) return s
|
|
262
|
-
}
|
|
263
|
-
return fallback
|
|
264
|
-
}
|
|
216
|
+
// extractApiError moved to ../utils/api-error (DOM-free, shared with the
|
|
217
|
+
// platform-neutral token-auth core). Imported above for internal use and
|
|
218
|
+
// re-exported here to preserve this module's public surface (e.g. AuthClient).
|
|
219
|
+
export { extractApiError }
|
|
265
220
|
|
|
266
221
|
function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
|
|
267
222
|
const controller = new AbortController();
|
package/src/client/index.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
export { AuthClient } from './auth-client';
|
|
2
|
+
export type { AuthBackend, RegisterPayload } from './backend';
|
|
3
|
+
export {
|
|
4
|
+
createMockAuthBackend,
|
|
5
|
+
type MockAuthBackend,
|
|
6
|
+
type MockAuthBackendOptions,
|
|
7
|
+
type MockAccount,
|
|
8
|
+
} from './mock-backend';
|
|
9
|
+
export {
|
|
10
|
+
createMemorySessionStorage,
|
|
11
|
+
createWebSessionStorage,
|
|
12
|
+
createRememberAwareSessionStorage,
|
|
13
|
+
SESSION_STORAGE_KEY,
|
|
14
|
+
type SessionStorage,
|
|
15
|
+
} from './session-storage';
|
|
16
|
+
export { createSecureSessionStorage } from './secure-session-storage';
|
|
2
17
|
export { AuthProvider, useAuthContext } from './auth-context';
|
|
3
18
|
export { useAuth, useRequireAuth, type UseAuthReturn, type UseRequireAuthReturn, type UseRequireAuthOptions } from './use-auth';
|
|
4
19
|
export { usePermissions, type UsePermissionsReturn } from './use-permissions';
|