@startsimpli/auth 0.4.15 → 0.4.17
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 +25 -12
- package/src/__tests__/auth-backend-contract.test.ts +84 -0
- package/src/__tests__/auth-client-oauth-register.test.ts +5 -8
- package/src/__tests__/auth-functions.test.ts +0 -1
- package/src/__tests__/session-user-groups.test.ts +45 -0
- package/src/__tests__/useauth-shape-contract.test.ts +0 -1
- package/src/client/__tests__/mock-backend.test.ts +141 -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 +71 -11
- package/src/client/auth-context.tsx +94 -17
- package/src/client/backend.ts +67 -0
- package/src/client/functions.ts +38 -57
- package/src/client/index.ts +15 -0
- package/src/client/mock-backend.ts +255 -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/client/use-auth.ts +6 -1
- package/src/components/forgot-password-form.tsx +97 -0
- package/src/components/index.ts +5 -1
- package/src/components/oauth-callback.tsx +5 -2
- package/src/components/reset-password-form.tsx +124 -0
- package/src/components/sign-in-form.tsx +125 -0
- package/src/components/signup-form.tsx +161 -0
- package/src/components/use-oauth-callback.ts +14 -2
- package/src/hooks/__tests__/use-domain-claims.test.tsx +95 -0
- package/src/hooks/__tests__/use-invitations.test.tsx +90 -0
- package/src/hooks/__tests__/use-membership.test.tsx +136 -0
- package/src/hooks/index.ts +34 -0
- package/src/hooks/use-domain-claims.ts +144 -0
- package/src/hooks/use-invitations.ts +138 -0
- package/src/hooks/use-membership.ts +192 -0
- package/src/index.ts +43 -1
- package/src/server/index.ts +4 -0
- package/src/types/index.ts +5 -1
- package/src/utils/api-error.ts +54 -0
- package/src/utils/central-auth.ts +91 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/validation.ts +10 -21
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createMemorySessionStorage,
|
|
4
|
+
createWebSessionStorage,
|
|
5
|
+
createRememberAwareSessionStorage,
|
|
6
|
+
SESSION_STORAGE_KEY,
|
|
7
|
+
} from '../session-storage';
|
|
8
|
+
import type { Session } from '../../types';
|
|
9
|
+
|
|
10
|
+
const session: Session = {
|
|
11
|
+
user: {
|
|
12
|
+
id: 'u1',
|
|
13
|
+
email: 'a@x.test',
|
|
14
|
+
firstName: 'A',
|
|
15
|
+
lastName: 'B',
|
|
16
|
+
isEmailVerified: true,
|
|
17
|
+
createdAt: '',
|
|
18
|
+
updatedAt: '',
|
|
19
|
+
},
|
|
20
|
+
accessToken: 'tok',
|
|
21
|
+
expiresAt: Date.now() + 1e6,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('createMemorySessionStorage', () => {
|
|
25
|
+
it('round-trips and clears', async () => {
|
|
26
|
+
const s = createMemorySessionStorage();
|
|
27
|
+
expect(await s.load()).toBeNull();
|
|
28
|
+
await s.save(session);
|
|
29
|
+
expect((await s.load())?.user.email).toBe('a@x.test');
|
|
30
|
+
await s.clear();
|
|
31
|
+
expect(await s.load()).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('createWebSessionStorage', () => {
|
|
36
|
+
beforeEach(() => localStorage.clear());
|
|
37
|
+
|
|
38
|
+
it('persists to localStorage and reads it back', async () => {
|
|
39
|
+
const s = createWebSessionStorage();
|
|
40
|
+
await s.save(session);
|
|
41
|
+
expect(localStorage.getItem(SESSION_STORAGE_KEY)).toBeTruthy();
|
|
42
|
+
expect((await s.load())?.accessToken).toBe('tok');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('honors a custom key and an injected Storage', async () => {
|
|
46
|
+
const backing = createMemoryStorage();
|
|
47
|
+
const s = createWebSessionStorage({ key: 'k2', storage: backing });
|
|
48
|
+
await s.save(session);
|
|
49
|
+
expect(backing.getItem('k2')).toBeTruthy();
|
|
50
|
+
expect((await s.load())?.user.id).toBe('u1');
|
|
51
|
+
await s.clear();
|
|
52
|
+
expect(backing.getItem('k2')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null on malformed JSON rather than throwing', async () => {
|
|
56
|
+
localStorage.setItem(SESSION_STORAGE_KEY, '{not json');
|
|
57
|
+
const s = createWebSessionStorage();
|
|
58
|
+
expect(await s.load()).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns null when the stored value is not a Session shape', async () => {
|
|
62
|
+
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ nope: true }));
|
|
63
|
+
const s = createWebSessionStorage();
|
|
64
|
+
expect(await s.load()).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('createRememberAwareSessionStorage', () => {
|
|
69
|
+
it('persists to the persistent store when remembering', async () => {
|
|
70
|
+
const persistent = createMemorySessionStorage();
|
|
71
|
+
const s = createRememberAwareSessionStorage(persistent, () => true);
|
|
72
|
+
await s.save(session);
|
|
73
|
+
expect((await persistent.load())?.accessToken).toBe('tok');
|
|
74
|
+
expect((await s.load())?.accessToken).toBe('tok');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('does NOT persist (and clears) when not remembering', async () => {
|
|
78
|
+
const persistent = createMemorySessionStorage();
|
|
79
|
+
const s = createRememberAwareSessionStorage(persistent, () => false);
|
|
80
|
+
await s.save(session);
|
|
81
|
+
expect(await persistent.load()).toBeNull();
|
|
82
|
+
expect(await s.load()).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('clears the persistent store when switching from remember to not', async () => {
|
|
86
|
+
const persistent = createMemorySessionStorage();
|
|
87
|
+
let remember = true;
|
|
88
|
+
const s = createRememberAwareSessionStorage(persistent, () => remember);
|
|
89
|
+
await s.save(session);
|
|
90
|
+
expect(await s.load()).not.toBeNull();
|
|
91
|
+
remember = false;
|
|
92
|
+
await s.save(session);
|
|
93
|
+
expect(await persistent.load()).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('clear() wipes both stores', async () => {
|
|
97
|
+
const persistent = createMemorySessionStorage();
|
|
98
|
+
const s = createRememberAwareSessionStorage(persistent, () => true);
|
|
99
|
+
await s.save(session);
|
|
100
|
+
await s.clear();
|
|
101
|
+
expect(await s.load()).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Minimal Storage impl for the injected-storage test.
|
|
106
|
+
function createMemoryStorage(): Storage {
|
|
107
|
+
const map = new Map<string, string>();
|
|
108
|
+
return {
|
|
109
|
+
get length() {
|
|
110
|
+
return map.size;
|
|
111
|
+
},
|
|
112
|
+
clear: () => map.clear(),
|
|
113
|
+
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
|
114
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
115
|
+
removeItem: (k: string) => void map.delete(k),
|
|
116
|
+
setItem: (k: string, v: string) => void map.set(k, String(v)),
|
|
117
|
+
} as Storage;
|
|
118
|
+
}
|
|
@@ -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+/) : [];
|
|
@@ -138,7 +132,6 @@ export class AuthClient {
|
|
|
138
132
|
body: JSON.stringify({
|
|
139
133
|
email: payload.email,
|
|
140
134
|
password: payload.password,
|
|
141
|
-
password_confirm: payload.passwordConfirm,
|
|
142
135
|
name: payload.name,
|
|
143
136
|
first_name: payload.firstName ?? firstFromName ?? undefined,
|
|
144
137
|
last_name: payload.lastName ?? lastFromName ?? undefined,
|
|
@@ -197,8 +190,66 @@ export class AuthClient {
|
|
|
197
190
|
* Complete a Google OAuth callback: exchange the code + state for a session.
|
|
198
191
|
*/
|
|
199
192
|
async completeGoogleCallback(code: string, state: string): Promise<Session> {
|
|
193
|
+
return this.completeOAuthCallback('google', code, state);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Kick off Microsoft OAuth (Azure AD / Entra ID). Mirrors the Google flow;
|
|
198
|
+
* the backend returns `{ authorization_url }`.
|
|
199
|
+
*/
|
|
200
|
+
async signInWithMicrosoft(redirectTo?: string): Promise<string> {
|
|
201
|
+
const defaultRedirect =
|
|
202
|
+
typeof window !== 'undefined'
|
|
203
|
+
? `${window.location.origin}/oauth/microsoft/callback`
|
|
204
|
+
: '';
|
|
205
|
+
const redirectUri = redirectTo ?? defaultRedirect;
|
|
206
|
+
|
|
207
|
+
const response = await fetch(
|
|
208
|
+
`${this.config.apiBaseUrl}/api/v1/auth/oauth/microsoft/initiate/`,
|
|
209
|
+
{
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
credentials: 'include',
|
|
213
|
+
body: JSON.stringify({ redirect_uri: redirectUri }),
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const data = await response.json().catch(() => ({} as Record<string, unknown>));
|
|
218
|
+
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
extractApiError(data as Record<string, unknown>, 'Failed to initiate Microsoft OAuth')
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Microsoft backend returns `authorization_url`; fall back to other casings.
|
|
226
|
+
const obj = data as { authorization_url?: string; auth_url?: string; authUrl?: string };
|
|
227
|
+
const url = obj.authorization_url ?? obj.auth_url ?? obj.authUrl;
|
|
228
|
+
if (!url) {
|
|
229
|
+
throw new Error('OAuth initiation succeeded but no authorization_url was returned');
|
|
230
|
+
}
|
|
231
|
+
return url;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Complete a Microsoft OAuth callback: exchange the code + state for a session.
|
|
236
|
+
*/
|
|
237
|
+
async completeMicrosoftCallback(code: string, state: string): Promise<Session> {
|
|
238
|
+
return this.completeOAuthCallback('microsoft', code, state);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Provider-agnostic OAuth callback completion. Both Google and Microsoft
|
|
243
|
+
* speak the same `GET /api/v1/auth/oauth/<provider>/callback/?code=&state=`
|
|
244
|
+
* shape, so we share the implementation and just swap the path segment.
|
|
245
|
+
*/
|
|
246
|
+
private async completeOAuthCallback(
|
|
247
|
+
provider: 'google' | 'microsoft',
|
|
248
|
+
code: string,
|
|
249
|
+
state: string,
|
|
250
|
+
): Promise<Session> {
|
|
200
251
|
const url = new URL(
|
|
201
|
-
`${this.config.apiBaseUrl}/api/v1/auth/oauth/
|
|
252
|
+
`${this.config.apiBaseUrl}/api/v1/auth/oauth/${provider}/callback/`
|
|
202
253
|
);
|
|
203
254
|
url.searchParams.set('code', code);
|
|
204
255
|
url.searchParams.set('state', state);
|
|
@@ -508,6 +559,15 @@ export class AuthClient {
|
|
|
508
559
|
}
|
|
509
560
|
}
|
|
510
561
|
|
|
562
|
+
/**
|
|
563
|
+
* AuthBackend contract: restore a persisted session on mount. For the
|
|
564
|
+
* Django client this is the refresh-token-cookie bootstrap; aliased so the
|
|
565
|
+
* provider can call a backend-neutral name.
|
|
566
|
+
*/
|
|
567
|
+
restoreSession(): Promise<Session | null> {
|
|
568
|
+
return this.bootstrapFromCookies();
|
|
569
|
+
}
|
|
570
|
+
|
|
511
571
|
/**
|
|
512
572
|
* Get current session
|
|
513
573
|
*/
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from 'react';
|
|
16
16
|
import { AuthClient } from './auth-client';
|
|
17
17
|
import { setOnSessionExpired } from './functions';
|
|
18
|
+
import type { AuthBackend } from './backend';
|
|
18
19
|
import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
|
|
19
20
|
|
|
20
21
|
interface AuthContextValue extends AuthState {
|
|
@@ -25,13 +26,14 @@ interface AuthContextValue extends AuthState {
|
|
|
25
26
|
register: (payload: {
|
|
26
27
|
email: string;
|
|
27
28
|
password: string;
|
|
28
|
-
passwordConfirm: string;
|
|
29
29
|
name?: string;
|
|
30
30
|
firstName?: string;
|
|
31
31
|
lastName?: string;
|
|
32
32
|
}) => Promise<void>;
|
|
33
33
|
signInWithGoogle: (redirectTo?: string) => Promise<string>;
|
|
34
34
|
completeGoogleCallback: (code: string, state: string) => Promise<void>;
|
|
35
|
+
signInWithMicrosoft: (redirectTo?: string) => Promise<string>;
|
|
36
|
+
completeMicrosoftCallback: (code: string, state: string) => Promise<void>;
|
|
35
37
|
/**
|
|
36
38
|
* Hydrate the provider with an externally-acquired session. Used by OAuth
|
|
37
39
|
* callback flows that run the token exchange via a component (OAuthCallback)
|
|
@@ -45,16 +47,33 @@ const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
|
|
45
47
|
|
|
46
48
|
interface AuthProviderProps {
|
|
47
49
|
children: ReactNode;
|
|
48
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Django/JWT configuration. Used to construct the default {@link AuthClient}.
|
|
52
|
+
* Optional when an explicit `backend` is supplied instead.
|
|
53
|
+
*/
|
|
54
|
+
config?: AuthConfig;
|
|
55
|
+
/**
|
|
56
|
+
* An explicit {@link AuthBackend} to drive auth state. When provided, the
|
|
57
|
+
* provider uses it directly and ignores `config` (no AuthClient is created).
|
|
58
|
+
* This is how backendless apps inject a mock/offline backend.
|
|
59
|
+
*/
|
|
60
|
+
backend?: AuthBackend;
|
|
49
61
|
initialSession?: Session | null;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
export function AuthProvider({
|
|
53
65
|
children,
|
|
54
66
|
config,
|
|
67
|
+
backend,
|
|
55
68
|
initialSession,
|
|
56
69
|
}: AuthProviderProps) {
|
|
57
|
-
const [authClient] = useState(() =>
|
|
70
|
+
const [authClient] = useState<AuthBackend>(() => {
|
|
71
|
+
if (backend) return backend;
|
|
72
|
+
if (!config) {
|
|
73
|
+
throw new Error('AuthProvider requires either a `config` or a `backend` prop');
|
|
74
|
+
}
|
|
75
|
+
return new AuthClient(config);
|
|
76
|
+
});
|
|
58
77
|
const [state, setState] = useState<AuthState>(() => ({
|
|
59
78
|
session: initialSession || null,
|
|
60
79
|
isLoading: !initialSession,
|
|
@@ -90,7 +109,7 @@ export function AuthProvider({
|
|
|
90
109
|
}
|
|
91
110
|
|
|
92
111
|
authClient
|
|
93
|
-
.
|
|
112
|
+
.restoreSession()
|
|
94
113
|
.then((session) => {
|
|
95
114
|
if (cancelled) return;
|
|
96
115
|
setState({
|
|
@@ -111,12 +130,12 @@ export function AuthProvider({
|
|
|
111
130
|
|
|
112
131
|
// Keep refs current on every render so the stable handleExpired closure
|
|
113
132
|
// below always reads the latest config values without being in deps.
|
|
114
|
-
const loginPathRef = useRef(config
|
|
115
|
-
const callbackParamRef = useRef(config
|
|
116
|
-
const onSessionExpiredRef = useRef(config
|
|
117
|
-
loginPathRef.current = config
|
|
118
|
-
callbackParamRef.current = config
|
|
119
|
-
onSessionExpiredRef.current = config
|
|
133
|
+
const loginPathRef = useRef(config?.loginPath);
|
|
134
|
+
const callbackParamRef = useRef(config?.callbackParam ?? 'callbackUrl');
|
|
135
|
+
const onSessionExpiredRef = useRef(config?.onSessionExpired);
|
|
136
|
+
loginPathRef.current = config?.loginPath;
|
|
137
|
+
callbackParamRef.current = config?.callbackParam ?? 'callbackUrl';
|
|
138
|
+
onSessionExpiredRef.current = config?.onSessionExpired;
|
|
120
139
|
|
|
121
140
|
// Session expiration handler — covers both AuthClient timer and authFetch 401.
|
|
122
141
|
// Depends only on authClient (a useState singleton that never changes after
|
|
@@ -133,21 +152,51 @@ export function AuthProvider({
|
|
|
133
152
|
onSessionExpiredRef.current?.();
|
|
134
153
|
|
|
135
154
|
if (loginPathRef.current && typeof window !== 'undefined') {
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
const loginPath = loginPathRef.current;
|
|
156
|
+
const callbackParam = callbackParamRef.current;
|
|
157
|
+
const here = window.location.href;
|
|
158
|
+
const isAbsolute = /^https?:\/\//i.test(loginPath);
|
|
159
|
+
|
|
160
|
+
if (isAbsolute) {
|
|
161
|
+
// Full-URL loginPath (e.g. central auth host at
|
|
162
|
+
// https://auth.startsimpli.com/signin?app=vault). Avoid bouncing if
|
|
163
|
+
// we're already on that host+pathname, and preserve any pre-existing
|
|
164
|
+
// query params on the loginPath (e.g. ?app=vault) by appending the
|
|
165
|
+
// callback via URL/searchParams instead of naive string concat.
|
|
166
|
+
try {
|
|
167
|
+
const target = new URL(loginPath);
|
|
168
|
+
const current = new URL(window.location.href);
|
|
169
|
+
const isOnLogin =
|
|
170
|
+
current.host === target.host && current.pathname === target.pathname;
|
|
171
|
+
if (!isOnLogin) {
|
|
172
|
+
target.searchParams.set(callbackParam, here);
|
|
173
|
+
window.location.href = target.toString();
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Malformed loginPath — fall through to no-op rather than crash.
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
const path = window.location.pathname + window.location.search;
|
|
180
|
+
const isOnLogin = window.location.pathname.startsWith(loginPath);
|
|
181
|
+
if (!isOnLogin) {
|
|
182
|
+
const callback = encodeURIComponent(path);
|
|
183
|
+
const sep = loginPath.includes('?') ? '&' : '?';
|
|
184
|
+
window.location.href = `${loginPath}${sep}${callbackParam}=${callback}`;
|
|
185
|
+
}
|
|
141
186
|
}
|
|
142
187
|
}
|
|
143
188
|
};
|
|
144
189
|
|
|
145
190
|
// Wire AuthClient's internal expiry calls and the functional API (FetchWrapper).
|
|
146
|
-
config.
|
|
191
|
+
// The Django client reads expiry via config; non-config backends (e.g. mock)
|
|
192
|
+
// accept the handler through the optional setOnSessionExpired method.
|
|
193
|
+
if (config) config.onSessionExpired = handleExpired;
|
|
194
|
+
authClient.setOnSessionExpired?.(handleExpired);
|
|
147
195
|
setOnSessionExpired(handleExpired);
|
|
148
196
|
|
|
149
197
|
return () => {
|
|
150
198
|
setOnSessionExpired(null);
|
|
199
|
+
authClient.setOnSessionExpired?.(null);
|
|
151
200
|
authClient.destroy();
|
|
152
201
|
};
|
|
153
202
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -208,7 +257,6 @@ export function AuthProvider({
|
|
|
208
257
|
async (payload: {
|
|
209
258
|
email: string;
|
|
210
259
|
password: string;
|
|
211
|
-
passwordConfirm: string;
|
|
212
260
|
name?: string;
|
|
213
261
|
firstName?: string;
|
|
214
262
|
lastName?: string;
|
|
@@ -247,6 +295,33 @@ export function AuthProvider({
|
|
|
247
295
|
[authClient]
|
|
248
296
|
);
|
|
249
297
|
|
|
298
|
+
const signInWithMicrosoft = useCallback(
|
|
299
|
+
async (redirectTo?: string) => {
|
|
300
|
+
if (!authClient.signInWithMicrosoft) {
|
|
301
|
+
throw new Error('signInWithMicrosoft is not supported by this auth backend');
|
|
302
|
+
}
|
|
303
|
+
return authClient.signInWithMicrosoft(redirectTo);
|
|
304
|
+
},
|
|
305
|
+
[authClient]
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const completeMicrosoftCallback = useCallback(
|
|
309
|
+
async (code: string, state: string) => {
|
|
310
|
+
if (!authClient.completeMicrosoftCallback) {
|
|
311
|
+
throw new Error('completeMicrosoftCallback is not supported by this auth backend');
|
|
312
|
+
}
|
|
313
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
314
|
+
try {
|
|
315
|
+
const session = await authClient.completeMicrosoftCallback(code, state);
|
|
316
|
+
setState({ session, isLoading: false, isAuthenticated: true });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
[authClient]
|
|
323
|
+
);
|
|
324
|
+
|
|
250
325
|
const hydrateSession = useCallback(
|
|
251
326
|
(session: Session) => {
|
|
252
327
|
authClient.setSession(session);
|
|
@@ -264,6 +339,8 @@ export function AuthProvider({
|
|
|
264
339
|
register,
|
|
265
340
|
signInWithGoogle,
|
|
266
341
|
completeGoogleCallback,
|
|
342
|
+
signInWithMicrosoft,
|
|
343
|
+
completeMicrosoftCallback,
|
|
267
344
|
hydrateSession,
|
|
268
345
|
};
|
|
269
346
|
|