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
37 changes: 37 additions & 0 deletions apps/api/src/auth/guards/authentication.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/** Establishes a typed, trusted request identity from the server-side session. */
import type { Request, RequestHandler } from 'express';

export type AuthenticatedRequest = Request & {
session: Request['session'] & { userId: string };
};

export function isAuthenticatedRequest(
request: Request,
): request is AuthenticatedRequest {
return (
typeof request.session.userId === 'string' &&
request.session.userId.length > 0
);
}

export function getAuthenticatedUserId(request: Request) {
if (!isAuthenticatedRequest(request)) {
throw new Error('Authenticated request identity is unavailable');
}

return request.session.userId;
}

export const requireAuthentication: RequestHandler = (
request,
response,
next,
) => {
const userId = request.session.userId;
if (typeof userId !== 'string' || userId.length === 0) {
response.status(401).json({ error: 'authentication_required' });
return;
}

next();
};
129 changes: 129 additions & 0 deletions apps/api/src/auth/guards/authorization.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/** Verifies authentication and ownership authorization through protected HTTP behavior. */
import express from 'express';
import session from 'express-session';
import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';

import { createSessionMiddleware } from '../session/session.middleware.js';
import {
establishAuthenticatedTestSession,
withAuthenticatedTestUser,
} from '../../test/authenticated-session.test-helper.js';
import {
getAuthenticatedUserId,
requireAuthentication,
} from './authentication.middleware.js';
import { requireAuthorization } from './authorization.middleware.js';

const sessionConfiguration = {
SESSION_SECRET: 'test-session-secret-at-least-32-characters',
SESSION_DURATION_SECONDS: 604800,
SESSION_COOKIE: {
name: 'goforlift.sid',
httpOnly: true,
secure: false,
sameSite: 'lax' as const,
path: '/',
maxAgeMs: 604800000,
},
};

function createProtectedTestApp(
userOwnsResource: (resourceId: string, userId: string) => Promise<boolean>,
) {
const app = express();
app.use(
createSessionMiddleware(new session.MemoryStore(), sessionConfiguration),
);
app.use(establishAuthenticatedTestSession());
app.get(
'/protected/:resourceId',
requireAuthentication,
requireAuthorization((authenticatedRequest) => {
const resourceId = authenticatedRequest.params.resourceId;
return (
typeof resourceId === 'string' &&
userOwnsResource(resourceId, authenticatedRequest.session.userId)
);
}),
(authenticatedRequest, response) => {
response.status(200).json({
resourceId: authenticatedRequest.params.resourceId,
userId: getAuthenticatedUserId(authenticatedRequest),
});
},
);

return app;
}

describe('authentication and authorization guards', () => {
it('returns 401 when a protected request has no authenticated session', async () => {
const userOwnsResource = vi.fn(() => Promise.resolve(true));
const app = createProtectedTestApp(userOwnsResource);

const response = await request(app).get('/protected/routine-123');

expect(response.status).toBe(401);
expect(response.body).toEqual({ error: 'authentication_required' });
expect(userOwnsResource).not.toHaveBeenCalled();
});

it('uses the session user ID for an allowed ownership check', async () => {
const userOwnsResource = vi.fn(() => Promise.resolve(true));
const app = createProtectedTestApp(userOwnsResource);

const response = await request(app)
.get('/protected/routine-123')
.set(withAuthenticatedTestUser('owner-123'));

expect(response.status).toBe(200);
expect(response.body).toEqual({
resourceId: 'routine-123',
userId: 'owner-123',
});
expect(userOwnsResource).toHaveBeenCalledWith('routine-123', 'owner-123');
});

it('returns 403 for authenticated cross-user access', async () => {
const userOwnsResource = vi.fn((_resourceId, userId) =>
Promise.resolve(userId === 'owner-123'),
);
const app = createProtectedTestApp(userOwnsResource);

const response = await request(app)
.get('/protected/routine-123')
.set(withAuthenticatedTestUser('different-user'));

expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'forbidden' });
});

it('does not let client-provided ownership override the session identity', async () => {
const userOwnsResource = vi.fn((_resourceId, userId) =>
Promise.resolve(userId === 'owner-123'),
);
const app = createProtectedTestApp(userOwnsResource);

const response = await request(app)
.get('/protected/routine-123?userId=owner-123')
.set(withAuthenticatedTestUser('attacker-456'));

expect(response.status).toBe(403);
expect(userOwnsResource).toHaveBeenCalledWith(
'routine-123',
'attacker-456',
);
});

it('fails closed when ownership cannot be established', async () => {
const app = createProtectedTestApp(() => Promise.resolve(false));

const response = await request(app)
.get('/protected/missing-resource')
.set(withAuthenticatedTestUser('owner-123'));

expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'forbidden' });
});
});
33 changes: 33 additions & 0 deletions apps/api/src/auth/guards/authorization.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** Enforces fail-closed authorization checks using the trusted session identity. */
import type { RequestHandler } from 'express';

import {
isAuthenticatedRequest,
type AuthenticatedRequest,
} from './authentication.middleware.js';

export type AuthorizationCheck = (
request: AuthenticatedRequest,
) => boolean | Promise<boolean>;

export function requireAuthorization(
isAuthorized: AuthorizationCheck,
): RequestHandler {
return async (request, response, next) => {
if (!isAuthenticatedRequest(request)) {
response.status(401).json({ error: 'authentication_required' });
return;
}

try {
if (!(await isAuthorized(request))) {
response.status(403).json({ error: 'forbidden' });
return;
}

next();
} catch (error) {
next(error);
}
};
}
18 changes: 18 additions & 0 deletions apps/api/src/test/authenticated-session.test-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Provides reusable test middleware for establishing a server-side session identity. */
import type { RequestHandler } from 'express';

const TEST_USER_HEADER = 'x-test-authenticated-user-id';

export function establishAuthenticatedTestSession(): RequestHandler {
return (request, _response, next) => {
const userId = request.header(TEST_USER_HEADER);
if (userId) {
request.session.userId = userId;
}
next();
};
}

export function withAuthenticatedTestUser(userId: string) {
return { [TEST_USER_HEADER]: userId };
}
2 changes: 1 addition & 1 deletion apps/api/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"sourceMap": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
"exclude": ["src/**/*.test.ts", "src/**/*.test-helper.ts"]
}
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,12 @@ A user must not be able to access or modify another user's:

Client-side hiding of UI elements is not authorization.

Protected endpoints return `401 Unauthorized` when no valid authenticated
session identity exists. They return `403 Forbidden` when an authenticated user
is not allowed to perform the requested action. Resource queries and ownership
checks must use the user ID established from the server-side session; ownership
identifiers supplied by the client are never authoritative.


# 28. Guest Architecture

Expand Down