Skip to content

Commit 5d0704f

Browse files
committed
more issue fixes
1 parent 3618e79 commit 5d0704f

6 files changed

Lines changed: 97 additions & 37 deletions

File tree

frontend/src/app/api/auth/session/route.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server';
2+
import { signSession, SESSION_MAX_AGE } from '@/lib/sessionToken';
23

34
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
4-
const SESSION_MAX_AGE = 2592000; // 30 days
55

66
export async function POST(request: NextRequest) {
77
const { userId } = await request.json();
@@ -26,14 +26,9 @@ export async function POST(request: NextRequest) {
2626
return NextResponse.json({ error: 'Not approved' }, { status: 403 });
2727
}
2828

29+
const token = await signSession(userId);
2930
const response = NextResponse.json({ ok: true });
30-
response.cookies.set('sapling_session', userId, {
31-
httpOnly: true,
32-
sameSite: 'lax',
33-
path: '/',
34-
maxAge: SESSION_MAX_AGE,
35-
});
36-
response.cookies.set('sapling_approved', '1', {
31+
response.cookies.set('sapling_session', token, {
3732
httpOnly: true,
3833
sameSite: 'lax',
3934
path: '/',
@@ -45,6 +40,5 @@ export async function POST(request: NextRequest) {
4540
export async function DELETE() {
4641
const response = NextResponse.json({ ok: true });
4742
response.cookies.set('sapling_session', '', { httpOnly: true, maxAge: 0, path: '/' });
48-
response.cookies.set('sapling_approved', '', { httpOnly: true, maxAge: 0, path: '/' });
4943
return response;
5044
}

frontend/src/app/pending/page.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,14 @@
11
'use client';
22

3-
import { useEffect } from 'react';
43
import { useRouter } from 'next/navigation';
54
import Image from 'next/image';
65

76
export default function PendingPage() {
87
const router = useRouter();
98

10-
useEffect(() => {
11-
const approved = document.cookie
12-
.split('; ')
13-
.find(row => row.startsWith('sapling_approved='))
14-
?.split('=')[1];
15-
if (approved === '1') {
16-
router.replace('/dashboard');
17-
}
18-
}, [router]);
19-
20-
function handleSignOut() {
9+
async function handleSignOut() {
2110
localStorage.removeItem('sapling_user');
22-
document.cookie = 'sapling_approved=; path=/; max-age=0; SameSite=Lax';
23-
document.cookie = 'sapling_uid=; path=/; max-age=0; SameSite=Lax';
11+
await fetch('/api/auth/session', { method: 'DELETE' });
2412
router.replace('/signin');
2513
}
2614

frontend/src/app/signin/callback/page.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useUser } from '@/context/UserContext';
77
function CallbackInner() {
88
const searchParams = useSearchParams();
99
const router = useRouter();
10-
const { setActiveUser } = useUser();
10+
const { setActiveUser, confirmApproved } = useUser();
1111

1212
useEffect(() => {
1313
const userId = searchParams.get('user_id');
@@ -22,12 +22,19 @@ function CallbackInner() {
2222
}
2323

2424
if (userId && name) {
25-
setActiveUser(userId, name, avatar || '', true);
25+
setActiveUser(userId, name, avatar || '');
2626
fetch('/api/auth/session', {
2727
method: 'POST',
2828
headers: { 'Content-Type': 'application/json' },
2929
body: JSON.stringify({ userId }),
30-
}).then(() => router.replace('/dashboard'));
30+
}).then(res => {
31+
if (res.ok) {
32+
confirmApproved();
33+
router.replace('/dashboard');
34+
} else {
35+
router.replace('/signin');
36+
}
37+
});
3138
} else {
3239
router.replace('/signin');
3340
}

frontend/src/context/UserContext.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ interface UserContextValue {
1717
userReady: boolean;
1818
isAuthenticated: boolean;
1919
isApproved: boolean;
20-
setActiveUser: (id: string, name: string, avatar?: string, approved?: boolean) => void;
20+
setActiveUser: (id: string, name: string, avatar?: string) => void;
21+
confirmApproved: () => void;
2122
signOut: () => void;
2223
}
2324

@@ -30,6 +31,7 @@ const UserContext = createContext<UserContextValue>({
3031
isAuthenticated: false,
3132
isApproved: false,
3233
setActiveUser: () => {},
34+
confirmApproved: () => {},
3335
signOut: () => {},
3436
});
3537

@@ -49,12 +51,11 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
4951
const saved = localStorage.getItem('sapling_user');
5052
if (saved) {
5153
try {
52-
const { id, name, avatar, isApproved: savedApproved } = JSON.parse(saved);
54+
const { id, name, avatar } = JSON.parse(saved);
5355
setUserId(id);
5456
setUserName(name);
5557
if (avatar) setAvatarUrl(avatar);
5658
setIsAuthenticated(true);
57-
setIsApproved(savedApproved === true);
5859
} catch {}
5960
}
6061
setUserReady(true);
@@ -78,15 +79,16 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
7879
.catch(() => {});
7980
}, []);
8081

81-
const setActiveUser = (id: string, name: string, avatar?: string, approved?: boolean) => {
82+
const setActiveUser = (id: string, name: string, avatar?: string) => {
8283
setUserId(id);
8384
setUserName(name);
8485
if (avatar) setAvatarUrl(avatar);
8586
setIsAuthenticated(true);
86-
setIsApproved(approved === true);
87-
localStorage.setItem('sapling_user', JSON.stringify({ id, name, avatar: avatar || '', isApproved: approved === true }));
87+
localStorage.setItem('sapling_user', JSON.stringify({ id, name, avatar: avatar || '' }));
8888
};
8989

90+
const confirmApproved = () => setIsApproved(true);
91+
9092
const signOut = () => {
9193
setUserId('');
9294
setUserName('');
@@ -98,7 +100,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
98100
};
99101

100102
const value = useMemo(
101-
() => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved, setActiveUser, signOut }),
103+
() => ({ userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved, setActiveUser, confirmApproved, signOut }),
102104
[userId, userName, avatarUrl, users, userReady, isAuthenticated, isApproved]
103105
);
104106

frontend/src/lib/sessionToken.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
export const SESSION_MAX_AGE = 2592000; // 30 days in seconds
2+
3+
function getSecret(): string {
4+
const secret = process.env.SESSION_SECRET;
5+
if (!secret) throw new Error('SESSION_SECRET env var is not set');
6+
return secret;
7+
}
8+
9+
function toBase64Url(buf: ArrayBuffer): string {
10+
const bytes = new Uint8Array(buf);
11+
let binary = '';
12+
for (const byte of bytes) binary += String.fromCharCode(byte);
13+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
14+
}
15+
16+
function fromBase64Url(str: string): Uint8Array {
17+
const padded = str.replace(/-/g, '+').replace(/_/g, '/');
18+
const padding = '='.repeat((4 - (padded.length % 4)) % 4);
19+
const binary = atob(padded + padding);
20+
const bytes = new Uint8Array(binary.length);
21+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
22+
return bytes;
23+
}
24+
25+
async function importKey(): Promise<CryptoKey> {
26+
const raw = new TextEncoder().encode(getSecret());
27+
return crypto.subtle.importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
28+
}
29+
30+
export async function signSession(userId: string): Promise<string> {
31+
const payload = JSON.stringify({
32+
userId,
33+
approved: true,
34+
exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE,
35+
});
36+
const payloadB64 = toBase64Url(new TextEncoder().encode(payload));
37+
const key = await importKey();
38+
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payloadB64));
39+
return `${payloadB64}.${toBase64Url(sig)}`;
40+
}
41+
42+
export async function verifySession(
43+
token: string,
44+
): Promise<{ userId: string; approved: boolean } | null> {
45+
const dot = token.lastIndexOf('.');
46+
if (dot < 0) return null;
47+
const payloadB64 = token.slice(0, dot);
48+
const sigB64 = token.slice(dot + 1);
49+
try {
50+
const key = await importKey();
51+
const valid = await crypto.subtle.verify(
52+
'HMAC',
53+
key,
54+
fromBase64Url(sigB64),
55+
new TextEncoder().encode(payloadB64),
56+
);
57+
if (!valid) return null;
58+
const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(payloadB64)));
59+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) return null;
60+
if (typeof payload.userId !== 'string') return null;
61+
return { userId: payload.userId, approved: payload.approved === true };
62+
} catch {
63+
return null;
64+
}
65+
}

frontend/src/middleware.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
import { NextResponse } from 'next/server'
22
import type { NextRequest } from 'next/server'
3+
import { verifySession } from '@/lib/sessionToken'
34

45
const PROTECTED = [
56
'/dashboard', '/learn', '/study', '/tree',
67
'/flashcards', '/library', '/calendar', '/social'
78
]
89

9-
export function middleware(request: NextRequest) {
10+
export async function middleware(request: NextRequest) {
1011
const { pathname } = request.nextUrl
1112
const isProtected = PROTECTED.some(p => pathname.startsWith(p))
1213
if (!isProtected) return NextResponse.next()
1314

14-
const session = request.cookies.get('sapling_session')?.value
15-
const approved = request.cookies.get('sapling_approved')?.value
15+
const token = request.cookies.get('sapling_session')?.value
16+
if (!token) {
17+
return NextResponse.redirect(new URL('/signin', request.url))
18+
}
1619

20+
const session = await verifySession(token)
1721
if (!session) {
1822
return NextResponse.redirect(new URL('/signin', request.url))
1923
}
20-
if (approved !== '1') {
24+
if (!session.approved) {
2125
return NextResponse.redirect(new URL('/pending', request.url))
2226
}
2327
return NextResponse.next()

0 commit comments

Comments
 (0)