feat: resolve + cache auth at startup via getAuth() — fail-fast on invalid token

This commit is contained in:
Janpeter Visser 2026-05-01 14:59:52 +02:00
parent 994f28f103
commit 0b75a08d7e
3 changed files with 81 additions and 0 deletions

73
__tests__/auth.test.ts Normal file
View file

@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
apiToken: {
findUnique: vi.fn(),
},
},
}))
import { prisma } from '../src/prisma.js'
import { getAuth, resetAuthCache } from '../src/auth.js'
const mockPrisma = prisma as unknown as {
apiToken: { findUnique: ReturnType<typeof vi.fn> }
}
const VALID_TOKEN_ROW = {
id: 'token-id-1',
user_id: 'user-1',
token_hash: 'irrelevant-checked-by-crypto',
revoked_at: null,
user: { username: 'alice', is_demo: false },
}
beforeEach(() => {
vi.clearAllMocks()
resetAuthCache()
process.env.SCRUM4ME_TOKEN = 'test-token-value'
})
describe('getAuth', () => {
it('returns AuthContext for a valid token', async () => {
mockPrisma.apiToken.findUnique.mockResolvedValue(VALID_TOKEN_ROW)
const auth = await getAuth()
expect(auth.tokenId).toBe('token-id-1')
expect(auth.userId).toBe('user-1')
expect(auth.username).toBe('alice')
expect(auth.isDemo).toBe(false)
})
it('caches the result — prisma is only called once on repeated calls', async () => {
mockPrisma.apiToken.findUnique.mockResolvedValue(VALID_TOKEN_ROW)
await getAuth()
await getAuth()
expect(mockPrisma.apiToken.findUnique).toHaveBeenCalledTimes(1)
})
it('throws when SCRUM4ME_TOKEN is not set', async () => {
delete process.env.SCRUM4ME_TOKEN
await expect(getAuth()).rejects.toThrow('SCRUM4ME_TOKEN is not set')
})
it('throws when token is not found in the database', async () => {
mockPrisma.apiToken.findUnique.mockResolvedValue(null)
await expect(getAuth()).rejects.toThrow('invalid or revoked')
})
it('throws when token is revoked', async () => {
mockPrisma.apiToken.findUnique.mockResolvedValue({
...VALID_TOKEN_ROW,
revoked_at: new Date(),
})
await expect(getAuth()).rejects.toThrow('invalid or revoked')
})
})

View file

@ -10,6 +10,10 @@ export type AuthContext = {
let cached: AuthContext | null = null
export function resetAuthCache(): void {
cached = null
}
export async function getAuth(): Promise<AuthContext> {
if (cached) return cached

View file

@ -22,10 +22,14 @@ import { registerUpdateJobStatusTool } from './tools/update-job-status.js'
import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js'
import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js'
import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js'
import { getAuth } from './auth.js'
const VERSION = '0.1.0'
async function main() {
const auth = await getAuth()
console.error(`scrum4me-mcp: authenticated as ${auth.username} (token ${auth.tokenId})`)
const server = new McpServer(
{ name: 'scrum4me-mcp', version: VERSION },
{