@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 +1 -1
- package/src/__tests__/middleware.test.ts +130 -0
- package/src/server/middleware.ts +36 -11
- package/src/utils/token.ts +14 -2
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/server/middleware.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Next.js middleware helpers for authentication.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1. `auth_session`
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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);
|
package/src/utils/token.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
75
|
+
const decoded = JSON.parse(_base64UrlDecode(payload));
|
|
64
76
|
|
|
65
77
|
if (typeof decoded !== 'object' || decoded === null) {
|
|
66
78
|
return null;
|