diff --git a/.gitignore b/.gitignore index e70749f..855d5c4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ lerna-debug.log* .DS_Store _old/ +*.tsbuildinfo diff --git a/apps/server/drizzle/migrations/0004_add_google_id.sql b/apps/server/drizzle/migrations/0004_add_google_id.sql new file mode 100644 index 0000000..911f7d9 --- /dev/null +++ b/apps/server/drizzle/migrations/0004_add_google_id.sql @@ -0,0 +1 @@ +ALTER TABLE `account` ADD `google_id` text; \ No newline at end of file diff --git a/apps/server/drizzle/migrations/meta/0004_snapshot.json b/apps/server/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..572727a --- /dev/null +++ b/apps/server/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,669 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a03f3943-8578-4cd9-bbc0-445b936c59f1", + "prevId": "e8fff387-69e6-4382-b4c3-a65bca2d9a9a", + "tables": { + "access_key": { + "name": "access_key", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "friendly_name": { + "name": "friendly_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_session": { + "name": "is_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "access_key_name_unique": { + "name": "access_key_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "access_key_account_id_account_id_fk": { + "name": "access_key_account_id_account_id_fk", + "tableFrom": "access_key", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_id": { + "name": "github_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_id": { + "name": "google_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "account_email_unique": { + "name": "account_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app": { + "name": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_label": { + "name": "client_label", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "client_label_client_id_deployment_id_pk": { + "columns": [ + "client_id", + "deployment_id" + ], + "name": "client_label_client_id_deployment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collaborator": { + "name": "collaborator", + "columns": { + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "collaborator_app_id_app_id_fk": { + "name": "collaborator_app_id_app_id_fk", + "tableFrom": "collaborator", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "collaborator_account_id_account_id_fk": { + "name": "collaborator_account_id_account_id_fk", + "tableFrom": "collaborator", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "collaborator_app_id_account_id_pk": { + "columns": [ + "app_id", + "account_id" + ], + "name": "collaborator_app_id_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deployment": { + "name": "deployment", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_time": { + "name": "created_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "deployment_key_unique": { + "name": "deployment_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "deployment_app_id_app_id_fk": { + "name": "deployment_app_id_app_id_fk", + "tableFrom": "deployment", + "tableTo": "app", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "metric": { + "name": "metric", + "columns": { + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "metric_deployment_id_label_type_pk": { + "columns": [ + "deployment_id", + "label", + "type" + ], + "name": "metric_deployment_id_label_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package_diff": { + "name": "package_diff", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "package_id": { + "name": "package_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_package_hash": { + "name": "source_package_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_path": { + "name": "blob_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "package_diff_package_id_source_package_hash_unique": { + "name": "package_diff_package_id_source_package_hash_unique", + "columns": [ + "package_id", + "source_package_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "package_diff_package_id_package_id_fk": { + "name": "package_diff_package_id_package_id_fk", + "tableFrom": "package_diff", + "tableTo": "package", + "columnsFrom": [ + "package_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "package": { + "name": "package", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rollout": { + "name": "rollout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob_path": { + "name": "blob_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest_blob_path": { + "name": "manifest_blob_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "package_hash": { + "name": "package_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "release_method": { + "name": "release_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_label": { + "name": "original_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_deployment": { + "name": "original_deployment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "released_by": { + "name": "released_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upload_time": { + "name": "upload_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "package_deployment_id_deployment_id_fk": { + "name": "package_deployment_id_deployment_id_fk", + "tableFrom": "package", + "tableTo": "deployment", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/migrations/meta/_journal.json b/apps/server/drizzle/migrations/meta/_journal.json index 379fb55..c744133 100644 --- a/apps/server/drizzle/migrations/meta/_journal.json +++ b/apps/server/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1737298919485, "tag": "0003_striped_tomas", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1784008415419, + "tag": "0004_add_google_id", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index cb99c83..e4d806b 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -13,6 +13,7 @@ export const account = sqliteTable("account", { email: text("email").notNull().unique(), name: text("name").notNull(), githubId: text("github_id"), + googleId: text("google_id"), createdTime: integer("created_time").notNull(), deletedAt: integer("deleted_at"), }); diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts index 803ec90..466b7b1 100644 --- a/apps/server/src/middleware/auth.ts +++ b/apps/server/src/middleware/auth.ts @@ -168,6 +168,76 @@ export async function isGitHubOrgMember( return membership.state === "active"; } +// Google OAuth helpers +export interface GoogleUser { + id: string; + email: string; + name: string; +} + +export async function getGoogleAccessToken( + code: string, + env: Env["Bindings"], +): Promise { + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: env.GOOGLE_CLIENT_ID ?? "", + client_secret: env.GOOGLE_CLIENT_SECRET ?? "", + redirect_uri: `${env.SERVER_URL}/auth/google/callback`, + }), + }); + + const data = (await response.json()) as { + access_token?: string; + error?: string; + error_description?: string; + }; + if (!data.access_token) { + throw new Error( + data.error_description || "Failed to get Google access token", + ); + } + + return data.access_token; +} + +// ID token 파싱 대신 userinfo 엔드포인트 사용 — Workers엔 Buffer가 없고, +// 토큰이 Google에서 TLS로 직접 온 것이라 서명 검증이 불필요하다. +export async function getGoogleUser(accessToken: string): Promise { + const response = await fetch( + "https://openidconnect.googleapis.com/v1/userinfo", + { + headers: { authorization: `Bearer ${accessToken}` }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to get Google user data"); + } + + const data = await response.json<{ + sub: string; + email?: string; + email_verified?: boolean; + name?: string; + }>(); + + // 도메인 게이트가 이메일 기반이므로 미검증 이메일은 거부 + if (!data.email || data.email_verified === false) { + throw new Error("No verified email in Google userinfo"); + } + + return { + id: data.sub, + email: data.email, + name: data.name || data.email.split("@")[0], + }; +} + export async function getGitHubUser(accessToken: string): Promise { const [userResponse, emailsResponse] = await Promise.all([ fetch("https://api.github.com/user", { diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index c2ee4be..cf6bc4a 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -4,6 +4,8 @@ import { z } from "zod"; import { getGitHubAccessToken, getGitHubUser, + getGoogleAccessToken, + getGoogleUser, isGitHubOrgMember, } from "../middleware/auth"; import { getStorageProvider } from "../storage/factory"; @@ -53,6 +55,40 @@ const routes = { }, }), + googleLogin: createRoute({ + method: "get", + path: "/google/login", + responses: { + 302: { + description: "Redirect to Google OAuth", + }, + }, + }), + + googleCallback: createRoute({ + method: "get", + path: "/google/callback", + request: { + query: z.object({ + code: z.string(), + state: z.string().optional(), + }), + }, + responses: { + 302: { + description: "Redirect to dashboard with session", + }, + 400: { + content: { + "application/json": { + schema: LoginErrorResponse, + }, + }, + description: "Login error", + }, + }, + }), + logout: createRoute({ method: "get", path: "/logout", @@ -213,6 +249,157 @@ router.openapi(routes.callback, async (c) => { } }); +// Google OAuth login +router.openapi(routes.googleLogin, async (c) => { + if (!c.env.GOOGLE_CLIENT_ID) { + return c.json( + { + error: "google_not_configured", + error_description: "Google OAuth is not configured", + }, + 400, + ); + } + + const state = JSON.stringify({ + client: c.req.query("client") ?? "cli", + redirectTo: c.req.query("redirect_to") ?? "/", + }); + const params = new URLSearchParams({ + client_id: c.env.GOOGLE_CLIENT_ID, + redirect_uri: `${c.env.SERVER_URL}/auth/google/callback`, + response_type: "code", + scope: "openid email profile", + state, + }); + + return c.redirect( + `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`, + ); +}); + +// Google OAuth callback +router.openapi(routes.googleCallback, async (c) => { + try { + const { code, state } = c.req.valid("query"); + if (!code) { + return c.json( + { + error: "invalid_request", + error_description: "No code provided", + }, + 400, + ); + } + + let client = "cli"; + let redirectTo = "/"; + try { + const parsed = JSON.parse(state ?? "{}"); + client = parsed.client ?? "cli"; + redirectTo = parsed.redirectTo ?? "/"; + } catch {} + const loginErrorRedirect = (errorCode: string) => + client === "web" + ? c.redirect( + new URL(`/login?error=${errorCode}`, redirectTo).toString(), + ) + : c.redirect(`/login?error=${errorCode}`); + + const accessToken = await getGoogleAccessToken(code, c.env); + const googleUser = await getGoogleUser(accessToken); + + // 허용 도메인 게이트 — GOOGLE_ALLOWED_DOMAIN(콤마 구분) 미설정 시 전부 거부 + const emailDomain = googleUser.email.split("@")[1]?.toLowerCase() ?? ""; + const allowedDomains = (c.env.GOOGLE_ALLOWED_DOMAIN ?? "") + .split(",") + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean); + if (!allowedDomains.includes(emailDomain)) { + return loginErrorRedirect("google_domain_not_allowed"); + } + + const storage = getStorageProvider(c); + + // Find or create account + let accountId: string; + try { + const account = await storage.getAccountByEmail(googleUser.email); + + // Update Google ID if not set + if (!account.googleId) { + await storage.updateAccount(account.email, { + ...account, + googleId: googleUser.id, + }); + } + + accountId = account.id; + } catch { + // Create new account if registration is enabled + if (c.env.ENABLE_ACCOUNT_REGISTRATION !== "true") { + return loginErrorRedirect("registration_disabled"); + } + + accountId = await storage.addAccount({ + email: googleUser.email, + name: googleUser.name, + googleId: googleUser.id, + createdTime: Date.now(), + linkedProviders: ["Google"], + }); + } + + // Create access key for session + const accessKeyName = generateKey(); + await storage.addAccessKey(accountId, { + name: accessKeyName, + friendlyName: "Google OAuth Session", + createdBy: c.req.header("User-Agent") ?? "Unknown", + createdTime: Date.now(), + expires: Date.now() + 60 * 24 * 60 * 60 * 1000, // 60 days + isSession: true, + }); + // Create JWT token + const token = await sign( + { + sub: accountId, + email: googleUser.email, + }, + c.env.JWT_SECRET, + ); + + // Set session cookie + setCookie(c, "session", token, { + httpOnly: true, + secure: true, + sameSite: "Lax", + path: "/", + domain: c.env.COOKIE_DOMAIN || undefined, + maxAge: 60 * 60 * 24, // 1 day + }); + + // 웹: 세션 쿠키로 대시보드 리다이렉트 / CLI: 기존 JSON 토큰 유지 + if (client === "web") { + return c.redirect(redirectTo); + } + return c.json({ + accessKeyName, + token, + }); + } catch (error) { + console.error("Auth error:", error); + + return c.json( + { + error: "auth_failed", + error_description: "Authentication failed", + }, + 400, + ); + } +}); + // Logout router.openapi(routes.logout, async (c) => { // Clear session cookie diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index 4252c88..0b745b3 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -91,6 +91,7 @@ export class D1StorageProvider implements StorageProvider { email: account.email.toLowerCase(), name: account.name, githubId: account.gitHubId, + googleId: account.googleId, createdTime: account.createdTime, }); @@ -109,14 +110,7 @@ export class D1StorageProvider implements StorageProvider { throw createStorageError(ErrorCode.NotFound, "Account not found"); } - return { - id: account.id, - email: account.email, - name: account.name, - gitHubId: account.githubId ?? undefined, - createdTime: account.createdTime, - linkedProviders: account.githubId ? ["GitHub"] : [], - }; + return this.mapAccountFromDB(account); } async getAccounts(): Promise { @@ -124,14 +118,24 @@ export class D1StorageProvider implements StorageProvider { where: isNull(schema.account.deletedAt), }); - return accounts.map((account) => ({ + return accounts.map((account) => this.mapAccountFromDB(account)); + } + + private mapAccountFromDB( + account: typeof schema.account.$inferSelect, + ): Account { + return { id: account.id, email: account.email, name: account.name, gitHubId: account.githubId ?? undefined, + googleId: account.googleId ?? undefined, createdTime: account.createdTime, - linkedProviders: account.githubId ? ["GitHub"] : [], - })); + linkedProviders: [ + ...(account.githubId ? ["GitHub"] : []), + ...(account.googleId ? ["Google"] : []), + ], + }; } async getAccountByEmail(email: string): Promise { @@ -146,14 +150,7 @@ export class D1StorageProvider implements StorageProvider { throw createStorageError(ErrorCode.NotFound, "Account not found"); } - return { - id: account.id, - email: account.email, - name: account.name, - gitHubId: account.githubId ?? undefined, - createdTime: account.createdTime, - linkedProviders: account.githubId ? ["GitHub"] : [], - }; + return this.mapAccountFromDB(account); } async updateAccount(email: string, updates: Partial): Promise { @@ -172,6 +169,7 @@ export class D1StorageProvider implements StorageProvider { .update(schema.account) .set({ githubId: updates.gitHubId, + googleId: updates.googleId, name: updates.name, }) .where(eq(schema.account.id, account.id)); diff --git a/apps/server/src/types/env.ts b/apps/server/src/types/env.ts index 533189d..dabe7d9 100644 --- a/apps/server/src/types/env.ts +++ b/apps/server/src/types/env.ts @@ -12,6 +12,10 @@ export interface Env { CORS_ORIGINS: string; // 세션 쿠키를 서브도메인 간 공유하기 위한 상위 도메인(예: yplabs.workers.dev). 로컬은 미설정. COOKIE_DOMAIN?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; + // Google 로그인 허용 이메일 도메인(콤마 구분, 예: "yplabs.kr"). 미설정 시 Google 로그인 전부 거부. + GOOGLE_ALLOWED_DOMAIN?: string; ACCOUNT_ID: string; R2_BUCKET_NAME: string; R2_ACCESS_KEY_ID: string; diff --git a/apps/server/src/types/schemas.ts b/apps/server/src/types/schemas.ts index cb04641..5090a3c 100644 --- a/apps/server/src/types/schemas.ts +++ b/apps/server/src/types/schemas.ts @@ -118,6 +118,7 @@ export const AccountSchema = z.object({ name: z.string(), linkedProviders: z.array(z.string()), gitHubId: z.string().optional(), + googleId: z.string().optional(), createdTime: z.number(), }); export type Account = z.infer; diff --git a/apps/server/test/routes/auth.test.ts b/apps/server/test/routes/auth.test.ts index 87e5490..5d5fe25 100644 --- a/apps/server/test/routes/auth.test.ts +++ b/apps/server/test/routes/auth.test.ts @@ -144,4 +144,104 @@ describe("Auth Routes", () => { expect(response.headers.get("Set-Cookie")).toBeNull(); }); }); + + describe("GET /auth/google/login", () => { + it("should redirect to Google OAuth", async () => { + const response = await SELF.fetch( + "https://example.com/auth/google/login", + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toMatch( + /^https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth/, + ); + }); + }); + + describe("GET /auth/google/callback - 도메인 게이트", () => { + const webState = encodeURIComponent( + JSON.stringify({ client: "web", redirectTo: "http://localhost:5173/" }), + ); + + // Google OAuth 왕복(토큰 교환 → userinfo)을 목킹한다. 이메일만 케이스별로 바꿔 + // 도메인 게이트(GOOGLE_ALLOWED_DOMAIN=test.dev, vitest.config.ts) 동작을 검증한다. + const mockGoogleFlow = (email: string) => { + fetchMock + .get("https://oauth2.googleapis.com") + .intercept({ path: "/token", method: "POST" }) + .reply(200, { access_token: "ya29_test" }); + fetchMock + .get("https://openidconnect.googleapis.com") + .intercept({ path: "/v1/userinfo", method: "GET" }) + .reply(200, { + sub: "108123456789", + email, + email_verified: true, + name: "Tester", + }); + }; + + afterEach(() => { + fetchMock.assertNoPendingInterceptors(); + }); + + it("허용 도메인 이메일이면 로그인 통과 후 웹 오리진으로 리다이렉트", async () => { + mockGoogleFlow("tester@test.dev"); + + const response = await SELF.fetch( + `https://example.com/auth/google/callback?code=testcode&state=${webState}`, + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("http://localhost:5173/"); + expect(response.headers.get("Set-Cookie")).toMatch(/^session=/); + }); + + it("허용 외 도메인이면 google_domain_not_allowed로 거부(세션 미발급)", async () => { + mockGoogleFlow("tester@gmail.com"); + + const response = await SELF.fetch( + `https://example.com/auth/google/callback?code=testcode&state=${webState}`, + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe( + "http://localhost:5173/login?error=google_domain_not_allowed", + ); + expect(response.headers.get("Set-Cookie")).toBeNull(); + }); + + it("기존 계정과 같은 이메일이면 새 계정을 만들지 않고 연결한다", async () => { + // 허용 도메인 이메일로 기존(GitHub 기반) 계정 생성 + const db = (await import("../utils/db")).getTestDb(); + const schema = await import("../../src/db/schema"); + await db.insert(schema.account).values({ + id: "existing-account-id", + email: "tester@test.dev", + name: "Existing", + githubId: "4242", + createdTime: Date.now(), + }); + + mockGoogleFlow("tester@test.dev"); + + const response = await SELF.fetch( + `https://example.com/auth/google/callback?code=testcode&state=${webState}`, + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Set-Cookie")).toMatch(/^session=/); + + // 계정이 늘지 않고 기존 계정에 googleId가 연결됨 + const accounts = await db.select().from(schema.account); + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toBe("existing-account-id"); + expect(accounts[0].googleId).toBe("108123456789"); + expect(accounts[0].githubId).toBe("4242"); + }); + }); }); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index e096abb..d398e4c 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -29,6 +29,9 @@ export default defineWorkersProject(async () => { GITHUB_CLIENT_ID: "xxx", GITHUB_CLIENT_SECRET: "xxx", GITHUB_ORG: "test-org", + GOOGLE_CLIENT_ID: "xxx", + GOOGLE_CLIENT_SECRET: "xxx", + GOOGLE_ALLOWED_DOMAIN: "test.dev", JWT_SECRET: "test", ACCOUNT_ID: "test", R2_BUCKET_NAME: "test", diff --git a/apps/server/wrangler.toml b/apps/server/wrangler.toml index 5d82081..34cc2a2 100644 --- a/apps/server/wrangler.toml +++ b/apps/server/wrangler.toml @@ -20,6 +20,9 @@ CORS_ORIGINS = "http://localhost:5173" ENABLE_PACKAGE_DIFFING = "true" GITHUB_CLIENT_ID="Ov23liuoVBhHRJ02UweV" GITHUB_ORG="yplabs-ltd" +# Google Cloud Console에서 로컬용 OAuth 클라이언트 생성 후 채울 것 (redirect URI: http://localhost:8787/auth/google/callback) +GOOGLE_CLIENT_ID="898879441077-3u83tsjeg5fis4614vc6t8juip4283gn.apps.googleusercontent.com" +GOOGLE_ALLOWED_DOMAIN="yplabs.kr" [env.production] @@ -38,6 +41,9 @@ COOKIE_DOMAIN = "yplabs.workers.dev" ENABLE_PACKAGE_DIFFING = "true" GITHUB_CLIENT_ID="Ov23liyNc4o1iJYk03YU" GITHUB_ORG="yplabs-ltd" +# Google Cloud Console에서 프로덕션용 OAuth 클라이언트 생성 후 채울 것. GOOGLE_CLIENT_SECRET은 wrangler secret put으로 등록. +GOOGLE_CLIENT_ID="898879441077-3u83tsjeg5fis4614vc6t8juip4283gn.apps.googleusercontent.com" +GOOGLE_ALLOWED_DOMAIN="yplabs.kr" R2_BUCKET_NAME="code-push-storage" ACCOUNT_ID="3ed9862a49fe75464126e11a462fd26e" AWS_REGION = "ap-northeast-2" diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index 9009769..74ab6c0 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -2,6 +2,7 @@ export const config = { apiUrl: import.meta.env.VITE_API_URL, // 콜백이 웹 오리진으로 되돌아오도록 redirect_to에 현재 오리진을 실어 보낸다. githubOAuthUrl: `${import.meta.env.VITE_API_URL}/auth/login?client=web&redirect_to=${encodeURIComponent(`${window.location.origin}/`)}`, + googleOAuthUrl: `${import.meta.env.VITE_API_URL}/auth/google/login?client=web&redirect_to=${encodeURIComponent(`${window.location.origin}/`)}`, // 로그아웃 후 웹 오리진의 /login으로 되돌아오도록 절대 URL을 redirect_to로 실어 보낸다. // (미지정 시 서버가 상대경로 "/login"으로 리다이렉트 → 서버 도메인에 갇힘) logoutUrl: `${import.meta.env.VITE_API_URL}/auth/logout?redirect_to=${encodeURIComponent(`${window.location.origin}/login`)}`, diff --git a/apps/web/src/pages/auth/LoginPage.tsx b/apps/web/src/pages/auth/LoginPage.tsx index 70ba2ad..0b84c60 100644 --- a/apps/web/src/pages/auth/LoginPage.tsx +++ b/apps/web/src/pages/auth/LoginPage.tsx @@ -30,7 +30,7 @@ export const LoginPage = () => { CodePush 로그인 - GitHub 계정으로 로그인하세요 + GitHub 또는 Google 계정으로 로그인하세요 {error && ( @@ -39,15 +39,43 @@ export const LoginPage = () => { ? "가입이 비활성화되어 있습니다." : error === "not_org_member" ? "기관(GitHub 조직) 소속 유저만 로그인할 수 있습니다." - : "인증 중 오류가 발생했습니다."} + : error === "google_domain_not_allowed" + ? "허용된 도메인의 Google 계정만 로그인할 수 있습니다." + : "인증 중 오류가 발생했습니다."} )} - +