diff --git a/app/domain/user/entities/user.ts b/app/domain/user/entities/user.ts new file mode 100644 index 00000000..e3852306 --- /dev/null +++ b/app/domain/user/entities/user.ts @@ -0,0 +1,11 @@ +export type UserRole = "admin" | "staff" | "viewer" + +export interface User { + id: string + oidcSub: string + email: string + name: string | null + role: UserRole + createdAt: Date + updatedAt: Date +} diff --git a/app/domain/user/repositories/userCommandRepository.test.ts b/app/domain/user/repositories/userCommandRepository.test.ts new file mode 100644 index 00000000..70cefd86 --- /dev/null +++ b/app/domain/user/repositories/userCommandRepository.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "bun:test" +import type { TransactionDbClient } from "../../../infrastructure/db/client" +import type { User, UserRole } from "../entities/user" +import { + type CreateUser, + createUser, + type UpdateUser, + updateUser, +} from "./userCommandRepository" + +const validUser: Omit = { + oidcSub: "auth0|123456789", + email: "test@example.com", + name: "Test User", + role: "staff" as UserRole, +} + +describe("createUser", () => { + const mockDbClient = {} as TransactionDbClient + + it("バリデーションを通過したユーザーを作成できる", async () => { + const mockImpl: CreateUser = async ({ user }) => ({ + ...user, + id: "test-id-123", + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + }) + + const result = await createUser({ + user: validUser, + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).not.toBeNull() + expect(result.email).toBe(validUser.email) + expect(result.oidcSub).toBe(validUser.oidcSub) + expect(result.role).toBe(validUser.role) + }) + + it("OIDC Subjectが空の場合はエラーを返す", async () => { + await expect( + createUser({ + user: { ...validUser, oidcSub: "" }, + repositoryImpl: async () => ({} as User), + dbClient: mockDbClient, + }), + ).rejects.toThrow("OIDC Subjectは必須です") + }) + + it("無効なメールアドレスの場合はエラーを返す", async () => { + await expect( + createUser({ + user: { ...validUser, email: "invalid-email" }, + repositoryImpl: async () => ({} as User), + dbClient: mockDbClient, + }), + ).rejects.toThrow("有効なメールアドレスを入力してください") + }) + + it("メールアドレスが空の場合はエラーを返す", async () => { + await expect( + createUser({ + user: { ...validUser, email: "" }, + repositoryImpl: async () => ({} as User), + dbClient: mockDbClient, + }), + ).rejects.toThrow("有効なメールアドレスを入力してください") + }) + + it("無効なロールの場合はエラーを返す", async () => { + await expect( + createUser({ + user: { ...validUser, role: "invalid" as UserRole }, + repositoryImpl: async () => ({} as User), + dbClient: mockDbClient, + }), + ).rejects.toThrow("無効なロールです") + }) + + it("nameがnullでも作成できる", async () => { + const mockImpl: CreateUser = async ({ user }) => ({ + ...user, + id: "test-id-123", + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + }) + + const result = await createUser({ + user: { ...validUser, name: null }, + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).not.toBeNull() + expect(result.name).toBeNull() + }) +}) + +describe("updateUser", () => { + const mockDbClient = {} as TransactionDbClient + + it("バリデーションを通過したユーザーを更新できる", async () => { + const existingUser: Omit = { + ...validUser, + id: "existing-id", + updatedAt: new Date("2025-01-02"), + } + + const mockImpl: UpdateUser = async ({ user }) => ({ + ...user, + createdAt: new Date("2025-01-01"), + }) + + const result = await updateUser({ + user: existingUser, + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).not.toBeNull() + expect(result.id).toBe(existingUser.id) + expect(result.email).toBe(existingUser.email) + }) + + it("無効なメールアドレスで更新しようとするとエラーを返す", async () => { + await expect( + updateUser({ + user: { + ...validUser, + id: "test-id", + email: "not-an-email", + updatedAt: new Date(), + }, + repositoryImpl: async () => ({} as User), + dbClient: mockDbClient, + }), + ).rejects.toThrow("有効なメールアドレスを入力してください") + }) +}) diff --git a/app/domain/user/repositories/userCommandRepository.ts b/app/domain/user/repositories/userCommandRepository.ts new file mode 100644 index 00000000..f0eabb5d --- /dev/null +++ b/app/domain/user/repositories/userCommandRepository.ts @@ -0,0 +1,49 @@ +import { + createUserImpl, + updateUserImpl, +} from "../../../infrastructure/domain/user/userCommandRepositoryImpl" +import type { CommandRepositoryFunction, WithRepositoryImpl } from "../../types" +import type { User, UserRole } from "../entities/user" + +const validateUser = (user: Omit) => { + if (!user.oidcSub || user.oidcSub.trim() === "") { + throw new Error("OIDC Subjectは必須です") + } + + if (!user.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) { + throw new Error("有効なメールアドレスを入力してください") + } + + const validRoles: UserRole[] = ["admin", "staff", "viewer"] + if (!validRoles.includes(user.role)) { + throw new Error("無効なロールです") + } +} + +export type CreateUser = CommandRepositoryFunction< + { user: Omit }, + User +> + +export type UpdateUser = CommandRepositoryFunction< + { user: Omit }, + User +> + +export const createUser: WithRepositoryImpl = async ({ + repositoryImpl = createUserImpl, + dbClient, + user, +}) => { + validateUser(user) + return repositoryImpl({ user, dbClient }) +} + +export const updateUser: WithRepositoryImpl = async ({ + repositoryImpl = updateUserImpl, + dbClient, + user, +}) => { + validateUser(user) + return repositoryImpl({ user, dbClient }) +} diff --git a/app/domain/user/repositories/userQueryRepository.test.ts b/app/domain/user/repositories/userQueryRepository.test.ts new file mode 100644 index 00000000..d839f1e9 --- /dev/null +++ b/app/domain/user/repositories/userQueryRepository.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test" +import type { DbClient } from "../../../infrastructure/db/client" +import type { User } from "../entities/user" +import { + type FindUserById, + type FindUserByOidcSub, + findUserById, + findUserByOidcSub, +} from "./userQueryRepository" + +const mockUser: User = { + id: "test-id-123", + oidcSub: "auth0|123456789", + email: "test@example.com", + name: "Test User", + role: "staff", + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), +} + +describe("findUserById", () => { + const mockDbClient = {} as DbClient + + it("存在するユーザーをIDで取得できる", async () => { + const mockImpl: FindUserById = async ({ user }) => + user.id === mockUser.id ? mockUser : null + + const result = await findUserById({ + user: { id: "test-id-123" }, + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).not.toBeNull() + expect(result?.id).toBe(mockUser.id) + expect(result?.email).toBe(mockUser.email) + }) + + it("存在しないIDならnullを返す", async () => { + const mockImpl: FindUserById = async () => null + + const result = await findUserById({ + user: { id: "non-existent" }, + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).toBeNull() + }) +}) + +describe("findUserByOidcSub", () => { + const mockDbClient = {} as DbClient + + it("存在するユーザーをOIDC Subjectで取得できる", async () => { + const mockImpl: FindUserByOidcSub = async ({ oidcSub }) => + oidcSub === mockUser.oidcSub ? mockUser : null + + const result = await findUserByOidcSub({ + oidcSub: "auth0|123456789", + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).not.toBeNull() + expect(result?.id).toBe(mockUser.id) + expect(result?.oidcSub).toBe(mockUser.oidcSub) + }) + + it("存在しないOIDC Subjectならnullを返す", async () => { + const mockImpl: FindUserByOidcSub = async () => null + + const result = await findUserByOidcSub({ + oidcSub: "non-existent", + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).toBeNull() + }) + + it("異なるプロバイダーのOIDC Subjectでも取得できる", async () => { + const googleUser: User = { + ...mockUser, + id: "google-user-id", + oidcSub: "google-oauth2|987654321", + } + + const mockImpl: FindUserByOidcSub = async ({ oidcSub }) => { + if (oidcSub === mockUser.oidcSub) return mockUser + if (oidcSub === googleUser.oidcSub) return googleUser + return null + } + + const result = await findUserByOidcSub({ + oidcSub: "google-oauth2|987654321", + repositoryImpl: mockImpl, + dbClient: mockDbClient, + }) + + expect(result).not.toBeNull() + expect(result?.oidcSub).toBe(googleUser.oidcSub) + }) +}) diff --git a/app/domain/user/repositories/userQueryRepository.ts b/app/domain/user/repositories/userQueryRepository.ts new file mode 100644 index 00000000..67821d37 --- /dev/null +++ b/app/domain/user/repositories/userQueryRepository.ts @@ -0,0 +1,32 @@ +import { + findUserByIdImpl, + findUserByOidcSubImpl, +} from "../../../infrastructure/domain/user/userQueryRepositoryImpl" +import type { QueryRepositoryFunction, WithRepositoryImpl } from "../../types" +import type { User } from "../entities/user" + +export type FindUserById = QueryRepositoryFunction< + { user: Pick }, + User | null +> + +export type FindUserByOidcSub = QueryRepositoryFunction< + { oidcSub: string }, + User | null +> + +export const findUserById: WithRepositoryImpl = async ({ + user, + repositoryImpl = findUserByIdImpl, + dbClient, +}) => { + return repositoryImpl({ user, dbClient }) +} + +export const findUserByOidcSub: WithRepositoryImpl = async ({ + oidcSub, + repositoryImpl = findUserByOidcSubImpl, + dbClient, +}) => { + return repositoryImpl({ oidcSub, dbClient }) +} diff --git a/app/infrastructure/db/schema.ts b/app/infrastructure/db/schema.ts index fa8f968b..af15fcb2 100644 --- a/app/infrastructure/db/schema.ts +++ b/app/infrastructure/db/schema.ts @@ -154,3 +154,32 @@ export const orderItemRelations = relations(orderItemTable, ({ one }) => ({ references: [orderTable.id], }), })) + +export const userRoleEnum = pgEnum("user_role", ["admin", "staff", "viewer"]) + +/** ユーザー */ +export const userTable = pgTable( + "user", + { + id: text("id").primaryKey(), // nanoidで生成される21文字のID + oidcSub: text("oidc_sub").notNull().unique(), + email: text("email").notNull(), + name: text("name"), + role: userRoleEnum().notNull().default("viewer"), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + index("user_oidc_sub_idx").on(table.oidcSub), + index("user_email_idx").on(table.email), + check( + "user_email_format", + sql`${table.email} ~ '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$'`, + ), + check("user_oidc_sub_not_empty", sql`char_length(${table.oidcSub}) >= 1`), + ], +) diff --git a/app/infrastructure/domain/user/userCommandRepositoryImpl.ts b/app/infrastructure/domain/user/userCommandRepositoryImpl.ts new file mode 100644 index 00000000..048731cf --- /dev/null +++ b/app/infrastructure/domain/user/userCommandRepositoryImpl.ts @@ -0,0 +1,49 @@ +import { eq } from "drizzle-orm" +import type { + CreateUser, + UpdateUser, +} from "../../../domain/user/repositories/userCommandRepository" +import { generateId } from "../../../utils/id" +import { userTable } from "../../db/schema" + +export const createUserImpl: CreateUser = async ({ user, dbClient }) => { + const id = generateId() + const now = new Date() + + const newUser = { + id, + oidcSub: user.oidcSub, + email: user.email, + name: user.name, + role: user.role, + createdAt: now, + updatedAt: now, + } + + await dbClient.insert(userTable).values(newUser) + + return newUser +} + +export const updateUserImpl: UpdateUser = async ({ user, dbClient }) => { + const now = new Date() + + const [updatedUser] = await dbClient + .update(userTable) + .set({ + email: user.email, + name: user.name, + role: user.role, + updatedAt: now, + }) + .where(eq(userTable.id, user.id)) + .returning() + + return ( + updatedUser || { + ...user, + createdAt: now, + updatedAt: now, + } + ) +} diff --git a/app/infrastructure/domain/user/userQueryRepositoryImpl.ts b/app/infrastructure/domain/user/userQueryRepositoryImpl.ts new file mode 100644 index 00000000..9d44fb24 --- /dev/null +++ b/app/infrastructure/domain/user/userQueryRepositoryImpl.ts @@ -0,0 +1,56 @@ +import { eq } from "drizzle-orm" +import type { User } from "../../../domain/user/entities/user" +import type { + FindUserById, + FindUserByOidcSub, +} from "../../../domain/user/repositories/userQueryRepository" +import { userTable } from "../../db/schema" + +export const findUserByIdImpl: FindUserById = async ({ user, dbClient }) => { + const results = await dbClient + .select() + .from(userTable) + .where(eq(userTable.id, user.id)) + .limit(1) + + const result = results[0] + if (!result) { + return null + } + + return { + id: result.id, + oidcSub: result.oidcSub, + email: result.email, + name: result.name, + role: result.role as User["role"], + createdAt: result.createdAt, + updatedAt: result.updatedAt, + } +} + +export const findUserByOidcSubImpl: FindUserByOidcSub = async ({ + oidcSub, + dbClient, +}) => { + const results = await dbClient + .select() + .from(userTable) + .where(eq(userTable.oidcSub, oidcSub)) + .limit(1) + + const result = results[0] + if (!result) { + return null + } + + return { + id: result.id, + oidcSub: result.oidcSub, + email: result.email, + name: result.name, + role: result.role as User["role"], + createdAt: result.createdAt, + updatedAt: result.updatedAt, + } +} diff --git a/app/middlewares/auth.test.ts b/app/middlewares/auth.test.ts new file mode 100644 index 00000000..ac5a9f79 --- /dev/null +++ b/app/middlewares/auth.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, beforeEach, afterEach, mock, spyOn } from "bun:test" +import { Hono } from "hono" +import type { User } from "../domain/user/entities/user" +import type { DbClient } from "../infrastructure/db/client" +import * as oidcAuth from "@hono/oidc-auth" +import * as resolveUserModule from "../usecases/user/resolveUserByOidcProfile" +import { setUserMiddleware, requireAuth, requireRole, getCurrentUser, isAuthenticated } from "./auth" + +const mockUser: User = { + id: "test-id-123", + oidcSub: "auth0|123456789", + email: "test@example.com", + name: "Test User", + role: "staff", + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), +} + +describe("setUserMiddleware", () => { + let getAuthSpy: ReturnType + let findOrCreateUserSpy: ReturnType + const mockDbClient = {} as DbClient + + beforeEach(() => { + getAuthSpy = spyOn(oidcAuth, "getAuth") + findOrCreateUserSpy = spyOn( + resolveUserModule, + "resolveUserByOidcProfile", + ) + }) + + afterEach(() => { + mock.restore() + }) + + it("認証済みユーザーの情報をContextに設定する", async () => { + const app = new Hono() + + getAuthSpy.mockImplementation(async () => ({ + sub: mockUser.oidcSub, + email: mockUser.email, + name: mockUser.name, + })) + + findOrCreateUserSpy.mockImplementation(async () => ({ + user: mockUser, + isNewUser: false, + })) + + app.use("*", async (c, next) => { + c.set("dbClient", mockDbClient) + await next() + }) + app.use("*", setUserMiddleware) + app.get("/", (c) => { + const user = c.get("user") + const isAuth = c.get("isAuthenticated") + return c.json({ user, isAuthenticated: isAuth }) + }) + + const res = await app.request("/") + const json = await res.json() + + expect(json.isAuthenticated).toBe(true) + expect(json.user).toEqual(mockUser) + }) + + it("未認証の場合はisAuthenticatedがfalseになる", async () => { + const app = new Hono() + + getAuthSpy.mockImplementation(async () => null) + + app.use("*", async (c, next) => { + c.set("dbClient", mockDbClient) + await next() + }) + app.use("*", setUserMiddleware) + app.get("/", (c) => { + const user = c.get("user") + const isAuth = c.get("isAuthenticated") + return c.json({ user, isAuthenticated: isAuth }) + }) + + const res = await app.request("/") + const json = await res.json() + + expect(json.isAuthenticated).toBe(false) + expect(json.user).toBeUndefined() + }) + + it("ユーザー作成/取得でエラーが発生した場合はisAuthenticatedがfalseになる", async () => { + const app = new Hono() + + getAuthSpy.mockImplementation(async () => ({ + sub: "error-user", + email: "error@example.com", + name: "Error User", + })) + + findOrCreateUserSpy.mockImplementation(async () => { + throw new Error("Database error") + }) + + const consoleSpy = spyOn(console, "error").mockImplementation(() => {}) + + app.use("*", async (c, next) => { + c.set("dbClient", mockDbClient) + await next() + }) + app.use("*", setUserMiddleware) + app.get("/", (c) => { + const isAuth = c.get("isAuthenticated") + return c.json({ isAuthenticated: isAuth }) + }) + + const res = await app.request("/") + const json = await res.json() + + expect(json.isAuthenticated).toBe(false) + expect(consoleSpy).toHaveBeenCalled() + }) +}) + +describe("requireAuth", () => { + it("認証済みユーザーは次の処理へ進める", async () => { + const app = new Hono() + + app.use("*", async (c, next) => { + c.set("isAuthenticated", true) + c.set("user", mockUser) + await next() + }) + app.use("*", requireAuth) + app.get("/", (c) => c.text("OK")) + + const res = await app.request("/") + expect(res.status).toBe(200) + expect(await res.text()).toBe("OK") + }) + + it("未認証ユーザーは401エラーになる", async () => { + const app = new Hono() + + app.use("*", async (c, next) => { + c.set("isAuthenticated", false) + await next() + }) + app.use("*", requireAuth) + app.get("/", (c) => c.text("OK")) + + const res = await app.request("/") + expect(res.status).toBe(401) + expect(await res.text()).toBe("Unauthorized") + }) +}) + +describe("requireRole", () => { + it("必要なロールを持つユーザーは次の処理へ進める", async () => { + const app = new Hono() + + app.use("*", async (c, next) => { + c.set("user", mockUser) + await next() + }) + app.use("*", requireRole(["staff", "admin"])) + app.get("/", (c) => c.text("OK")) + + const res = await app.request("/") + expect(res.status).toBe(200) + expect(await res.text()).toBe("OK") + }) + + it("必要なロールを持たないユーザーは403エラーになる", async () => { + const app = new Hono() + const viewerUser: User = { ...mockUser, role: "viewer" } + + app.use("*", async (c, next) => { + c.set("user", viewerUser) + await next() + }) + app.use("*", requireRole(["staff", "admin"])) + app.get("/", (c) => c.text("OK")) + + const res = await app.request("/") + expect(res.status).toBe(403) + expect(await res.text()).toBe("Forbidden") + }) + + it("ユーザー情報がない場合は401エラーになる", async () => { + const app = new Hono() + + app.use("*", requireRole(["staff"])) + app.get("/", (c) => c.text("OK")) + + const res = await app.request("/") + expect(res.status).toBe(401) + expect(await res.text()).toBe("Unauthorized") + }) + + it("adminロールはadmin専用ルートにアクセスできる", async () => { + const app = new Hono() + const adminUser: User = { ...mockUser, role: "admin" } + + app.use("*", async (c, next) => { + c.set("user", adminUser) + await next() + }) + app.use("*", requireRole(["admin"])) + app.get("/", (c) => c.text("Admin only")) + + const res = await app.request("/") + expect(res.status).toBe(200) + expect(await res.text()).toBe("Admin only") + }) +}) + +describe("Helper functions", () => { + type MockContext = { + get: (key: K) => K extends 'user' ? User | undefined : K extends 'isAuthenticated' ? boolean | undefined : undefined + } + + it("getCurrentUserは現在のユーザーを返す", () => { + const mockContext: MockContext = { + get: (key) => key === "user" ? mockUser : undefined + } + + const user = getCurrentUser(mockContext as Parameters[0]) + expect(user).toEqual(mockUser) + }) + + it("getCurrentUserはユーザーがいない場合undefinedを返す", () => { + const mockContext: MockContext = { + get: () => undefined + } + + const user = getCurrentUser(mockContext as Parameters[0]) + expect(user).toBeUndefined() + }) + + it("isAuthenticatedは認証状態を返す", () => { + const mockContext = { + get: (key: 'isAuthenticated') => true as boolean | undefined + } + + expect(isAuthenticated(mockContext)).toBe(true) + }) + + it("isAuthenticatedは未設定の場合falseを返す", () => { + const mockContext = { + get: (key: 'isAuthenticated') => undefined + } + + expect(isAuthenticated(mockContext)).toBe(false) + }) +}) diff --git a/app/middlewares/auth.ts b/app/middlewares/auth.ts new file mode 100644 index 00000000..e1f04016 --- /dev/null +++ b/app/middlewares/auth.ts @@ -0,0 +1,121 @@ +import { + getAuth, + initOidcAuthMiddleware, + processOAuthCallback, + revokeSession, +} from "@hono/oidc-auth" +import { createMiddleware } from "hono/factory" +import type { User, UserRole } from "../domain/user/entities/user" +import { resolveUserByOidcProfile } from "../usecases/user/resolveUserByOidcProfile" + +// Honoの型拡張 +declare module "hono" { + interface ContextVariableMap { + user?: User + isAuthenticated: boolean + } + + interface OidcAuthClaims { + sub: string + email: string + name?: string + } +} + +/** + * OIDC認証ミドルウェア設定 + */ +export const oidcAuthMiddleware = initOidcAuthMiddleware({ + OIDC_AUTH_SECRET: process.env.OIDC_AUTH_SECRET || "", + OIDC_ISSUER: process.env.OIDC_ISSUER || "", + OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "", + OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "", + OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "/auth/callback", + OIDC_SCOPES: process.env.OIDC_SCOPES || "openid email profile", + OIDC_COOKIE_NAME: process.env.OIDC_COOKIE_NAME || "auth-session", + OIDC_COOKIE_PATH: process.env.OIDC_COOKIE_PATH || "/", + OIDC_AUTH_EXPIRES: process.env.OIDC_AUTH_EXPIRES + ? String(Number.parseInt(process.env.OIDC_AUTH_EXPIRES, 10)) + : String(60 * 60 * 24), // 1 day + OIDC_AUTH_REFRESH_INTERVAL: process.env.OIDC_AUTH_REFRESH_INTERVAL || "900", // 15 minutes +}) + +/** + * ユーザー情報をContextに設定するミドルウェア + */ +export const setUserMiddleware = createMiddleware(async (c, next) => { + const auth = await getAuth(c) + + if (auth) { + try { + const dbClient = c.get("dbClient") + const { user } = await resolveUserByOidcProfile({ + oidcSub: auth.sub, + email: auth.email, + name: auth.name, + dbClient, + }) + + c.set("user", user) + c.set("isAuthenticated", true) + } catch (error) { + console.error("Failed to resolve user by OIDC profile:", error) + c.set("isAuthenticated", false) + } + } else { + c.set("isAuthenticated", false) + } + + await next() +}) + +/** + * 認証が必須のルート用ミドルウェア + */ +export const requireAuth = createMiddleware(async (c, next) => { + if (!c.get("isAuthenticated")) { + // OIDCミドルウェアが自動的にログインページへリダイレクト + return c.text("Unauthorized", 401) + } + await next() +}) + +/** + * 特定のロールが必要なルート用ミドルウェア + * @param roles 必要なロールの配列 + */ +export const requireRole = (roles: UserRole[]) => + createMiddleware(async (c, next) => { + const user = c.get("user") + + if (!user) { + return c.text("Unauthorized", 401) + } + + if (!roles.includes(user.role)) { + return c.text("Forbidden", 403) + } + + await next() + }) + +/** + * 現在のユーザー情報を取得するヘルパー関数 + */ +export const getCurrentUser = (c: { + get: (key: "user") => User | undefined +}): User | undefined => { + return c.get("user") +} + +/** + * 認証状態を確認するヘルパー関数 + */ +export const isAuthenticated = (c: { + get: (key: "isAuthenticated") => boolean | undefined +}): boolean => { + return c.get("isAuthenticated") || false +} + +// @hono/oidc-authのヘルパー関数をエクスポート +export { processOAuthCallback, revokeSession } diff --git a/app/routes/_middleware.ts b/app/routes/_middleware.ts index 5a2d580c..7eabb17a 100644 --- a/app/routes/_middleware.ts +++ b/app/routes/_middleware.ts @@ -1,10 +1,13 @@ import { createMiddleware } from "hono/factory" import { createRoute } from "honox/factory" import { createDbClient } from "../infrastructure/db/client" +import { oidcAuthMiddleware, setUserMiddleware } from "../middlewares/auth" export default createRoute( createMiddleware(async (c, next) => { c.set("dbClient", await createDbClient()) await next() }), + oidcAuthMiddleware, + setUserMiddleware, ) diff --git a/app/routes/auth/callback/index.tsx b/app/routes/auth/callback/index.tsx new file mode 100644 index 00000000..d7cdd679 --- /dev/null +++ b/app/routes/auth/callback/index.tsx @@ -0,0 +1,6 @@ +import { createRoute } from "honox/factory" +import { processOAuthCallback } from "../../../middlewares/auth" + +export default createRoute(async (c) => { + return processOAuthCallback(c) +}) diff --git a/app/routes/auth/logout/index.tsx b/app/routes/auth/logout/index.tsx new file mode 100644 index 00000000..bb735a77 --- /dev/null +++ b/app/routes/auth/logout/index.tsx @@ -0,0 +1,7 @@ +import { createRoute } from "honox/factory" +import { revokeSession } from "../../../middlewares/auth" + +export default createRoute(async (c) => { + await revokeSession(c) + return c.redirect('/') +}) diff --git a/app/routes/staff/_middleware.ts b/app/routes/staff/_middleware.ts new file mode 100644 index 00000000..a141d7fd --- /dev/null +++ b/app/routes/staff/_middleware.ts @@ -0,0 +1,7 @@ +import { createRoute } from "honox/factory" +import { requireAuth, requireRole } from "../../middlewares/auth" + +export default createRoute( + requireAuth, + requireRole(['admin', 'staff']) +) diff --git a/app/usecases/user/resolveUserByOidcProfile.test.ts b/app/usecases/user/resolveUserByOidcProfile.test.ts new file mode 100644 index 00000000..6cc66516 --- /dev/null +++ b/app/usecases/user/resolveUserByOidcProfile.test.ts @@ -0,0 +1,196 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test" +import type { DbClient, TransactionDbClient } from "../../infrastructure/db/client" +import type { User } from "../../domain/user/entities/user" +import * as userQueryRepository from "../../domain/user/repositories/userQueryRepository" +import * as userCommandRepository from "../../domain/user/repositories/userCommandRepository" +import { resolveUserByOidcProfile, determineInitialRole } from "./resolveUserByOidcProfile" + +const mockUser: User = { + id: "test-id-123", + oidcSub: "auth0|123456789", + email: "test@example.com", + name: "Test User", + role: "viewer", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), +} + +describe("resolveUserByOidcProfile", () => { + let findUserByOidcSubSpy: ReturnType + let createUserSpy: ReturnType + let updateUserSpy: ReturnType + let transactionSpy: ReturnType + let txMock: TransactionDbClient + let dbClient: DbClient + + beforeEach(() => { + // トランザクションのモック設定 + txMock = {} as TransactionDbClient + const transactionHolder = { + async transaction(callback: (tx: TransactionDbClient) => Promise) { + return callback(txMock) + }, + } + dbClient = transactionHolder as unknown as DbClient + + transactionSpy = spyOn(transactionHolder, "transaction").mockImplementation( + async (callback: (tx: TransactionDbClient) => Promise) => + callback(txMock), + ) + + // リポジトリ関数のスパイ設定 + findUserByOidcSubSpy = spyOn( + userQueryRepository, + "findUserByOidcSub", + ) + createUserSpy = spyOn( + userCommandRepository, + "createUser", + ) + updateUserSpy = spyOn( + userCommandRepository, + "updateUser", + ) + }) + + afterEach(() => { + mock.restore() + }) + + it("既存ユーザーが存在する場合はそのユーザーを返す", async () => { + findUserByOidcSubSpy.mockImplementation(async () => mockUser) + + const result = await resolveUserByOidcProfile({ + oidcSub: mockUser.oidcSub, + email: mockUser.email, + name: mockUser.name ?? undefined, + dbClient, + }) + + expect(result.user).toEqual(mockUser) + expect(result.isNewUser).toBe(false) + expect(transactionSpy).toHaveBeenCalledTimes(1) + expect(findUserByOidcSubSpy).toHaveBeenCalledTimes(1) + expect(findUserByOidcSubSpy).toHaveBeenCalledWith( + expect.objectContaining({ + oidcSub: mockUser.oidcSub, + dbClient: txMock, + }) + ) + expect(createUserSpy).not.toHaveBeenCalled() + expect(updateUserSpy).not.toHaveBeenCalled() + }) + + it("既存ユーザーのメールアドレスが変更されている場合は更新する", async () => { + findUserByOidcSubSpy.mockImplementation(async () => mockUser) + const updatedUser = { ...mockUser, email: "new@example.com" } + updateUserSpy.mockImplementation(async () => updatedUser) + + const result = await resolveUserByOidcProfile({ + oidcSub: mockUser.oidcSub, + email: "new@example.com", + name: mockUser.name ?? undefined, + dbClient, + }) + + expect(result.user.email).toBe("new@example.com") + expect(result.isNewUser).toBe(false) + expect(transactionSpy).toHaveBeenCalledTimes(1) + expect(findUserByOidcSubSpy).toHaveBeenCalledTimes(1) + expect(updateUserSpy).toHaveBeenCalledTimes(1) + expect(updateUserSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dbClient: txMock, + }) + ) + expect(createUserSpy).not.toHaveBeenCalled() + }) + + it("既存ユーザーの名前が変更されている場合は更新する", async () => { + findUserByOidcSubSpy.mockImplementation(async () => mockUser) + const updatedUser = { ...mockUser, name: "Updated Name" } + updateUserSpy.mockImplementation(async () => updatedUser) + + const result = await resolveUserByOidcProfile({ + oidcSub: mockUser.oidcSub, + email: mockUser.email, + name: "Updated Name", + dbClient, + }) + + expect(result.user.name).toBe("Updated Name") + expect(result.isNewUser).toBe(false) + expect(transactionSpy).toHaveBeenCalledTimes(1) + expect(updateUserSpy).toHaveBeenCalledTimes(1) + }) + + it("新規ユーザーの場合は作成する", async () => { + findUserByOidcSubSpy.mockImplementation(async () => null) + const newUser = { + ...mockUser, + id: "new-id-456", + oidcSub: "google-oauth2|987654321", + email: "newuser@example.com", + } + createUserSpy.mockImplementation(async () => newUser) + + const result = await resolveUserByOidcProfile({ + oidcSub: "google-oauth2|987654321", + email: "newuser@example.com", + name: "New User", + dbClient, + }) + + expect(result.user.email).toBe("newuser@example.com") + expect(result.isNewUser).toBe(true) + expect(transactionSpy).toHaveBeenCalledTimes(1) + expect(findUserByOidcSubSpy).toHaveBeenCalledTimes(1) + expect(createUserSpy).toHaveBeenCalledTimes(1) + expect(createUserSpy).toHaveBeenCalledWith( + expect.objectContaining({ + dbClient: txMock, + }) + ) + expect(updateUserSpy).not.toHaveBeenCalled() + }) + + it("名前がundefinedの場合はnullとして扱う", async () => { + findUserByOidcSubSpy.mockImplementation(async () => null) + createUserSpy.mockImplementation(async ({ user }) => ({ + ...user, + id: "new-id", + createdAt: new Date(), + updatedAt: new Date(), + })) + + await resolveUserByOidcProfile({ + oidcSub: "auth0|new", + email: "test@example.com", + dbClient, + }) + + expect(transactionSpy).toHaveBeenCalledTimes(1) + expect(createUserSpy).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ + name: null, + }), + dbClient: txMock, + }) + ) + }) +}) + +describe("determineInitialRole", () => { + it("常にviewerロールを返す", () => { + expect(determineInitialRole()).toBe("viewer") + }) +}) diff --git a/app/usecases/user/resolveUserByOidcProfile.ts b/app/usecases/user/resolveUserByOidcProfile.ts new file mode 100644 index 00000000..e89c4992 --- /dev/null +++ b/app/usecases/user/resolveUserByOidcProfile.ts @@ -0,0 +1,92 @@ +import type { User, UserRole } from "../../domain/user/entities/user" +import { createUser, updateUser } from "../../domain/user/repositories/userCommandRepository" +import { findUserByOidcSub } from "../../domain/user/repositories/userQueryRepository" + +// DbClientの型を柔軟に受け入れる +export interface ResolveUserByOidcProfileInput { + oidcSub: string + email: string + name?: string + dbClient: Parameters[0]['dbClient'] +} + +export interface ResolveUserByOidcProfileOutput { + user: User + isNewUser: boolean +} + +/** + * OIDC認証情報からユーザーを取得または作成 + */ +export const resolveUserByOidcProfile = async ({ + oidcSub, + email, + name, + dbClient, +}: ResolveUserByOidcProfileInput): Promise => { + let result: ResolveUserByOidcProfileOutput | null = null + + await dbClient.transaction(async (tx) => { + // 既存ユーザーを検索(トランザクション内で実行) + const existingUser = await findUserByOidcSub({ oidcSub, dbClient: tx }) + + if (existingUser) { + // ユーザー情報の更新が必要か確認 + if (existingUser.email !== email || existingUser.name !== (name ?? null)) { + const updatedUser = await updateUser({ + user: { + id: existingUser.id, + oidcSub: existingUser.oidcSub, + email, + name: name ?? null, + role: existingUser.role, + updatedAt: new Date(), + }, + dbClient: tx, + }) + result = { + user: updatedUser, + isNewUser: false, + } + } else { + result = { + user: existingUser, + isNewUser: false, + } + } + } else { + const role = determineInitialRole() + const newUser = await createUser({ + user: { + oidcSub, + email, + name: name ?? null, + role, + }, + dbClient: tx, + }) + + result = { + user: newUser, + isNewUser: true, + } + } + }) + + if (!result) { + throw new Error("ユーザーの取得または作成に失敗しました") + } + + return result +} + +/** + * 初期ロールを決定 + * @returns 初期ロール(常にviewer) + */ +export const determineInitialRole = (): UserRole => { + // 全員デフォルトでviewerとして登録 + // 管理者権限が必要な場合は、DBを直接更新するか、 + // 管理画面を作成して変更する + return 'viewer' +} diff --git a/app/utils/id.ts b/app/utils/id.ts new file mode 100644 index 00000000..dcaf32ce --- /dev/null +++ b/app/utils/id.ts @@ -0,0 +1,13 @@ +import { customAlphabet } from "nanoid" + +const alphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +const nanoid = customAlphabet(alphabet, 21) + +/** + * ユニークなIDを生成する + * @returns 21文字のURL-safeなID + */ +export const generateId = (): string => { + return nanoid() +} diff --git a/bun.lock b/bun.lock index 5bf4e245..ce10b520 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,11 @@ "": { "name": "basic", "dependencies": { + "@hono/oidc-auth": "^1.7.1", "drizzle-orm": "^0.44.4", "hono": "^4.7.10", "honox": "^0.1.41", + "nanoid": "^5.1.6", "postgres": "^3.4.7", "tailwind-merge": "^3.3.1", "tailwind-variants": "^3.0.0", @@ -127,6 +129,8 @@ "@hono/node-server": ["@hono/node-server@1.19.5", "", { "peerDependencies": { "hono": "^4" } }, "sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ=="], + "@hono/oidc-auth": ["@hono/oidc-auth@1.7.1", "", { "dependencies": { "oauth4webapi": "^2.6.0" }, "peerDependencies": { "hono": ">=3.0.0" } }, "sha512-prlPqD6Y+VmWZyhlphBSn1Rqvz10cGn3maONJFGTjhohYCnhYjjZWyj6AaDL1incFf1wu+1KqL4vFEitiAkOyg=="], + "@hono/vite-build": ["@hono/vite-build@1.7.0", "", { "peerDependencies": { "hono": "*" } }, "sha512-L73WBed5teC7DHTzXYkho83POYYluD2rTkbT76FJuqpfXPgTW/PsbIa8O0YcCETE3VUoh584fe6vuLAM5ctjww=="], "@hono/vite-dev-server": ["@hono/vite-dev-server@0.19.0", "", { "dependencies": { "@hono/node-server": "^1.12.0", "minimatch": "^9.0.3" }, "peerDependencies": { "hono": "*", "miniflare": "*", "wrangler": "*" }, "optionalPeers": ["miniflare", "wrangler"] }, "sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw=="], @@ -439,10 +443,12 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "node-source-walk": ["node-source-walk@7.0.1", "", { "dependencies": { "@babel/parser": "^7.26.7" } }, "sha512-3VW/8JpPqPvnJvseXowjZcirPisssnBuDikk6JIZ8jQzF7KJQX52iPFX4RYYxLycYH7IbMRSPUOga/esVjy5Yg=="], + "oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], @@ -567,6 +573,8 @@ "node-source-walk/@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/package.json b/package.json index a9299985..33f74165 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ }, "private": true, "dependencies": { + "@hono/oidc-auth": "^1.7.1", "drizzle-orm": "^0.44.4", "hono": "^4.7.10", "honox": "^0.1.41", + "nanoid": "^5.1.6", "postgres": "^3.4.7", "tailwind-merge": "^3.3.1", "tailwind-variants": "^3.0.0"