diff --git a/app/api/projects/[projectId]/members/route.ts b/app/api/projects/[projectId]/members/route.ts index 642306f..cd11988 100644 --- a/app/api/projects/[projectId]/members/route.ts +++ b/app/api/projects/[projectId]/members/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCurrentUser } from '@/lib/auth/getCurrentUser'; import { createProjectService, createUserRepository } from '@/lib/api/services'; +import { validateProjectMemberRole } from '@/lib/validators/projectValidator'; import { UnauthorizedError, NotFoundError } from '@/lib/errors'; import { handleApiError, jsonError } from '@/lib/api/handleError'; -import type { ProjectMemberRole } from '@/lib/types'; export const runtime = 'nodejs'; @@ -44,9 +44,9 @@ export async function POST( } const email = String(body.email ?? ''); - const role = ( + const role = validateProjectMemberRole( typeof body.role === 'string' ? body.role : 'member' - ) as ProjectMemberRole; + ); // メールアドレスからユーザーを解決する const targetUser = createUserRepository().findByEmail(email); diff --git a/lib/auth/session.ts b/lib/auth/session.ts index 8346fa0..097af27 100644 --- a/lib/auth/session.ts +++ b/lib/auth/session.ts @@ -50,8 +50,15 @@ export function verifySessionToken(token: string): number | null { try { const payload = JSON.parse( Buffer.from(encoded, 'base64url').toString('utf-8') - ) as { uid?: unknown }; + ) as { uid?: unknown; iat?: unknown }; if (typeof payload.uid !== 'number') return null; + // サーバ側でも有効期限を検証する(クッキーのmaxAgeだけに依存しない) + if ( + typeof payload.iat !== 'number' || + Date.now() - payload.iat > SESSION_MAX_AGE_SECONDS * 1000 + ) { + return null; + } return payload.uid; } catch { return null; diff --git a/lib/validators/projectValidator.ts b/lib/validators/projectValidator.ts index b47592e..72fe9a0 100644 --- a/lib/validators/projectValidator.ts +++ b/lib/validators/projectValidator.ts @@ -1,5 +1,5 @@ import { ValidationError } from '@/lib/errors'; -import type { ProjectStatus } from '@/lib/types'; +import type { ProjectMemberRole, ProjectStatus } from '@/lib/types'; const MAX_NAME_LENGTH = 200; const MAX_DESCRIPTION_LENGTH = 2000; @@ -9,6 +9,7 @@ const VALID_STATUSES: ProjectStatus[] = [ 'completed', 'archived', ]; +const VALID_MEMBER_ROLES: ProjectMemberRole[] = ['admin', 'member', 'guest']; export interface ProjectCreateInput { name: string; @@ -64,3 +65,10 @@ export function validateProjectUpdate(input: ProjectUpdateInput): void { throw new ValidationError('無効なステータスです', 'status'); } } + +export function validateProjectMemberRole(role: string): ProjectMemberRole { + if (!VALID_MEMBER_ROLES.includes(role as ProjectMemberRole)) { + throw new ValidationError('無効なメンバーロールです', 'role'); + } + return role as ProjectMemberRole; +} diff --git a/tests/unit/lib/auth/session.test.ts b/tests/unit/lib/auth/session.test.ts index ac30155..3d7cc2c 100644 --- a/tests/unit/lib/auth/session.test.ts +++ b/tests/unit/lib/auth/session.test.ts @@ -1,6 +1,18 @@ import { describe, it, expect } from 'vitest'; +import crypto from 'node:crypto'; import { createSessionToken, verifySessionToken } from '@/lib/auth/session'; +function forgeToken(uid: number, iat: number): string { + const secret = process.env.SESSION_SECRET!; + const payload = JSON.stringify({ uid, iat }); + const encoded = Buffer.from(payload, 'utf-8').toString('base64url'); + const signature = crypto + .createHmac('sha256', secret) + .update(encoded) + .digest('base64url'); + return `${encoded}.${signature}`; +} + describe('session token', () => { it('round-trips a user id through create and verify', () => { const token = createSessionToken(42); @@ -34,6 +46,15 @@ describe('session token', () => { expect(verifySessionToken('')).toBeNull(); }); + it('returns null for an expired token (stale iat)', () => { + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + expect(verifySessionToken(forgeToken(42, eightDaysAgo))).toBeNull(); + }); + + it('accepts a freshly-issued token', () => { + expect(verifySessionToken(forgeToken(42, Date.now()))).toBe(42); + }); + it('returns null when the payload uid is not a number', () => { const encoded = Buffer.from( JSON.stringify({ uid: 'not-a-number', iat: Date.now() }) diff --git a/vitest.config.ts b/vitest.config.ts index 4318b8b..e697520 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -22,11 +22,14 @@ export default defineConfig({ coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], + include: ['repositories/**', 'services/**'], exclude: [ 'node_modules/**', 'dist/**', '.next/**', '.steering/**', + 'app/**', + 'components/**', '**/*.config.{ts,js,mjs}', '**/types/**', 'tests/**',