@startsimpli/auth 0.4.9 → 0.4.13

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.9",
3
+ "version": "0.4.13",
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
+ });
@@ -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);
@@ -4,6 +4,18 @@
4
4
 
5
5
  import type { TokenPayload, DecodedToken } from '../types';
6
6
 
7
+ // JWT payloads are base64url-encoded. Browser atob() only accepts standard
8
+ // base64, so we have to translate `-`/`_` → `+`/`/` and restore padding before
9
+ // decoding. Without this, any token whose payload contains a url-safe char
10
+ // (common once claims grow beyond a few short ASCII fields) throws in atob
11
+ // and decodeToken silently returns null — which causes login to reject a
12
+ // valid server-issued token with "Invalid token received" and never stores it.
13
+ function _base64UrlDecode(input: string): string {
14
+ const base64 = input.replace(/-/g, '+').replace(/_/g, '/');
15
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
16
+ return atob(padded);
17
+ }
18
+
7
19
  /**
8
20
  * Decode JWT token payload (does NOT verify signature)
9
21
  */
@@ -15,7 +27,7 @@ export function decodeToken(token: string): TokenPayload | null {
15
27
  }
16
28
 
17
29
  const payload = parts[1];
18
- const decoded = JSON.parse(atob(payload));
30
+ const decoded = JSON.parse(_base64UrlDecode(payload));
19
31
  return decoded as TokenPayload;
20
32
  } catch (error) {
21
33
  console.error('Failed to decode token:', error);
@@ -60,7 +72,7 @@ export function getTokenPayload(token: string): DecodedToken | null {
60
72
  }
61
73
 
62
74
  const payload = parts[1];
63
- const decoded = JSON.parse(atob(payload));
75
+ const decoded = JSON.parse(_base64UrlDecode(payload));
64
76
 
65
77
  if (typeof decoded !== 'object' || decoded === null) {
66
78
  return null;