remove dumb auth

This commit is contained in:
2026-03-22 13:16:17 -04:00
parent d38392ac9a
commit 6c2c4f6b8b
8 changed files with 84 additions and 169 deletions

View File

@@ -24,6 +24,9 @@ Regenerate:
- `go generate ./api/v1/generate.go` - `go generate ./api/v1/generate.go`
- `cd frontend && bun run generate:api` - `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: Examples of generated files:
- `api/v1/api.gen.go` - `api/v1/api.gen.go`
- `frontend/src/generated/**/*.ts` - `frontend/src/generated/**/*.ts`

View File

@@ -2031,13 +2031,21 @@ type LoginResponseObject interface {
VisitLoginResponse(w http.ResponseWriter) error 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 { func (response Login200JSONResponse) VisitLoginResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
w.WriteHeader(200) w.WriteHeader(200)
return json.NewEncoder(w).Encode(response) return json.NewEncoder(w).Encode(response.Body)
} }
type Login400JSONResponse ErrorResponse type Login400JSONResponse ErrorResponse
@@ -2124,13 +2132,21 @@ type RegisterResponseObject interface {
VisitRegisterResponse(w http.ResponseWriter) error 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 { func (response Register201JSONResponse) VisitRegisterResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Set-Cookie", fmt.Sprint(response.Headers.SetCookie))
w.WriteHeader(201) w.WriteHeader(201)
return json.NewEncoder(w).Encode(response) return json.NewEncoder(w).Encode(response.Body)
} }
type Register400JSONResponse ErrorResponse type Register400JSONResponse ErrorResponse

View File

@@ -41,8 +41,13 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
} }
return Login200JSONResponse{ return Login200JSONResponse{
Body: LoginResponse{
Username: user.ID, Username: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
},
Headers: Login200ResponseHeaders{
SetCookie: s.getSetCookieFromContext(ctx),
},
}, nil }, nil
} }
@@ -81,8 +86,13 @@ func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (R
} }
return Register201JSONResponse{ return Register201JSONResponse{
Body: LoginResponse{
Username: user.ID, Username: user.ID,
IsAdmin: user.Admin, IsAdmin: user.Admin,
},
Headers: Register201ResponseHeaders{
SetCookie: s.getSetCookieFromContext(ctx),
},
}, nil }, nil
} }
@@ -207,6 +217,14 @@ func (s *Server) getResponseWriterFromContext(ctx context.Context) http.Response
return w 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 // getSession retrieves auth data from the session cookie
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) { func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
// Get session from cookie store // Get session from cookie store

View File

@@ -66,6 +66,13 @@ func (suite *AuthTestSuite) createTestUser(username, password string) {
suite.Require().NoError(err) 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 { func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
reqBody := LoginRequest{ reqBody := LoginRequest{
Username: username, Username: username,
@@ -86,6 +93,7 @@ func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
cookies := w.Result().Cookies() cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1, "should have session cookie") suite.Require().Len(cookies, 1, "should have session cookie")
suite.assertSessionCookie(cookies[0])
return cookies[0] return cookies[0]
} }
@@ -109,6 +117,10 @@ func (suite *AuthTestSuite) TestAPILogin() {
var resp LoginResponse var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username) suite.Equal("testuser", resp.Username)
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1)
suite.assertSessionCookie(cookies[0])
} }
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() { 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") suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
cookies := w.Result().Cookies() 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") user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
suite.Require().NoError(err) suite.Require().NoError(err)
@@ -182,6 +195,10 @@ func (suite *AuthTestSuite) TestAPILogout() {
suite.srv.ServeHTTP(w, req) suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code) 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() { func (suite *AuthTestSuite) TestAPIGetMe() {

View File

@@ -626,8 +626,9 @@ components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
type: http type: apiKey
scheme: bearer in: cookie
name: token
paths: paths:
/documents: /documents:
@@ -1174,6 +1175,11 @@ paths:
responses: responses:
200: 200:
description: Successful login description: Successful login
headers:
Set-Cookie:
description: HttpOnly session cookie for authenticated requests.
schema:
type: string
content: content:
application/json: application/json:
schema: schema:
@@ -1212,6 +1218,11 @@ paths:
responses: responses:
201: 201:
description: Successful registration description: Successful registration
headers:
Set-Cookie:
description: HttpOnly session cookie for authenticated requests.
schema:
type: string
content: content:
application/json: application/json:
schema: schema:

View File

@@ -1,115 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { describe, expect, it } from 'vitest';
import { setupAuthInterceptors, TOKEN_KEY } from './authInterceptor'; import { setupAuthInterceptors } from './authInterceptor';
type RequestConfig = {
headers?: Record<string, string>;
};
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<never>]
>();
const responseHandlers = new Map<
number,
[(response: ResponseValue) => ResponseValue, (error: ResponseError) => Promise<never>]
>();
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);
},
};
}
describe('setupAuthInterceptors', () => { describe('setupAuthInterceptors', () => {
beforeEach(() => { it('is a no-op when auth is handled by HttpOnly cookies', () => {
localStorage.clear(); const cleanup = setupAuthInterceptors();
});
it('registers request and response interceptors and adds the auth header when a token exists', () => { expect(typeof cleanup).toBe('function');
const axiosInstance = createMockAxiosInstance(); expect(() => cleanup()).not.toThrow();
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<string, string> } = { 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);
}); });
}); });

View File

@@ -1,46 +1,3 @@
import axios, { type AxiosInstance } from 'axios'; export function setupAuthInterceptors() {
return () => {};
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 { TOKEN_KEY };
export default axios;

View File

@@ -2,14 +2,11 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { ToastProvider } from './components/ToastContext'; import { ToastProvider } from './components/ToastContext';
import { setupAuthInterceptors } from './auth/authInterceptor';
import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider'; import { ThemeProvider, initializeThemeMode } from './theme/ThemeProvider';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
setupAuthInterceptors(axios);
initializeThemeMode(); initializeThemeMode();
const queryClient = new QueryClient({ const queryClient = new QueryClient({