@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
package/README.md
CHANGED
|
@@ -1,54 +1,38 @@
|
|
|
1
1
|
# @startsimpli/auth
|
|
2
2
|
|
|
3
|
-
Shared authentication
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- `POST /api/v1/auth/logout/` - Logout
|
|
37
|
-
- `GET /api/v1/auth/me/` - Get current user data
|
|
38
|
-
|
|
39
|
-
**Token flow:**
|
|
40
|
-
1. Login returns `{ access: "jwt-token", user: {...} }`
|
|
41
|
-
2. Refresh token stored as httpOnly cookie (managed by Django)
|
|
42
|
-
3. Access token stored in memory (not localStorage)
|
|
43
|
-
4. Automatic refresh before expiration (default: 4 minutes)
|
|
44
|
-
|
|
45
|
-
## Usage
|
|
46
|
-
|
|
47
|
-
### Client-Side Authentication
|
|
48
|
-
|
|
49
|
-
#### 1. Setup Auth Provider
|
|
50
|
-
|
|
51
|
-
Wrap your app with `AuthProvider` in your root layout:
|
|
3
|
+
Shared authentication for every StartSimpli frontend. JWT lifecycle, React
|
|
4
|
+
context, an `authFetch` with transparent 401-retry, Next.js server helpers,
|
|
5
|
+
session-storage adapters for web **and** React Native, and an in-memory mock
|
|
6
|
+
backend for tests/demos.
|
|
7
|
+
|
|
8
|
+
Drives auth in `raise-simpli`, `market-simpli`, `trade-simpli`, `vault-web`,
|
|
9
|
+
and the `examples/mobile-rn` Expo demo on the in-progress
|
|
10
|
+
`react-native-enablement` branch.
|
|
11
|
+
|
|
12
|
+
## Subpath exports
|
|
13
|
+
|
|
14
|
+
The package ships TypeScript source — consumers bundle it directly. The
|
|
15
|
+
`package.json` `exports` map is the contract:
|
|
16
|
+
|
|
17
|
+
| Import | Use from | Notes |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `@startsimpli/auth` | anywhere (client-safe) | Re-exports `./client` + `./types` + `./utils` (no `next/headers`). |
|
|
20
|
+
| `@startsimpli/auth/client` | browser / RN | `AuthProvider`, `useAuth`, `authFetch`, session-storage factories, mock backend. |
|
|
21
|
+
| `@startsimpli/auth/server` | Next.js server | `getServerSession`, `requireAuth`, `createAuthMiddleware`, `withAuth`, `withRole`, `getTokenFromRequest`. Uses `next/headers` — never import from a client component. |
|
|
22
|
+
| `@startsimpli/auth/token` | React Native | DOM-free `TokenAuthClient` + `SecureTokenStorage` (Keychain/Keystore via `expo-secure-store`). |
|
|
23
|
+
| `@startsimpli/auth/components` | browser | `GoogleSignInButton`, `OAuthCallback`, `useOAuthCallback`, `OAuthConnectionCard`. |
|
|
24
|
+
| `@startsimpli/auth/email` | server | `createEmailService` (Resend, optional peer). |
|
|
25
|
+
| `@startsimpli/auth/types` | anywhere | Shared types — `Session`, `AuthUser`, `AuthConfig`, `CompanyRole`. |
|
|
26
|
+
|
|
27
|
+
> The native/web split for storage is resolved by **Metro file extensions**
|
|
28
|
+
> (`secure-session-storage.native.ts` / `secure-token-storage.native.ts`), not a
|
|
29
|
+
> `./native` subpath. Import the same `createSecureSessionStorage` /
|
|
30
|
+
> `SecureTokenStorage` from `@startsimpli/auth/client` (or `/token`) and the
|
|
31
|
+
> bundler picks the right file. `expo-secure-store` is an optional peer; when
|
|
32
|
+
> the native module is missing, both adapters degrade to in-memory storage
|
|
33
|
+
> instead of crashing.
|
|
34
|
+
|
|
35
|
+
## Quick start (Next.js)
|
|
52
36
|
|
|
53
37
|
```tsx
|
|
54
38
|
// app/layout.tsx
|
|
@@ -63,9 +47,7 @@ export default function RootLayout({ children }) {
|
|
|
63
47
|
<AuthProvider
|
|
64
48
|
config={{
|
|
65
49
|
apiBaseUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
66
|
-
|
|
67
|
-
window.location.href = '/login';
|
|
68
|
-
},
|
|
50
|
+
loginPath: '/auth/signin',
|
|
69
51
|
}}
|
|
70
52
|
>
|
|
71
53
|
{children}
|
|
@@ -76,409 +58,241 @@ export default function RootLayout({ children }) {
|
|
|
76
58
|
}
|
|
77
59
|
```
|
|
78
60
|
|
|
79
|
-
#### 2. Use Authentication Hook
|
|
80
|
-
|
|
81
61
|
```tsx
|
|
82
|
-
//
|
|
62
|
+
// any client component
|
|
83
63
|
'use client';
|
|
84
64
|
|
|
85
65
|
import { useAuth } from '@startsimpli/auth/client';
|
|
86
66
|
|
|
87
|
-
export
|
|
88
|
-
const { user,
|
|
89
|
-
|
|
90
|
-
if (isLoading) {
|
|
91
|
-
return <div>Loading...</div>;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (!isAuthenticated) {
|
|
95
|
-
return <div>Please login</div>;
|
|
96
|
-
}
|
|
97
|
-
|
|
67
|
+
export function Header() {
|
|
68
|
+
const { user, isAuthenticated, logout } = useAuth();
|
|
69
|
+
if (!isAuthenticated) return null;
|
|
98
70
|
return (
|
|
99
71
|
<div>
|
|
100
|
-
|
|
101
|
-
<button onClick={logout}>Logout</button>
|
|
72
|
+
Hi {user!.firstName} · <button onClick={logout}>Sign out</button>
|
|
102
73
|
</div>
|
|
103
74
|
);
|
|
104
75
|
}
|
|
105
76
|
```
|
|
106
77
|
|
|
107
|
-
|
|
78
|
+
## `authFetch` — token attach + 401-retry + base-URL resolution
|
|
108
79
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
'use client';
|
|
80
|
+
`authFetch` is the only HTTP helper every app should use for authenticated
|
|
81
|
+
calls. It:
|
|
112
82
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
83
|
+
1. Pulls the current access token from storage (see below) and attaches
|
|
84
|
+
`Authorization: Bearer <token>` if no header is set already.
|
|
85
|
+
2. Resolves relative URLs against `NEXT_PUBLIC_API_URL` (or
|
|
86
|
+
`NEXT_PUBLIC_API_BASE_URL`) via `resolveAuthUrl`.
|
|
87
|
+
3. Sets `credentials: 'include'` by default so the refresh cookie travels.
|
|
88
|
+
4. On `401`, calls `refreshAccessToken()` **once** (concurrent 401s share one
|
|
89
|
+
refresh promise) and retries the original request with the new token.
|
|
90
|
+
5. If the refresh fails or the retried request is still `401`, clears the
|
|
91
|
+
stored token and fires `notifySessionExpired()` — which calls the callback
|
|
92
|
+
registered via `setOnSessionExpired` (wired automatically by
|
|
93
|
+
`AuthProvider`).
|
|
122
94
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
await login(email, password);
|
|
128
|
-
router.push('/dashboard');
|
|
129
|
-
} catch (error) {
|
|
130
|
-
console.error('Login failed:', error);
|
|
131
|
-
}
|
|
132
|
-
};
|
|
95
|
+
```ts
|
|
96
|
+
import { authFetch } from '@startsimpli/auth/client';
|
|
133
97
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
type="email"
|
|
138
|
-
value={email}
|
|
139
|
-
onChange={(e) => setEmail(e.target.value)}
|
|
140
|
-
placeholder="Email"
|
|
141
|
-
/>
|
|
142
|
-
<input
|
|
143
|
-
type="password"
|
|
144
|
-
value={password}
|
|
145
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
146
|
-
placeholder="Password"
|
|
147
|
-
/>
|
|
148
|
-
<button type="submit">Login</button>
|
|
149
|
-
</form>
|
|
150
|
-
);
|
|
98
|
+
const res = await authFetch('/api/v1/me/'); // resolved to NEXT_PUBLIC_API_URL
|
|
99
|
+
if (res.ok) {
|
|
100
|
+
const me = await res.json();
|
|
151
101
|
}
|
|
152
102
|
```
|
|
153
103
|
|
|
154
|
-
|
|
104
|
+
`@startsimpli/api`'s `FetchWrapper` routes its own 401-after-refresh through
|
|
105
|
+
the same `notifySessionExpired` sink so every consumer hits a single redirect
|
|
106
|
+
path.
|
|
155
107
|
|
|
156
|
-
|
|
157
|
-
'use client';
|
|
108
|
+
### Refresh-token classification
|
|
158
109
|
|
|
159
|
-
|
|
110
|
+
`refreshAccessToken` distinguishes "session is dead" from "backend is sick":
|
|
160
111
|
|
|
161
|
-
|
|
162
|
-
|
|
112
|
+
| Status | Result |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `401` / `403` / `400` | Clear the stored token, return `null`. |
|
|
115
|
+
| `5xx` / network error | Throw `TransientRefreshError`. Do **NOT** log the user out — the access token still lives, callers should surface a retry. |
|
|
116
|
+
| `200` | Return the new access token; persist via `setAccessToken`. |
|
|
163
117
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
) : (
|
|
176
|
-
<p>View only</p>
|
|
177
|
-
)}
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
118
|
+
## Storage adapters (`SessionStorage`)
|
|
119
|
+
|
|
120
|
+
Backends that own their session (the mock backend, the React Native
|
|
121
|
+
`TokenAuthClient`, offline-first clients) need somewhere to persist it across
|
|
122
|
+
reloads/app restarts. The shared contract:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
interface SessionStorage {
|
|
126
|
+
load(): Promise<Session | null>;
|
|
127
|
+
save(session: Session): Promise<void>;
|
|
128
|
+
clear(): Promise<void>;
|
|
180
129
|
}
|
|
181
130
|
```
|
|
182
131
|
|
|
183
|
-
|
|
132
|
+
Factories in `@startsimpli/auth/client`:
|
|
184
133
|
|
|
185
|
-
|
|
134
|
+
- `createMemorySessionStorage()` — the safe default for SSR + tests.
|
|
135
|
+
- `createWebSessionStorage({ key?, storage? })` — `localStorage` by default;
|
|
136
|
+
silently falls back to memory when no Storage is available.
|
|
137
|
+
- `createSecureSessionStorage(key?)` — **Metro-resolved**. On the web it
|
|
138
|
+
delegates to `createWebSessionStorage`; on React Native it persists to the
|
|
139
|
+
iOS Keychain / Android Keystore via `expo-secure-store`. Falls back to
|
|
140
|
+
in-memory when the native module isn't in the build.
|
|
141
|
+
- `createRememberAwareSessionStorage(persistent, shouldRemember, transient?)`
|
|
142
|
+
— wraps two storages. When `shouldRemember()` is true at save time, the
|
|
143
|
+
session goes to `persistent` and `transient` is cleared; otherwise the
|
|
144
|
+
reverse. `load` always reads `persistent`, so a session restores only if
|
|
145
|
+
the last save was "remembered".
|
|
186
146
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
import { redirect } from 'next/navigation';
|
|
147
|
+
The web Django client doesn't take a `SessionStorage` at all — its refresh
|
|
148
|
+
token lives in an httpOnly cookie managed by the browser. The token-mode
|
|
149
|
+
client (`@startsimpli/auth/token`) is the one that needs storage on RN.
|
|
191
150
|
|
|
192
|
-
|
|
193
|
-
const session = await getServerSession(process.env.API_BASE_URL!);
|
|
151
|
+
### React Native parity (`TokenAuthClient`)
|
|
194
152
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
153
|
+
```ts
|
|
154
|
+
// On RN (Expo)
|
|
155
|
+
import { TokenAuthClient, SecureTokenStorage } from '@startsimpli/auth/token';
|
|
198
156
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
);
|
|
204
|
-
}
|
|
157
|
+
const auth = new TokenAuthClient({
|
|
158
|
+
apiBaseUrl: 'https://api.example.com',
|
|
159
|
+
storage: new SecureTokenStorage(),
|
|
160
|
+
});
|
|
205
161
|
```
|
|
206
162
|
|
|
207
|
-
|
|
163
|
+
This is the same lifecycle as the web `AuthClient` (login, refresh, logout,
|
|
164
|
+
getCurrentUser) but DOM-free: no cookies, no `window`/`document`. It opts
|
|
165
|
+
into the backend's token mode via `X-Auth-Mode: token` and persists the
|
|
166
|
+
refresh token through the injected `TokenStorage`. See
|
|
167
|
+
`examples/mobile-rn/App.tsx` for the wiring used by the Expo demo.
|
|
208
168
|
|
|
209
|
-
|
|
169
|
+
## Server helpers (Next.js)
|
|
210
170
|
|
|
211
171
|
```ts
|
|
212
172
|
// middleware.ts
|
|
213
173
|
import { createAuthMiddleware } from '@startsimpli/auth/server';
|
|
214
174
|
|
|
215
175
|
export const middleware = createAuthMiddleware({
|
|
216
|
-
|
|
217
|
-
publicPaths: ['/
|
|
218
|
-
loginPath: '/login',
|
|
176
|
+
loginPath: '/auth/signin',
|
|
177
|
+
publicPaths: ['/auth/signin', '/auth/signup', '/auth/forgot-password'],
|
|
219
178
|
});
|
|
220
179
|
|
|
221
180
|
export const config = {
|
|
222
|
-
matcher: [
|
|
223
|
-
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
|
224
|
-
],
|
|
181
|
+
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
|
225
182
|
};
|
|
226
183
|
```
|
|
227
184
|
|
|
228
|
-
|
|
185
|
+
The middleware uses a two-tier check: it lets the request through if either
|
|
186
|
+
a fresh access cookie (`auth_session` / `access_token`) is present OR a
|
|
187
|
+
`refresh_token` cookie is present (the client will refresh on mount). This
|
|
188
|
+
avoids the every-30-minute redirect dance when the access JWT expires
|
|
189
|
+
mid-session (see raise-simpli-qcw).
|
|
229
190
|
|
|
230
|
-
|
|
191
|
+
### Route-handler guards
|
|
231
192
|
|
|
232
193
|
```ts
|
|
233
|
-
// app/api/
|
|
234
|
-
import { NextRequest } from 'next/server';
|
|
194
|
+
// app/api/me/route.ts
|
|
195
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
235
196
|
import { withAuth } from '@startsimpli/auth/server';
|
|
236
197
|
|
|
237
|
-
export const GET = withAuth(async (
|
|
238
|
-
const
|
|
239
|
-
headers: {
|
|
240
|
-
Authorization: `Bearer ${token}`,
|
|
241
|
-
},
|
|
198
|
+
export const GET = withAuth(async (req: NextRequest, token: string) => {
|
|
199
|
+
const res = await fetch(`${process.env.API_BASE_URL}/api/v1/auth/me/`, {
|
|
200
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
242
201
|
});
|
|
243
|
-
|
|
244
|
-
const data = await response.json();
|
|
245
|
-
return NextResponse.json(data);
|
|
202
|
+
return NextResponse.json(await res.json());
|
|
246
203
|
});
|
|
247
204
|
```
|
|
248
205
|
|
|
249
|
-
|
|
206
|
+
`withRole(requiredRole, getUserRole, handler)` adds an `owner > admin >
|
|
207
|
+
member > viewer` check on top. Both extract the token via
|
|
208
|
+
`getRequestToken(req)` (NextRequest).
|
|
250
209
|
|
|
251
|
-
|
|
252
|
-
// app/api/admin/users/route.ts
|
|
253
|
-
import { NextRequest } from 'next/server';
|
|
254
|
-
import { withRole } from '@startsimpli/auth/server';
|
|
210
|
+
### Framework-agnostic token extraction
|
|
255
211
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
212
|
+
`getTokenFromRequest(req: Request)` works on any `Request`-compatible
|
|
213
|
+
object (Node, Edge, Bun). It checks `Authorization: Bearer ...` then falls
|
|
214
|
+
back to an `access_token` cookie. It returns the raw token string without
|
|
215
|
+
expiry validation — used by `vault-web` API routes that hand the token
|
|
216
|
+
straight to a downstream Django call.
|
|
260
217
|
|
|
261
|
-
|
|
262
|
-
'admin',
|
|
263
|
-
getUserRole,
|
|
264
|
-
async (request: NextRequest, token: string) => {
|
|
265
|
-
// Only accessible to admins
|
|
266
|
-
return NextResponse.json({ message: 'Admin data' });
|
|
267
|
-
}
|
|
268
|
-
);
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### Making Authenticated API Calls
|
|
218
|
+
### Server components
|
|
272
219
|
|
|
273
220
|
```tsx
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
import {
|
|
277
|
-
|
|
278
|
-
export default function DataFetcher() {
|
|
279
|
-
const { getAccessToken } = useAuth();
|
|
280
|
-
|
|
281
|
-
const fetchData = async () => {
|
|
282
|
-
const token = await getAccessToken();
|
|
283
|
-
|
|
284
|
-
if (!token) {
|
|
285
|
-
console.error('No access token');
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const response = await fetch('/api/data', {
|
|
290
|
-
headers: {
|
|
291
|
-
Authorization: `Bearer ${token}`,
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
return response.json();
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
return <button onClick={fetchData}>Fetch Data</button>;
|
|
299
|
-
}
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
## API Reference
|
|
303
|
-
|
|
304
|
-
### Client Exports
|
|
305
|
-
|
|
306
|
-
#### `AuthProvider`
|
|
307
|
-
|
|
308
|
-
React context provider for authentication.
|
|
309
|
-
|
|
310
|
-
```tsx
|
|
311
|
-
interface AuthProviderProps {
|
|
312
|
-
children: ReactNode;
|
|
313
|
-
config: AuthConfig;
|
|
314
|
-
initialSession?: Session | null;
|
|
315
|
-
}
|
|
316
|
-
```
|
|
317
|
-
|
|
318
|
-
#### `useAuth()`
|
|
319
|
-
|
|
320
|
-
Hook to access authentication state and methods.
|
|
321
|
-
|
|
322
|
-
```tsx
|
|
323
|
-
interface UseAuthReturn {
|
|
324
|
-
user: AuthUser | null;
|
|
325
|
-
session: Session | null;
|
|
326
|
-
isLoading: boolean;
|
|
327
|
-
isAuthenticated: boolean;
|
|
328
|
-
login: (email: string, password: string) => Promise<void>;
|
|
329
|
-
logout: () => Promise<void>;
|
|
330
|
-
refreshUser: () => Promise<void>;
|
|
331
|
-
getAccessToken: () => Promise<string | null>;
|
|
332
|
-
}
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
#### `usePermissions()`
|
|
336
|
-
|
|
337
|
-
Hook for permission/role checks.
|
|
338
|
-
|
|
339
|
-
```tsx
|
|
340
|
-
interface UsePermissionsReturn {
|
|
341
|
-
hasRole: (requiredRole: CompanyRole, companyId?: string) => boolean;
|
|
342
|
-
isOwner: (companyId?: string) => boolean;
|
|
343
|
-
isAdmin: (companyId?: string) => boolean;
|
|
344
|
-
canEdit: (companyId?: string) => boolean;
|
|
345
|
-
canView: (companyId?: string) => boolean;
|
|
346
|
-
currentRole: CompanyRole | null;
|
|
347
|
-
currentCompanyId: string | null;
|
|
348
|
-
}
|
|
349
|
-
```
|
|
350
|
-
|
|
351
|
-
#### `AuthClient`
|
|
352
|
-
|
|
353
|
-
Low-level authentication client (used internally by hooks).
|
|
221
|
+
// app/dashboard/page.tsx
|
|
222
|
+
import { getServerSession } from '@startsimpli/auth/server';
|
|
223
|
+
import { redirect } from 'next/navigation';
|
|
354
224
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
logout(): Promise<void>;
|
|
360
|
-
refreshToken(): Promise<string>;
|
|
361
|
-
getCurrentUser(): Promise<AuthUser>;
|
|
362
|
-
getSession(): Session | null;
|
|
363
|
-
getAccessToken(): Promise<string | null>;
|
|
225
|
+
export default async function Dashboard() {
|
|
226
|
+
const session = await getServerSession(process.env.API_BASE_URL!);
|
|
227
|
+
if (!session) redirect('/auth/signin');
|
|
228
|
+
return <div>Welcome, {session.user.firstName}</div>;
|
|
364
229
|
}
|
|
365
230
|
```
|
|
366
231
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
Get authenticated session from server-side cookies.
|
|
372
|
-
|
|
373
|
-
```tsx
|
|
374
|
-
async function getServerSession(apiBaseUrl: string): Promise<Session | null>;
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
#### `validateSession()`
|
|
378
|
-
|
|
379
|
-
Validate session and return user or null.
|
|
380
|
-
|
|
381
|
-
```tsx
|
|
382
|
-
async function validateSession(apiBaseUrl: string): Promise<AuthUser | null>;
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
#### `requireAuth()`
|
|
386
|
-
|
|
387
|
-
Require authenticated session (throws if not authenticated).
|
|
388
|
-
|
|
389
|
-
```tsx
|
|
390
|
-
async function requireAuth(apiBaseUrl: string): Promise<Session>;
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
#### `createAuthMiddleware()`
|
|
394
|
-
|
|
395
|
-
Create Next.js middleware for route protection.
|
|
396
|
-
|
|
397
|
-
```tsx
|
|
398
|
-
function createAuthMiddleware(config: AuthMiddlewareConfig): Middleware;
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
#### `withAuth()`
|
|
402
|
-
|
|
403
|
-
HOF to wrap API routes with auth guard.
|
|
404
|
-
|
|
405
|
-
```tsx
|
|
406
|
-
function withAuth<T>(
|
|
407
|
-
handler: (request: NextRequest, token: string) => Promise<NextResponse<T>>
|
|
408
|
-
): (request: NextRequest) => Promise<NextResponse<T>>;
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
#### `withRole()`
|
|
232
|
+
`validateSession(apiBaseUrl)` and `requireAuth(apiBaseUrl)` are the
|
|
233
|
+
shorter forms. `refreshServerToken(apiBaseUrl)` hits the refresh endpoint
|
|
234
|
+
from a server context with cookies forwarded.
|
|
412
235
|
|
|
413
|
-
|
|
236
|
+
## Mock backend (tests, Storybook, offline demos)
|
|
414
237
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
getUserRole: () => Promise<CompanyRole | null>,
|
|
419
|
-
handler: (request: NextRequest, token: string) => Promise<NextResponse<T>>
|
|
420
|
-
): (request: NextRequest) => Promise<NextResponse<T>>;
|
|
421
|
-
```
|
|
422
|
-
|
|
423
|
-
### Types
|
|
238
|
+
`createMockAuthBackend()` returns a full `AuthBackend` implementation —
|
|
239
|
+
exactly what `AuthProvider` needs, with no network. Pair it with any
|
|
240
|
+
`SessionStorage` to survive reloads.
|
|
424
241
|
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
currentCompanyId?: string;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
interface Session {
|
|
445
|
-
user: AuthUser;
|
|
446
|
-
accessToken: string;
|
|
447
|
-
expiresAt: number;
|
|
448
|
-
}
|
|
242
|
+
```ts
|
|
243
|
+
import {
|
|
244
|
+
AuthProvider,
|
|
245
|
+
createMockAuthBackend,
|
|
246
|
+
createWebSessionStorage,
|
|
247
|
+
} from '@startsimpli/auth/client';
|
|
248
|
+
|
|
249
|
+
const backend = createMockAuthBackend({
|
|
250
|
+
accounts: [
|
|
251
|
+
{ email: 'demo@example.com', password: 'hunter2',
|
|
252
|
+
user: { id: 'u1', email: 'demo@example.com', firstName: 'Demo',
|
|
253
|
+
lastName: 'User', isEmailVerified: true,
|
|
254
|
+
createdAt: '', updatedAt: '' } },
|
|
255
|
+
],
|
|
256
|
+
storage: createWebSessionStorage(),
|
|
257
|
+
});
|
|
449
258
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
tokenRefreshInterval?: number;
|
|
453
|
-
onSessionExpired?: () => void;
|
|
454
|
-
onUnauthorized?: () => void;
|
|
259
|
+
export default function App() {
|
|
260
|
+
return <AuthProvider backend={backend}>{/* ... */}</AuthProvider>;
|
|
455
261
|
}
|
|
456
262
|
```
|
|
457
263
|
|
|
458
|
-
|
|
264
|
+
Pass `backend` *instead of* `config` and `AuthProvider` skips constructing
|
|
265
|
+
the Django `AuthClient` entirely. The mock surface adds `requestPasswordReset`,
|
|
266
|
+
`resetPassword`, `requestEmailVerification`, `verifyEmail`, and
|
|
267
|
+
`upsertAccount` on top of the base contract — see
|
|
268
|
+
`packages/billing/src/mock` for an example consumer.
|
|
459
269
|
|
|
460
|
-
|
|
270
|
+
## Verification
|
|
461
271
|
|
|
272
|
+
```bash
|
|
273
|
+
pnpm --filter @startsimpli/auth test # 140 tests across 17 files
|
|
274
|
+
pnpm --filter @startsimpli/auth type-check
|
|
462
275
|
```
|
|
463
|
-
owner (4) > admin (3) > member (2) > viewer (1)
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
Use `hasRolePermission(userRole, requiredRole)` to check if a user has sufficient permissions.
|
|
467
276
|
|
|
468
|
-
|
|
277
|
+
The suite covers `auth-client`, `authFetch` retry, middleware, the mock
|
|
278
|
+
backend, the secure-storage adapters (native + web), the token-auth core,
|
|
279
|
+
permissions, and validation.
|
|
469
280
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
281
|
+
**Browser verification is not optional.** Any sign-in / sign-out / OAuth
|
|
282
|
+
flow change MUST be driven through a localhost dev server with
|
|
283
|
+
`mcp__debugg-ai__check_app_in_browser` — type-checks and unit tests do not
|
|
284
|
+
prove a redirect chain works. See the MCP-default rule in
|
|
285
|
+
`/Users/qosha/Repos/start-simpli/CLAUDE.md` (rule 10 / the
|
|
286
|
+
"feedback_verify_ui_with_mcp" memory).
|
|
475
287
|
|
|
476
|
-
##
|
|
288
|
+
## Shared-package policy
|
|
477
289
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
290
|
+
Per `CLAUDE.md` rule 9, every app-side auth helper that another app might
|
|
291
|
+
plausibly want belongs here — **not** in `apps/*/src`. No new
|
|
292
|
+
`AuthProvider`, no app-local `authFetch` wrapper, no per-app `useAuth`
|
|
293
|
+
hook. Extend this package instead. The current consumers all go through
|
|
294
|
+
the public surface above.
|
|
481
295
|
|
|
482
296
|
## License
|
|
483
297
|
|
|
484
|
-
Private package for StartSimpli monorepo.
|
|
298
|
+
Private package for the StartSimpli monorepo.
|