Skip to content

Commit 37603cd

Browse files
accorvinclaude
andauthored
fix: await async validateToken in auth middleware (#36)
tokenValidator.validateToken() is async but was not being awaited in authMiddleware. The returned Promise is truthy, so the null-check never triggered — causing token auth to silently proceed with undefined ownerEmail, undefined scopes, and isAdmin=false for all API tokens. Adds regression tests with an async mock to match real-world behavior. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3657328 commit 37603cd

2 files changed

Lines changed: 76 additions & 1 deletion

File tree

shared/server/__tests__/auth-tokens.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,81 @@ describe('requireScope', () => {
240240
})
241241
})
242242

243+
describe('authMiddleware with async tokenValidator (real-world behavior)', () => {
244+
let storage, authMiddleware, requireAdmin
245+
246+
beforeEach(() => {
247+
storage = createMockStorage()
248+
// The real tokenValidator.validateToken is async — the mock must match
249+
const tokenValidator = {
250+
async validateToken(rawToken) {
251+
if (rawToken === 'tt_validtoken00000000000000000000') {
252+
return { id: 'tok-1', ownerEmail: 'admin@test.com', name: 'Test', scopes: null }
253+
}
254+
if (rawToken === 'tt_scopedtoken000000000000000000000') {
255+
return { id: 'tok-2', ownerEmail: 'admin@test.com', name: 'Scoped', scopes: ['roster:read'] }
256+
}
257+
return null
258+
},
259+
touchLastUsed() {}
260+
}
261+
const result = createAuthMiddleware(
262+
storage.readFromStorage.bind(storage),
263+
storage.writeToStorage.bind(storage),
264+
{ tokenValidator }
265+
)
266+
authMiddleware = result.authMiddleware
267+
requireAdmin = result.requireAdmin
268+
})
269+
270+
it('resolves ownerEmail from async validateToken', async () => {
271+
const req = createMockReq({
272+
headers: { authorization: 'Bearer tt_validtoken00000000000000000000' }
273+
})
274+
const res = createMockRes()
275+
let nextCalled = false
276+
await authMiddleware(req, res, () => { nextCalled = true })
277+
expect(nextCalled).toBe(true)
278+
expect(req.userEmail).toBe('admin@test.com')
279+
expect(req.authMethod).toBe('token')
280+
})
281+
282+
it('grants admin to token owner who is in the admin allowlist', async () => {
283+
const req = createMockReq({
284+
headers: { authorization: 'Bearer tt_validtoken00000000000000000000' }
285+
})
286+
const res = createMockRes()
287+
await authMiddleware(req, res, () => {})
288+
expect(req.isAdmin).toBe(true)
289+
290+
// requireAdmin should pass
291+
const res2 = createMockRes()
292+
let adminNextCalled = false
293+
requireAdmin(req, res2, () => { adminNextCalled = true })
294+
expect(adminNextCalled).toBe(true)
295+
})
296+
297+
it('rejects invalid token with 401 when validateToken is async', async () => {
298+
const req = createMockReq({
299+
headers: { authorization: 'Bearer tt_garbage0000000000000000000000' }
300+
})
301+
const res = createMockRes()
302+
let nextCalled = false
303+
await authMiddleware(req, res, () => { nextCalled = true })
304+
expect(nextCalled).toBe(false)
305+
expect(res.statusCode).toBe(401)
306+
})
307+
308+
it('preserves scopes from async validateToken', async () => {
309+
const req = createMockReq({
310+
headers: { authorization: 'Bearer tt_scopedtoken000000000000000000000' }
311+
})
312+
const res = createMockRes()
313+
await authMiddleware(req, res, () => {})
314+
expect(req.tokenScopes).toEqual(['roster:read'])
315+
})
316+
})
317+
243318
describe('proxySecretGuard with Bearer tokens', () => {
244319
let originalEnv, tokenValidator
245320

shared/server/auth.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ function createAuthMiddleware(readFromStorage, writeToStorage, options = {}) {
182182
if (authHeader && authHeader.startsWith('Bearer tt_')) {
183183
const rawToken = authHeader.slice('Bearer '.length);
184184
if (tokenValidator) {
185-
const tokenRecord = tokenValidator.validateToken(rawToken);
185+
const tokenRecord = await tokenValidator.validateToken(rawToken);
186186
if (!tokenRecord) {
187187
// HARD STOP: invalid/expired token must NEVER fall through
188188
return res.status(401).json({ error: 'Invalid or expired API token' });

0 commit comments

Comments
 (0)