diff --git a/apps/opencase/src/interfaces/http/middleware/__tests__/auth.test.ts b/apps/opencase/src/interfaces/http/middleware/__tests__/auth.test.ts index aec3dc9..2bcafb2 100644 --- a/apps/opencase/src/interfaces/http/middleware/__tests__/auth.test.ts +++ b/apps/opencase/src/interfaces/http/middleware/__tests__/auth.test.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from 'express' -import { makeAuthMiddleware } from '../auth' +import { makeAuthMiddleware, makeOptionalAuthMiddleware } from '../auth' import { type JwtPayload } from 'jsonwebtoken' describe('makeAuthMiddleware', () => { @@ -100,3 +100,92 @@ describe('makeAuthMiddleware', () => { }) }) +describe('makeOptionalAuthMiddleware', () => { + let mockVerifier: { verify: jest.Mock, [string]> } + let middleware: any + let mockRequest: Partial + let mockResponse: Partial + let mockNext: jest.Mock + let responseJson: jest.Mock + let responseStatus: jest.Mock + let responseSetHeader: jest.Mock + + beforeEach(() => { + mockVerifier = { + verify: jest.fn() + } + + middleware = makeOptionalAuthMiddleware(mockVerifier as any) + + responseJson = jest.fn() + responseStatus = jest.fn().mockReturnValue({ json: responseJson }) + responseSetHeader = jest.fn() + + mockRequest = { + header: jest.fn() + } + + mockResponse = { + status: responseStatus, + json: responseJson, + setHeader: responseSetHeader + } + + mockNext = jest.fn() + }) + + it('should treat a request with no Authorization header as anonymous', async () => { + (mockRequest.header as jest.Mock).mockReturnValue(undefined) + + await middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect((mockRequest as any).isAuthenticated).toBe(false) + expect(mockNext).toHaveBeenCalled() + expect(responseStatus).not.toHaveBeenCalled() + }) + + it('should authenticate when the token is valid', async () => { + const token = 'valid-token' + const payload = { sub: 'user-123', tenantId: 'tenant-1' }; + + (mockRequest.header as jest.Mock).mockReturnValue(`Bearer ${token}`) + mockVerifier.verify.mockResolvedValue(payload as any) + + await middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect((mockRequest as any).isAuthenticated).toBe(true) + expect((mockRequest as any).tenantId).toBe('tenant-1') + expect(mockNext).toHaveBeenCalled() + expect(responseStatus).not.toHaveBeenCalled() + }) + + it('should treat a valid token missing tenantId as anonymous', async () => { + const token = 'valid-token' + const payload = { sub: 'user-123' }; + + (mockRequest.header as jest.Mock).mockReturnValue(`Bearer ${token}`) + mockVerifier.verify.mockResolvedValue(payload as any) + + await middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect((mockRequest as any).isAuthenticated).toBe(false) + expect(mockNext).toHaveBeenCalled() + expect(responseStatus).not.toHaveBeenCalled() + }) + + it('should return 401 when an explicitly supplied token fails verification', async () => { + const token = 'garbage-token' + const error = new Error('jwt expired'); + + (mockRequest.header as jest.Mock).mockReturnValue(`Bearer ${token}`) + mockVerifier.verify.mockRejectedValue(error) + + await middleware(mockRequest as Request, mockResponse as Response, mockNext) + + expect(responseSetHeader).toHaveBeenCalledWith('WWW-Authenticate', 'Bearer error="invalid_token"') + expect(responseStatus).toHaveBeenCalledWith(401) + expect(responseJson).toHaveBeenCalledWith({ error: 'Invalid token', message: 'jwt expired' }) + expect(mockNext).not.toHaveBeenCalled() + }) +}) + diff --git a/apps/opencase/src/interfaces/http/middleware/auth.ts b/apps/opencase/src/interfaces/http/middleware/auth.ts index 6631142..57dd0ee 100644 --- a/apps/opencase/src/interfaces/http/middleware/auth.ts +++ b/apps/opencase/src/interfaces/http/middleware/auth.ts @@ -32,8 +32,13 @@ export function makeAuthMiddleware (verifier: OidcJwtVerifier) { /** * Optional auth middleware — attempts JWT verification but passes through - * even if the token is missing or invalid. Used for CASE Provider API routes - * where public-licensed frameworks are accessible without auth. + * unauthenticated if no token is supplied at all. Used for CASE Provider API + * routes where public-licensed frameworks are accessible without auth. + * + * If a Bearer token IS supplied but fails verification, this rejects with 401 + * rather than silently falling back to anonymous access — an explicitly + * presented credential that doesn't verify should not be indistinguishable + * from "no credential was offered" (see RFC 6750 §3, invalid_token). * * Sets `req.isAuthenticated` to true/false so controllers can decide. * @@ -63,12 +68,13 @@ export function makeOptionalAuthMiddleware (verifier: OidcJwtVerifier) { ;(req as any).tenantId = tokenTenantId ;(req as any).user = payload ;(req as any).isAuthenticated = true - } catch { - // Token was invalid — treat as unauthenticated - ;(req as any).isAuthenticated = false + return next() + } catch (err: any) { + // A token was explicitly presented but failed verification — reject + // rather than treating this the same as an anonymous request. + res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"') + return res.status(401).json({ error: 'Invalid token', message: err?.message }) } - - return next() } }