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
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -100,3 +100,92 @@ describe('makeAuthMiddleware', () => {
})
})

describe('makeOptionalAuthMiddleware', () => {
let mockVerifier: { verify: jest.Mock<Promise<JwtPayload>, [string]> }
let middleware: any
let mockRequest: Partial<Request>
let mockResponse: Partial<Response>
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()
})
})

20 changes: 13 additions & 7 deletions apps/opencase/src/interfaces/http/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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()
}
}

Loading