From 6c2c4f6b8b04b3655e2b1c2569dc241809380e45 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 22 Mar 2026 13:16:17 -0400 Subject: [PATCH] remove dumb auth --- AGENTS.md | 3 + api/v1/api.gen.go | 24 ++++- api/v1/auth.go | 26 ++++- api/v1/auth_test.go | 19 +++- api/v1/openapi.yaml | 15 ++- frontend/src/auth/authInterceptor.test.ts | 116 ++-------------------- frontend/src/auth/authInterceptor.ts | 47 +-------- frontend/src/main.tsx | 3 - 8 files changed, 84 insertions(+), 169 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dd1f4b..329ca39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,9 @@ Regenerate: - `go generate ./api/v1/generate.go` - `cd frontend && bun run generate:api` +Notes: +- If you add response headers in `api/v1/openapi.yaml` (for example `Set-Cookie`), `oapi-codegen` will generate typed response header structs in `api/v1/api.gen.go`; update the handler response values to populate those headers explicitly. + Examples of generated files: - `api/v1/api.gen.go` - `frontend/src/generated/**/*.ts` diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go index 73f501f..4f7e8af 100644 --- a/api/v1/api.gen.go +++ b/api/v1/api.gen.go @@ -2031,13 +2031,21 @@ type LoginResponseObject interface { VisitLoginResponse(w http.ResponseWriter) error } -type Login200JSONResponse LoginResponse +type Login200ResponseHeaders struct { + SetCookie string +} + +type Login200JSONResponse struct { + Body LoginResponse + Headers Login200ResponseHeaders +} func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie)) w.WriteHeader(200) - return json.NewEncoder(w).Encode(response) + return json.NewEncoder(w).Encode(response.Body) } type Login400JSONResponse ErrorResponse @@ -2124,13 +2132,21 @@ type RegisterResponseObject interface { VisitRegisterResponse(w http.ResponseWriter) error } -type Register201JSONResponse LoginResponse +type Register201ResponseHeaders struct { + SetCookie string +} + +type Register201JSONResponse struct { + Body LoginResponse + Headers Register201ResponseHeaders +} func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie)) w.WriteHeader(201) - return json.NewEncoder(w).Encode(response) + return json.NewEncoder(w).Encode(response.Body) } type Register400JSONResponse ErrorResponse diff --git a/api/v1/auth.go b/api/v1/auth.go index d152630..ca824a8 100644 --- a/api/v1/auth.go +++ b/api/v1/auth.go @@ -41,8 +41,13 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe } return Login200JSONResponse{ - Username: user.ID, - IsAdmin: user.Admin, + Body: LoginResponse{ + Username: user.ID, + IsAdmin: user.Admin, + }, + Headers: Login200ResponseHeaders{ + SetCookie: s.getSetCookieFromContext(ctx), + }, }, nil } @@ -81,8 +86,13 @@ func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (R } return Register201JSONResponse{ - Username: user.ID, - IsAdmin: user.Admin, + Body: LoginResponse{ + Username: user.ID, + IsAdmin: user.Admin, + }, + Headers: Register201ResponseHeaders{ + SetCookie: s.getSetCookieFromContext(ctx), + }, }, nil } @@ -207,6 +217,14 @@ func (s *Server) getResponseWriterFromContext(ctx context.Context) http.Response return w } +func (s *Server) getSetCookieFromContext(ctx context.Context) string { + w := s.getResponseWriterFromContext(ctx) + if w == nil { + return "" + } + return w.Header().Get("Set-Cookie") +} + // getSession retrieves auth data from the session cookie func (s *Server) getSession(r *http.Request) (auth authData, ok bool) { // Get session from cookie store diff --git a/api/v1/auth_test.go b/api/v1/auth_test.go index e6cb4cd..fc3fcfe 100644 --- a/api/v1/auth_test.go +++ b/api/v1/auth_test.go @@ -66,6 +66,13 @@ func (suite *AuthTestSuite) createTestUser(username, password string) { suite.Require().NoError(err) } +func (suite *AuthTestSuite) assertSessionCookie(cookie *http.Cookie) { + suite.Require().NotNil(cookie) + suite.Equal("token", cookie.Name) + suite.NotEmpty(cookie.Value) + suite.True(cookie.HttpOnly) +} + func (suite *AuthTestSuite) login(username, password string) *http.Cookie { reqBody := LoginRequest{ Username: username, @@ -86,6 +93,7 @@ func (suite *AuthTestSuite) login(username, password string) *http.Cookie { cookies := w.Result().Cookies() suite.Require().Len(cookies, 1, "should have session cookie") + suite.assertSessionCookie(cookies[0]) return cookies[0] } @@ -109,6 +117,10 @@ func (suite *AuthTestSuite) TestAPILogin() { var resp LoginResponse suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) suite.Equal("testuser", resp.Username) + + cookies := w.Result().Cookies() + suite.Require().Len(cookies, 1) + suite.assertSessionCookie(cookies[0]) } func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() { @@ -146,7 +158,8 @@ func (suite *AuthTestSuite) TestAPIRegister() { suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior") cookies := w.Result().Cookies() - suite.Require().NotEmpty(cookies, "register should set a session cookie") + suite.Require().Len(cookies, 1, "register should set a session cookie") + suite.assertSessionCookie(cookies[0]) user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser") suite.Require().NoError(err) @@ -182,6 +195,10 @@ func (suite *AuthTestSuite) TestAPILogout() { suite.srv.ServeHTTP(w, req) suite.Equal(http.StatusOK, w.Code) + + cookies := w.Result().Cookies() + suite.Require().Len(cookies, 1) + suite.Equal("token", cookies[0].Name) } func (suite *AuthTestSuite) TestAPIGetMe() { diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index ee67ca7..94c7d28 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -626,8 +626,9 @@ components: securitySchemes: BearerAuth: - type: http - scheme: bearer + type: apiKey + in: cookie + name: token paths: /documents: @@ -1174,6 +1175,11 @@ paths: responses: 200: description: Successful login + headers: + Set-Cookie: + description: HttpOnly session cookie for authenticated requests. + schema: + type: string content: application/json: schema: @@ -1212,6 +1218,11 @@ paths: responses: 201: description: Successful registration + headers: + Set-Cookie: + description: HttpOnly session cookie for authenticated requests. + schema: + type: string content: application/json: schema: diff --git a/frontend/src/auth/authInterceptor.test.ts b/frontend/src/auth/authInterceptor.test.ts index 581cde5..de23385 100644 --- a/frontend/src/auth/authInterceptor.test.ts +++ b/frontend/src/auth/authInterceptor.test.ts @@ -1,115 +1,11 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { setupAuthInterceptors, TOKEN_KEY } from './authInterceptor'; - -type RequestConfig = { - headers?: Record; -}; - -type ResponseValue = { - status?: number; - data?: unknown; -}; - -type ResponseError = { - response?: { - status?: number; - }; -}; - -function createMockAxiosInstance() { - let nextRequestId = 1; - let nextResponseId = 1; - - const requestHandlers = new Map< - number, - [(config: RequestConfig) => RequestConfig, (error: unknown) => Promise] - >(); - const responseHandlers = new Map< - number, - [(response: ResponseValue) => ResponseValue, (error: ResponseError) => Promise] - >(); - - return { - interceptors: { - request: { - use: vi.fn((fulfilled, rejected) => { - const id = nextRequestId++; - requestHandlers.set(id, [fulfilled, rejected]); - return id; - }), - eject: vi.fn((id: number) => { - requestHandlers.delete(id); - }), - }, - response: { - use: vi.fn((fulfilled, rejected) => { - const id = nextResponseId++; - responseHandlers.set(id, [fulfilled, rejected]); - return id; - }), - eject: vi.fn((id: number) => { - responseHandlers.delete(id); - }), - }, - }, - getRequestHandler(id = 1) { - return requestHandlers.get(id); - }, - getResponseHandler(id = 1) { - return responseHandlers.get(id); - }, - }; -} +import { describe, expect, it } from 'vitest'; +import { setupAuthInterceptors } from './authInterceptor'; describe('setupAuthInterceptors', () => { - beforeEach(() => { - localStorage.clear(); - }); + it('is a no-op when auth is handled by HttpOnly cookies', () => { + const cleanup = setupAuthInterceptors(); - it('registers request and response interceptors and adds the auth header when a token exists', () => { - const axiosInstance = createMockAxiosInstance(); - - setupAuthInterceptors(axiosInstance as never); - - expect(axiosInstance.interceptors.request.use).toHaveBeenCalledTimes(1); - expect(axiosInstance.interceptors.response.use).toHaveBeenCalledTimes(1); - - localStorage.setItem(TOKEN_KEY, 'token-123'); - - const requestHandler = axiosInstance.getRequestHandler()?.[0]; - const config: { headers: Record } = { headers: {} }; - const nextConfig = requestHandler?.(config); - - expect(nextConfig).toBe(config); - expect(config.headers.Authorization).toBe('Bearer token-123'); - }); - - it('clears the auth token on 401 responses', async () => { - const axiosInstance = createMockAxiosInstance(); - setupAuthInterceptors(axiosInstance as never); - - localStorage.setItem(TOKEN_KEY, 'token-123'); - - const responseErrorHandler = axiosInstance.getResponseHandler()?.[1]; - - await expect(responseErrorHandler?.({ response: { status: 401 } })).rejects.toEqual({ - response: { status: 401 }, - }); - expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); - }); - - it('ejects previous interceptors before installing a new set', () => { - const firstInstance = createMockAxiosInstance(); - const secondInstance = createMockAxiosInstance(); - - const cleanup = setupAuthInterceptors(firstInstance as never); - setupAuthInterceptors(secondInstance as never); - - expect(firstInstance.interceptors.request.eject).toHaveBeenCalledWith(1); - expect(firstInstance.interceptors.response.eject).toHaveBeenCalledWith(1); - - cleanup(); - expect(firstInstance.interceptors.request.eject).toHaveBeenCalledWith(1); - expect(firstInstance.interceptors.response.eject).toHaveBeenCalledWith(1); + expect(typeof cleanup).toBe('function'); + expect(() => cleanup()).not.toThrow(); }); }); diff --git a/frontend/src/auth/authInterceptor.ts b/frontend/src/auth/authInterceptor.ts index c61445a..6a764c0 100644 --- a/frontend/src/auth/authInterceptor.ts +++ b/frontend/src/auth/authInterceptor.ts @@ -1,46 +1,3 @@ -import axios, { type AxiosInstance } from 'axios'; - -const TOKEN_KEY = 'antholume_token'; - -let interceptorCleanup: (() => void) | null = null; - -export function setupAuthInterceptors(axiosInstance: AxiosInstance = axios) { - if (interceptorCleanup) { - interceptorCleanup(); - } - - const requestInterceptorId = axiosInstance.interceptors.request.use( - config => { - const token = localStorage.getItem(TOKEN_KEY); - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; - }, - error => { - return Promise.reject(error); - } - ); - - const responseInterceptorId = axiosInstance.interceptors.response.use( - response => { - return response; - }, - error => { - if (error.response?.status === 401) { - localStorage.removeItem(TOKEN_KEY); - } - return Promise.reject(error); - } - ); - - interceptorCleanup = () => { - axiosInstance.interceptors.request.eject(requestInterceptorId); - axiosInstance.interceptors.response.eject(responseInterceptorId); - }; - - return interceptorCleanup; +export function setupAuthInterceptors() { + return () => {}; } - -export { TOKEN_KEY }; -export default axios; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0d7f026..f6cb638 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,14 +2,11 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import axios from 'axios'; import { ToastProvider } from './components/ToastContext'; -import { setupAuthInterceptors } from './auth/authInterceptor'; import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider'; import App from './App'; import './index.css'; -setupAuthInterceptors(axios); initializeThemeMode(); const queryClient = new QueryClient({