Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/auth/session/session.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const sessionConfiguration = {
};

const publicUser = {
id: 'user-123',
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
email: 'lifter@example.com',
displayName: 'Go For Lifter',
avatarUrl: 'https://images.example.com/avatar.png',
Expand Down
18 changes: 14 additions & 4 deletions apps/api/src/auth/session/session.routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/** Defines current-user, CSRF-token, and logout endpoints for browser sessions. */
import {
csrfTokenResponseSchema,
currentUserResponseSchema,
type PublicUser,
} from '@goforlift/contracts';
import { Router } from 'express';

import type { PublicUser } from '../user/current-user.service.js';
import { generateCsrfToken, revokeCsrfToken } from '../csrf/csrf.middleware.js';

type SessionRouterOptions = {
Expand All @@ -20,20 +24,26 @@ export function createSessionRouter({
router.get('/me', async (request, response, next) => {
const userId = request.session.userId;
if (!userId) {
response.status(200).json({ user: null });
response
.status(200)
.json(currentUserResponseSchema.parse({ user: null }));
return;
}

try {
const user = await findPublicUser(userId);
response.status(200).json({ user });
response.status(200).json(currentUserResponseSchema.parse({ user }));
} catch (error) {
next(error);
}
});

router.get('/csrf-token', (request, response) => {
response.status(200).json({ csrfToken: generateCsrfToken(request) });
response.status(200).json(
csrfTokenResponseSchema.parse({
csrfToken: generateCsrfToken(request),
}),
);
});

router.post('/logout', (request, response, next) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/auth/user/current-user.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { findPublicUserById } from './current-user.service.js';
describe('findPublicUserById', () => {
it('returns the public user projection', async () => {
const publicUser = {
id: 'user-123',
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
email: 'lifter@example.com',
displayName: 'Go For Lifter',
avatarUrl: null,
Expand Down
8 changes: 1 addition & 7 deletions apps/api/src/auth/user/current-user.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
/** Loads the public profile fields exposed for the authenticated application user. */
import type { PublicUser } from '@goforlift/contracts';
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';

import { users } from '../../db/schema/index.js';

export type PublicUser = {
avatarUrl: string | null;
displayName: string | null;
email: string;
id: string;
};

export async function findPublicUserById(
database: NodePgDatabase,
userId: string,
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/features/auth/auth.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/** Verifies credential inclusion, response validation, CSRF attachment, and 401 behavior. */
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
AuthApiError,
fetchCurrentUser,
logout,
requestWithCsrf,
} from './auth.api';

const publicUser = {
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
email: 'lifter@example.com',
displayName: 'Go For Lifter',
avatarUrl: null,
};

afterEach(() => vi.unstubAllGlobals());

describe('fetchCurrentUser', () => {
it('fetches and validates the current user with credentials', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(Response.json({ user: publicUser }));
vi.stubGlobal('fetch', fetchMock);

await expect(fetchCurrentUser()).resolves.toEqual({ user: publicUser });
expect(fetchMock).toHaveBeenCalledWith('/auth/me', {
credentials: 'include',
});
});

it('represents a 401 response as anonymous without retrying', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(null, { status: 401 }));
vi.stubGlobal('fetch', fetchMock);

await expect(fetchCurrentUser()).resolves.toEqual({ user: null });
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('rejects an invalid server response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ user: { id: 'unsafe' } })),
);

await expect(fetchCurrentUser()).rejects.toThrow();
});
});

describe('requestWithCsrf', () => {
it('fetches and attaches a session-bound token to unsafe requests', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ csrfToken: 'csrf-token-123' }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);

await logout();

expect(fetchMock).toHaveBeenNthCalledWith(1, '/auth/csrf-token', {
credentials: 'include',
});
const mutationInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
expect(fetchMock.mock.calls[1]?.[0]).toBe('/auth/logout');
expect(mutationInit.credentials).toBe('include');
expect(mutationInit.method).toBe('POST');
expect(new Headers(mutationInit.headers).get('X-CSRF-Token')).toBe(
'csrf-token-123',
);
});

it('does not fetch or attach CSRF state for safe requests', async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ ok: true }));
vi.stubGlobal('fetch', fetchMock);

await requestWithCsrf('/safe-resource');

expect(fetchMock).toHaveBeenCalledTimes(1);
const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(fetchMock.mock.calls[0]?.[0]).toBe('/safe-resource');
expect(requestInit.credentials).toBe('include');
expect(requestInit.method).toBe('GET');
expect(new Headers(requestInit.headers).has('X-CSRF-Token')).toBe(false);
});

it('normalizes authentication failures into a typed error', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(Response.json({ csrfToken: 'csrf-token-123' }))
.mockResolvedValueOnce(new Response(null, { status: 401 }));
vi.stubGlobal('fetch', fetchMock);

await expect(logout()).rejects.toEqual(new AuthApiError(401));
});
});
73 changes: 73 additions & 0 deletions apps/web/src/features/auth/auth.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** Provides typed, credentialed requests to the backend authentication API. */
import {
csrfTokenResponseSchema,
currentUserResponseSchema,
type CurrentUserResponse,
} from '@goforlift/contracts';
import type { ZodType } from 'zod';

const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);

export class AuthApiError extends Error {
constructor(public readonly status: number) {
super(`Authentication API request failed with status ${status}`);
this.name = 'AuthApiError';
}
}

export function isAuthenticationRequiredError(error: unknown) {
return error instanceof AuthApiError && error.status === 401;
}

export async function fetchCurrentUser(): Promise<CurrentUserResponse> {
const response = await credentialedFetch('/auth/me');
if (response.status === 401) {
return { user: null };
}

return parseJsonResponse(response, currentUserResponseSchema);
}

export async function requestWithCsrf(
input: RequestInfo | URL,
init: RequestInit = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
const headers = new Headers(init.headers);

if (!SAFE_METHODS.has(method)) {
const csrfResponse = await credentialedFetch('/auth/csrf-token');
const { csrfToken } = await parseJsonResponse(
csrfResponse,
csrfTokenResponseSchema,
);
headers.set('X-CSRF-Token', csrfToken);
}

const response = await credentialedFetch(input, { ...init, method, headers });
if (!response.ok) {
throw new AuthApiError(response.status);
}

return response;
}

export async function logout() {
await requestWithCsrf('/auth/logout', { method: 'POST' });
}

async function credentialedFetch(
input: RequestInfo | URL,
init: RequestInit = {},
) {
return fetch(input, { ...init, credentials: 'include' });
}

async function parseJsonResponse<T>(response: Response, schema: ZodType<T>) {
if (!response.ok) {
throw new AuthApiError(response.status);
}

const data: unknown = await response.json();
return schema.parse(data);
}
94 changes: 94 additions & 0 deletions apps/web/src/features/auth/current-user.query.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/** Verifies loading, anonymous, authenticated, error, and non-looping 401 states. */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { useCurrentUser } from './current-user.query';

const publicUser = {
id: '26d34dc0-8e4c-4bd0-9e3b-7b839b44e486',
email: 'lifter@example.com',
displayName: 'Go For Lifter',
avatarUrl: null,
};

afterEach(() => vi.unstubAllGlobals());

function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

return function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}

describe('useCurrentUser', () => {
it('represents the loading state', () => {
vi.stubGlobal(
'fetch',
vi.fn(() => new Promise(() => {})),
);

const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});

expect(result.current.authenticationStatus).toBe('loading');
expect(result.current.user).toBeNull();
});

it('represents the authenticated state', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ user: publicUser })),
);

const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});

await waitFor(() =>
expect(result.current.authenticationStatus).toBe('authenticated'),
);
expect(result.current.user).toEqual(publicUser);
});

it.each([
['anonymous response', Response.json({ user: null })],
['401 response', new Response(null, { status: 401 })],
])('represents %s as anonymous', async (_label, response) => {
const fetchMock = vi.fn().mockResolvedValue(response);
vi.stubGlobal('fetch', fetchMock);

const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});

await waitFor(() =>
expect(result.current.authenticationStatus).toBe('anonymous'),
);
expect(result.current.user).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('represents an API failure as an error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(null, { status: 500 })),
);

const { result } = renderHook(() => useCurrentUser(), {
wrapper: createWrapper(),
});

await waitFor(() =>
expect(result.current.authenticationStatus).toBe('error'),
);
expect(result.current.user).toBeNull();
});
});
34 changes: 34 additions & 0 deletions apps/web/src/features/auth/current-user.query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Exposes the authoritative TanStack Query source for browser authentication state. */
import { queryOptions, useQuery } from '@tanstack/react-query';

import { fetchCurrentUser } from './auth.api';

export const currentUserQueryKey = ['auth', 'current-user'] as const;

export const currentUserQueryOptions = queryOptions({
queryKey: currentUserQueryKey,
queryFn: fetchCurrentUser,
retry: false,
});

export type AuthenticationStatus =
'loading' | 'anonymous' | 'authenticated' | 'error';

export function useCurrentUser() {
const query = useQuery(currentUserQueryOptions);
let authenticationStatus: AuthenticationStatus = 'anonymous';

if (query.isPending) {
authenticationStatus = 'loading';
} else if (query.isError) {
authenticationStatus = 'error';
} else if (query.data.user) {
authenticationStatus = 'authenticated';
}

return {
...query,
authenticationStatus,
user: query.data?.user ?? null,
};
}
Loading