@startsimpli/auth 0.4.11 → 0.4.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.11",
3
+ "version": "0.4.14",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tests for createAuthMiddleware (raise-simpli-qcw).
3
+ *
4
+ * The bug: middleware redirects to /signin whenever the access-token cookie
5
+ * is missing or expired, even when a refresh_token cookie is still present.
6
+ * That's the wrong call — the client AuthProvider can transparently refresh
7
+ * via the refresh_token cookie. By bouncing to /signin we force the user
8
+ * into a 3-refresh dance to recover (signin redirects back to /dashboard
9
+ * once bootstrapFromCookies completes).
10
+ *
11
+ * The fix: if access_token/auth_session is missing or expired but
12
+ * refresh_token is present, let the request through. Pages then handle
13
+ * the JWT refresh client-side via authFetch / AuthProvider bootstrap.
14
+ *
15
+ * If NO cookies at all (or only an expired refresh_token) — still redirect
16
+ * to /signin. That's the genuinely-unauthenticated case.
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
20
+ import { NextRequest } from 'next/server';
21
+ import { createAuthMiddleware } from '../server/middleware';
22
+
23
+ // JWT factory: build a token with arbitrary exp
24
+ function makeJwt(expSecondsFromNow: number): string {
25
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).replace(/=/g, '');
26
+ const exp = Math.floor(Date.now() / 1000) + expSecondsFromNow;
27
+ const payload = btoa(JSON.stringify({ exp, sub: 'test-user' })).replace(/=/g, '');
28
+ return `${header}.${payload}.fake-signature`;
29
+ }
30
+
31
+ function makeRequest(
32
+ pathname: string,
33
+ cookies: Record<string, string> = {}
34
+ ): NextRequest {
35
+ const url = new URL(`http://localhost:4001${pathname}`);
36
+ const cookieHeader = Object.entries(cookies)
37
+ .map(([k, v]) => `${k}=${v}`)
38
+ .join('; ');
39
+ const init: RequestInit = cookieHeader ? { headers: { cookie: cookieHeader } } : {};
40
+ return new NextRequest(url, init);
41
+ }
42
+
43
+ describe('createAuthMiddleware — JWT refresh tolerance', () => {
44
+ let middleware: ReturnType<typeof createAuthMiddleware>;
45
+
46
+ beforeEach(() => {
47
+ middleware = createAuthMiddleware({
48
+ publicPaths: ['/auth/signin', '/auth/signup'],
49
+ loginPath: '/auth/signin',
50
+ callbackParam: 'callbackUrl',
51
+ });
52
+ });
53
+
54
+ afterEach(() => {
55
+ vi.useRealTimers();
56
+ });
57
+
58
+ describe('valid access token', () => {
59
+ it('lets the request through when auth_session is fresh', () => {
60
+ const fresh = makeJwt(60 * 30); // 30 min from now
61
+ const req = makeRequest('/dashboard', { auth_session: fresh });
62
+ const res = middleware(req);
63
+ // NextResponse.next() has no Location header
64
+ expect(res.headers.get('location')).toBeNull();
65
+ });
66
+
67
+ it('lets the request through when access_token is fresh (legacy cookie name)', () => {
68
+ const fresh = makeJwt(60 * 30);
69
+ const req = makeRequest('/dashboard', { access_token: fresh });
70
+ const res = middleware(req);
71
+ expect(res.headers.get('location')).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe('expired access token + valid refresh token', () => {
76
+ it('lets the request through (does NOT redirect to /signin) — raise-simpli-qcw fix', () => {
77
+ // The auth_session cookie expired (30-min JWT) but the user is still
78
+ // logged in via their longer-lived refresh_token cookie. Middleware
79
+ // must let this request pass so the page can refresh client-side.
80
+ const expired = makeJwt(-60); // 1 min ago
81
+ const req = makeRequest('/dashboard', {
82
+ auth_session: expired,
83
+ refresh_token: 'opaque-refresh-jwt',
84
+ });
85
+ const res = middleware(req);
86
+ expect(res.headers.get('location')).toBeNull();
87
+ });
88
+
89
+ it('lets the request through when only refresh_token is present (no access cookies)', () => {
90
+ // The auth_session cookie was already dropped by the browser after
91
+ // expiry. Only refresh_token (HttpOnly, longer-lived) survives.
92
+ const req = makeRequest('/dashboard', {
93
+ refresh_token: 'opaque-refresh-jwt',
94
+ });
95
+ const res = middleware(req);
96
+ expect(res.headers.get('location')).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe('genuinely unauthenticated', () => {
101
+ it('redirects to /signin when no cookies are set', () => {
102
+ const req = makeRequest('/dashboard');
103
+ const res = middleware(req);
104
+ expect(res.headers.get('location')).toContain('/auth/signin');
105
+ expect(res.headers.get('location')).toContain('callbackUrl=%2Fdashboard');
106
+ });
107
+
108
+ it('redirects to /signin when access_token is expired and no refresh_token', () => {
109
+ const expired = makeJwt(-60);
110
+ const req = makeRequest('/dashboard', { auth_session: expired });
111
+ const res = middleware(req);
112
+ expect(res.headers.get('location')).toContain('/auth/signin');
113
+ });
114
+ });
115
+
116
+ describe('public paths', () => {
117
+ it('lets unauthenticated users access /auth/signin', () => {
118
+ const req = makeRequest('/auth/signin');
119
+ const res = middleware(req);
120
+ expect(res.headers.get('location')).toBeNull();
121
+ });
122
+
123
+ it('redirects authenticated users away from /auth/signin to /dashboard', () => {
124
+ const fresh = makeJwt(60 * 30);
125
+ const req = makeRequest('/auth/signin', { auth_session: fresh });
126
+ const res = middleware(req);
127
+ expect(res.headers.get('location')).toContain('/dashboard');
128
+ });
129
+ });
130
+ });
@@ -53,6 +53,8 @@ export class AuthClient {
53
53
  tokenRefreshInterval: 4 * 60 * 1000, // 4 minutes
54
54
  onSessionExpired: () => {},
55
55
  onUnauthorized: () => {},
56
+ loginPath: '',
57
+ callbackParam: 'callbackUrl',
56
58
  ...config,
57
59
  };
58
60
  }
@@ -110,13 +110,30 @@ export function AuthProvider({
110
110
 
111
111
  // Session expiration handler — covers both AuthClient timer and authFetch 401
112
112
  useEffect(() => {
113
+ // Capture the consumer's onSessionExpired before we overwrite it below.
114
+ const consumerCallback = config.onSessionExpired;
115
+ const loginPath = config.loginPath;
116
+ const callbackParam = config.callbackParam ?? 'callbackUrl';
117
+
113
118
  const handleExpired = () => {
114
119
  setState({
115
120
  session: null,
116
121
  isLoading: false,
117
122
  isAuthenticated: false,
118
123
  });
119
- config.onSessionExpired?.();
124
+ consumerCallback?.();
125
+
126
+ // Redirect to login if configured. Done after state reset + consumer
127
+ // callback so any cleanup runs first. window.location avoids pulling
128
+ // a router dep into the shared package — works in any framework.
129
+ if (loginPath && typeof window !== 'undefined') {
130
+ const here = window.location.pathname + window.location.search;
131
+ const isOnLogin = window.location.pathname.startsWith(loginPath);
132
+ if (!isOnLogin) {
133
+ const callback = encodeURIComponent(here);
134
+ window.location.href = `${loginPath}?${callbackParam}=${callback}`;
135
+ }
136
+ }
120
137
  };
121
138
 
122
139
  config.onSessionExpired = handleExpired;
@@ -1,16 +1,23 @@
1
1
  /**
2
2
  * Next.js middleware helpers for authentication.
3
3
  *
4
- * Checks client-managed access-token cookies:
5
- * 1. `auth_session` non-HttpOnly cookie the client writes after login.
6
- * 2. `access_token` – legacy/alternative cookie name for the same.
4
+ * Two-tier check:
5
+ * 1. Access cookies (`auth_session`, `access_token`) non-HttpOnly,
6
+ * written client-side after login. Validates JWT exp claim.
7
+ * 2. Refresh cookie (`refresh_token`) — HttpOnly, server-managed, longer-
8
+ * lived. Treated as a "the client can probably refresh" signal that
9
+ * lets the request pass to the page; AuthProvider then runs
10
+ * bootstrapFromCookies to mint a fresh access token before any data
11
+ * fetch fires.
7
12
  *
8
- * We intentionally do NOT treat the HttpOnly `refresh_token` cookie as
9
- * proof of authentication. The refresh token survives client-side logout
10
- * (Chromium can drop server-issued delete-cookie responses when attributes
11
- * diverge), which would let a "logged out" user bounce back into protected
12
- * routes via the middleware. Access tokens are short-lived and kept in
13
- * sync with the in-memory session, so they're the correct signal here.
13
+ * Why not treat refresh_token alone as full auth? It survives client-side
14
+ * logout (Chromium can drop server-issued delete-cookie responses when
15
+ * attributes diverge). Logout proper is enforced by `setLoggedOutFlag()`
16
+ * in the client + the server-side blacklist; middleware just needs to
17
+ * decide whether to bounce the request. Bouncing on every JWT expiry
18
+ * (every 30 min by default see SIMPLE_JWT.ACCESS_TOKEN_LIFETIME) forces
19
+ * users into a 3-refresh dance instead of letting authFetch refresh
20
+ * transparently. See raise-simpli-qcw.
14
21
  */
15
22
 
16
23
  import { NextRequest, NextResponse } from 'next/server';
@@ -19,6 +26,9 @@ import { isTokenExpired } from '../utils';
19
26
  /** Cookie names checked for a valid JWT, in priority order. */
20
27
  const AUTH_COOKIE_NAMES = ['auth_session', 'access_token'] as const;
21
28
 
29
+ /** Cookie name for the refresh token (HttpOnly, server-managed). */
30
+ const REFRESH_COOKIE_NAME = 'refresh_token';
31
+
22
32
  /** Find the first valid (non-expired) JWT from known auth cookies. */
23
33
  function findValidToken(request: NextRequest): string | null {
24
34
  for (const name of AUTH_COOKIE_NAMES) {
@@ -28,6 +38,12 @@ function findValidToken(request: NextRequest): string | null {
28
38
  return null;
29
39
  }
30
40
 
41
+ /** Whether the request carries a refresh_token cookie (existence-only). */
42
+ function hasRefreshCookie(request: NextRequest): boolean {
43
+ const value = request.cookies.get(REFRESH_COOKIE_NAME)?.value;
44
+ return !!value && value.length > 0;
45
+ }
46
+
31
47
  export interface AuthMiddlewareConfig {
32
48
  publicPaths?: string[];
33
49
  loginPath?: string;
@@ -52,7 +68,10 @@ export function createAuthMiddleware(config: AuthMiddlewareConfig = {}) {
52
68
  );
53
69
 
54
70
  if (isPublicPath) {
55
- // Redirect authenticated users away from login/signup
71
+ // Redirect authenticated users away from login/signup. We only
72
+ // bounce them off /signin if they have a *fresh* access cookie —
73
+ // a lone refresh_token isn't enough, because we don't trust it as
74
+ // proof of being logged in (see module docstring).
56
75
  if (findValidToken(request) && (pathname.startsWith(loginPath) || pathname === '/auth/signup')) {
57
76
  const url = request.nextUrl.clone();
58
77
  url.pathname = '/dashboard';
@@ -61,7 +80,13 @@ export function createAuthMiddleware(config: AuthMiddlewareConfig = {}) {
61
80
  return NextResponse.next();
62
81
  }
63
82
 
64
- if (!findValidToken(request)) {
83
+ // Protected route. Allow if either:
84
+ // (a) there's a fresh access JWT cookie, or
85
+ // (b) there's a refresh_token cookie — the client AuthProvider will
86
+ // refresh on mount and authFetch will retry any 401 that races
87
+ // the refresh. Bouncing here would force the user into a
88
+ // redirect-to-/signin-then-back dance every 30 minutes.
89
+ if (!findValidToken(request) && !hasRefreshCookie(request)) {
65
90
  const url = request.nextUrl.clone();
66
91
  url.pathname = loginPath;
67
92
  url.searchParams.set(callbackParam, pathname);
@@ -111,6 +111,21 @@ export interface AuthConfig {
111
111
  tokenRefreshInterval?: number; // milliseconds, default 4 minutes
112
112
  onSessionExpired?: () => void;
113
113
  onUnauthorized?: () => void;
114
+ /**
115
+ * If set, AuthProvider redirects the browser here when the session is
116
+ * lost (refresh-token rejected, manual logout, etc.). The current path
117
+ * is appended as a query param so the login page can return the user.
118
+ * Same value the server-side middleware uses, e.g. `/auth/signin`.
119
+ * Without this set, session loss only resets React state and the user
120
+ * can be left on a page where every subsequent request silently 403s
121
+ * (raise-simpli-lxv).
122
+ */
123
+ loginPath?: string;
124
+ /**
125
+ * Query-param name appended to `loginPath` to carry the return URL.
126
+ * Defaults to `callbackUrl` to match the shared server middleware.
127
+ */
128
+ callbackParam?: string;
114
129
  }
115
130
 
116
131
  /**