najm-auth 3.0.0 → 3.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 CHANGED
@@ -198,13 +198,13 @@ All routes are prefixed with `/auth` and auto-registered by the plugin.
198
198
 
199
199
  ### Identity presets
200
200
 
201
- Login lookup, lockout accounting, and rate-limit bucketing all normalize the
202
- submitted identifier the same way. The pipeline is: email (lowercased) →
203
- project extensions → the country preset → generic E.164.
204
-
205
- The resolved pipeline belongs to the specific `auth()` plugin/server instance.
206
- Multiple isolated Najm servers can therefore use different country presets in
207
- one process without replacing each other's login or rate-limit behavior.
201
+ Login lookup, lockout accounting, and rate-limit bucketing all normalize the
202
+ submitted identifier the same way. The pipeline is: email (lowercased) →
203
+ project extensions → the country preset → generic E.164.
204
+
205
+ The resolved pipeline belongs to the specific `auth()` plugin/server instance.
206
+ Multiple isolated Najm servers can therefore use different country presets in
207
+ one process without replacing each other's login or rate-limit behavior.
208
208
 
209
209
  Morocco is the default, so `0612345678`, `212612345678`, and `+212612345678`
210
210
  all resolve to `+212612345678` with no configuration.
@@ -249,10 +249,10 @@ await authService.provisionUser({
249
249
  });
250
250
  ```
251
251
 
252
- Supplying both `password` and `temporaryCredential` is rejected, so an account
253
- can never hold a permanent password that something also treats as temporary.
254
- Typed helpers such as `moroccanCinTemporaryCredential()` validate their value,
255
- and every temporary credential remains limited to bcrypt's 72-byte boundary.
252
+ Supplying both `password` and `temporaryCredential` is rejected, so an account
253
+ can never hold a permanent password that something also treats as temporary.
254
+ Typed helpers such as `moroccanCinTemporaryCredential()` validate their value,
255
+ and every temporary credential remains limited to bcrypt's 72-byte boundary.
256
256
 
257
257
  Login then answers a discriminated result instead of a token pair:
258
258
 
@@ -743,6 +743,95 @@ auth({
743
743
 
744
744
  ---
745
745
 
746
+ ## Next.js App Router Structure
747
+
748
+ Every App Router application keeps the same three files. Copying more than this
749
+ between apps means logic that belongs in the package has leaked into them.
750
+
751
+ ```text
752
+ src/lib/auth.ts defineAuth() configuration — browser, server, and proxy safe
753
+ src/lib/session.ts one createReactServerAuth() instance for Server Components
754
+ src/proxy.ts imports auth.ts only, and exports auth.middleware
755
+ ```
756
+
757
+ ```typescript
758
+ // src/lib/auth.ts
759
+ import { defineAuth } from 'najm-auth/client/server';
760
+
761
+ export const auth = defineAuth({
762
+ apiBaseURL: '/api',
763
+ loginRoute: '/login',
764
+ forbiddenRoute: '/forbidden',
765
+ publicRoutes: ['/', '/login'],
766
+ protectedRoutes: ['/dashboard/:path*', '/admin/:path*'],
767
+ roleRoutes: { '/admin/:path*': ['admin'] },
768
+ });
769
+ ```
770
+
771
+ ```typescript
772
+ // src/lib/session.ts
773
+ import 'server-only';
774
+
775
+ import { createReactServerAuth } from 'najm-auth/client/server/react';
776
+
777
+ import { auth } from './auth';
778
+
779
+ export const serverAuth = createReactServerAuth(auth);
780
+ ```
781
+
782
+ ```typescript
783
+ // src/proxy.ts
784
+ import { auth } from './lib/auth';
785
+
786
+ export default auth.middleware;
787
+ export const config = auth.config;
788
+ ```
789
+
790
+ ### Why `session.ts` exists
791
+
792
+ A Next.js page is not one function. The root layout, each nested layout, and the
793
+ page render separately, and each one that asks for the session pays for its own
794
+ cookie verification and possibly its own recovery round trip. React's `cache()`
795
+ collapses those into one — but only for callers that go through the *same*
796
+ memoized function, which means the application has to own one module that
797
+ creates it. `session.ts` is that module and nothing else; strictness, redirect
798
+ targets, role fallback, and error classification all stay in the package.
799
+
800
+ ```tsx
801
+ // Root layout, nested layout, and page: one resolution between them.
802
+ const session = await serverAuth.getSession(); // null when anonymous
803
+ const session = await serverAuth.requireSession(); // redirects to loginRoute
804
+ const session = await serverAuth.requireRole(['admin', 'operator']);
805
+ ```
806
+
807
+ - `requireSession()` redirects to `loginRoute` when the visitor is missing,
808
+ invalid, or revoked. An unreachable recovery endpoint or an unset session
809
+ secret is an operational fault, not an anonymous visitor: those stay visible
810
+ errors instead of becoming a login redirect that hides the outage.
811
+ - `requireRole()` redirects to `forbiddenRoute`, never to login — the visitor is
812
+ already authenticated, so signing in again cannot change the answer.
813
+ - `session.roles` is authoritative when present, with `user.role` as the
814
+ single-role fallback.
815
+
816
+ ### Scope and limits
817
+
818
+ - **React Server Components only.** Route handlers, server actions, proxy/Edge
819
+ code, and scripts keep using `auth.getSession()`, `auth.requireSession()`, and
820
+ `auth.requireRole()`. Outside a render there is no request cache for `cache()`
821
+ to write to, so the adapter would resolve the session again on every call.
822
+ - **Call the factory once, at module scope.** Calling it inside a layout, page,
823
+ or component builds a fresh memoized resolver per call and shares nothing.
824
+ - **The snapshot is stable for one render.** Code that mutates authentication
825
+ must redirect or refresh into a new render to observe the result.
826
+ - **Requests never share.** The cache is React's per-request cache — no module
827
+ map, no global, no Redis, no `unstable_cache`, no `"use cache"`.
828
+ - **Requires React 18.3 or newer** (the first version exporting `cache()`); the
829
+ factory throws a named error on older versions. The subpath is opt-in, so
830
+ non-React consumers of `najm-auth` are unaffected. Importing it from a Client
831
+ Component or the Edge runtime fails at build time.
832
+
833
+ ---
834
+
746
835
  ## TypeScript Types
747
836
 
748
837
  ```typescript
@@ -0,0 +1,120 @@
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';
2
+
3
+ interface FetchClientConfig {
4
+ baseURL: string;
5
+ credentials?: RequestCredentials;
6
+ timeout?: number;
7
+ getToken?: () => string | null;
8
+ onUnauthorized?: () => Promise<string | null>;
9
+ retry?: RetryConfig;
10
+ defaultHeaders?: Record<string, string>;
11
+ }
12
+ declare class FetchClient {
13
+ private config;
14
+ constructor(config: FetchClientConfig);
15
+ get<T>(path: string, opts?: RequestOptions): Promise<T>;
16
+ post<T>(path: string, opts?: RequestOptions): Promise<T>;
17
+ put<T>(path: string, opts?: RequestOptions): Promise<T>;
18
+ patch<T>(path: string, opts?: RequestOptions): Promise<T>;
19
+ delete<T>(path: string, opts?: RequestOptions): Promise<T>;
20
+ private request;
21
+ private execute;
22
+ private doFetch;
23
+ private parseBody;
24
+ }
25
+
26
+ interface HydrateSession {
27
+ user: AuthUser | null;
28
+ accessToken?: string | null;
29
+ roles?: string[];
30
+ permissions?: string[];
31
+ }
32
+ declare class NajmAuthClient {
33
+ private config;
34
+ private static readonly MAX_REFRESH_FAILURES;
35
+ private static readonly CIRCUIT_RESET_MS;
36
+ private state;
37
+ private refreshTimer;
38
+ private refreshCircuitTimer;
39
+ private refreshPromise;
40
+ private fetchUserPromise;
41
+ private refreshFailures;
42
+ private _hydrated;
43
+ private listeners;
44
+ private eventListeners;
45
+ private tabSync;
46
+ api: FetchClient;
47
+ private readonly prefix;
48
+ private readonly threshold;
49
+ constructor(config: AuthClientConfig);
50
+ login(credentials: LoginCredentials): Promise<LoginResult>;
51
+ register(data: Record<string, unknown>): Promise<AuthUser>;
52
+ getOAuthLoginUrl(provider: OAuthProvider, options?: OAuthLoginOptions): string;
53
+ loginWithOAuth(provider: OAuthProvider, options?: OAuthLoginOptions): void;
54
+ loginWithGoogle(options?: OAuthLoginOptions): void;
55
+ linkOAuthAccount(provider: OAuthProvider, options?: OAuthLoginOptions): Promise<void>;
56
+ completeOAuthLogin(): Promise<AuthUser>;
57
+ logout(): Promise<void>;
58
+ refresh(): Promise<void>;
59
+ fetchUser(): Promise<AuthUser | null>;
60
+ private _doFetchUser;
61
+ forgotPassword(data: {
62
+ email: string;
63
+ }): Promise<void>;
64
+ changePassword(data: {
65
+ currentPassword: string;
66
+ newPassword: string;
67
+ }): Promise<void>;
68
+ resetPassword(data: {
69
+ token: string;
70
+ newPassword: string;
71
+ }): Promise<void>;
72
+ can(permission: string): boolean;
73
+ hasRole(role: string): boolean;
74
+ hasAnyRole(roles: string[]): boolean;
75
+ hasPermission(permission: string): boolean;
76
+ getUser(): AuthUser | null;
77
+ getAccessToken(): string | null;
78
+ isAuthenticated(): boolean;
79
+ getState(): AuthState;
80
+ /**
81
+ * Hydrate the client with a session resolved server-side.
82
+ * Use to skip the initial loading flicker on SSR-rendered pages.
83
+ * Safe to call multiple times — subsequent calls are no-ops.
84
+ */
85
+ hydrate(session: HydrateSession | null): void;
86
+ isHydrated(): boolean;
87
+ /**
88
+ * A fresh client with the same config — unhydrated, and without tab sync.
89
+ *
90
+ * Server rendering needs one client per request. A single process serves
91
+ * every user, so the hydration latch on a shared client would otherwise pin
92
+ * every later render to the first request's session.
93
+ */
94
+ fork(): NajmAuthClient;
95
+ on<K extends AuthEvent>(event: K, handler: AuthEventHandler<K>): void;
96
+ off<K extends AuthEvent>(event: K, handler: AuthEventHandler<K>): void;
97
+ subscribe(listener: (state: AuthState) => void): () => void;
98
+ destroy(): void;
99
+ private _refreshWithCircuit;
100
+ private _doRefresh;
101
+ private handleUnauthorized;
102
+ private applyTokens;
103
+ private scheduleRefresh;
104
+ private clearRefreshTimer;
105
+ private clearRefreshCircuitTimer;
106
+ private resetRefreshFailures;
107
+ private registerRefreshFailure;
108
+ private resetState;
109
+ private getSyncPayload;
110
+ private handleTabMessage;
111
+ private notify;
112
+ private emit;
113
+ private validateReturnTo;
114
+ }
115
+ /**
116
+ * Factory function to create an auth client.
117
+ */
118
+ declare function createAuthClient(config: AuthClientConfig): NajmAuthClient;
119
+
120
+ export { FetchClient as F, type HydrateSession as H, NajmAuthClient as N, createAuthClient as c };
@@ -1,21 +1,6 @@
1
1
  import * as next_server from 'next/server';
2
-
3
- type SessionRecoveryFailureReason = 'invalid-cookie-name' | 'invalid-refresh-cookie' | 'invalid-endpoint' | 'fetch-error' | 'http-status' | 'missing-set-cookie' | 'session-cookie-parse' | 'session-cookie-hmac' | 'session-cookie-payload';
4
- interface SessionRecoveryErrorDetails {
5
- name: string;
6
- message: string;
7
- code?: string;
8
- cause?: {
9
- name: string;
10
- message: string;
11
- code?: string;
12
- };
13
- }
14
- interface SessionRecoveryFailure {
15
- reason: SessionRecoveryFailureReason;
16
- httpStatus?: number;
17
- error?: SessionRecoveryErrorDetails;
18
- }
2
+ import { S as SessionRecoveryFailure } from '../sessionRecovery-D5Fa0yZ1.js';
3
+ export { a as SessionRecoveryErrorDetails, b as SessionRecoveryFailureReason } from '../sessionRecovery-D5Fa0yZ1.js';
19
4
 
20
5
  interface AuthMiddlewareConfig {
21
6
  /** Routes that require authentication (glob patterns) */
@@ -82,4 +67,4 @@ interface AuthMiddlewareConfig {
82
67
  */
83
68
  declare function withAuthMiddleware(config: AuthMiddlewareConfig): (request: Request) => Promise<next_server.NextResponse<unknown>>;
84
69
 
85
- export { type AuthMiddlewareConfig, type SessionRecoveryErrorDetails, type SessionRecoveryFailure, type SessionRecoveryFailureReason, withAuthMiddleware };
70
+ export { type AuthMiddlewareConfig, SessionRecoveryFailure, withAuthMiddleware };
@@ -1,5 +1,6 @@
1
- import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../NajmAuthClient-D2fSvQ_H.js';
2
- export { A as AuthClientConfig, a as AuthError, b as AuthEvent, c as AuthEventHandler, d as AuthEventMap, e as AuthState, f as AuthUser, g as AuthenticatedLogin, C as CredentialSetupPending, F as FetchClient, H as HydrateSession, L as LoginCredentials, h as LoginResult, N as NajmAuthClient, O as OAuthLoginOptions, i as OAuthProvider, R as RequestOptions, j as RetryConfig, k as ServerResponse, l as TokenPair, m as createAuthClient } from '../NajmAuthClient-D2fSvQ_H.js';
1
+ export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-DqGucYXi.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';
3
4
 
4
5
  /**
5
6
  * Decode a JWT token payload without verification.
@@ -1,7 +1,8 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as react from 'react';
3
3
  import { ReactNode, CSSProperties, ReactElement } from 'react';
4
- import { N as NajmAuthClient, H as HydrateSession, e as AuthState, f as AuthUser, h as LoginResult, a as AuthError, L as LoginCredentials, O as OAuthLoginOptions, b as AuthEvent, d as AuthEventMap } from '../../NajmAuthClient-D2fSvQ_H.js';
4
+ import { N as NajmAuthClient, H as HydrateSession } from '../../NajmAuthClient-DqGucYXi.js';
5
+ 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';
5
6
 
6
7
  interface AuthProviderProps {
7
8
  client: NajmAuthClient;
@@ -1,6 +1,10 @@
1
- import { f as AuthUser, F as FetchClient, N as NajmAuthClient, j as RetryConfig } from '../../NajmAuthClient-D2fSvQ_H.js';
2
- import { SessionRecoveryFailure } from '../edge.js';
3
- export { SessionRecoveryErrorDetails, SessionRecoveryFailureReason, withAuthMiddleware } from '../edge.js';
1
+ import { A as AuthUser, R as RetryConfig } from '../../types-BaSfgxqE.js';
2
+ import { F as FetchClient, N as NajmAuthClient } from '../../NajmAuthClient-DqGucYXi.js';
3
+ export { withAuthMiddleware } from '../edge.js';
4
+ import { G as GetSessionConfig, S as ServerSession$1 } from '../../getSession-BthP85UA.js';
5
+ export { A as AuthConfigError, a as AuthTransportError, N as NoSessionError, g as getSession } from '../../getSession-BthP85UA.js';
6
+ import { S as SessionRecoveryFailure } from '../../sessionRecovery-D5Fa0yZ1.js';
7
+ export { a as SessionRecoveryErrorDetails, b as SessionRecoveryFailureReason } from '../../sessionRecovery-D5Fa0yZ1.js';
4
8
  import 'next/server';
5
9
 
6
10
  interface GetServerSessionOptions {
@@ -11,7 +15,7 @@ interface GetServerSessionOptions {
11
15
  /** Additional headers to forward (e.g., Authorization) */
12
16
  headers?: Record<string, string>;
13
17
  }
14
- interface ServerSession$1 {
18
+ interface ServerSession {
15
19
  user: AuthUser;
16
20
  }
17
21
  /**
@@ -35,7 +39,7 @@ interface ServerSession$1 {
35
39
  * }
36
40
  * ```
37
41
  */
38
- declare function getServerSession(opts: GetServerSessionOptions): Promise<ServerSession$1 | null>;
42
+ declare function getServerSession(opts: GetServerSessionOptions): Promise<ServerSession | null>;
39
43
 
40
44
  interface ServerClientConfig {
41
45
  /** API base URL (e.g., 'http://localhost:3000/api') */
@@ -65,70 +69,6 @@ interface ServerClientConfig {
65
69
  */
66
70
  declare function createServerClient(config: ServerClientConfig): FetchClient;
67
71
 
68
- interface ServerSession {
69
- user: AuthUser;
70
- roles?: string[];
71
- permissions?: string[];
72
- }
73
- interface GetSessionConfig {
74
- /**
75
- * Base URL for auth endpoints.
76
- * Defaults to `NEXT_PUBLIC_API_URL` or the same-origin `/api` path.
77
- */
78
- baseURL?: string;
79
- /** Auth route prefix appended to baseURL (default: '/auth'). */
80
- authPrefix?: string;
81
- /** Refresh token cookie name (default: 'refreshToken'). */
82
- cookieName?: string;
83
- /** Signed session cookie name (default: 'najm.session'). */
84
- sessionCookieName?: string;
85
- /**
86
- * Secret used to verify the session cookie HMAC signature.
87
- * Falls back to NAJM_SESSION_SECRET or JWT_ACCESS_SECRET env vars.
88
- */
89
- sessionSecret?: string;
90
- /**
91
- * Maximum accepted session-cookie age in seconds.
92
- * Must match the auth plugin's `session.maxAge`. Default: 300.
93
- */
94
- sessionMaxAge?: number;
95
- /**
96
- * Session-recovery endpoint. Defaults to
97
- * `${baseURL}${authPrefix}/session/recover`. Set to false to disable fallback.
98
- */
99
- recoveryURL?: string | false;
100
- /** Loopback-only recovery endpoint for self-hosted reverse-proxy setups. */
101
- internalRecoveryURL?: string;
102
- /**
103
- * Error handling mode:
104
- * - 'nullable' (default): returns null on any failure
105
- * - 'strict': throws typed errors for debugging
106
- */
107
- mode?: 'nullable' | 'strict';
108
- /** Secret-free diagnostic hook for failed recovery attempts. */
109
- onRecoveryFailure?: (failure: SessionRecoveryFailure) => void;
110
- }
111
- declare class NoSessionError extends Error {
112
- readonly code = "NO_SESSION";
113
- constructor(message?: string);
114
- }
115
- declare class AuthConfigError extends Error {
116
- readonly code = "AUTH_CONFIG_ERROR";
117
- constructor(message: string);
118
- }
119
- declare class AuthTransportError extends Error {
120
- readonly status?: number;
121
- readonly code = "AUTH_TRANSPORT_ERROR";
122
- constructor(message: string, status?: number);
123
- }
124
- /**
125
- * Resolve a session in a Next.js Server Component, Route Handler, or Server
126
- * Action. Recovery returns claims for the current render but cannot persist
127
- * response cookies during Server Component rendering; middleware performs that
128
- * persistence for protected navigation.
129
- */
130
- declare function getSession(config?: GetSessionConfig): Promise<ServerSession | null>;
131
-
132
72
  interface WithAuthOptions extends GetSessionConfig {
133
73
  /** Where to redirect unauthenticated requests (default: '/login') */
134
74
  redirectTo?: string;
@@ -138,7 +78,7 @@ interface WithAuthOptions extends GetSessionConfig {
138
78
  permission?: string;
139
79
  }
140
80
  interface WithAuthProps<P> {
141
- session: ServerSession;
81
+ session: ServerSession$1;
142
82
  props: P;
143
83
  }
144
84
  /**
@@ -219,9 +159,9 @@ interface AuthKit {
219
159
  /** Shortcut for `client.api` — the underlying FetchClient with auth attached. */
220
160
  readonly api: FetchClient;
221
161
  /** Resolve session — signed-cookie first, then non-rotating recovery. */
222
- getSession: (opts?: Pick<GetSessionConfig, 'mode'>) => Promise<ServerSession | null>;
162
+ getSession: (opts?: Pick<GetSessionConfig, 'mode'>) => Promise<ServerSession$1 | null>;
223
163
  /** Require session — throws if unauthenticated */
224
- requireSession: () => Promise<ServerSession>;
164
+ requireSession: () => Promise<ServerSession$1>;
225
165
  /**
226
166
  * Require one of `roles` — redirects to `loginRoute` when unauthenticated and
227
167
  * to `forbiddenRoute` when authenticated as the wrong role.
@@ -230,7 +170,7 @@ interface AuthKit {
230
170
  * const session = await auth.requireRole(['admin', 'operator']);
231
171
  * ```
232
172
  */
233
- requireRole: (roles: string[]) => Promise<ServerSession>;
173
+ requireRole: (roles: string[]) => Promise<ServerSession$1>;
234
174
  /** Generated Next.js middleware function */
235
175
  middleware: (request: Request) => Promise<Response>;
236
176
  /** Next.js middleware config with matcher */
@@ -242,7 +182,7 @@ interface AuthKit {
242
182
  * Passes session to the wrapped component.
243
183
  */
244
184
  protect: <P extends Record<string, unknown> = Record<string, unknown>>(Page: (args: {
245
- session: ServerSession;
185
+ session: ServerSession$1;
246
186
  children?: unknown;
247
187
  } & P) => Promise<unknown> | unknown, options?: {
248
188
  role?: string;
@@ -332,4 +272,4 @@ declare function makeSessionCookie(setCookie: string, authCookieNames?: string[]
332
272
  */
333
273
  declare function withAuthCookiePersistence(handler: RequestHandler, options?: AuthCookiePersistenceOptions): RequestHandler;
334
274
 
335
- export { AuthConfigError, type AuthCookiePersistenceOptions, type AuthKit, AuthTransportError, type DefineAuthConfig, type GetSessionConfig, NoSessionError, type SafeRedirectOptions, type ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getSafeRedirectPath, getServerSession, getSession, makeSessionCookie, withAuth, withAuthCookiePersistence };
275
+ export { type AuthCookiePersistenceOptions, type AuthKit, type DefineAuthConfig, GetSessionConfig, type SafeRedirectOptions, ServerSession$1 as ServerSession, SessionRecoveryFailure, type WithAuthOptions, type WithAuthProps, createServerClient, defineAuth, getSafeRedirectPath, getServerSession, makeSessionCookie, withAuth, withAuthCookiePersistence };