@startsimpli/auth 0.4.15 → 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 +32 -10
- 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
|
+
/**
|
|
2
|
+
* Platform-neutral, token-mode auth client.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the session lifecycle of the web cookie-based AuthClient (login /
|
|
5
|
+
* refreshToken / logout / getCurrentUser) but works WITHOUT cookies, so it runs
|
|
6
|
+
* on React Native (and any non-browser client). It opts into the backend's
|
|
7
|
+
* token mode via `X-Auth-Mode: token`, carries the refresh token through a
|
|
8
|
+
* pluggable TokenStorage, and sends the access token as a Bearer header.
|
|
9
|
+
*
|
|
10
|
+
* Backend contract (start-simpli-api, shipped by claude-mac):
|
|
11
|
+
* POST /api/v1/auth/token/ X-Auth-Mode: token -> { access, refresh }
|
|
12
|
+
* POST /api/v1/auth/token/refresh/ body { refresh } -> { access, refresh }
|
|
13
|
+
* POST /api/v1/auth/logout/ Bearer + body { refresh } (blacklists refresh)
|
|
14
|
+
* GET /api/v1/auth/me/ Bearer
|
|
15
|
+
*
|
|
16
|
+
* DOM-free by construction: no cookies, no window/document. The only platform
|
|
17
|
+
* detail — where the refresh token is persisted — is injected as TokenStorage.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { AuthUser, Session } from '../types';
|
|
21
|
+
import { getTokenExpiresAt } from '../utils/token';
|
|
22
|
+
import { extractApiError } from '../utils/api-error';
|
|
23
|
+
|
|
24
|
+
/** Persistence for the refresh token. Web uses cookies (and never needs this);
|
|
25
|
+
* native provides a SecureStore-backed implementation. */
|
|
26
|
+
export interface TokenStorage {
|
|
27
|
+
getRefreshToken(): Promise<string | null>;
|
|
28
|
+
setRefreshToken(token: string): Promise<void>;
|
|
29
|
+
clear(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** In-memory TokenStorage — used in tests and as a non-persistent fallback. */
|
|
33
|
+
export class InMemoryTokenStorage implements TokenStorage {
|
|
34
|
+
private refresh: string | null = null;
|
|
35
|
+
async getRefreshToken(): Promise<string | null> {
|
|
36
|
+
return this.refresh;
|
|
37
|
+
}
|
|
38
|
+
async setRefreshToken(token: string): Promise<void> {
|
|
39
|
+
this.refresh = token;
|
|
40
|
+
}
|
|
41
|
+
async clear(): Promise<void> {
|
|
42
|
+
this.refresh = null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TokenAuthConfig {
|
|
47
|
+
apiBaseUrl: string;
|
|
48
|
+
storage: TokenStorage;
|
|
49
|
+
/** Injectable for tests; defaults to the global fetch. */
|
|
50
|
+
fetch?: typeof fetch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeUser(raw: unknown): AuthUser | null {
|
|
54
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
55
|
+
const obj = raw as Record<string, unknown>;
|
|
56
|
+
const p = (obj.user && typeof obj.user === 'object' ? obj.user : obj) as Record<string, unknown>;
|
|
57
|
+
if (!p.id || !p.email) return null;
|
|
58
|
+
return {
|
|
59
|
+
id: String(p.id),
|
|
60
|
+
email: String(p.email),
|
|
61
|
+
firstName: (p.first_name ?? p.firstName ?? '') as string,
|
|
62
|
+
lastName: (p.last_name ?? p.lastName ?? '') as string,
|
|
63
|
+
isEmailVerified: Boolean(p.is_email_verified ?? p.isEmailVerified ?? false),
|
|
64
|
+
createdAt: (p.created_at ?? p.createdAt ?? '') as string,
|
|
65
|
+
updatedAt: (p.updated_at ?? p.updatedAt ?? '') as string,
|
|
66
|
+
name: (p.name as string | null | undefined) ?? null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class TokenAuthClient {
|
|
71
|
+
private apiBaseUrl: string;
|
|
72
|
+
private storage: TokenStorage;
|
|
73
|
+
private fetchImpl: typeof fetch;
|
|
74
|
+
private accessToken: string | null = null;
|
|
75
|
+
private session: Session | null = null;
|
|
76
|
+
|
|
77
|
+
constructor(config: TokenAuthConfig) {
|
|
78
|
+
this.apiBaseUrl = config.apiBaseUrl;
|
|
79
|
+
this.storage = config.storage;
|
|
80
|
+
this.fetchImpl = config.fetch ?? (globalThis.fetch.bind(globalThis) as typeof fetch);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getAccessToken(): string | null {
|
|
84
|
+
return this.accessToken;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
getSession(): Session | null {
|
|
88
|
+
return this.session;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async login(email: string, password: string): Promise<Session> {
|
|
92
|
+
const response = await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/token/`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json', 'X-Auth-Mode': 'token' },
|
|
95
|
+
body: JSON.stringify({ email, password }),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const data = await response.json().catch(() => ({}) as Record<string, unknown>);
|
|
100
|
+
throw new Error(extractApiError(data as Record<string, unknown>, 'Login failed'));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
104
|
+
const access = data.access as string;
|
|
105
|
+
const expiresAt = getTokenExpiresAt(access);
|
|
106
|
+
if (!expiresAt) {
|
|
107
|
+
throw new Error('Invalid token received');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await this.storage.setRefreshToken(data.refresh as string);
|
|
111
|
+
this.accessToken = access;
|
|
112
|
+
|
|
113
|
+
let user = data.user ? normalizeUser(data.user) : null;
|
|
114
|
+
if (!user) {
|
|
115
|
+
user = await this.getCurrentUser(access);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.session = { user, accessToken: access, expiresAt };
|
|
119
|
+
return this.session;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async refreshToken(): Promise<string> {
|
|
123
|
+
const refresh = await this.storage.getRefreshToken();
|
|
124
|
+
if (!refresh) {
|
|
125
|
+
throw new Error('No refresh token available');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const response = await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/token/refresh/`, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ refresh }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
await this.storage.clear();
|
|
136
|
+
this.accessToken = null;
|
|
137
|
+
this.session = null;
|
|
138
|
+
const data = await response.json().catch(() => ({}) as Record<string, unknown>);
|
|
139
|
+
throw new Error(extractApiError(data as Record<string, unknown>, 'Token refresh failed'));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
143
|
+
const access = data.access as string;
|
|
144
|
+
// The backend rotates the refresh token in token mode; persist the new one.
|
|
145
|
+
if (typeof data.refresh === 'string') {
|
|
146
|
+
await this.storage.setRefreshToken(data.refresh);
|
|
147
|
+
}
|
|
148
|
+
this.accessToken = access;
|
|
149
|
+
return access;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async logout(): Promise<void> {
|
|
153
|
+
const refresh = await this.storage.getRefreshToken();
|
|
154
|
+
try {
|
|
155
|
+
await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/logout/`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: {
|
|
158
|
+
'Content-Type': 'application/json',
|
|
159
|
+
...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}),
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify({ refresh: refresh ?? undefined }),
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
// Network failure shouldn't strand a logged-out user with local tokens.
|
|
165
|
+
} finally {
|
|
166
|
+
await this.storage.clear();
|
|
167
|
+
this.accessToken = null;
|
|
168
|
+
this.session = null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async getCurrentUser(accessToken: string): Promise<AuthUser> {
|
|
173
|
+
const response = await this.fetchImpl(`${this.apiBaseUrl}/api/v1/auth/me/`, {
|
|
174
|
+
method: 'GET',
|
|
175
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
const data = await response.json().catch(() => ({}) as Record<string, unknown>);
|
|
180
|
+
throw new Error(extractApiError(data as Record<string, unknown>, 'Failed to fetch user'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
184
|
+
const user = normalizeUser(data);
|
|
185
|
+
if (!user) {
|
|
186
|
+
throw new Error('Invalid user response');
|
|
187
|
+
}
|
|
188
|
+
return user;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/auth/token — platform-neutral, cookie-free auth entry.
|
|
3
|
+
*
|
|
4
|
+
* The RN-safe surface for token-mode auth: a TokenAuthClient that carries the
|
|
5
|
+
* refresh token through a TokenStorage instead of cookies. SecureTokenStorage
|
|
6
|
+
* resolves to an expo-secure-store impl on React Native and to a throwing stub
|
|
7
|
+
* on the web (where the cookie-based AuthClient should be used instead).
|
|
8
|
+
*
|
|
9
|
+
* Kept separate from '@startsimpli/auth/client' because that barrel pulls in the
|
|
10
|
+
* DOM/cookie-bound web client (functions, AuthProvider).
|
|
11
|
+
*/
|
|
12
|
+
export {
|
|
13
|
+
TokenAuthClient,
|
|
14
|
+
InMemoryTokenStorage,
|
|
15
|
+
type TokenStorage,
|
|
16
|
+
type TokenAuthConfig,
|
|
17
|
+
} from './token-auth-core';
|
|
18
|
+
export { SecureTokenStorage, REFRESH_TOKEN_KEY } from './secure-token-storage';
|
package/src/index.ts
CHANGED
|
@@ -38,7 +38,24 @@ export {
|
|
|
38
38
|
authFetch,
|
|
39
39
|
hasPermission,
|
|
40
40
|
hasGroup,
|
|
41
|
+
createMockAuthBackend,
|
|
42
|
+
createMemorySessionStorage,
|
|
43
|
+
createWebSessionStorage,
|
|
44
|
+
createRememberAwareSessionStorage,
|
|
45
|
+
createSecureSessionStorage,
|
|
46
|
+
SESSION_STORAGE_KEY,
|
|
47
|
+
} from './client';
|
|
48
|
+
export type {
|
|
49
|
+
UseAuthReturn,
|
|
50
|
+
UseRequireAuthReturn,
|
|
51
|
+
UseRequireAuthOptions,
|
|
52
|
+
UsePermissionsReturn,
|
|
53
|
+
AuthBackend,
|
|
54
|
+
RegisterPayload,
|
|
55
|
+
MockAuthBackend,
|
|
56
|
+
MockAuthBackendOptions,
|
|
57
|
+
MockAccount,
|
|
58
|
+
SessionStorage,
|
|
41
59
|
} from './client';
|
|
42
|
-
export type { UseAuthReturn, UseRequireAuthReturn, UseRequireAuthOptions, UsePermissionsReturn } from './client';
|
|
43
60
|
|
|
44
61
|
// DO NOT export './server' here - it uses next/headers and must be imported explicitly via '@startsimpli/auth/server'
|
package/src/types/index.ts
CHANGED
|
@@ -51,6 +51,11 @@ export interface AuthUser {
|
|
|
51
51
|
isStaff?: boolean;
|
|
52
52
|
isActive?: boolean;
|
|
53
53
|
name?: string | null;
|
|
54
|
+
// Role/permission membership. Mirrors the functional-API AuthUser so the
|
|
55
|
+
// session user works directly with hasGroup()/hasPermission(). Backends that
|
|
56
|
+
// model roles as string groups (incl. the mock backend) populate these.
|
|
57
|
+
groups?: string[];
|
|
58
|
+
permissions?: string[];
|
|
54
59
|
// Company/team context (if applicable)
|
|
55
60
|
companies?: Array<{
|
|
56
61
|
id: string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a human-readable message from a Django REST Framework error response body.
|
|
3
|
+
*
|
|
4
|
+
* Handles the shapes we've seen in practice:
|
|
5
|
+
* { detail: "..." } → the string
|
|
6
|
+
* { detail: ["...", "..."] } → first string
|
|
7
|
+
* { detail: { token: ["Invalid..."] } } → first nested string
|
|
8
|
+
* { email: ["already exists"] } → first field-level string
|
|
9
|
+
* { non_field_errors: ["..."] } → first field-level string
|
|
10
|
+
* { error: "CODE", detail: { field: ["..."] } } → first nested string
|
|
11
|
+
*
|
|
12
|
+
* Pure and DOM-free, so it is shared by the web functional client (functions.ts)
|
|
13
|
+
* and the platform-neutral token-auth core (token-auth-core.ts).
|
|
14
|
+
*
|
|
15
|
+
* @internal Implementation detail of the auth package; do not rely on from outside.
|
|
16
|
+
*/
|
|
17
|
+
export function extractApiError(d: Record<string, unknown>, fallback: string): string {
|
|
18
|
+
const pluck = (val: unknown): string | null => {
|
|
19
|
+
if (typeof val === 'string') return val
|
|
20
|
+
if (Array.isArray(val) && val.length > 0) {
|
|
21
|
+
for (const item of val) {
|
|
22
|
+
const s = pluck(item)
|
|
23
|
+
if (s) return s
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (val && typeof val === 'object') {
|
|
27
|
+
for (const v of Object.values(val as Record<string, unknown>)) {
|
|
28
|
+
const s = pluck(v)
|
|
29
|
+
if (s) return s
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Standard DRF: { detail: "..." }
|
|
36
|
+
const fromDetail = pluck(d.detail)
|
|
37
|
+
if (fromDetail) return fromDetail
|
|
38
|
+
// Some backend shapes use `error` as the human-readable message (e.g.
|
|
39
|
+
// our Django auth errors: { "error": "No active account...", "code": "unauthorized" }).
|
|
40
|
+
// Prefer this over field-level probing so we don't accidentally return a
|
|
41
|
+
// code like "unauthorized" from a sibling field.
|
|
42
|
+
const fromError = pluck(d.error)
|
|
43
|
+
if (fromError) return fromError
|
|
44
|
+
// Field-level: { email: ["already exists"] } or { non_field_errors: ["..."] }
|
|
45
|
+
// Skip known meta/code fields so `{ code: "unauthorized" }` doesn't leak
|
|
46
|
+
// an internal identifier as a user-facing message.
|
|
47
|
+
const META_KEYS = new Set(['detail', 'error', 'code', 'statusCode', 'status', 'timestamp'])
|
|
48
|
+
for (const [key, val] of Object.entries(d)) {
|
|
49
|
+
if (META_KEYS.has(key)) continue
|
|
50
|
+
const s = pluck(val)
|
|
51
|
+
if (s) return s
|
|
52
|
+
}
|
|
53
|
+
return fallback
|
|
54
|
+
}
|