feat(auth): accept internal token in authMiddleware#393
Conversation
Agents and CLI tools authenticate via X-Internal-Token header, but authMiddleware (mounted on all /api/* routes) only accepted cookie/bearer auth. This blocked programmatic access to task board, template, and loop endpoints. Add internal token check as a fallback before cookie auth. Prerequisite for PR Shepherd → Task Board goal tree integration (Phase 3 multi-agent orchestration). Co-Authored-By: Claude Opus 4.6 <[email protected]>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 3 issue(s) (2 warning).
server/auth.ts
Correct and consistent with existing internal-token patterns in the codebase, but needs test coverage for the new middleware path and should ideally use constant-time comparison for the token check.
- 🟡 missing_tests (L65): No test covers the new internal-token auth path in
authMiddleware. The existingauth.test.tsonly testsvalidateConfig,login/verifyToken, andverifyWsAuth— there are no tests for the middleware itself. At minimum, test that a request with a validx-internal-tokenheader bypasses JWT auth, and that an invalid token is rejected.[fixable] - 🟡 unsafe_assumptions (L65): The
===comparison is not constant-time, making it theoretically vulnerable to timing attacks against the 64-char hex token. While the risk is low in practice (network jitter dominates, and the token is random per startup),crypto.timingSafeEqualis the idiomatic choice for secret comparison and is already available via Node'scryptomodule. The existingverifyInternalTokeninapp.ts(line 345) has the same pattern, so this is a pre-existing convention — but both should ideally use constant-time comparison.[fixable] - 🔵 regressions (L65): The internal-token check is placed before JWT verification, which means any route behind
authMiddleware(line 588 ofapp.ts) is now accessible with the internal token. This is likely intentional —app.tsalready has per-routeverifyInternalTokenguards for/api/reposand/api/sessions— but the middleware-level bypass is broader. Confirm this is the desired scope (all/api/*routes, not just specific internal endpoints).
| if (req.path === '/auth/login') return next(); | ||
|
|
||
| // Allow internal-token auth for programmatic access (agents, CLI) | ||
| if (req.headers['x-internal-token'] === INTERNAL_TOKEN) return next(); |
There was a problem hiding this comment.
🟡 missing_tests: No test covers the new internal-token auth path in authMiddleware. The existing auth.test.ts only tests validateConfig, login/verifyToken, and verifyWsAuth — there are no tests for the middleware itself. At minimum, test that a request with a valid x-internal-token header bypasses JWT auth, and that an invalid token is rejected. [fixable]
| if (req.path === '/auth/login') return next(); | ||
|
|
||
| // Allow internal-token auth for programmatic access (agents, CLI) | ||
| if (req.headers['x-internal-token'] === INTERNAL_TOKEN) return next(); |
There was a problem hiding this comment.
🟡 unsafe_assumptions: The === comparison is not constant-time, making it theoretically vulnerable to timing attacks against the 64-char hex token. While the risk is low in practice (network jitter dominates, and the token is random per startup), crypto.timingSafeEqual is the idiomatic choice for secret comparison and is already available via Node's crypto module. The existing verifyInternalToken in app.ts (line 345) has the same pattern, so this is a pre-existing convention — but both should ideally use constant-time comparison. [fixable]
| if (req.path === '/auth/login') return next(); | ||
|
|
||
| // Allow internal-token auth for programmatic access (agents, CLI) | ||
| if (req.headers['x-internal-token'] === INTERNAL_TOKEN) return next(); |
There was a problem hiding this comment.
🔵 regressions: The internal-token check is placed before JWT verification, which means any route behind authMiddleware (line 588 of app.ts) is now accessible with the internal token. This is likely intentional — app.ts already has per-route verifyInternalToken guards for /api/repos and /api/sessions — but the middleware-level bypass is broader. Confirm this is the desired scope (all /api/* routes, not just specific internal endpoints).
- Use crypto.timingSafeEqual for constant-time token comparison instead of plain === to prevent timing side-channel attacks - Add 4 authMiddleware tests: valid token bypass, invalid token rejection, no-auth rejection, /auth/login passthrough - Document intentional broad /api/* scope for internal token Co-Authored-By: Claude Opus 4.6 <[email protected]>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 3 issue(s) (1 warning).
server/__tests__/auth.test.ts
Solid implementation — timingSafeEqual is the right call. Two gaps: the test doesn't exercise the timingSafeEqual path (wrong-length token skips it), and a pre-existing === comparison of the same token in app.ts undermines the timing-safety added here.
- 🟡 missing_tests (L119): The 'rejects invalid internal token' test uses
'wrong-token'(11 chars), which fails theinternalToken.length === INTERNAL_TOKEN.lengthcheck and never exercises thetimingSafeEqualbranch. Add a test with a 64-char hex string (same length as INTERNAL_TOKEN) to verify the constant-time comparison itself rejects wrong values.[fixable]
server/auth.ts
Solid implementation — timingSafeEqual is the right call. Two gaps: the test doesn't exercise the timingSafeEqual path (wrong-length token skips it), and a pre-existing === comparison of the same token in app.ts undermines the timing-safety added here.
- 🔵 bugs (L71): The length guard compares string lengths (
internalToken.length), buttimingSafeEqualoperates on buffer byte lengths. If a header value contains multi-byte UTF-8 characters with the same string length (64),Buffer.from(internalToken)will produce a longer buffer thanBuffer.from(INTERNAL_TOKEN)(which is pure hex/ASCII), andtimingSafeEqualwill throw aTypeErrorinstead of returning false. This surfaces as a 500 to the client instead of 401. Safer pattern: create both buffers first, compare their.lengthproperties, then calltimingSafeEqual.[fixable] - 🔵 style (L68): This PR correctly uses
timingSafeEqualfor the internal token, but the pre-existingverifyInternalToken()helper atserver/app.ts:344still uses plain===for the same token. Consider updating it totimingSafeEqualas well for consistency — otherwise the timing-safe check here can be bypassed by targeting routes that use the other helper.[fixable]
| }); | ||
|
|
||
| it('rejects invalid internal token', () => { | ||
| const req = mockReq({ 'x-internal-token': 'wrong-token' }); |
There was a problem hiding this comment.
🟡 missing_tests: The 'rejects invalid internal token' test uses 'wrong-token' (11 chars), which fails the internalToken.length === INTERNAL_TOKEN.length check and never exercises the timingSafeEqual branch. Add a test with a 64-char hex string (same length as INTERNAL_TOKEN) to verify the constant-time comparison itself rejects wrong values. [fixable]
| const internalToken = req.headers['x-internal-token'] as string | undefined; | ||
| if ( | ||
| internalToken && | ||
| internalToken.length === INTERNAL_TOKEN.length && |
There was a problem hiding this comment.
🔵 bugs: The length guard compares string lengths (internalToken.length), but timingSafeEqual operates on buffer byte lengths. If a header value contains multi-byte UTF-8 characters with the same string length (64), Buffer.from(internalToken) will produce a longer buffer than Buffer.from(INTERNAL_TOKEN) (which is pure hex/ASCII), and timingSafeEqual will throw a TypeError instead of returning false. This surfaces as a 500 to the client instead of 401. Safer pattern: create both buffers first, compare their .length properties, then call timingSafeEqual. [fixable]
| // Allow internal-token auth for programmatic access (agents, CLI). | ||
| // All /api/* routes are accessible with the internal token — this is | ||
| // intentional to support task board, template, and loop endpoints. | ||
| const internalToken = req.headers['x-internal-token'] as string | undefined; |
There was a problem hiding this comment.
🔵 style: This PR correctly uses timingSafeEqual for the internal token, but the pre-existing verifyInternalToken() helper at server/app.ts:344 still uses plain === for the same token. Consider updating it to timingSafeEqual as well for consistency — otherwise the timing-safe check here can be bypassed by targeting routes that use the other helper. [fixable]
Centaur ReviewFound 2 issue(s) (2 warning).
|
Address second Centaur review: - Update verifyInternalToken in app.ts to use timingSafeEqual (was using === for all /api/repos, /api/sessions, /api/internal/* routes) - Add test with same-length invalid token to exercise the timingSafeEqual codepath (not just the length short-circuit) Co-Authored-By: Claude Opus 4.6 <[email protected]>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 3 issue(s) (1 warning).
server/auth.ts
Clean security improvement — timingSafeEqual is correctly applied with length pre-check in both locations. Main concern is the duplicated verification logic between auth.ts and app.ts, and missing tests for the app.ts copy.
- 🔵 style (L69): The length-check + timingSafeEqual pattern is duplicated between authMiddleware (auth.ts:69-73) and verifyInternalToken (app.ts:345-347). Consider extracting a shared helper like
isValidInternalToken(candidate: string): booleanin internal-token.ts to keep the comparison logic in one place.[fixable]
server/app.ts
Clean security improvement — timingSafeEqual is correctly applied with length pre-check in both locations. Main concern is the duplicated verification logic between auth.ts and app.ts, and missing tests for the app.ts copy.
- 🟡 missing_tests (L344): verifyInternalToken in app.ts was updated to use timingSafeEqual in this PR but has zero test coverage — no unit or integration test exercises this function. The authMiddleware tests cover the identical logic in auth.ts, but if the two copies diverge, there's no safety net for this one. A supertest-based test hitting e.g. GET /api/repos with valid/invalid tokens would close the gap.
[fixable]
server/__tests__/auth.test.ts
Clean security improvement — timingSafeEqual is correctly applied with length pre-check in both locations. Main concern is the duplicated verification logic between auth.ts and app.ts, and missing tests for the app.ts copy.
- 🔵 missing_tests (L92): The new authMiddleware test suite doesn't verify that normal JWT/cookie auth still works when no x-internal-token header is present. The existing 'login and verifyToken' tests cover this indirectly through other describe blocks, but adding one test here (valid JWT, no internal-token header → next() called) would document the fall-through contract in the same place the internal-token tests live.
[fixable]
| // All /api/* routes are accessible with the internal token — this is | ||
| // intentional to support task board, template, and loop endpoints. | ||
| const internalToken = req.headers['x-internal-token'] as string | undefined; | ||
| if ( |
There was a problem hiding this comment.
🔵 style: The length-check + timingSafeEqual pattern is duplicated between authMiddleware (auth.ts:69-73) and verifyInternalToken (app.ts:345-347). Consider extracting a shared helper like isValidInternalToken(candidate: string): boolean in internal-token.ts to keep the comparison logic in one place. [fixable]
| @@ -342,7 +342,9 @@ app.post('/api/permission/:permId/respond', (req, res) => { | |||
| // --- Repo registry API (internal-token auth, no cookie needed) --- | |||
|
|
|||
| function verifyInternalToken(req: express.Request): boolean { | |||
There was a problem hiding this comment.
🟡 missing_tests: verifyInternalToken in app.ts was updated to use timingSafeEqual in this PR but has zero test coverage — no unit or integration test exercises this function. The authMiddleware tests cover the identical logic in auth.ts, but if the two copies diverge, there's no safety net for this one. A supertest-based test hitting e.g. GET /api/repos with valid/invalid tokens would close the gap. [fixable]
| }); | ||
| }); | ||
|
|
||
| describe('authMiddleware — internal token', () => { |
There was a problem hiding this comment.
🔵 missing_tests: The new authMiddleware test suite doesn't verify that normal JWT/cookie auth still works when no x-internal-token header is present. The existing 'login and verifyToken' tests cover this indirectly through other describe blocks, but adding one test here (valid JWT, no internal-token header → next() called) would document the fall-through contract in the same place the internal-token tests live. [fixable]
…h test Address third Centaur review: - Extract shared isValidInternalToken() into internal-token.ts to eliminate duplicated timingSafeEqual logic between auth.ts and app.ts - Add JWT fall-through test documenting that Bearer auth works when no internal token header is present Co-Authored-By: Claude Opus 4.6 <[email protected]>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 1 issue(s).
server/__tests__/auth.test.ts
Clean, well-tested PR. The isValidInternalToken helper correctly uses timingSafeEqual with a length guard, the middleware integration is placed at the right precedence (after login bypass, before JWT), and the test suite covers valid token, wrong-length, same-length-wrong-value, no-auth, login bypass, and JWT fall-through paths.
- 🔵 style (L2):
verifyTokenis imported statically but never used — the existing tests that reference it (lines 55, 63) use dynamicimport('../auth.js')instead, shadowing the static import. RemoveverifyTokenfrom the static import to keep it clean.[fixable]
| import { describe, it, expect } from 'vitest'; | ||
| import { validateConfig } from '../auth.js'; | ||
| import { describe, it, expect, vi } from 'vitest'; | ||
| import { validateConfig, authMiddleware, verifyToken } from '../auth.js'; |
There was a problem hiding this comment.
🔵 style: verifyToken is imported statically but never used — the existing tests that reference it (lines 55, 63) use dynamic import('../auth.js') instead, shadowing the static import. Remove verifyToken from the static import to keep it clean. [fixable]
Co-Authored-By: Claude Opus 4.6 <[email protected]>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
LGTM — no issues found.
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 2 issue(s).
server/auth.ts
Clean, well-tested PR. The isValidInternalToken extraction with timingSafeEqual is correct, and tests cover all key branches (valid, wrong-length, same-length-wrong-value, no-auth, JWT fallthrough). The main consideration is the broadened scope of internal-token auth — from a handful of internal endpoints to all API routes.
- 🔵 unsafe_assumptions (L67): The
as string | undefinedcast onreq.headers['x-internal-token']hides the fact that Express types this asstring | string[] | undefined. In practice this is safe — Node.js joins duplicate non-set-cookieheaders with,so the runtime type is alwaysstring | undefined. But the same cast appears inapp.ts:345too. Consider wideningisValidInternalTokento acceptstring | string[] | undefined(rejecting arrays early) to eliminate both casts and make the code self-documenting.[fixable] - 🔵 style (L64): Prior to this PR, internal-token auth was scoped to ~6 explicitly guarded endpoints (
/api/repos,/api/sessionsPOST,/api/internal/task-tools/*). Placing it inauthMiddlewareextends access to all ~40+ routes behindapp.use('/api', authMiddleware)— file writes, inbox CRUD, push token management, etc. The comment documents this as intentional, but it's a meaningful privilege escalation worth a second look: any process on the machine that can read~/.mitzo/internal-tokennow has full API access rather than limited internal-tool access.
| // Allow internal-token auth for programmatic access (agents, CLI). | ||
| // All /api/* routes are accessible with the internal token — this is | ||
| // intentional to support task board, template, and loop endpoints. | ||
| if (isValidInternalToken(req.headers['x-internal-token'] as string | undefined)) { |
There was a problem hiding this comment.
🔵 unsafe_assumptions: The as string | undefined cast on req.headers['x-internal-token'] hides the fact that Express types this as string | string[] | undefined. In practice this is safe — Node.js joins duplicate non-set-cookie headers with , so the runtime type is always string | undefined. But the same cast appears in app.ts:345 too. Consider widening isValidInternalToken to accept string | string[] | undefined (rejecting arrays early) to eliminate both casts and make the code self-documenting. [fixable]
| export function authMiddleware(req: Request, res: Response, next: NextFunction) { | ||
| if (req.path === '/auth/login') return next(); | ||
|
|
||
| // Allow internal-token auth for programmatic access (agents, CLI). |
There was a problem hiding this comment.
🔵 style: Prior to this PR, internal-token auth was scoped to ~6 explicitly guarded endpoints (/api/repos, /api/sessions POST, /api/internal/task-tools/*). Placing it in authMiddleware extends access to all ~40+ routes behind app.use('/api', authMiddleware) — file writes, inbox CRUD, push token management, etc. The comment documents this as intentional, but it's a meaningful privilege escalation worth a second look: any process on the machine that can read ~/.mitzo/internal-token now has full API access rather than limited internal-tool access.
Accept Express header type (string | string[] | undefined) directly, rejecting arrays early. Removes unsafe casts in both call sites. Co-Authored-By: Claude Opus 4.6 <[email protected]>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
LGTM — no issues found.
Summary
X-Internal-Tokencheck toauthMiddlewareso agents and CLI tools can access task board, template, and loop endpoints programmaticallyPrerequisite for PR Shepherd -> Task Board goal tree integration (Phase 3 multi-agent orchestration, dimakis/mgmt#TBD).
Test plan
POST /api/tasks,POST /api/loop/start, etc.🤖 Generated with Claude Code