najm-auth 3.3.2 → 4.0.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 CHANGED
@@ -98,6 +98,10 @@ GOOGLE_CLIENT_ID=<google-web-client-id>
98
98
  GOOGLE_CLIENT_SECRET=<google-web-client-secret>
99
99
  # Optional for a split frontend/API deployment. Otherwise FRONTEND_URL is used.
100
100
  GOOGLE_CALLBACK_URL=https://app.example.com/api/auth/oauth/google/callback
101
+ # Optional GitHub sign-in
102
+ GITHUB_CLIENT_ID=<github-oauth-app-client-id>
103
+ GITHUB_CLIENT_SECRET=<github-oauth-app-client-secret>
104
+ GITHUB_CALLBACK_URL=https://app.example.com/api/auth/oauth/github/callback
101
105
  ```
102
106
 
103
107
  > ⚠️ **Security:** Generate secrets with `openssl rand -base64 32`
@@ -129,10 +133,10 @@ auth({
129
133
  database?: string // Default: 'default'
130
134
  blacklistPrefix?: string // Default: 'auth:blacklist:'
131
135
 
132
- // Registration
133
- defaultRole?: string | null // Auto-assign role to new users
134
- publicRegistration?: boolean // Default: true; mounts POST /auth/register
135
- bcryptRounds?: number // Default: 10 (valid: 4-31)
136
+ // Registration
137
+ defaultRole?: string | null // Auto-assign role to new users
138
+ publicRegistration?: boolean // Default: true; mounts POST /auth/register
139
+ bcryptRounds?: number // Default: 10 (valid: 4-31)
136
140
 
137
141
  // Frontend
138
142
  frontendUrl?: string // Password reset link base URL
@@ -152,7 +156,7 @@ auth({
152
156
  }
153
157
  }
154
158
 
155
- // Optional Google OpenID Connect
159
+ // Optional external identity providers
156
160
  oauth?: {
157
161
  google?: true | {
158
162
  clientId?: string // Or GOOGLE_CLIENT_ID
@@ -164,6 +168,15 @@ auth({
164
168
  autoLinkVerifiedEmail?: boolean // Default: false
165
169
  allowedHostedDomains?: string[] // Validates the Google hd claim
166
170
  }
171
+ github?: true | {
172
+ clientId?: string // Or GITHUB_CLIENT_ID
173
+ clientSecret?: string // Or GITHUB_CLIENT_SECRET
174
+ callbackUrl?: string // Or GITHUB_CALLBACK_URL
175
+ frontendCallbackPath?: string // Default: /auth/oauth/callback
176
+ errorRedirectPath?: string // Default: /login
177
+ allowSignup?: boolean // Default: true
178
+ autoLinkVerifiedEmail?: boolean // Default: false
179
+ }
167
180
  }
168
181
 
169
182
  // Dependencies (forwarded to plugins)
@@ -182,7 +195,7 @@ All routes are prefixed with `/auth` and auto-registered by the plugin.
182
195
 
183
196
  | Method | Path | Description | Auth |
184
197
  |--------|------|-------------|------|
185
- | `POST` | `/auth/register` | Register new user (omitted when `publicRegistration: false`) | None |
198
+ | `POST` | `/auth/register` | Register new user (omitted when `publicRegistration: false`) | None |
186
199
  | `POST` | `/auth/login` | Login with email/password | None |
187
200
  | `POST` | `/auth/refresh` | Refresh access token (cookie) | None (uses refresh cookie) |
188
201
  | `POST` | `/auth/session/recover` | Reissue signed session without token rotation | Refresh cookie + recovery header |
@@ -195,12 +208,12 @@ All routes are prefixed with `/auth` and auto-registered by the plugin.
195
208
  | `POST` | `/auth/oauth/google/link` | Link Google to the current user | ✅ Required |
196
209
  | `GET` | `/auth/credential-setup/setup` | Read the pending setup session | Setup cookie |
197
210
  | `POST` | `/auth/credential-setup/change` | Replace the temporary credential | Setup cookie |
198
- | `POST` | `/auth/credential-setup/cancel` | Abandon the setup session | Setup cookie |
199
-
200
- Applications with an approval-owned onboarding flow should set
201
- `publicRegistration: false`. This removes the unauthenticated route while
202
- retaining `AuthService.registerUser()`, `provisionUser()`, and other internal
203
- account-management APIs for trusted application services.
211
+ | `POST` | `/auth/credential-setup/cancel` | Abandon the setup session | Setup cookie |
212
+
213
+ Applications with an approval-owned onboarding flow should set
214
+ `publicRegistration: false`. This removes the unauthenticated route while
215
+ retaining `AuthService.registerUser()`, `provisionUser()`, and other internal
216
+ account-management APIs for trusted application services.
204
217
 
205
218
  ### Identity presets
206
219
 
@@ -336,6 +349,20 @@ user has the same email but is not linked, sign-in fails with
336
349
  `client.linkOAuthAccount('google')` to prove control of both accounts. Setting
337
350
  `autoLinkVerifiedEmail: true` opts into verified-email linking.
338
351
 
352
+ ### GitHub Sign-In
353
+
354
+ Enable GitHub with `oauth: { github: true }`. Credentials come from
355
+ `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`; the callback defaults to
356
+ `${FRONTEND_URL}/api/auth/oauth/github/callback`. Register that exact callback
357
+ on the GitHub OAuth App, and set `GITHUB_CALLBACK_URL` only for a split-origin
358
+ deployment. GitHub login uses authorization code plus PKCE, requests
359
+ `user:email`, requires a verified primary email, and keys the durable provider
360
+ link by GitHub's numeric user ID.
361
+
362
+ The client exposes `loginWithGitHub()`, `useGitHubLogin()`, and the headless
363
+ `GitHubLoginButton`; the generic `linkOAuthAccount('github')` method links an
364
+ authenticated Najm user.
365
+
339
366
  ### Admin Routes (all require `@isAdmin()`)
340
367
 
341
368
  | Method | Path | Description |
@@ -637,7 +664,7 @@ credential_setup_requirements
637
664
  Existing databases must generate and run a migration after upgrading so the
638
665
  new `oauth_accounts`, `credential_setup_sessions`, and
639
666
  `credential_setup_requirements` tables exist. Custom `AuthSchema` objects may
640
- omit `oauthAccounts` while OAuth is disabled, but Google configuration fails
667
+ omit `oauthAccounts` while OAuth is disabled, but provider configuration fails
641
668
  fast unless the custom schema supplies it. Both credential-setup tables are
642
669
  required of a custom schema, because the setup flow is always mounted.
643
670
 
@@ -822,6 +849,31 @@ Next.js 16 requires the exported Proxy `config` to be a statically analyzable
822
849
  object literal. Turbopack rejects `export const config = auth.config`, so the
823
850
  matcher is the one integration value that cannot be composed at runtime.
824
851
 
852
+ When a Proxy generates request-scoped headers for the downstream render, pass
853
+ only the overrides as the optional second argument. Najm merges them over the
854
+ incoming request and preserves them when session recovery replaces the cookie.
855
+ The application still owns any matching response header:
856
+
857
+ ```typescript
858
+ export default async function proxy(request: Request) {
859
+ const nonce = btoa(globalThis.crypto.randomUUID());
860
+ const policy = `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`;
861
+ const response = await auth.proxy(request, {
862
+ requestHeaders: {
863
+ 'content-security-policy': policy,
864
+ 'x-nonce': nonce,
865
+ },
866
+ });
867
+ response.headers.set('Content-Security-Policy', policy);
868
+ return response;
869
+ }
870
+ ```
871
+
872
+ Authentication reads the original request. `requestHeaders` controls only what
873
+ the successful Next.js render receives. Attempts to override `cookie` or
874
+ `authorization` fail closed; only Najm's validated recovery path may replace
875
+ the cookie after it authorizes the recovered session.
876
+
825
877
  ```typescript
826
878
  // src/app/api/[...route]/route.ts
827
879
  import { handle } from 'najm-core';
@@ -1025,8 +1077,9 @@ throw new HttpError(403, 'Insufficient permissions for this action');
1025
1077
  - Login uses a dummy password hash for missing users to reduce timing leaks.
1026
1078
  - Forgot-password responses avoid email enumeration.
1027
1079
  - Auth routes register `najm-rate` and ship route-level brute-force limits.
1028
- - Session cookies are signed and short-lived; server auth resolution checks
1029
- their session version.
1080
+ - Session cookies are signed, short-lived, and bound to their refresh-token
1081
+ family; server auth resolution checks both the session version and positive
1082
+ family liveness.
1030
1083
  - Expired signed sessions recover through authoritative, non-rotating refresh
1031
1084
  validation; middleware verifies the reissued HMAC before using its claims.
1032
1085
  - Server-side recovery sends only the configured refresh cookie and accepts
@@ -1065,17 +1118,20 @@ errors cannot change the authentication result.
1065
1118
 
1066
1119
  ### Password Reset Tokens
1067
1120
 
1068
- ⚠️ **Current behavior:** Reset tokens use JWT expiry (default 1h) for single-use validation. To add database-backed single-use tokens:
1121
+ Reset and invite links are signed JWTs whose `jti` is stored in the configured
1122
+ cache with the same expiry. `verifyResetToken()` atomically compares and
1123
+ deletes that value, so exactly one concurrent caller can consume a link and a
1124
+ stale link cannot delete the value for a newer one.
1069
1125
 
1070
- ```typescript
1071
- // In AuthService.resetPassword():
1072
- async resetPassword(token: string, newPassword: string) {
1073
- const userId = this.tokenService.verifyResetToken(token);
1074
- // ... update password ...
1075
- // Blacklist the reset token to prevent reuse
1076
- await this.tokenService.blacklistCurrentToken(token);
1077
- }
1078
- ```
1126
+ `AuthService.resetPassword()` validates the replacement password before
1127
+ consumption. Once consumed, a token stays consumed even if the later user
1128
+ mutation fails; restoring it would make the link replayable, so the user must
1129
+ request a new one.
1130
+
1131
+ The built-in memory and Redis drivers implement the required atomic primitive.
1132
+ A custom cache driver may omit `compareAndDelete()` for compatibility with
1133
+ unrelated cache usage, but reset and invite consumption then fails closed. Do
1134
+ not emulate this operation with separate `get()` and `del()` calls.
1079
1135
 
1080
1136
  ### Purpose-Bound Credential Setup
1081
1137
 
@@ -1125,8 +1181,9 @@ consuming it; `cancel()` revokes it and clears the cookie.
1125
1181
  ### Session Management
1126
1182
 
1127
1183
  - Sessions are multi-device: the token table stores one refresh row per login session (keyed by a unique `tokenFamily`), so a user can stay logged in on several devices at once. Logout and rotation are scoped to the current session; password change/reset revoke every session
1184
+ - Revocation changes the refresh row to a durable `revoked` tombstone until its original expiry. Active-session reads and rotations require `status = active`, so losing Redis cannot revive a logged-out database session; expired tombstones are removed by normal session cleanup
1128
1185
  - A stale refresh token presented after the 120-second rotation grace window revokes only that session's family as reuse protection
1129
- - The signed session cookie is accepted for up to its configured TTL (5 minutes by default) without a database or revocation-cache read
1186
+ - The signed session cookie is accepted on the fast path only while Redis positively identifies its family as live and owned by the same user; an unknown cache state falls back to authoritative refresh-row recovery
1130
1187
  - Use `@RateLimit` on logout for DDoS protection
1131
1188
 
1132
1189
  ### Token Blacklist
@@ -1150,8 +1207,24 @@ consuming it; `cancel()` revokes it and clears the cookie.
1150
1207
  ```bash
1151
1208
  bun run test # Run all tests
1152
1209
  bun run test:auth # Run auth tests only
1210
+ bun run --cwd packages/najm-auth test:real-infra # Opt-in PostgreSQL + Redis races
1211
+ bun packages/najm-auth/integration/mailpit-forgot-password/run.ts # Loopback Redis + Mailpit HTTP acceptance
1153
1212
  ```
1154
1213
 
1214
+ The real-infrastructure suite runs only with `NAJM_AUTH_REAL_INFRA=1`. Supply
1215
+ loopback-only `NAJM_AUTH_REAL_POSTGRES_URL` and `NAJM_AUTH_REAL_REDIS_URL` (or
1216
+ the conventional `DATABASE_URL` and `REDIS_URL`). It creates and drops its own
1217
+ randomly named PostgreSQL database and cleans only its unique Redis key prefix;
1218
+ remote endpoints fail before either service is touched.
1219
+
1220
+ The Mailpit acceptance runner requires Redis on `127.0.0.1:6399`, Mailpit SMTP
1221
+ on `127.0.0.1:1025`, and the Mailpit API on `127.0.0.1:8025` by default. It
1222
+ boots the real auth plugin over HTTP with an ephemeral SQLite fixture, proves
1223
+ ignored fields and spoofed forwarding headers cannot buy more reset emails,
1224
+ and removes only its run-specific messages and Redis keys. The three endpoints
1225
+ can be changed with the `NAJM_AUTH_MAILPIT_*` variables, but non-loopback
1226
+ values fail before the fixture is created.
1227
+
1155
1228
  Test files include:
1156
1229
  - `schema.test.ts` — Schema exports validation
1157
1230
  - `auth.test.ts` — Authentication flow
@@ -1,4 +1,4 @@
1
- import { R as RetryConfig, a as RequestOptions, A as AuthUser, b as AuthClientConfig, L as LoginCredentials, c as LoginResult, O as OAuthProvider, d as OAuthLoginOptions, e as AuthState, f as AuthEvent, g as AuthEventHandler } from './types-BaSfgxqE.js';
1
+ import { R as RetryConfig, a as RequestOptions, A as AuthUser, b as AuthClientConfig, L as LoginCredentials, c as LoginResult, O as OAuthProvider, d as OAuthLoginOptions, e as AuthState, f as AuthEvent, g as AuthEventHandler } from './types-CI2t8wpJ.js';
2
2
 
3
3
  interface FetchClientConfig {
4
4
  baseURL: string;
@@ -63,6 +63,7 @@ declare class NajmAuthClient {
63
63
  getOAuthLoginUrl(provider: OAuthProvider, options?: OAuthLoginOptions): string;
64
64
  loginWithOAuth(provider: OAuthProvider, options?: OAuthLoginOptions): void;
65
65
  loginWithGoogle(options?: OAuthLoginOptions): void;
66
+ loginWithGitHub(options?: OAuthLoginOptions): void;
66
67
  linkOAuthAccount(provider: OAuthProvider, options?: OAuthLoginOptions): Promise<void>;
67
68
  completeOAuthLogin(): Promise<AuthUser>;
68
69
  logout(): Promise<void>;
@@ -3,6 +3,19 @@ import { S as SessionRecoveryFailure } from '../sessionRecovery-D5Fa0yZ1.js';
3
3
  export { a as SessionRecoveryErrorDetails, b as SessionRecoveryFailureReason } from '../sessionRecovery-D5Fa0yZ1.js';
4
4
 
5
5
  type ProxySessionMode = 'optimistic' | 'authoritative';
6
+ /**
7
+ * Per-request headers an application proxy needs to expose to the downstream
8
+ * Next.js render. This is primarily useful for request-scoped values such as a
9
+ * Content Security Policy nonce.
10
+ *
11
+ * Values are merged over the incoming request headers. Authentication always
12
+ * evaluates the original request, so these overrides cannot change which
13
+ * principal or cookie Najm authorizes. `authorization` and `cookie` are
14
+ * rejected here; only Najm's validated recovery path may replace the cookie.
15
+ */
16
+ interface AuthProxyOptions {
17
+ requestHeaders?: HeadersInit;
18
+ }
6
19
  interface AuthMiddlewareConfig {
7
20
  /** Routes that require authentication (glob patterns) */
8
21
  protectedRoutes?: string[];
@@ -73,6 +86,6 @@ interface AuthMiddlewareConfig {
73
86
  * };
74
87
  * ```
75
88
  */
76
- declare function withAuthMiddleware(config: AuthMiddlewareConfig): (request: Request) => Promise<next_server.NextResponse<unknown>>;
89
+ declare function withAuthMiddleware(config: AuthMiddlewareConfig): (request: Request, options?: AuthProxyOptions) => Promise<next_server.NextResponse<unknown>>;
77
90
 
78
- export { type AuthMiddlewareConfig, type ProxySessionMode, SessionRecoveryFailure, withAuthMiddleware };
91
+ export { type AuthMiddlewareConfig, type AuthProxyOptions, type ProxySessionMode, SessionRecoveryFailure, withAuthMiddleware };
@@ -19,6 +19,7 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
19
19
  if (!isRecord(data) || !isValidUser(data.user)) return null;
20
20
  if (!isStringArray(data.roles) || !isStringArray(data.permissions)) return null;
21
21
  if (!Number.isInteger(data.sessionVersion) || data.sessionVersion < 0) return null;
22
+ if (typeof data.tokenFamily !== "string" || !data.tokenFamily) return null;
22
23
  if (!Number.isFinite(data.iat) || !Number.isInteger(data.iat) || data.iat <= 0) return null;
23
24
  const issuedAt = data.iat;
24
25
  if (issuedAt > now + MAX_CLOCK_SKEW_MS) return null;
@@ -28,6 +29,7 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
28
29
  roles: [...data.roles],
29
30
  permissions: [...data.permissions],
30
31
  sessionVersion: data.sessionVersion,
32
+ tokenFamily: data.tokenFamily,
31
33
  iat: issuedAt
32
34
  };
33
35
  } catch {
@@ -353,8 +355,25 @@ function withAuthMiddleware(config) {
353
355
  } = config;
354
356
  const verifyAlways = proxySessionMode === void 0 ? legacyVerifyAlways : proxySessionMode === "authoritative";
355
357
  const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
356
- return /* @__PURE__ */ __name(async function middleware(request) {
358
+ return /* @__PURE__ */ __name(async function middleware(request, options = {}) {
357
359
  const { NextResponse } = await import("next/server");
360
+ const downstreamHeaders = new Headers(request.headers);
361
+ if (options.requestHeaders) {
362
+ const overrides = new Headers(options.requestHeaders);
363
+ for (const identityHeader of ["authorization", "cookie"]) {
364
+ if (overrides.has(identityHeader)) {
365
+ throw new TypeError(
366
+ `Auth proxy requestHeaders cannot override ${identityHeader}`
367
+ );
368
+ }
369
+ }
370
+ overrides.forEach((value, key) => {
371
+ downstreamHeaders.set(key, value);
372
+ });
373
+ }
374
+ const continueRequest = /* @__PURE__ */ __name(() => NextResponse.next({
375
+ request: { headers: downstreamHeaders }
376
+ }), "continueRequest");
358
377
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
359
378
  const loginUrl = new URL(loginRoute, request.url);
360
379
  loginUrl.searchParams.set("from", returnPath2);
@@ -371,10 +390,10 @@ function withAuthMiddleware(config) {
371
390
  const pathname = url.pathname;
372
391
  const returnPath = `${url.pathname}${url.search}`;
373
392
  if (matchesAny(pathname, publicRoutes)) {
374
- return NextResponse.next();
393
+ return continueRequest();
375
394
  }
376
395
  const isProtected = protectedRoutes.length === 0 || matchesAny(pathname, protectedRoutes);
377
- if (!isProtected) return NextResponse.next();
396
+ if (!isProtected) return continueRequest();
378
397
  const cookie = request.headers.get("cookie") ?? "";
379
398
  const sessionCookie = readCookieValue(cookie, sessionCookieName);
380
399
  const secret = resolveSessionSecret(sessionSecret);
@@ -420,16 +439,15 @@ function withAuthMiddleware(config) {
420
439
  return forbidden;
421
440
  }
422
441
  if (recovery?.status === "recovered") {
423
- const requestHeaders = new Headers(request.headers);
424
- requestHeaders.set(
442
+ downstreamHeaders.set(
425
443
  "cookie",
426
444
  replaceCookieValue(cookie, sessionCookieName, recovery.sessionCookieValue)
427
445
  );
428
- const response = NextResponse.next({ request: { headers: requestHeaders } });
446
+ const response = continueRequest();
429
447
  response.headers.append("Set-Cookie", recovery.setCookie);
430
448
  return response;
431
449
  }
432
- return NextResponse.next();
450
+ return continueRequest();
433
451
  }, "middleware");
434
452
  }
435
453
  __name(withAuthMiddleware, "withAuthMiddleware");
@@ -1,6 +1,6 @@
1
- export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-BBl-GRis.js';
2
- import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../types-BaSfgxqE.js';
3
- export { b as AuthClientConfig, h as AuthError, f as AuthEvent, g as AuthEventHandler, i as AuthEventMap, e as AuthState, A as AuthUser, j as AuthenticatedLogin, C as CredentialSetupPending, L as LoginCredentials, c as LoginResult, d as OAuthLoginOptions, O as OAuthProvider, a as RequestOptions, R as RetryConfig, k as ServerResponse, l as TokenPair } from '../types-BaSfgxqE.js';
1
+ export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-BygfBCpF.js';
2
+ import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../types-CI2t8wpJ.js';
3
+ export { b as AuthClientConfig, h as AuthError, f as AuthEvent, g as AuthEventHandler, i as AuthEventMap, e as AuthState, A as AuthUser, j as AuthenticatedLogin, C as CredentialSetupPending, L as LoginCredentials, c as LoginResult, d as OAuthLoginOptions, O as OAuthProvider, a as RequestOptions, R as RetryConfig, k as ServerResponse, l as TokenPair } from '../types-CI2t8wpJ.js';
4
4
 
5
5
  /**
6
6
  * Decode a JWT token payload without verification.
@@ -334,6 +334,9 @@ var NajmAuthClient = class _NajmAuthClient {
334
334
  loginWithGoogle(options) {
335
335
  this.loginWithOAuth("google", options);
336
336
  }
337
+ loginWithGitHub(options) {
338
+ this.loginWithOAuth("github", options);
339
+ }
337
340
  async linkOAuthAccount(provider, options = {}) {
338
341
  const query = options.returnTo ? `?${new URLSearchParams({ returnTo: this.validateReturnTo(options.returnTo) })}` : "";
339
342
  const res = await this.api.post(
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, CSSProperties, ReactElement } from 'react';
3
- import { N as NajmAuthClient, H as HydrateSession } from '../../NajmAuthClient-BBl-GRis.js';
4
- import { e as AuthState, A as AuthUser, c as LoginResult, h as AuthError, L as LoginCredentials, d as OAuthLoginOptions, f as AuthEvent, i as AuthEventMap } from '../../types-BaSfgxqE.js';
3
+ import { N as NajmAuthClient, H as HydrateSession } from '../../NajmAuthClient-BygfBCpF.js';
4
+ import { e as AuthState, A as AuthUser, c as LoginResult, h as AuthError, L as LoginCredentials, d as OAuthLoginOptions, f as AuthEvent, i as AuthEventMap } from '../../types-CI2t8wpJ.js';
5
5
 
6
6
  interface AuthProviderProps {
7
7
  client: NajmAuthClient;
@@ -177,6 +177,17 @@ interface UseGoogleLoginReturn {
177
177
  }
178
178
  declare function useGoogleLogin(options?: UseGoogleLoginOptions): UseGoogleLoginReturn;
179
179
 
180
+ interface UseGitHubLoginOptions {
181
+ onError?: (error: AuthError | Error) => void;
182
+ }
183
+ interface UseGitHubLoginReturn {
184
+ loginWithGitHub: (options?: OAuthLoginOptions) => void;
185
+ linkGitHub: (options?: OAuthLoginOptions) => Promise<void>;
186
+ isRedirecting: boolean;
187
+ error: AuthError | Error | null;
188
+ }
189
+ declare function useGitHubLogin(options?: UseGitHubLoginOptions): UseGitHubLoginReturn;
190
+
180
191
  interface UseOAuthCallbackOptions {
181
192
  onSuccess?: (user: AuthUser) => void;
182
193
  onError?: (error: AuthError | Error) => void;
@@ -571,6 +582,15 @@ declare function GoogleLoginButton({ children, returnTo, onError }: GoogleLoginB
571
582
  disabled?: boolean;
572
583
  }, string | react.JSXElementConstructor<any>>;
573
584
 
585
+ interface GitHubLoginButtonProps extends OAuthLoginOptions {
586
+ children: ReactNode;
587
+ onError?: (error: Error) => void;
588
+ }
589
+ declare function GitHubLoginButton({ children, returnTo, onError }: GitHubLoginButtonProps): ReactElement<{
590
+ onClick?: (event: unknown) => void;
591
+ disabled?: boolean;
592
+ }, string | react.JSXElementConstructor<any>>;
593
+
574
594
  interface OAuthCallbackProps {
575
595
  fallback?: ReactNode;
576
596
  errorFallback?: ReactNode | ((props: {
@@ -580,4 +600,4 @@ interface OAuthCallbackProps {
580
600
  }
581
601
  declare function OAuthCallback({ fallback, errorFallback, defaultRedirect, }: OAuthCallbackProps): react.JSX.Element;
582
602
 
583
- export { AuthBoundary, type AuthEventEntry, AuthGate, AuthLoading, AuthProvider, Can, GoogleLoginButton, IfAuth, LoginButton, OAuthCallback, PermissionList, Protected, RedirectToLogin, Role, type SessionStatus, SignOutButton, SignedIn, SignedOut, UserAvatar, UserEmail, UserName, UserRole, useAuth, useAuthClient, useAuthEvent, useAuthEvents, useChangePassword, useForgotPassword, useGoogleLogin, useLogin, useLogout, useOAuthCallback, usePermissions, useRegister, useResetPassword, useSession, useUser };
603
+ export { AuthBoundary, type AuthEventEntry, AuthGate, AuthLoading, AuthProvider, Can, GitHubLoginButton, GoogleLoginButton, IfAuth, LoginButton, OAuthCallback, PermissionList, Protected, RedirectToLogin, Role, type SessionStatus, SignOutButton, SignedIn, SignedOut, UserAvatar, UserEmail, UserName, UserRole, useAuth, useAuthClient, useAuthEvent, useAuthEvents, useChangePassword, useForgotPassword, useGitHubLogin, useGoogleLogin, useLogin, useLogout, useOAuthCallback, usePermissions, useRegister, useResetPassword, useSession, useUser };
@@ -354,14 +354,48 @@ function useGoogleLogin(options) {
354
354
  }
355
355
  __name(useGoogleLogin, "useGoogleLogin");
356
356
 
357
+ // src/client/react/useGitHubLogin.ts
358
+ import { useCallback as useCallback8, useState as useState10 } from "react";
359
+ function useGitHubLogin(options) {
360
+ const client = useAuthClient();
361
+ const [isRedirecting, setIsRedirecting] = useState10(false);
362
+ const [error, setError] = useState10(null);
363
+ const fail = useCallback8((value) => {
364
+ const next = value instanceof Error ? value : new Error(String(value));
365
+ setIsRedirecting(false);
366
+ setError(next);
367
+ options?.onError?.(next);
368
+ }, [options?.onError]);
369
+ const loginWithGitHub = useCallback8((loginOptions) => {
370
+ setError(null);
371
+ setIsRedirecting(true);
372
+ try {
373
+ client.loginWithGitHub(loginOptions);
374
+ } catch (value) {
375
+ fail(value);
376
+ }
377
+ }, [client, fail]);
378
+ const linkGitHub = useCallback8(async (loginOptions) => {
379
+ setError(null);
380
+ setIsRedirecting(true);
381
+ try {
382
+ await client.linkOAuthAccount("github", loginOptions);
383
+ } catch (value) {
384
+ fail(value);
385
+ }
386
+ }, [client, fail]);
387
+ return { loginWithGitHub, linkGitHub, isRedirecting, error };
388
+ }
389
+ __name(useGitHubLogin, "useGitHubLogin");
390
+
357
391
  // src/client/react/useOAuthCallback.ts
358
- import { useCallback as useCallback8, useRef as useRef3, useState as useState10 } from "react";
392
+ import { useCallback as useCallback9, useRef as useRef3, useState as useState11 } from "react";
359
393
  function useOAuthCallback(options) {
360
394
  const client = useAuthClient();
361
395
  const promise = useRef3(null);
362
- const [isLoading, setIsLoading] = useState10(false);
363
- const [error, setError] = useState10(null);
364
- const complete = useCallback8(async () => {
396
+ const [isLoading, setIsLoading] = useState11(false);
397
+ const [error, setError] = useState11(null);
398
+ const complete = useCallback9(async () => {
365
399
  if (promise.current) return promise.current;
366
400
  setIsLoading(true);
367
401
  setError(null);
@@ -398,7 +432,7 @@ function useAuthEvent(event, handler) {
398
432
  __name(useAuthEvent, "useAuthEvent");
399
433
 
400
434
  // src/client/react/useAuthEvents.ts
401
- import { useEffect as useEffect4, useRef as useRef5, useState as useState11 } from "react";
435
+ import { useEffect as useEffect4, useRef as useRef5, useState as useState12 } from "react";
402
436
  var ALL_EVENTS = [
403
437
  "login",
404
438
  "logout",
@@ -411,7 +445,7 @@ var ALL_EVENTS = [
411
445
  function useAuthEvents(opts) {
412
446
  const client = useAuthClient();
413
447
  const max = opts?.maxEntries ?? 100;
414
- const [entries, setEntries] = useState11([]);
448
+ const [entries, setEntries] = useState12([]);
415
449
  const maxRef = useRef5(max);
416
450
  maxRef.current = max;
417
451
  useEffect4(() => {
@@ -714,6 +748,27 @@ function GoogleLoginButton({ children, returnTo, onError }) {
714
748
  }
715
749
  __name(GoogleLoginButton, "GoogleLoginButton");
716
750
 
751
+ // src/client/react/GitHubLoginButton.tsx
752
+ import { Children as Children4, cloneElement as cloneElement4, isValidElement as isValidElement4 } from "react";
753
+ function GitHubLoginButton({ children, returnTo, onError }) {
754
+ const { loginWithGitHub, isRedirecting } = useGitHubLogin({ onError });
755
+ const child = Children4.only(children);
756
+ if (!isValidElement4(child)) {
757
+ throw new Error("<GitHubLoginButton> expects a single React element as its child");
758
+ }
759
+ const element = child;
760
+ return cloneElement4(element, {
761
+ onClick: /* @__PURE__ */ __name((event) => {
762
+ element.props.onClick?.(event);
763
+ if (!event?.defaultPrevented) {
764
+ loginWithGitHub({ returnTo });
765
+ }
766
+ }, "onClick"),
767
+ disabled: Boolean(element.props.disabled || isRedirecting)
768
+ });
769
+ }
770
+ __name(GitHubLoginButton, "GitHubLoginButton");
771
+
717
772
  // src/client/react/OAuthCallback.tsx
718
773
  import { useEffect as useEffect7 } from "react";
719
774
  import { Fragment as Fragment14, jsx as jsx16 } from "react/jsx-runtime";
@@ -755,6 +810,7 @@ export {
755
810
  AuthLoading,
756
811
  AuthProvider,
757
812
  Can,
813
+ GitHubLoginButton,
758
814
  GoogleLoginButton,
759
815
  IfAuth,
760
816
  LoginButton,
@@ -776,6 +832,7 @@ export {
776
832
  useAuthEvents,
777
833
  useChangePassword,
778
834
  useForgotPassword,
835
+ useGitHubLogin,
779
836
  useGoogleLogin,
780
837
  useLogin,
781
838
  useLogout,
@@ -1,9 +1,9 @@
1
- import { A as AuthUser, R as RetryConfig } from '../../types-BaSfgxqE.js';
2
- import { F as FetchClient, N as NajmAuthClient } from '../../NajmAuthClient-BBl-GRis.js';
3
- import { ProxySessionMode } from '../edge.js';
1
+ import { A as AuthUser, R as RetryConfig } from '../../types-CI2t8wpJ.js';
2
+ import { F as FetchClient, N as NajmAuthClient } from '../../NajmAuthClient-BygfBCpF.js';
3
+ import { AuthProxyOptions, ProxySessionMode } from '../edge.js';
4
4
  export { withAuthMiddleware } from '../edge.js';
5
- import { G as GetSessionConfig, S as ServerSession$1 } from '../../getSession-BthP85UA.js';
6
- export { A as AuthConfigError, a as AuthTransportError, N as NoSessionError, g as getSession } from '../../getSession-BthP85UA.js';
5
+ import { G as GetSessionConfig, S as ServerSession$1 } from '../../getSession-DBX8XpsR.js';
6
+ export { A as AuthConfigError, a as AuthTransportError, N as NoSessionError, g as getSession } from '../../getSession-DBX8XpsR.js';
7
7
  import { S as SessionRecoveryFailure } from '../../sessionRecovery-D5Fa0yZ1.js';
8
8
  export { a as SessionRecoveryErrorDetails, b as SessionRecoveryFailureReason } from '../../sessionRecovery-D5Fa0yZ1.js';
9
9
  import 'next/server';
@@ -244,9 +244,9 @@ interface AuthKit {
244
244
  */
245
245
  requireRole: (roles: string[]) => Promise<ServerSession$1>;
246
246
  /** Generated Next.js 16 Proxy function. */
247
- proxy: (request: Request) => Promise<Response>;
247
+ proxy: (request: Request, options?: AuthProxyOptions) => Promise<Response>;
248
248
  /** @deprecated Next.js 16 renamed Middleware to Proxy. Use `proxy`. */
249
- middleware: (request: Request) => Promise<Response>;
249
+ middleware: (request: Request, options?: AuthProxyOptions) => Promise<Response>;
250
250
  /** @deprecated Next.js 16 requires a static config literal in `proxy.ts`. */
251
251
  config: {
252
252
  matcher: string[];
@@ -298,4 +298,4 @@ interface SafeRedirectOptions {
298
298
  */
299
299
  declare function getSafeRedirectPath(value: string | string[] | undefined | null, options?: SafeRedirectOptions | string): string;
300
300
 
301
- export { type AuthCookiePersistenceOptions, type AuthKit, type AuthRouteHandler, type DefineAuthConfig, GetSessionConfig, type NextAuthRouteHandlers, ProxySessionMode, type SafeRedirectOptions, ServerSession$1 as ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getSafeRedirectPath, getServerSession, makeSessionCookie, withAuth, withAuthCookiePersistence };
301
+ export { type AuthCookiePersistenceOptions, type AuthKit, AuthProxyOptions, type AuthRouteHandler, type DefineAuthConfig, GetSessionConfig, type NextAuthRouteHandlers, ProxySessionMode, type SafeRedirectOptions, ServerSession$1 as ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getSafeRedirectPath, getServerSession, makeSessionCookie, withAuth, withAuthCookiePersistence };
@@ -22,6 +22,7 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
22
22
  if (!isRecord(data) || !isValidUser(data.user)) return null;
23
23
  if (!isStringArray(data.roles) || !isStringArray(data.permissions)) return null;
24
24
  if (!Number.isInteger(data.sessionVersion) || data.sessionVersion < 0) return null;
25
+ if (typeof data.tokenFamily !== "string" || !data.tokenFamily) return null;
25
26
  if (!Number.isFinite(data.iat) || !Number.isInteger(data.iat) || data.iat <= 0) return null;
26
27
  const issuedAt = data.iat;
27
28
  if (issuedAt > now + MAX_CLOCK_SKEW_MS) return null;
@@ -31,6 +32,7 @@ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_
31
32
  roles: [...data.roles],
32
33
  permissions: [...data.permissions],
33
34
  sessionVersion: data.sessionVersion,
35
+ tokenFamily: data.tokenFamily,
34
36
  iat: issuedAt
35
37
  };
36
38
  } catch {
@@ -764,8 +766,25 @@ function withAuthMiddleware(config) {
764
766
  } = config;
765
767
  const verifyAlways = proxySessionMode === void 0 ? legacyVerifyAlways : proxySessionMode === "authoritative";
766
768
  const resolvedInternalRecoveryURL = resolveInternalRecoveryURL(internalRecoveryURL);
767
- return /* @__PURE__ */ __name(async function middleware(request) {
769
+ return /* @__PURE__ */ __name(async function middleware(request, options = {}) {
768
770
  const { NextResponse } = await import("next/server");
771
+ const downstreamHeaders = new Headers(request.headers);
772
+ if (options.requestHeaders) {
773
+ const overrides = new Headers(options.requestHeaders);
774
+ for (const identityHeader of ["authorization", "cookie"]) {
775
+ if (overrides.has(identityHeader)) {
776
+ throw new TypeError(
777
+ `Auth proxy requestHeaders cannot override ${identityHeader}`
778
+ );
779
+ }
780
+ }
781
+ overrides.forEach((value, key) => {
782
+ downstreamHeaders.set(key, value);
783
+ });
784
+ }
785
+ const continueRequest = /* @__PURE__ */ __name(() => NextResponse.next({
786
+ request: { headers: downstreamHeaders }
787
+ }), "continueRequest");
769
788
  const redirectToLogin = /* @__PURE__ */ __name((returnPath2, clearCookies) => {
770
789
  const loginUrl = new URL(loginRoute, request.url);
771
790
  loginUrl.searchParams.set("from", returnPath2);
@@ -782,10 +801,10 @@ function withAuthMiddleware(config) {
782
801
  const pathname = url.pathname;
783
802
  const returnPath = `${url.pathname}${url.search}`;
784
803
  if (matchesAny(pathname, publicRoutes)) {
785
- return NextResponse.next();
804
+ return continueRequest();
786
805
  }
787
806
  const isProtected = protectedRoutes.length === 0 || matchesAny(pathname, protectedRoutes);
788
- if (!isProtected) return NextResponse.next();
807
+ if (!isProtected) return continueRequest();
789
808
  const cookie = request.headers.get("cookie") ?? "";
790
809
  const sessionCookie = readCookieValue(cookie, sessionCookieName);
791
810
  const secret = resolveSessionSecret(sessionSecret);
@@ -831,16 +850,15 @@ function withAuthMiddleware(config) {
831
850
  return forbidden;
832
851
  }
833
852
  if (recovery?.status === "recovered") {
834
- const requestHeaders = new Headers(request.headers);
835
- requestHeaders.set(
853
+ downstreamHeaders.set(
836
854
  "cookie",
837
855
  replaceCookieValue(cookie, sessionCookieName, recovery.sessionCookieValue)
838
856
  );
839
- const response = NextResponse.next({ request: { headers: requestHeaders } });
857
+ const response = continueRequest();
840
858
  response.headers.append("Set-Cookie", recovery.setCookie);
841
859
  return response;
842
860
  }
843
- return NextResponse.next();
861
+ return continueRequest();
844
862
  }, "middleware");
845
863
  }
846
864
  __name(withAuthMiddleware, "withAuthMiddleware");
@@ -1045,6 +1063,9 @@ var NajmAuthClient = class _NajmAuthClient {
1045
1063
  loginWithGoogle(options) {
1046
1064
  this.loginWithOAuth("google", options);
1047
1065
  }
1066
+ loginWithGitHub(options) {
1067
+ this.loginWithOAuth("github", options);
1068
+ }
1048
1069
  async linkOAuthAccount(provider, options = {}) {
1049
1070
  const query = options.returnTo ? `?${new URLSearchParams({ returnTo: this.validateReturnTo(options.returnTo) })}` : "";
1050
1071
  const res = await this.api.post(
@@ -1,5 +1,5 @@
1
- import { S as ServerSession } from '../../getSession-BthP85UA.js';
2
- import '../../types-BaSfgxqE.js';
1
+ import { S as ServerSession } from '../../getSession-DBX8XpsR.js';
2
+ import '../../types-CI2t8wpJ.js';
3
3
  import '../../sessionRecovery-D5Fa0yZ1.js';
4
4
 
5
5
  /**