@go-mondo/nextjs-auth 0.1.0

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 ADDED
@@ -0,0 +1,462 @@
1
+ # @go-mondo/nextjs-auth
2
+
3
+ Next.js authentication helpers for Mondo Identity.
4
+
5
+ This package provides a small OAuth/OIDC auth layer for modern Next.js apps. It
6
+ is centered around a single auth client that can mount auth routes, protect
7
+ routes from `proxy.ts`, read the current session, and return or refresh access
8
+ tokens.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pnpm add @go-mondo/nextjs-auth
14
+ ```
15
+
16
+ ## Public Entry Points
17
+
18
+ This package uses explicit subpath exports for supporting types. Import the
19
+ auth client from `@go-mondo/nextjs-auth` or `@go-mondo/nextjs-auth/client`, and
20
+ import supporting public types from `@go-mondo/nextjs-auth/config`,
21
+ `@go-mondo/nextjs-auth/session`, `@go-mondo/nextjs-auth/oauth`, or
22
+ `@go-mondo/nextjs-auth/errors`. Hooks are exported from
23
+ `@go-mondo/nextjs-auth/hooks`.
24
+
25
+ ## Environment
26
+
27
+ At minimum, configure:
28
+
29
+ ```env
30
+ MONDO_SECRET="replace-with-at-least-32-characters"
31
+ MONDO_ISSUER_BASE_URL="https://identity.example.com"
32
+ APP_BASE_URL="http://localhost:3000"
33
+ MONDO_CLIENT_ID="your-client-id"
34
+ MONDO_CLIENT_SECRET="your-client-secret"
35
+ ```
36
+
37
+ Common optional values:
38
+
39
+ ```env
40
+ MONDO_AUDIENCE="https://api.example.com"
41
+ MONDO_SCOPE="openid profile email offline_access"
42
+
43
+ NEXT_PUBLIC_LOGIN_ROUTE="/auth/login"
44
+ NEXT_PUBLIC_SESSION_ROUTE="/auth/session"
45
+ NEXT_PUBLIC_ACCESS_TOKEN_ROUTE="/auth/access-token"
46
+ CALLBACK_ROUTE="/auth/callback"
47
+ LOGOUT_ROUTE="/auth/logout"
48
+ SESSION_ROUTE="/auth/session"
49
+ ACCESS_TOKEN_ROUTE="/auth/access-token"
50
+ POST_LOGOUT_REDIRECT_ROUTE="/"
51
+
52
+ MONDO_SESSION_IDLE_DURATION="86400"
53
+ MONDO_SESSION_ABSOLUTE_DURATION="604800"
54
+ MONDO_COOKIE_SECURE="true"
55
+ MONDO_COOKIE_SAME_SITE="lax"
56
+ ```
57
+
58
+ `MONDO_SECRET` is used by `iron-session` to seal session and transaction
59
+ cookies. Use at least 32 characters. For secret rotation, pass an array of
60
+ secrets when creating the auth client.
61
+
62
+ ## Quick Start
63
+
64
+ Create one auth client and reuse it everywhere.
65
+
66
+ ```ts
67
+ // src/lib/auth.ts
68
+ import { createAuth } from '@go-mondo/nextjs-auth';
69
+
70
+ export const auth = createAuth();
71
+ ```
72
+
73
+ Mount the auth routes.
74
+
75
+ ```ts
76
+ // src/app/auth/[...auth]/route.ts
77
+ import { auth } from '@/lib/auth';
78
+
79
+ export const GET = auth.handleAuth();
80
+ export const POST = auth.handleAuth();
81
+ ```
82
+
83
+ Protect routes with `proxy.ts`.
84
+
85
+ ```ts
86
+ // src/proxy.ts
87
+ import { auth } from '@/lib/auth';
88
+
89
+ export const proxy = auth.proxy;
90
+
91
+ export const config = {
92
+ matcher: [
93
+ '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
94
+ ],
95
+ };
96
+ ```
97
+
98
+ Link users to login and logout with normal anchors.
99
+
100
+ ```tsx
101
+ export function AuthLinks() {
102
+ return (
103
+ <nav>
104
+ <a href="/auth/login">Log in</a>
105
+ <a href="/auth/logout">Log out</a>
106
+ </nav>
107
+ );
108
+ }
109
+ ```
110
+
111
+ ## Public Routes
112
+
113
+ If your matcher covers the whole app, pass public paths to `auth.proxy`.
114
+
115
+ ```ts
116
+ // src/proxy.ts
117
+ import { auth } from '@/lib/auth';
118
+
119
+ export function proxy(request: Request) {
120
+ return auth.proxy(request, {
121
+ publicPaths: ['/', '/pricing', /^\/blog(\/.*)?$/],
122
+ });
123
+ }
124
+ ```
125
+
126
+ Unauthenticated users are redirected to the configured login route with a
127
+ `returnTo` query parameter.
128
+
129
+ You can also compose `auth.proxy()` with other proxy checks. This is useful
130
+ when one route family needs different behavior than the rest of the protected
131
+ app.
132
+
133
+ ```ts
134
+ // src/proxy.ts
135
+ import { NextResponse, type NextRequest } from 'next/server';
136
+ import { auth } from '@/lib/auth';
137
+
138
+ const publicPaths = ['/', '/pricing', /^\/blog(\/.*)?$/];
139
+
140
+ export async function proxy(request: NextRequest) {
141
+ const { pathname, search } = request.nextUrl;
142
+
143
+ if (pathname === '/healthz') {
144
+ return Response.json({ ok: true });
145
+ }
146
+
147
+ if (pathname.startsWith('/api/webhooks/')) {
148
+ const signature = request.headers.get('x-webhook-signature');
149
+
150
+ if (signature !== process.env.WEBHOOK_SHARED_SECRET) {
151
+ return new Response(null, { status: 401 });
152
+ }
153
+
154
+ return NextResponse.next();
155
+ }
156
+
157
+ if (pathname.startsWith('/admin')) {
158
+ const response = await auth.proxy(request, {
159
+ returnTo: `${pathname}${search}`,
160
+ });
161
+
162
+ response?.headers.set('x-route-scope', 'admin');
163
+ return response;
164
+ }
165
+
166
+ return auth.proxy(request, {
167
+ publicPaths,
168
+ returnTo: `${pathname}${search}`,
169
+ });
170
+ }
171
+
172
+ export const config = {
173
+ matcher: [
174
+ '/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
175
+ ],
176
+ };
177
+ ```
178
+
179
+ ## Reading the Session
180
+
181
+ Use the auth client from server components, route handlers, and server actions.
182
+
183
+ ```tsx
184
+ // src/app/account/page.tsx
185
+ import { auth } from '@/lib/auth';
186
+
187
+ export default async function AccountPage() {
188
+ const session = await auth.getSession();
189
+
190
+ if (!session) {
191
+ return null;
192
+ }
193
+
194
+ return <h1>{session.user.email}</h1>;
195
+ }
196
+ ```
197
+
198
+ The default session JSON endpoint is mounted at `/auth/session`.
199
+
200
+ ```ts
201
+ const response = await fetch('/auth/session');
202
+ ```
203
+
204
+ ## Reading the User in Client Components
205
+
206
+ Client components can read the current user with the TanStack Query hook from
207
+ the focused user hook entry point. Your app must provide a
208
+ `QueryClientProvider`.
209
+
210
+ ```sh
211
+ pnpm add @tanstack/react-query
212
+ ```
213
+
214
+ ```tsx
215
+ // src/app/providers.tsx
216
+ 'use client';
217
+
218
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
219
+ import { type ReactNode, useState } from 'react';
220
+
221
+ export function Providers({ children }: { children: ReactNode }) {
222
+ const [queryClient] = useState(() => new QueryClient());
223
+
224
+ return (
225
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
226
+ );
227
+ }
228
+ ```
229
+
230
+ ```tsx
231
+ 'use client';
232
+
233
+ import { useUserProfile } from '@go-mondo/nextjs-auth/hooks';
234
+
235
+ type MondoClaims = {
236
+ roles?: string[];
237
+ org_id?: string;
238
+ };
239
+
240
+ export function ProfileButton() {
241
+ const { data: user, isLoading } = useUserProfile<MondoClaims>();
242
+
243
+ if (isLoading) {
244
+ return null;
245
+ }
246
+
247
+ return <span>{user?.email ?? 'Signed out'}</span>;
248
+ }
249
+ ```
250
+
251
+ The hook calls `/auth/session` by default and returns `undefined` for 401/403
252
+ responses. It can read either the default session JSON shape or a transformed
253
+ route that returns `session.user` directly.
254
+
255
+ ## Getting an Access Token
256
+
257
+ On the server, call `getAccessToken`. If the stored access token is expired and
258
+ a refresh token is available, the package refreshes the access token and writes
259
+ the updated authorization data back to the sealed session cookies.
260
+
261
+ ```ts
262
+ // src/app/api/reports/route.ts
263
+ import { auth } from '@/lib/auth';
264
+
265
+ export async function GET() {
266
+ const { accessToken } = await auth.getAccessToken({
267
+ scopes: ['reports:read'],
268
+ });
269
+
270
+ const upstream = await fetch('https://api.example.com/reports', {
271
+ headers: {
272
+ authorization: `Bearer ${accessToken}`,
273
+ },
274
+ });
275
+
276
+ return Response.json(await upstream.json(), { status: upstream.status });
277
+ }
278
+ ```
279
+
280
+ The default access-token JSON endpoint is mounted at `/auth/access-token`.
281
+ Prefer server-side access-token usage when possible; expose this endpoint only
282
+ when browser code truly needs the token.
283
+
284
+ Client components can request a current or refreshed access token with
285
+ `useAccessToken`. The server still owns the refresh token; browser code only
286
+ receives the short-lived access token returned by the mounted access-token
287
+ route.
288
+
289
+ ```tsx
290
+ 'use client';
291
+
292
+ import { useAccessToken } from '@go-mondo/nextjs-auth/hooks';
293
+
294
+ export function ReportsClient() {
295
+ const { data: token } = useAccessToken({
296
+ scopes: ['reports:read'],
297
+ refresh: true,
298
+ });
299
+
300
+ return <button disabled={!token}>Load reports</button>;
301
+ }
302
+ ```
303
+
304
+ When `scopes`, `refresh`, or `refreshBeforeExpiresIn` are provided, the hook
305
+ POSTs those options to `/auth/access-token`. The authorization server validates
306
+ whether requested scopes are allowed for the stored refresh token.
307
+
308
+ For imperative browser API clients, use `createAccessTokenProvider` so repeated
309
+ API calls do not hit `/auth/access-token` before every request. The cache is
310
+ memory-only, expires entries from the returned `expiresAt` value, and shares one
311
+ in-flight token request across concurrent callers.
312
+
313
+ ```ts
314
+ // src/lib/api.ts
315
+ import { createAccessTokenProvider } from '@go-mondo/nextjs-auth/hooks';
316
+
317
+ const tokens = createAccessTokenProvider({
318
+ scopes: ['reports:read'],
319
+ });
320
+
321
+ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
322
+ const { accessToken } = await tokens.getAccessToken();
323
+ const headers = new Headers(init?.headers);
324
+
325
+ headers.set('authorization', `Bearer ${accessToken}`);
326
+
327
+ return fetch(input, {
328
+ ...init,
329
+ headers,
330
+ });
331
+ }
332
+ ```
333
+
334
+ ## Custom Configuration
335
+
336
+ You can configure the client in code instead of relying only on environment
337
+ variables.
338
+
339
+ ```ts
340
+ // src/lib/auth.ts
341
+ import { createAuth } from '@go-mondo/nextjs-auth';
342
+
343
+ export const auth = createAuth({
344
+ baseURL: 'https://app.example.com',
345
+ issuerBaseURL: 'https://identity.example.com',
346
+ clientId: 'client-id',
347
+ clientSecret: 'client-secret',
348
+ secret: [
349
+ 'new-32-character-or-longer-secret',
350
+ 'old-32-character-or-longer-secret',
351
+ ],
352
+ authorization: {
353
+ audience: 'https://api.example.com',
354
+ scope: 'openid profile email offline_access reports:read',
355
+ },
356
+ session: {
357
+ idleDuration: 60 * 60 * 24,
358
+ absoluteDuration: 60 * 60 * 24 * 7,
359
+ cookie: {
360
+ secure: true,
361
+ sameSite: 'lax',
362
+ },
363
+ },
364
+ });
365
+ ```
366
+
367
+ Configuration is validated with Zod at client initialization. The schema is
368
+ described in code so validation errors, generated docs, and future examples can
369
+ all draw from the same source of truth.
370
+
371
+ ## Typed Claims
372
+
373
+ Pass your app-specific claims to `createAuth` to type `session.user`.
374
+
375
+ ```ts
376
+ import { createAuth } from '@go-mondo/nextjs-auth';
377
+
378
+ type MondoClaims = {
379
+ roles?: string[];
380
+ org_id?: string;
381
+ };
382
+
383
+ export const auth = createAuth<MondoClaims>();
384
+ ```
385
+
386
+ ```ts
387
+ const session = await auth.getSession();
388
+ session?.user.roles;
389
+ ```
390
+
391
+ ## Mounted Routes
392
+
393
+ By default, `auth.handleAuth()` handles:
394
+
395
+ - `/auth/login`: starts the authorization-code login flow.
396
+ - `/auth/callback`: verifies the callback and stores the session.
397
+ - `/auth/logout`: clears the local application session.
398
+ - `/auth/session`: returns the current session as JSON.
399
+ - `/auth/access-token`: returns or refreshes the current access token.
400
+
401
+ ## Session Cookies
402
+
403
+ The session is split into sealed `iron-session` cookies:
404
+
405
+ - `Mondo.Session`: user claims and session timestamps.
406
+ - `Mondo.Authorization`: access token, expiry, scopes, and refresh token.
407
+ - `Mondo.Authentication`: raw ID token.
408
+
409
+ This keeps the session stateless and tamper-proof while avoiding a server-side
410
+ session database. Cookies are HTTP-only by default.
411
+
412
+ ## Session Expiration
413
+
414
+ Sessions support both idle and absolute expiration. `idleDuration` extends the
415
+ session when authenticated activity touches it, such as protected requests
416
+ handled by `auth.proxy()` or the session JSON route. `absoluteDuration` caps the
417
+ session lifetime from the original login time, regardless of activity.
418
+
419
+ The stored `expiresAt` timestamp is the earlier of the idle and absolute
420
+ expiration times. Set `idleDuration: false` to disable activity-based extension;
421
+ set `absoluteDuration: false` to disable the hard maximum lifetime. At least one
422
+ expiration mode must be enabled.
423
+
424
+ ## Development
425
+
426
+ ```sh
427
+ pnpm install
428
+ pnpm run check
429
+ ```
430
+
431
+ `pnpm run check` runs library type-checking, example type-checking, linting,
432
+ formatting checks, tests, and the package build.
433
+
434
+ ## Examples
435
+
436
+ This repository includes two runnable Next.js examples. Both require your own
437
+ Mondo Identity OIDC application credentials.
438
+
439
+ ```sh
440
+ pnpm install
441
+ pnpm run build
442
+ ```
443
+
444
+ Server-rendered profile:
445
+
446
+ ```sh
447
+ cd examples/server-profile
448
+ cp .env.example .env.local
449
+ pnpm dev
450
+ ```
451
+
452
+ Client-rendered profile:
453
+
454
+ ```sh
455
+ cd examples/client-profile
456
+ cp .env.example .env.local
457
+ pnpm dev
458
+ ```
459
+
460
+ The server example runs on port `3001`; the client example runs on port `3002`.
461
+ Register the matching `/auth/callback` URL with your identity provider before
462
+ logging in.
@@ -0,0 +1,27 @@
1
+ type AccessTokenResult = {
2
+ /** Bearer token value returned by the identity provider. */
3
+ accessToken: string;
4
+ /** Epoch seconds when the access token expires. */
5
+ expiresAt: number;
6
+ /** Space-delimited scopes granted to the access token. */
7
+ scope?: string;
8
+ /** Token type returned by the identity provider, usually `Bearer`. */
9
+ type?: string;
10
+ };
11
+ type GetAccessTokenOptions = {
12
+ /**
13
+ * Refresh even when the current access token is still valid.
14
+ */
15
+ refresh?: boolean;
16
+ /**
17
+ * Required scopes for the returned access token.
18
+ */
19
+ scopes?: string | Array<string>;
20
+ /**
21
+ * Number of seconds before expiry that should be treated as already expired.
22
+ */
23
+ refreshBeforeExpiresIn?: number;
24
+ };
25
+ type GetAccessToken = (options?: GetAccessTokenOptions) => Promise<AccessTokenResult>;
26
+
27
+ export type { AccessTokenResult as A, GetAccessTokenOptions as G, GetAccessToken as a };
@@ -0,0 +1,27 @@
1
+ type AccessTokenResult = {
2
+ /** Bearer token value returned by the identity provider. */
3
+ accessToken: string;
4
+ /** Epoch seconds when the access token expires. */
5
+ expiresAt: number;
6
+ /** Space-delimited scopes granted to the access token. */
7
+ scope?: string;
8
+ /** Token type returned by the identity provider, usually `Bearer`. */
9
+ type?: string;
10
+ };
11
+ type GetAccessTokenOptions = {
12
+ /**
13
+ * Refresh even when the current access token is still valid.
14
+ */
15
+ refresh?: boolean;
16
+ /**
17
+ * Required scopes for the returned access token.
18
+ */
19
+ scopes?: string | Array<string>;
20
+ /**
21
+ * Number of seconds before expiry that should be treated as already expired.
22
+ */
23
+ refreshBeforeExpiresIn?: number;
24
+ };
25
+ type GetAccessToken = (options?: GetAccessTokenOptions) => Promise<AccessTokenResult>;
26
+
27
+ export type { AccessTokenResult as A, GetAccessTokenOptions as G, GetAccessToken as a };