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