tangled
alpha
login
or
join now
cosmik.network
/
semble
43
fork
atom
A social knowledge tool for researchers built on ATProto
43
fork
atom
overview
issues
13
pulls
pipelines
update useAuth to handle refreshing in nextjs server
Wesley Finck
5 months ago
c18f04db
917cec13
+193
-141
8 changed files
expand all
collapse all
unified
split
src
webapp
api-client
clients
BaseClient.ts
app
api
auth
me
route.ts
status
route.ts
test-page
page.tsx
hooks
useAuth.tsx
services
auth
CookieAuthService.client.ts
index.ts
auth.ts
+16
-36
src/webapp/api-client/clients/BaseClient.ts
···
1
import { ApiError, ApiErrorResponse } from '../types/errors';
2
-
import { ClientCookieAuthService } from '@/services/auth';
3
4
export abstract class BaseClient {
5
constructor(protected baseUrl: string) {}
···
9
endpoint: string,
10
data?: any,
11
): Promise<T> {
12
-
const makeRequest = async (): Promise<T> => {
13
-
const url = `${this.baseUrl}${endpoint}`;
14
-
15
-
const headers: Record<string, string> = {
16
-
'Content-Type': 'application/json',
17
-
};
18
-
19
-
const config: RequestInit = {
20
-
method,
21
-
headers,
22
-
credentials: 'include', // Include cookies automatically (works for both client and server)
23
-
};
24
25
-
if (
26
-
data &&
27
-
(method === 'POST' || method === 'PUT' || method === 'PATCH')
28
-
) {
29
-
config.body = JSON.stringify(data);
30
-
}
31
32
-
const response = await fetch(url, config);
33
-
return this.handleResponse<T>(response);
0
0
34
};
35
36
-
try {
37
-
return await makeRequest();
38
-
} catch (error) {
39
-
// Handle 401/403 errors with automatic token refresh (client-side only)
40
-
if (
41
-
typeof window !== 'undefined' &&
42
-
error instanceof ApiError &&
43
-
(error.status === 401 || error.status === 403)
44
-
) {
45
-
const refreshed = await ClientCookieAuthService.refreshTokens();
46
-
if (refreshed) {
47
-
return makeRequest(); // Retry with new tokens
48
-
}
49
-
}
50
-
throw error;
51
}
0
0
0
52
}
53
54
private async handleResponse<T>(response: Response): Promise<T> {
···
1
import { ApiError, ApiErrorResponse } from '../types/errors';
0
2
3
export abstract class BaseClient {
4
constructor(protected baseUrl: string) {}
···
8
endpoint: string,
9
data?: any,
10
): Promise<T> {
11
+
const url = `${this.baseUrl}${endpoint}`;
0
0
0
0
0
0
0
0
0
0
0
12
13
+
const headers: Record<string, string> = {
14
+
'Content-Type': 'application/json',
15
+
};
0
0
0
16
17
+
const config: RequestInit = {
18
+
method,
19
+
headers,
20
+
credentials: 'include', // Include cookies automatically (works for both client and server)
21
};
22
23
+
if (
24
+
data &&
25
+
(method === 'POST' || method === 'PUT' || method === 'PATCH')
26
+
) {
27
+
config.body = JSON.stringify(data);
0
0
0
0
0
0
0
0
0
0
28
}
29
+
30
+
const response = await fetch(url, config);
31
+
return this.handleResponse<T>(response);
32
}
33
34
private async handleResponse<T>(response: Response): Promise<T> {
+150
src/webapp/app/api/auth/me/route.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { NextRequest, NextResponse } from 'next/server';
2
+
import { cookies } from 'next/headers';
3
+
4
+
// Helper to check if token is expired or will expire soon
5
+
function isTokenExpiringSoon(
6
+
token: string | null | undefined,
7
+
bufferMinutes: number = 5,
8
+
): boolean {
9
+
if (!token) return true;
10
+
11
+
try {
12
+
const payload = JSON.parse(
13
+
Buffer.from(token.split('.')[1], 'base64').toString(),
14
+
);
15
+
const expiry = payload.exp * 1000;
16
+
const bufferTime = bufferMinutes * 60 * 1000;
17
+
return Date.now() >= expiry - bufferTime;
18
+
} catch {
19
+
return true;
20
+
}
21
+
}
22
+
23
+
export async function GET(request: NextRequest) {
24
+
try {
25
+
const cookieStore = await cookies();
26
+
let accessToken = cookieStore.get('accessToken')?.value;
27
+
const refreshToken = cookieStore.get('refreshToken')?.value;
28
+
29
+
// No tokens at all - not authenticated
30
+
if (!accessToken && !refreshToken) {
31
+
return NextResponse.json(
32
+
{ error: 'Not authenticated' },
33
+
{ status: 401 },
34
+
);
35
+
}
36
+
37
+
// Check if accessToken is expired or expiring soon (< 5 min)
38
+
if (isTokenExpiringSoon(accessToken, 5) && refreshToken) {
39
+
try {
40
+
// Call backend to refresh tokens
41
+
const backendUrl =
42
+
process.env.API_BASE_URL || 'http://127.0.0.1:3000';
43
+
const refreshResponse = await fetch(
44
+
`${backendUrl}/api/users/oauth/refresh`,
45
+
{
46
+
method: 'POST',
47
+
headers: {
48
+
'Content-Type': 'application/json',
49
+
Cookie: `refreshToken=${refreshToken}`,
50
+
},
51
+
body: JSON.stringify({ refreshToken }),
52
+
},
53
+
);
54
+
55
+
if (!refreshResponse.ok) {
56
+
// Refresh failed - clear cookies and return 401
57
+
const response = NextResponse.json(
58
+
{ error: 'Token refresh failed' },
59
+
{ status: 401 },
60
+
);
61
+
response.cookies.delete('accessToken');
62
+
response.cookies.delete('refreshToken');
63
+
return response;
64
+
}
65
+
66
+
const newTokens = await refreshResponse.json();
67
+
accessToken = newTokens.accessToken;
68
+
69
+
// Fetch profile with new token
70
+
const profileResponse = await fetch(`${backendUrl}/api/users/me`, {
71
+
method: 'GET',
72
+
headers: {
73
+
'Content-Type': 'application/json',
74
+
Cookie: `accessToken=${accessToken}`,
75
+
},
76
+
});
77
+
78
+
if (!profileResponse.ok) {
79
+
return NextResponse.json(
80
+
{ error: 'Failed to fetch profile' },
81
+
{ status: profileResponse.status },
82
+
);
83
+
}
84
+
85
+
const user = await profileResponse.json();
86
+
87
+
// Create response with user profile and set new cookies
88
+
const response = NextResponse.json({ user });
89
+
90
+
response.cookies.set('accessToken', newTokens.accessToken, {
91
+
httpOnly: true,
92
+
secure: process.env.NODE_ENV === 'production',
93
+
sameSite: 'strict',
94
+
maxAge: 900, // 15 minutes
95
+
path: '/',
96
+
});
97
+
98
+
response.cookies.set('refreshToken', newTokens.refreshToken, {
99
+
httpOnly: true,
100
+
secure: process.env.NODE_ENV === 'production',
101
+
sameSite: 'strict',
102
+
maxAge: 604800, // 7 days
103
+
path: '/',
104
+
});
105
+
106
+
return response;
107
+
} catch (error) {
108
+
console.error('Token refresh error:', error);
109
+
return NextResponse.json(
110
+
{ error: 'Authentication failed' },
111
+
{ status: 500 },
112
+
);
113
+
}
114
+
}
115
+
116
+
// AccessToken is valid - fetch profile
117
+
try {
118
+
const backendUrl = process.env.API_BASE_URL || 'http://127.0.0.1:3000';
119
+
const profileResponse = await fetch(`${backendUrl}/api/users/me`, {
120
+
method: 'GET',
121
+
headers: {
122
+
'Content-Type': 'application/json',
123
+
Cookie: `accessToken=${accessToken}`,
124
+
},
125
+
});
126
+
127
+
if (!profileResponse.ok) {
128
+
return NextResponse.json(
129
+
{ error: 'Failed to fetch profile' },
130
+
{ status: profileResponse.status },
131
+
);
132
+
}
133
+
134
+
const user = await profileResponse.json();
135
+
return NextResponse.json({ user });
136
+
} catch (error) {
137
+
console.error('Profile fetch error:', error);
138
+
return NextResponse.json(
139
+
{ error: 'Failed to fetch profile' },
140
+
{ status: 500 },
141
+
);
142
+
}
143
+
} catch (error) {
144
+
console.error('Auth me error:', error);
145
+
return NextResponse.json(
146
+
{ error: 'Internal server error' },
147
+
{ status: 500 },
148
+
);
149
+
}
150
+
}
-31
src/webapp/app/api/auth/status/route.ts
···
1
-
import { NextRequest, NextResponse } from 'next/server';
2
-
import { cookies } from 'next/headers';
3
-
4
-
export async function GET(request: NextRequest) {
5
-
try {
6
-
const cookieStore = await cookies();
7
-
const accessToken = cookieStore.get('accessToken')?.value;
8
-
9
-
if (!accessToken) {
10
-
return NextResponse.json({ authenticated: false }, { status: 401 });
11
-
}
12
-
13
-
// Check if token is expired
14
-
try {
15
-
const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString());
16
-
const expiry = payload.exp * 1000;
17
-
const isExpired = Date.now() >= expiry;
18
-
19
-
if (isExpired) {
20
-
return NextResponse.json({ authenticated: false }, { status: 401 });
21
-
}
22
-
23
-
return NextResponse.json({ authenticated: true }, { status: 200 });
24
-
} catch {
25
-
return NextResponse.json({ authenticated: false }, { status: 401 });
26
-
}
27
-
} catch (error) {
28
-
console.error('Auth status check error:', error);
29
-
return NextResponse.json({ authenticated: false }, { status: 500 });
30
-
}
31
-
}
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
+6
-15
src/webapp/app/test-page/page.tsx
···
1
'use client';
2
3
import { useAuth } from '@/hooks/useAuth';
4
-
import { ClientCookieAuthService } from '@/services/auth';
5
import {
6
Container,
7
Stack,
···
29
30
const handleLogout = async () => {
31
await logout();
32
-
};
33
-
34
-
const handleCheckAuth = async () => {
35
-
const isAuth = await ClientCookieAuthService.checkAuthStatus();
36
-
console.log('Auth check:', { isAuthenticated: isAuth });
37
};
38
39
return (
···
122
123
<Stack gap="xs">
124
<Button onClick={handleRefresh} variant="light">
125
-
Refresh Test
126
-
</Button>
127
-
128
-
<Button onClick={handleCheckAuth} variant="outline">
129
-
Check Auth Status (Console)
130
</Button>
131
132
{authenticated && (
···
149
1. This page uses the <Code>useAuth</Code> hook
150
</Text>
151
<Text size="sm">
152
-
2. Cookies are HttpOnly and cannot be read from JavaScript
0
153
</Text>
154
<Text size="sm">
155
-
3. The browser automatically sends cookies with{' '}
156
<Code>credentials: 'include'</Code>
157
</Text>
158
<Text size="sm">
159
-
4. Auth status is checked via API endpoint
160
</Text>
161
<Text size="sm">
162
-
5. Success = client-side cookie authentication works! ✅
163
</Text>
164
</Stack>
165
</Card>
···
1
'use client';
2
3
import { useAuth } from '@/hooks/useAuth';
0
4
import {
5
Container,
6
Stack,
···
28
29
const handleLogout = async () => {
30
await logout();
0
0
0
0
0
31
};
32
33
return (
···
116
117
<Stack gap="xs">
118
<Button onClick={handleRefresh} variant="light">
119
+
Refresh Auth
0
0
0
0
120
</Button>
121
122
{authenticated && (
···
139
1. This page uses the <Code>useAuth</Code> hook
140
</Text>
141
<Text size="sm">
142
+
2. The hook calls <Code>/api/auth/me</Code> which handles auth +
143
+
refresh
144
</Text>
145
<Text size="sm">
146
+
3. HttpOnly cookies are sent automatically with{' '}
147
<Code>credentials: 'include'</Code>
148
</Text>
149
<Text size="sm">
150
+
4. Server refreshes tokens if needed (< 5 min left)
151
</Text>
152
<Text size="sm">
153
+
5. Success = seamless cookie authentication! ✅
154
</Text>
155
</Stack>
156
</Card>
+11
-11
src/webapp/hooks/useAuth.tsx
···
3
import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from 'react';
4
import { useRouter } from 'next/navigation';
5
import { ClientCookieAuthService } from '@/services/auth';
6
-
import { ApiClient, GetProfileResponse } from '@/api-client/ApiClient';
0
7
8
type UserProfile = GetProfileResponse;
9
···
30
31
const router = useRouter();
32
33
-
// Refresh authentication (fetch user profile with HttpOnly cookies)
34
const refreshAuth = useCallback(async (): Promise<boolean> => {
35
try {
36
-
// Check if authenticated via API (HttpOnly cookies sent automatically)
37
-
const isAuth = await ClientCookieAuthService.checkAuthStatus();
0
0
0
0
38
39
-
if (!isAuth) {
40
setAuthState({
41
isAuthenticated: false,
42
user: null,
···
45
return false;
46
}
47
48
-
// Fetch user profile (cookies sent automatically with credentials: 'include')
49
-
const apiClient = new ApiClient(
50
-
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000'
51
-
);
52
-
53
-
const user = await apiClient.getMyProfile();
54
55
setAuthState({
56
isAuthenticated: true,
···
3
import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from 'react';
4
import { useRouter } from 'next/navigation';
5
import { ClientCookieAuthService } from '@/services/auth';
6
+
import { ApiClient } from '@/api-client/ApiClient';
7
+
import type { GetProfileResponse } from '@/api-client/ApiClient';
8
9
type UserProfile = GetProfileResponse;
10
···
31
32
const router = useRouter();
33
34
+
// Refresh authentication (fetch user profile with automatic token refresh)
35
const refreshAuth = useCallback(async (): Promise<boolean> => {
36
try {
37
+
// Call /api/auth/me which handles token refresh + profile fetch
38
+
// HttpOnly cookies sent automatically with credentials: 'include'
39
+
const response = await fetch('/api/auth/me', {
40
+
method: 'GET',
41
+
credentials: 'include',
42
+
});
43
44
+
if (!response.ok) {
45
setAuthState({
46
isAuthenticated: false,
47
user: null,
···
50
return false;
51
}
52
53
+
const { user } = await response.json();
0
0
0
0
0
54
55
setAuthState({
56
isAuthenticated: true,
-1
src/webapp/services/auth.ts
···
4
5
// Re-export cookie auth services
6
export { ClientCookieAuthService } from './auth/CookieAuthService.client';
7
-
export type { AuthTokens } from './auth/CookieAuthService.client';
···
4
5
// Re-export cookie auth services
6
export { ClientCookieAuthService } from './auth/CookieAuthService.client';
0
+8
-43
src/webapp/services/auth/CookieAuthService.client.ts
···
1
-
export interface AuthTokens {
2
-
accessToken: string | null;
3
-
refreshToken: string | null;
4
-
}
5
-
6
export class ClientCookieAuthService {
7
// Note: With HttpOnly cookies, we cannot read tokens from document.cookie
8
-
// The browser will automatically send cookies with requests using credentials: 'include'
9
-
// To check auth status, make an API call instead of reading cookies directly
10
-
11
-
static async checkAuthStatus(): Promise<boolean> {
12
-
try {
13
-
const response = await fetch('/api/auth/status', {
14
-
method: 'GET',
15
-
credentials: 'include',
16
-
});
17
-
return response.ok;
18
-
} catch {
19
-
return false;
20
-
}
21
-
}
22
23
-
// Set cookies via API
24
-
static async setTokens(accessToken: string, refreshToken: string): Promise<void> {
0
0
0
25
await fetch('/api/auth/sync', {
26
method: 'POST',
27
headers: { 'Content-Type': 'application/json' },
···
30
});
31
}
32
33
-
// Clear cookies via API
34
static async clearTokens(): Promise<void> {
35
await fetch('/api/auth/logout', {
36
method: 'POST',
37
credentials: 'include',
38
});
39
-
}
40
-
41
-
// Refresh tokens (HttpOnly refreshToken cookie sent automatically)
42
-
static async refreshTokens(): Promise<boolean> {
43
-
try {
44
-
const response = await fetch('/api/users/oauth/refresh', {
45
-
method: 'POST',
46
-
credentials: 'include', // Sends HttpOnly cookies automatically
47
-
});
48
-
49
-
if (!response.ok) {
50
-
await this.clearTokens();
51
-
return false;
52
-
}
53
-
54
-
// New tokens are set as HttpOnly cookies by the backend
55
-
return true;
56
-
} catch {
57
-
await this.clearTokens();
58
-
return false;
59
-
}
60
}
61
}
···
0
0
0
0
0
1
export class ClientCookieAuthService {
2
// Note: With HttpOnly cookies, we cannot read tokens from document.cookie
3
+
// The browser automatically sends cookies with requests using credentials: 'include'
4
+
// All auth logic (checking status, refreshing tokens) is handled by /api/auth/me endpoint
0
0
0
0
0
0
0
0
0
0
0
0
5
6
+
// Set cookies via API (used after OAuth login)
7
+
static async setTokens(
8
+
accessToken: string,
9
+
refreshToken: string,
10
+
): Promise<void> {
11
await fetch('/api/auth/sync', {
12
method: 'POST',
13
headers: { 'Content-Type': 'application/json' },
···
16
});
17
}
18
19
+
// Clear cookies via API (logout)
20
static async clearTokens(): Promise<void> {
21
await fetch('/api/auth/logout', {
22
method: 'POST',
23
credentials: 'include',
24
});
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
25
}
26
}
+2
-4
src/webapp/services/auth/index.ts
···
1
// Client-side exports
2
export { ClientCookieAuthService } from './CookieAuthService.client';
3
4
-
// Server-side exports
5
export { ServerCookieAuthService } from './CookieAuthService.server';
6
-
7
-
// Shared types
8
-
export type { AuthTokens } from './CookieAuthService.client';
···
1
// Client-side exports
2
export { ClientCookieAuthService } from './CookieAuthService.client';
3
4
+
// Server-side exports
5
export { ServerCookieAuthService } from './CookieAuthService.server';
6
+
export type { AuthTokens } from './CookieAuthService.server';
0
0