Skip to content

Commit b8b816c

Browse files
committed
Bearer auth middleware
1 parent c8f4d62 commit b8b816c

File tree

9 files changed

+450
-2
lines changed

9 files changed

+450
-2
lines changed

src/server/auth/errors.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,13 @@ export class InvalidClientMetadataError extends OAuthError {
179179
constructor(message: string, errorUri?: string) {
180180
super("invalid_client_metadata", message, errorUri);
181181
}
182-
}
182+
}
183+
184+
/**
185+
* Insufficient scope error - The request requires higher privileges than provided by the access token.
186+
*/
187+
export class InsufficientScopeError extends OAuthError {
188+
constructor(message: string, errorUri?: string) {
189+
super("insufficient_scope", message, errorUri);
190+
}
191+
}

src/server/auth/handlers/authorize.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { OAuthRegisteredClientsStore } from '../clients.js';
44
import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js';
55
import express, { Response } from 'express';
66
import supertest from 'supertest';
7+
import { AuthInfo } from '../types.js';
8+
import { InvalidTokenError } from '../errors.js';
79

810
describe('Authorization Handler', () => {
911
// Mock client data
@@ -72,6 +74,18 @@ describe('Authorization Handler', () => {
7274
};
7375
},
7476

77+
async verifyAccessToken(token: string): Promise<AuthInfo> {
78+
if (token === 'valid_token') {
79+
return {
80+
token,
81+
clientId: 'valid-client',
82+
scopes: ['read', 'write'],
83+
expiresAt: Date.now() / 1000 + 3600
84+
};
85+
}
86+
throw new InvalidTokenError('Token is invalid or expired');
87+
},
88+
7589
async revokeToken(): Promise<void> {
7690
// Do nothing in mock
7791
}

src/server/auth/handlers/revoke.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { OAuthRegisteredClientsStore } from '../clients.js';
44
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js';
55
import express, { Response } from 'express';
66
import supertest from 'supertest';
7+
import { AuthInfo } from '../types.js';
8+
import { InvalidTokenError } from '../errors.js';
79

810
describe('Revocation Handler', () => {
911
// Mock client data
@@ -53,6 +55,18 @@ describe('Revocation Handler', () => {
5355
};
5456
},
5557

58+
async verifyAccessToken(token: string): Promise<AuthInfo> {
59+
if (token === 'valid_token') {
60+
return {
61+
token,
62+
clientId: 'valid-client',
63+
scopes: ['read', 'write'],
64+
expiresAt: Date.now() / 1000 + 3600
65+
};
66+
}
67+
throw new InvalidTokenError('Token is invalid or expired');
68+
},
69+
5670
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
5771
// Success - do nothing in mock
5872
}
@@ -86,6 +100,18 @@ describe('Revocation Handler', () => {
86100
expires_in: 3600,
87101
refresh_token: 'new_mock_refresh_token'
88102
};
103+
},
104+
105+
async verifyAccessToken(token: string): Promise<AuthInfo> {
106+
if (token === 'valid_token') {
107+
return {
108+
token,
109+
clientId: 'valid-client',
110+
scopes: ['read', 'write'],
111+
expiresAt: Date.now() / 1000 + 3600
112+
};
113+
}
114+
throw new InvalidTokenError('Token is invalid or expired');
89115
}
90116
// No revokeToken method
91117
};

src/server/auth/handlers/token.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens }
55
import express, { Response } from 'express';
66
import supertest from 'supertest';
77
import * as pkceChallenge from 'pkce-challenge';
8-
import { InvalidGrantError } from '../errors.js';
8+
import { InvalidGrantError, InvalidTokenError } from '../errors.js';
9+
import { AuthInfo } from '../types.js';
910

1011
// Mock pkce-challenge
1112
jest.mock('pkce-challenge', () => ({
@@ -84,6 +85,18 @@ describe('Token Handler', () => {
8485
throw new InvalidGrantError('The refresh token is invalid or has expired');
8586
},
8687

88+
async verifyAccessToken(token: string): Promise<AuthInfo> {
89+
if (token === 'valid_token') {
90+
return {
91+
token,
92+
clientId: 'valid-client',
93+
scopes: ['read', 'write'],
94+
expiresAt: Date.now() / 1000 + 3600
95+
};
96+
}
97+
throw new InvalidTokenError('Token is invalid or expired');
98+
},
99+
87100
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
88101
// Do nothing in mock
89102
}
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { Request, Response } from "express";
2+
import { requireBearerAuth } from "./bearerAuth.js";
3+
import { AuthInfo } from "../types.js";
4+
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
5+
import { OAuthServerProvider } from "../provider.js";
6+
import { OAuthRegisteredClientsStore } from "../clients.js";
7+
8+
// Mock provider
9+
const mockVerifyAccessToken = jest.fn();
10+
const mockProvider: OAuthServerProvider = {
11+
clientsStore: {} as OAuthRegisteredClientsStore,
12+
authorize: jest.fn(),
13+
challengeForAuthorizationCode: jest.fn(),
14+
exchangeAuthorizationCode: jest.fn(),
15+
exchangeRefreshToken: jest.fn(),
16+
verifyAccessToken: mockVerifyAccessToken,
17+
};
18+
19+
describe("requireBearerAuth middleware", () => {
20+
let mockRequest: Partial<Request>;
21+
let mockResponse: Partial<Response>;
22+
let nextFunction: jest.Mock;
23+
24+
beforeEach(() => {
25+
mockRequest = {
26+
headers: {},
27+
};
28+
mockResponse = {
29+
status: jest.fn().mockReturnThis(),
30+
json: jest.fn(),
31+
set: jest.fn().mockReturnThis(),
32+
};
33+
nextFunction = jest.fn();
34+
jest.clearAllMocks();
35+
});
36+
37+
it("should call next when token is valid", async () => {
38+
const validAuthInfo: AuthInfo = {
39+
token: "valid-token",
40+
clientId: "client-123",
41+
scopes: ["read", "write"],
42+
};
43+
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
44+
45+
mockRequest.headers = {
46+
authorization: "Bearer valid-token",
47+
};
48+
49+
const middleware = requireBearerAuth({ provider: mockProvider });
50+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
51+
52+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
53+
expect(mockRequest.auth).toEqual(validAuthInfo);
54+
expect(nextFunction).toHaveBeenCalled();
55+
expect(mockResponse.status).not.toHaveBeenCalled();
56+
expect(mockResponse.json).not.toHaveBeenCalled();
57+
});
58+
59+
it("should require specific scopes when configured", async () => {
60+
const authInfo: AuthInfo = {
61+
token: "valid-token",
62+
clientId: "client-123",
63+
scopes: ["read"],
64+
};
65+
mockVerifyAccessToken.mockResolvedValue(authInfo);
66+
67+
mockRequest.headers = {
68+
authorization: "Bearer valid-token",
69+
};
70+
71+
const middleware = requireBearerAuth({
72+
provider: mockProvider,
73+
requiredScopes: ["read", "write"]
74+
});
75+
76+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
77+
78+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
79+
expect(mockResponse.status).toHaveBeenCalledWith(403);
80+
expect(mockResponse.set).toHaveBeenCalledWith(
81+
"WWW-Authenticate",
82+
expect.stringContaining('Bearer error="insufficient_scope"')
83+
);
84+
expect(mockResponse.json).toHaveBeenCalledWith(
85+
expect.objectContaining({ error: "insufficient_scope", error_description: "Insufficient scope" })
86+
);
87+
expect(nextFunction).not.toHaveBeenCalled();
88+
});
89+
90+
it("should accept token with all required scopes", async () => {
91+
const authInfo: AuthInfo = {
92+
token: "valid-token",
93+
clientId: "client-123",
94+
scopes: ["read", "write", "admin"],
95+
};
96+
mockVerifyAccessToken.mockResolvedValue(authInfo);
97+
98+
mockRequest.headers = {
99+
authorization: "Bearer valid-token",
100+
};
101+
102+
const middleware = requireBearerAuth({
103+
provider: mockProvider,
104+
requiredScopes: ["read", "write"]
105+
});
106+
107+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
108+
109+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
110+
expect(mockRequest.auth).toEqual(authInfo);
111+
expect(nextFunction).toHaveBeenCalled();
112+
expect(mockResponse.status).not.toHaveBeenCalled();
113+
expect(mockResponse.json).not.toHaveBeenCalled();
114+
});
115+
116+
it("should return 401 when no Authorization header is present", async () => {
117+
const middleware = requireBearerAuth({ provider: mockProvider });
118+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
119+
120+
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
121+
expect(mockResponse.status).toHaveBeenCalledWith(401);
122+
expect(mockResponse.set).toHaveBeenCalledWith(
123+
"WWW-Authenticate",
124+
expect.stringContaining('Bearer error="invalid_token"')
125+
);
126+
expect(mockResponse.json).toHaveBeenCalledWith(
127+
expect.objectContaining({ error: "invalid_token", error_description: "Missing Authorization header" })
128+
);
129+
expect(nextFunction).not.toHaveBeenCalled();
130+
});
131+
132+
it("should return 401 when Authorization header format is invalid", async () => {
133+
mockRequest.headers = {
134+
authorization: "InvalidFormat",
135+
};
136+
137+
const middleware = requireBearerAuth({ provider: mockProvider });
138+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
139+
140+
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
141+
expect(mockResponse.status).toHaveBeenCalledWith(401);
142+
expect(mockResponse.set).toHaveBeenCalledWith(
143+
"WWW-Authenticate",
144+
expect.stringContaining('Bearer error="invalid_token"')
145+
);
146+
expect(mockResponse.json).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
error: "invalid_token",
149+
error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'"
150+
})
151+
);
152+
expect(nextFunction).not.toHaveBeenCalled();
153+
});
154+
155+
it("should return 401 when token verification fails with InvalidTokenError", async () => {
156+
mockRequest.headers = {
157+
authorization: "Bearer invalid-token",
158+
};
159+
160+
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired"));
161+
162+
const middleware = requireBearerAuth({ provider: mockProvider });
163+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
164+
165+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token");
166+
expect(mockResponse.status).toHaveBeenCalledWith(401);
167+
expect(mockResponse.set).toHaveBeenCalledWith(
168+
"WWW-Authenticate",
169+
expect.stringContaining('Bearer error="invalid_token"')
170+
);
171+
expect(mockResponse.json).toHaveBeenCalledWith(
172+
expect.objectContaining({ error: "invalid_token", error_description: "Token expired" })
173+
);
174+
expect(nextFunction).not.toHaveBeenCalled();
175+
});
176+
177+
it("should return 403 when access token has insufficient scopes", async () => {
178+
mockRequest.headers = {
179+
authorization: "Bearer valid-token",
180+
};
181+
182+
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write"));
183+
184+
const middleware = requireBearerAuth({ provider: mockProvider });
185+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
186+
187+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
188+
expect(mockResponse.status).toHaveBeenCalledWith(403);
189+
expect(mockResponse.set).toHaveBeenCalledWith(
190+
"WWW-Authenticate",
191+
expect.stringContaining('Bearer error="insufficient_scope"')
192+
);
193+
expect(mockResponse.json).toHaveBeenCalledWith(
194+
expect.objectContaining({ error: "insufficient_scope", error_description: "Required scopes: read, write" })
195+
);
196+
expect(nextFunction).not.toHaveBeenCalled();
197+
});
198+
199+
it("should return 500 when a ServerError occurs", async () => {
200+
mockRequest.headers = {
201+
authorization: "Bearer valid-token",
202+
};
203+
204+
mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue"));
205+
206+
const middleware = requireBearerAuth({ provider: mockProvider });
207+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
208+
209+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
210+
expect(mockResponse.status).toHaveBeenCalledWith(500);
211+
expect(mockResponse.json).toHaveBeenCalledWith(
212+
expect.objectContaining({ error: "server_error", error_description: "Internal server issue" })
213+
);
214+
expect(nextFunction).not.toHaveBeenCalled();
215+
});
216+
217+
it("should return 400 for generic OAuthError", async () => {
218+
mockRequest.headers = {
219+
authorization: "Bearer valid-token",
220+
};
221+
222+
mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error"));
223+
224+
const middleware = requireBearerAuth({ provider: mockProvider });
225+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
226+
227+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
228+
expect(mockResponse.status).toHaveBeenCalledWith(400);
229+
expect(mockResponse.json).toHaveBeenCalledWith(
230+
expect.objectContaining({ error: "custom_error", error_description: "Some OAuth error" })
231+
);
232+
expect(nextFunction).not.toHaveBeenCalled();
233+
});
234+
235+
it("should return 500 when unexpected error occurs", async () => {
236+
mockRequest.headers = {
237+
authorization: "Bearer valid-token",
238+
};
239+
240+
mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error"));
241+
242+
const middleware = requireBearerAuth({ provider: mockProvider });
243+
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
244+
245+
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
246+
expect(mockResponse.status).toHaveBeenCalledWith(500);
247+
expect(mockResponse.json).toHaveBeenCalledWith(
248+
expect.objectContaining({ error: "server_error", error_description: "Internal Server Error" })
249+
);
250+
expect(nextFunction).not.toHaveBeenCalled();
251+
});
252+
});

0 commit comments

Comments
 (0)