Skip to content

Commit 2e8c8d4

Browse files
feat: add HTTPOnly Cookie support (#371)
* add * add * add * add * add * Update login_form.svelte * add * add * add * add * Update login_form.svelte * add * add * ad * add * Update config.ts * add
1 parent 8cfbb9d commit 2e8c8d4

24 files changed

Lines changed: 305 additions & 277 deletions

File tree

src/backend/app/deps.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import jwt
55
import redis.asyncio as redis
66
from botocore.exceptions import ClientError
7-
from fastapi import Depends, HTTPException, status
7+
from fastapi import Depends, HTTPException, Request, status
88
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
99
from jwt import InvalidTokenError
1010
from pydantic import ValidationError
@@ -18,7 +18,25 @@
1818
from app.schemas.token import TokenPayload
1919
from app.settings import settings
2020

21-
bearer_scheme = HTTPBearer(auto_error=True)
21+
bearer_scheme = HTTPBearer(auto_error=False)
22+
23+
24+
async def get_token_from_cookie_or_header(
25+
request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)
26+
) -> HTTPAuthorizationCredentials:
27+
token = request.cookies.get("access_token")
28+
if token:
29+
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
30+
31+
# Fallback to Authorization header
32+
if credentials:
33+
return credentials
34+
35+
# If no token found
36+
raise HTTPException(
37+
status_code=status.HTTP_403_FORBIDDEN,
38+
detail="Could not validate credentials",
39+
)
2240

2341

2442
async def get_current_user(session: SessionDep, token: TokenDep) -> User:
@@ -27,7 +45,7 @@ async def get_current_user(session: SessionDep, token: TokenDep) -> User:
2745
token.credentials, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
2846
)
2947
token_data = TokenPayload(**payload)
30-
except (InvalidTokenError, ValidationError):
48+
except InvalidTokenError, ValidationError:
3149
raise HTTPException(
3250
status_code=status.HTTP_403_FORBIDDEN,
3351
detail="Could not validate credentials",
@@ -69,7 +87,7 @@ async def get_redis():
6987
SessionDep = Annotated[AsyncSession, Depends(get_session)]
7088
TokenDep = Annotated[
7189
HTTPAuthorizationCredentials,
72-
Depends(bearer_scheme),
90+
Depends(get_token_from_cookie_or_header),
7391
]
7492
CurrentUser = Annotated[User, Depends(get_current_user)]
7593
S3Dep = Annotated[S3Client, Depends(get_s3_client)]

src/backend/app/main.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
allow_origins=["*"],
2222
allow_methods=["*"],
2323
allow_headers=["*"],
24+
allow_credentials=True,
2425
)
2526

2627
from app.routes.admin.config import router as admin_config_router
@@ -66,3 +67,7 @@
6667
from app.routes.speedtest import router as speedtest_router
6768

6869
app.include_router(speedtest_router)
70+
71+
from app.routes.token import router as token_router
72+
73+
app.include_router(token_router)

src/backend/app/routes/login.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from app import security
99
from app.decorators.rate_limit import rate_limit
10-
from app.deps import SessionDep
10+
from app.deps import CurrentUser, SessionDep
1111
from app.models import User
1212
from app.schemas.token import Token
1313
from app.settings import settings
@@ -49,3 +49,5 @@ async def login_endpoint(
4949
)
5050

5151
return Token(access_token=token_string, token_type="bearer")
52+
53+

src/backend/app/routes/token.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from fastapi import APIRouter
2+
3+
from app.deps import CurrentUser
4+
5+
router = APIRouter()
6+
7+
8+
@router.get("/token/validate", response_model=dict)
9+
async def validate_token(_: CurrentUser):
10+
"""
11+
Validates the JWT and returns the corresponding user info.
12+
"""
13+
return {
14+
"valid": True,
15+
}

src/frontend/src/lib/consts/backend.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const USER_URL = `${BACKEND_API}/user`;
88
export const CONFIG_URL = `${BACKEND_API}/config`;
99
export const FILE_INFO_URL = `${BACKEND_API}/information`;
1010
export const ONBOARDING_URL = `${BACKEND_API}/onboarding`;
11+
export const TOKEN_VALIDATE_URL = `${BACKEND_API}/token/validate`;
1112

1213
export const ADMIN_CONFIG_URL = `${BACKEND_API}/admin/config`;
1314
export const ADMIN_USER_UPDATE_URL = `${BACKEND_API}/admin/user`;
Lines changed: 4 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ADMIN_USER_UPDATE_URL, LOGIN_URL, USER_URL } from '#consts/backend';
1+
import { ADMIN_USER_UPDATE_URL, USER_URL } from '#consts/backend';
22
import { browser } from '$app/environment';
33
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
44

@@ -10,15 +10,11 @@ const fetchUser = async ({
1010
fetch?: typeof globalThis.window.fetch;
1111
}) => {
1212
if (!browser) return null;
13-
const token = localStorage.getItem('auth_token');
14-
if (!token) return null;
15-
1613
const res = await fetch(USER_URL, {
17-
headers: { Authorization: `Bearer ${token}` }
14+
credentials: 'include'
1815
});
1916

2017
if (!res.ok || res.status === 401) {
21-
localStorage.removeItem('auth_token');
2218
return null;
2319
}
2420
return res.json();
@@ -42,52 +38,13 @@ export const useAuth = () => {
4238
staleTime: Infinity,
4339
retry: false
4440
}));
45-
46-
const login = async (username: string, password: string) => {
47-
// Build form data using FormData (let the browser set Content-Type)
48-
const form = new FormData();
49-
form.append('username', username);
50-
form.append('password', password);
51-
52-
const res = await fetch(LOGIN_URL, {
53-
method: 'POST',
54-
body: form
55-
});
56-
57-
if (!res.ok) {
58-
throw new Error('Invalid username or password');
59-
}
60-
61-
const data = await res.json();
62-
const token = data.access_token;
63-
if (browser && token) {
64-
localStorage.setItem('auth_token', token);
65-
}
66-
await queryClient.invalidateQueries({ queryKey: queryKey });
67-
68-
return token;
69-
};
70-
71-
const logout = () => {
72-
if (!browser) return null;
73-
if (!localStorage.getItem('auth_token')) {
74-
throw new Error('Not authenticated');
75-
}
76-
localStorage.removeItem('auth_token');
77-
queryClient.setQueryData(queryKey, null);
78-
queryClient.clear();
79-
};
80-
8141
const updateUser = async (data: { username?: string; email?: string | null }) => {
8242
if (!browser) return;
83-
const token = localStorage.getItem('auth_token');
84-
if (!token) throw new Error('Not authenticated');
8543

8644
const res = await fetch(ADMIN_USER_UPDATE_URL, {
8745
method: 'PATCH',
8846
headers: {
89-
'Content-Type': 'application/json',
90-
Authorization: `Bearer ${token}`
47+
'Content-Type': 'application/json'
9148
},
9249
body: JSON.stringify(data)
9350
});
@@ -101,16 +58,8 @@ export const useAuth = () => {
10158
return res.json();
10259
};
10360

104-
const isAuthenticated = () => {
105-
if (!browser) return false;
106-
return !!localStorage.getItem('auth_token');
107-
};
108-
10961
return {
11062
user: query,
111-
login,
112-
logout,
113-
updateUser,
114-
isAuthenticated
63+
updateUser
11564
};
11665
};

src/frontend/src/lib/queries/config.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ const fetchConfig = async ({
77
}: {
88
fetch?: typeof globalThis.window.fetch;
99
}) => {
10-
const res = await fetch(CONFIG_URL);
10+
const res = await fetch(CONFIG_URL, {
11+
credentials: 'include'
12+
});
1113

1214
return res.json();
1315
};
@@ -53,19 +55,17 @@ export const useConfigQuery = () => {
5355
}));
5456

5557
const update_config = async (data: Partial<ConfigIn>) => {
56-
const token = localStorage.getItem('auth_token');
57-
if (!token) return null;
58-
5958
const res = await fetch(ADMIN_CONFIG_URL, {
6059
method: 'PATCH',
6160
headers: {
62-
'Content-Type': 'application/json',
63-
Authorization: `Bearer ${token}`
61+
'Content-Type': 'application/json'
6462
},
63+
credentials: 'include',
6564
body: JSON.stringify(data)
6665
});
67-
68-
if (res.ok) await queryClient.invalidateQueries({ queryKey: ['config'] });
66+
if (res.ok) {
67+
await queryClient.invalidateQueries({ queryKey: ['config'] });
68+
}
6969
};
7070

7171
return {

src/frontend/src/lib/queries/files.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,8 @@ export const useFilesQuery = () => {
1717
const query = createQuery(() => ({
1818
queryKey: ['admin-files'],
1919
queryFn: async () => {
20-
const token = localStorage.getItem('auth_token');
21-
if (!token) {
22-
throw new Error('Not authenticated');
23-
}
24-
2520
const res = await fetch(ADMIN_FILES_URL, {
26-
headers: {
27-
Authorization: `Bearer ${token}`
28-
}
21+
credentials: 'include'
2922
});
3023

3124
if (!res.ok) {
@@ -41,14 +34,9 @@ export const useFilesQuery = () => {
4134
}));
4235

4336
const revokeFile = async (id: string) => {
44-
const token = localStorage.getItem('auth_token');
45-
if (!token) throw new Error('Not authenticated');
46-
4737
const res = await fetch(`${ADMIN_FILES_URL}/${id}`, {
4838
method: 'DELETE',
49-
headers: {
50-
Authorization: `Bearer ${token}`
51-
}
39+
credentials: 'include'
5240
});
5341

5442
if (res.ok) {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
let authenticated: null | boolean = null;
2+
3+
export const user_store = () => {
4+
return {
5+
authenticate() {
6+
authenticated = true;
7+
},
8+
get is_authenticated() {
9+
return authenticated;
10+
}
11+
};
12+
};

src/frontend/src/routes/(needs_onboarding)/(login_required)/+layout.svelte

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,20 @@
55
import * as Empty from '$lib/components/ui/empty/index';
66
import { LoaderCircle, Lock } from '@lucide/svelte';
77
import { Button } from '$lib/components/ui/button';
8+
import { user_store } from '$lib/store/user.svelte';
89
9-
const { isAuthenticated, user: userData } = useAuth();
10-
10+
const { user: userData } = useAuth();
11+
const { is_authenticated } = user_store();
1112
let { children } = $props();
1213
1314
$effect(() => {
14-
if (!isAuthenticated()) {
15+
if (is_authenticated === null) {
1516
goto(`/login?next=${page.url.pathname}`);
1617
}
1718
});
1819
</script>
1920

20-
{#if userData.isLoading}
21-
<div class="flex w-full flex-1 items-center justify-center">
22-
<LoaderCircle class="h-8 w-8 animate-spin text-muted-foreground" />
23-
</div>
24-
{:else if [null, undefined].includes(userData.data)}
21+
{#if [null, undefined].includes(userData.data)}
2522
<div class="flex min-h-svh w-full flex-1 items-center justify-center p-4">
2623
<Empty.Root>
2724
<Empty.Header class="text-center">

0 commit comments

Comments
 (0)