diff --git a/README.md b/README.md index 1f16161..b876bf7 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ Three auth methods, three roles: | Method | Details | |--------|----------| -| Session cookie | `POST /api/auth/login` sets `mc-session` (7-day expiry) | +| Session cookie | `POST /api/auth/login` sets `__Host-mc-session` (7-day expiry) for HTTPS, `mc-session` for HTTP | | API key | `x-api-key` header matches `API_KEY` env var | | Google Sign-In | OAuth with admin approval workflow | diff --git a/SKILL.md b/SKILL.md index c3b19e3..fe9f998 100644 --- a/SKILL.md +++ b/SKILL.md @@ -44,7 +44,7 @@ MC supports two auth methods: | Method | Header | Use Case | |--------|--------|----------| | API Key | `x-api-key: ` or `Authorization: Bearer ` | Agents, scripts, CI/CD | -| Session cookie | `Cookie: mc-session=` | Browser UI | +| Session cookie | `Cookie: __Host-mc-session=` (HTTPS) or `mc-session=` (HTTP) | Browser UI | **Roles (hierarchical):** `viewer` < `operator` < `admin` diff --git a/openapi.json b/openapi.json index dfe02b0..610e830 100644 --- a/openapi.json +++ b/openapi.json @@ -1757,7 +1757,7 @@ }, "responses": { "200": { - "description": "Login successful. Sets mc-session cookie.", + "description": "Login successful. Sets __Host-mc-session cookie (HTTPS) or mc-session (HTTP).", "content": { "application/json": { "schema": { @@ -1775,7 +1775,7 @@ "schema": { "type": "string" }, - "description": "mc-session cookie" + "description": "__Host-mc-session cookie (secure HTTPS) or mc-session (HTTP legacy)" } } }, @@ -7345,7 +7345,7 @@ "sessionCookie": { "type": "apiKey", "in": "cookie", - "name": "mc-session" + "name": "__Host-mc-session" }, "apiKey": { "type": "apiKey", diff --git a/src/app/api/auth/google/route.ts b/src/app/api/auth/google/route.ts index e9596f0..3507970 100644 --- a/src/app/api/auth/google/route.ts +++ b/src/app/api/auth/google/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server' import { createSession } from '@/lib/auth' import { getDatabase, logAuditEvent } from '@/lib/db' import { verifyGoogleIdToken } from '@/lib/google-auth' -import { getMcSessionCookieOptions } from '@/lib/session-cookie' +import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie' import { loginLimiter } from '@/lib/rate-limit' function upsertAccessRequest(input: { @@ -100,10 +100,10 @@ export async function POST(request: NextRequest) { }, }) - const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https' - || new URL(request.url).protocol === 'https:' + const isSecureRequest = isRequestSecure(request) + const cookieName = getMcSessionCookieName(isSecureRequest) - response.cookies.set('mc-session', token, { + response.cookies.set(cookieName, token, { ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }), }) diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 0269861..22f4879 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import { authenticateUser, createSession } from '@/lib/auth' import { logAuditEvent } from '@/lib/db' -import { getMcSessionCookieOptions } from '@/lib/session-cookie' +import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie' import { loginLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' @@ -43,10 +43,10 @@ export async function POST(request: Request) { }, }) - const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https' - || new URL(request.url).protocol === 'https:' + const isSecureRequest = isRequestSecure(request) + const cookieName = getMcSessionCookieName(isSecureRequest) - response.cookies.set('mc-session', token, { + response.cookies.set(cookieName, token, { ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }), }) diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 1eff1a8..15184ef 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,13 +1,12 @@ import { NextResponse } from 'next/server' import { destroySession, getUserFromRequest } from '@/lib/auth' import { logAuditEvent } from '@/lib/db' -import { getMcSessionCookieOptions } from '@/lib/session-cookie' +import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure, parseMcSessionCookieHeader } from '@/lib/session-cookie' export async function POST(request: Request) { const user = getUserFromRequest(request) const cookieHeader = request.headers.get('cookie') || '' - const match = cookieHeader.match(/(?:^|;\s*)mc-session=([^;]*)/) - const token = match ? decodeURIComponent(match[1]) : null + const token = parseMcSessionCookieHeader(cookieHeader) if (token) { destroySession(token) @@ -19,8 +18,10 @@ export async function POST(request: Request) { } const response = NextResponse.json({ ok: true }) - response.cookies.set('mc-session', '', { - ...getMcSessionCookieOptions({ maxAgeSeconds: 0 }), + const isSecureRequest = isRequestSecure(request) + const cookieName = getMcSessionCookieName(isSecureRequest) + response.cookies.set(cookieName, '', { + ...getMcSessionCookieOptions({ maxAgeSeconds: 0, isSecureRequest }), }) return response diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 27c6698..6c3e25c 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getUserFromRequest, updateUser, requireRole, destroyAllUserSessions, createSession } from '@/lib/auth' import { logAuditEvent } from '@/lib/db' import { verifyPassword } from '@/lib/password' -import { getMcSessionCookieOptions } from '@/lib/session-cookie' +import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie' import { logger } from '@/lib/logger' export async function GET(request: Request) { @@ -117,9 +117,9 @@ export async function PATCH(request: NextRequest) { // Issue a fresh session cookie after password change (old ones were just revoked) if (updates.password) { const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id ?? 1) - const isSecureRequest = request.headers.get('x-forwarded-proto') === 'https' - || new URL(request.url).protocol === 'https:' - response.cookies.set('mc-session', token, { + const isSecureRequest = isRequestSecure(request) + const cookieName = getMcSessionCookieName(isSecureRequest) + response.cookies.set(cookieName, token, { ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }), }) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5bc8b9d..dbbfde0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'crypto' import { getDatabase } from './db' import { hashPassword, verifyPassword } from './password' import { logSecurityEvent } from './security-events' +import { parseMcSessionCookieHeader } from './session-cookie' // Plugin hook: extensions can register a custom API key resolver without modifying this file. type AuthResolverHook = (apiKey: string, agentName: string | null) => User | null @@ -346,7 +347,7 @@ export function getUserFromRequest(request: Request): User | null { // Check session cookie const cookieHeader = request.headers.get('cookie') || '' - const sessionToken = parseCookie(cookieHeader, 'mc-session') + const sessionToken = parseMcSessionCookieHeader(cookieHeader) if (sessionToken) { const user = validateSession(sessionToken) if (user) return { ...user, agent_name: agentName } @@ -510,7 +511,3 @@ export function requireRole( return { user } } -function parseCookie(cookieHeader: string, name: string): string | null { - const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)) - return match ? decodeURIComponent(match[1]) : null -} diff --git a/src/lib/session-cookie.ts b/src/lib/session-cookie.ts index c427302..d3ffc4b 100644 --- a/src/lib/session-cookie.ts +++ b/src/lib/session-cookie.ts @@ -1,17 +1,27 @@ import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies' -// TODO: Migrate cookie name to use __Host- prefix for secure contexts. -// The __Host- prefix enforces Secure + Path=/ and prevents subdomain attacks. -// Migration path: add MC_SESSION_COOKIE_NAME usage to all callers -// (proxy.ts, auth/login, auth/logout, auth/google, lib/auth.ts, tests) -// then switch the default to use __Host- prefix when secure=true. -export const MC_SESSION_COOKIE_NAME = 'mc-session' +export const MC_SESSION_COOKIE_NAME = '__Host-mc-session' +export const LEGACY_MC_SESSION_COOKIE_NAME = 'mc-session' +const MC_SESSION_COOKIE_NAMES = [MC_SESSION_COOKIE_NAME, LEGACY_MC_SESSION_COOKIE_NAME] as const -export function getMcSessionCookieName(secure: boolean): string { - // TODO: Enable __Host- prefix once all callers use this function. - // When enabled: return secure ? '__Host-mc-session' : 'mc-session' - void secure - return MC_SESSION_COOKIE_NAME +export function getMcSessionCookieName(isSecureRequest: boolean): string { + return isSecureRequest ? MC_SESSION_COOKIE_NAME : LEGACY_MC_SESSION_COOKIE_NAME +} + +export function isRequestSecure(request: Request): boolean { + return request.headers.get('x-forwarded-proto') === 'https' + || new URL(request.url).protocol === 'https:' +} + +export function parseMcSessionCookieHeader(cookieHeader: string): string | null { + if (!cookieHeader) return null + for (const cookieName of MC_SESSION_COOKIE_NAMES) { + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]*)`)) + if (match) { + return decodeURIComponent(match[1]) + } + } + return null } function envFlag(name: string): boolean | undefined { @@ -25,23 +35,13 @@ function envFlag(name: string): boolean | undefined { export function getMcSessionCookieOptions(input: { maxAgeSeconds: number; isSecureRequest?: boolean }): Partial { const secureEnv = envFlag('MC_COOKIE_SECURE') - // Explicit env wins. Otherwise auto-detect: only set secure if request came over HTTPS. - // Falls back to NODE_ENV=production when no request hint is available. const secure = secureEnv ?? input.isSecureRequest ?? process.env.NODE_ENV === 'production' - // Strict is safest for this app (same-site UI + API), but allow override for edge cases. - const sameSiteRaw = (process.env.MC_COOKIE_SAMESITE || 'strict').toLowerCase() - const sameSite: ResponseCookie['sameSite'] = - sameSiteRaw === 'lax' ? 'lax' : - sameSiteRaw === 'none' ? 'none' : - 'strict' - return { httpOnly: true, secure, - sameSite, + sameSite: 'strict', maxAge: input.maxAgeSeconds, path: '/', } } - diff --git a/src/proxy.ts b/src/proxy.ts index 84fbc27..45e56d1 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto' import os from 'node:os' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' +import { MC_SESSION_COOKIE_NAME, LEGACY_MC_SESSION_COOKIE_NAME } from '@/lib/session-cookie' /** Constant-time string comparison using Node.js crypto. */ function safeCompare(a: string, b: string): boolean { @@ -189,7 +190,7 @@ export function proxy(request: NextRequest) { } // Check for session cookie - const sessionToken = request.cookies.get('mc-session')?.value + const sessionToken = request.cookies.get(MC_SESSION_COOKIE_NAME)?.value || request.cookies.get(LEGACY_MC_SESSION_COOKIE_NAME)?.value // API routes: accept session cookie OR API key if (pathname.startsWith('/api/')) { diff --git a/tests/login-flow.spec.ts b/tests/login-flow.spec.ts index 64d8522..86d6e28 100644 --- a/tests/login-flow.spec.ts +++ b/tests/login-flow.spec.ts @@ -45,7 +45,7 @@ test.describe('Login Flow', () => { const cookies = res.headers()['set-cookie'] expect(cookies).toBeDefined() - expect(cookies).toContain('mc-session') + expect(cookies).toMatch(/(__Host-)?mc-session/) }) test('login API rejects wrong password', async ({ request }) => { @@ -66,15 +66,16 @@ test.describe('Login Flow', () => { // Extract session cookie from Set-Cookie header const setCookie = loginRes.headers()['set-cookie'] || '' - const match = setCookie.match(/mc-session=([^;]+)/) + const match = setCookie.match(/(?:__Host-)?mc-session=([^;]+)/) expect(match).toBeTruthy() - const sessionToken = match![1] + const sessionCookiePair = match?.[0] || '' - // Use the session cookie to access /api/auth/me + // Use the same cookie name/value returned by login const meRes = await request.get('/api/auth/me', { - headers: { 'cookie': `mc-session=${sessionToken}`, 'x-forwarded-for': '10.88.88.2' } + headers: { 'cookie': sessionCookiePair, 'x-forwarded-for': '10.88.88.2' } }) expect(meRes.status()).toBe(200) + const body = await meRes.json() expect(body.user?.username).toBe(TEST_USER) expect(typeof body.user?.workspace_id).toBe('number')