{"version":3,"file":"callback.mjs","names":[],"sources":["../../../../../../../src/astro/routes/api/auth/oauth/[provider]/callback.ts"],"sourcesContent":["/**\n * GET /_emdash/api/auth/oauth/[provider]/callback\n *\n * Handle OAuth callback from provider\n */\n\nimport type { APIRoute } from \"astro\";\n\nexport const prerender = false;\n\nimport {\n\thandleOAuthCallback,\n\tOAuthError,\n\tRole,\n\ttype OAuthConsumerConfig,\n\ttype RoleLevel,\n} from \"@premium-cms/auth\";\nimport { createKyselyAdapter } from \"@premium-cms/auth/adapters/kysely\";\n\nimport { getPublicOrigin } from \"#api/public-url.js\";\nimport { finalizeSetup } from \"#api/setup-complete.js\";\nimport { createOAuthStateStore } from \"#auth/oauth-state-store.js\";\nimport { OptionsRepository } from \"#db/repositories/options.js\";\n\ntype ProviderName = \"github\" | \"google\";\n\nconst VALID_PROVIDERS = new Set<string>([\"github\", \"google\"]);\n\nfunction isValidProvider(provider: string): provider is ProviderName {\n\treturn VALID_PROVIDERS.has(provider);\n}\n\n/** Safely extract a string value from an env-like record */\nfunction envString(env: Record<string, unknown>, ...keys: string[]): string | undefined {\n\tfor (const key of keys) {\n\t\tconst val = env[key];\n\t\tif (typeof val === \"string\" && val) return val;\n\t}\n\treturn undefined;\n}\n\n/**\n * Get OAuth config from environment variables\n */\nfunction getOAuthConfig(env: Record<string, unknown>): OAuthConsumerConfig[\"providers\"] {\n\tconst providers: OAuthConsumerConfig[\"providers\"] = {};\n\n\t// GitHub\n\tconst githubClientId = envString(env, \"EMDASH_OAUTH_GITHUB_CLIENT_ID\", \"GITHUB_CLIENT_ID\");\n\tconst githubClientSecret = envString(\n\t\tenv,\n\t\t\"EMDASH_OAUTH_GITHUB_CLIENT_SECRET\",\n\t\t\"GITHUB_CLIENT_SECRET\",\n\t);\n\tif (githubClientId && githubClientSecret) {\n\t\tproviders.github = {\n\t\t\tclientId: githubClientId,\n\t\t\tclientSecret: githubClientSecret,\n\t\t};\n\t}\n\n\t// Google\n\tconst googleClientId = envString(env, \"EMDASH_OAUTH_GOOGLE_CLIENT_ID\", \"GOOGLE_CLIENT_ID\");\n\tconst googleClientSecret = envString(\n\t\tenv,\n\t\t\"EMDASH_OAUTH_GOOGLE_CLIENT_SECRET\",\n\t\t\"GOOGLE_CLIENT_SECRET\",\n\t);\n\tif (googleClientId && googleClientSecret) {\n\t\tproviders.google = {\n\t\t\tclientId: googleClientId,\n\t\t\tclientSecret: googleClientSecret,\n\t\t};\n\t}\n\n\treturn providers;\n}\n\nexport const GET: APIRoute = async ({ params, request, locals, session, redirect }) => {\n\tconst { emdash } = locals;\n\tconst provider = params.provider;\n\n\t// Validate provider\n\tif (!provider || !isValidProvider(provider)) {\n\t\treturn redirect(\n\t\t\t`/_emdash/admin/login?error=invalid_provider&message=${encodeURIComponent(\"Invalid OAuth provider\")}`,\n\t\t);\n\t}\n\n\tif (!emdash?.db) {\n\t\treturn redirect(\n\t\t\t`/_emdash/admin/login?error=server_error&message=${encodeURIComponent(\"Database not configured\")}`,\n\t\t);\n\t}\n\n\tconst url = new URL(request.url);\n\tconst code = url.searchParams.get(\"code\");\n\tconst state = url.searchParams.get(\"state\");\n\tconst error = url.searchParams.get(\"error\");\n\tconst errorDescription = url.searchParams.get(\"error_description\");\n\n\t// Handle OAuth errors from provider\n\tif (error) {\n\t\tconst message = errorDescription || error;\n\t\treturn redirect(\n\t\t\t`/_emdash/admin/login?error=oauth_denied&message=${encodeURIComponent(message)}`,\n\t\t);\n\t}\n\n\t// Validate required params\n\tif (!code || !state) {\n\t\treturn redirect(\n\t\t\t`/_emdash/admin/login?error=invalid_callback&message=${encodeURIComponent(\"Missing code or state parameter\")}`,\n\t\t);\n\t}\n\n\ttry {\n\t\t// Get OAuth providers from environment. Astro 6 removed\n\t\t// `Astro.locals.runtime.env` (accessing it throws rather than\n\t\t// returning undefined, so optional-chaining doesn't help) -- read\n\t\t// Cloudflare bindings via the emdash virtual module instead, which\n\t\t// re-exports `cloudflare:workers`' `env` under that adapter and\n\t\t// falls back to `import.meta.env` on Node (#1736).\n\t\t// @ts-ignore - virtual module, generated by the Astro integration\n\t\tconst { env: cfEnv } = (await import(\"virtual:emdash/env\")) as {\n\t\t\tenv?: Record<string, unknown>;\n\t\t};\n\t\tconst env = cfEnv ?? import.meta.env;\n\t\tconst providers = getOAuthConfig(env);\n\n\t\tif (!providers[provider]) {\n\t\t\treturn redirect(\n\t\t\t\t`/_emdash/admin/login?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured`)}`,\n\t\t\t);\n\t\t}\n\n\t\tconst adapter = createKyselyAdapter(emdash.db);\n\t\tconst stateStore = createOAuthStateStore(emdash.db);\n\n\t\tconst config: OAuthConsumerConfig = {\n\t\t\tbaseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`,\n\t\t\tproviders,\n\t\t\tcanSelfSignup: async (email: string) => {\n\t\t\t\t// During setup: first user becomes admin.\n\t\t\t\t// Check setup_complete flag instead of countUsers() to avoid\n\t\t\t\t// a TOCTOU race where concurrent callbacks both see 0 users.\n\t\t\t\tconst options = new OptionsRepository(emdash.db);\n\t\t\t\tconst setupComplete = await options.get(\"emdash:setup_complete\");\n\t\t\t\tif (setupComplete !== true && setupComplete !== \"true\") {\n\t\t\t\t\treturn { allowed: true, role: Role.ADMIN };\n\t\t\t\t}\n\n\t\t\t\t// Extract domain from email\n\t\t\t\tconst domain = email.split(\"@\")[1]?.toLowerCase();\n\t\t\t\tif (!domain) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\n\t\t\t\t// Check allowed_domains table for a matching, enabled entry\n\t\t\t\tconst entry = await emdash.db\n\t\t\t\t\t.selectFrom(\"allowed_domains\")\n\t\t\t\t\t.selectAll()\n\t\t\t\t\t.where(\"domain\", \"=\", domain)\n\t\t\t\t\t.where(\"enabled\", \"=\", 1)\n\t\t\t\t\t.executeTakeFirst();\n\n\t\t\t\tif (!entry) {\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\n\t\t\t\t// Map the stored role level to the Role enum\n\t\t\t\tconst roleLevel = entry.default_role;\n\t\t\t\tconst roleMap: Record<number, RoleLevel> = {\n\t\t\t\t\t50: Role.ADMIN,\n\t\t\t\t\t40: Role.EDITOR,\n\t\t\t\t\t30: Role.AUTHOR,\n\t\t\t\t\t20: Role.CONTRIBUTOR,\n\t\t\t\t\t10: Role.SUBSCRIBER,\n\t\t\t\t};\n\t\t\t\tconst role = roleMap[roleLevel] ?? Role.CONTRIBUTOR;\n\t\t\t\tif (!roleMap[roleLevel]) {\n\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t`[oauth] Unknown role level ${roleLevel} for domain ${domain}, defaulting to CONTRIBUTOR`,\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\treturn { allowed: true, role };\n\t\t\t},\n\t\t};\n\n\t\tconst options = new OptionsRepository(emdash.db);\n\t\tconst setupCompleteBefore = await options.get(\"emdash:setup_complete\");\n\t\tconst user = await handleOAuthCallback(config, adapter, provider, code, state, stateStore);\n\t\tconst isFirstUser = setupCompleteBefore !== true && setupCompleteBefore !== \"true\";\n\n\t\t// Finalize setup outside the transaction (idempotent, safe if two callbacks race).\n\t\tif (isFirstUser) {\n\t\t\tawait finalizeSetup(emdash.db);\n\t\t\tconsole.log(`[oauth] Setup complete: created admin user via ${provider} (${user.email})`);\n\t\t}\n\n\t\t// Create session\n\t\tif (session) {\n\t\t\tsession.set(\"user\", { id: user.id });\n\t\t}\n\n\t\t// Redirect to admin dashboard\n\t\treturn redirect(\"/_emdash/admin\");\n\t} catch (callbackError) {\n\t\tconsole.error(\"OAuth callback error:\", callbackError);\n\n\t\tlet message = \"Authentication failed\";\n\t\tlet errorCode = \"oauth_error\";\n\n\t\tif (callbackError instanceof OAuthError) {\n\t\t\terrorCode = callbackError.code;\n\n\t\t\t// Map all error codes to user-friendly messages (never expose raw error.message)\n\t\t\tswitch (callbackError.code) {\n\t\t\t\tcase \"invalid_state\":\n\t\t\t\t\tmessage = \"OAuth session expired or invalid. Please try again.\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"signup_not_allowed\":\n\t\t\t\t\tmessage = \"Self-signup is not allowed for your email. Please contact an administrator.\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"invite_invalid\":\n\t\t\t\t\tmessage = \"This invite link is invalid or has expired. Please ask for a new one.\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"invite_email_mismatch\":\n\t\t\t\t\tmessage = \"This invite was sent to a different email address than your account.\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"invite_email_unverified\":\n\t\t\t\t\tmessage =\n\t\t\t\t\t\t\"Your account's email is not verified by the provider. Please verify it and try again.\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"user_not_found\":\n\t\t\t\t\tmessage = \"Your account was not found. It may have been deleted.\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"token_exchange_failed\":\n\t\t\t\t\tmessage = \"Failed to complete authentication. Please try again.\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"profile_fetch_failed\":\n\t\t\t\t\tmessage = \"Failed to retrieve your profile. Please try again.\";\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tmessage = \"Authentication failed. Please try again.\";\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\t// For generic errors, keep the default \"Authentication failed\" message\n\n\t\treturn redirect(\n\t\t\t`/_emdash/admin/login?error=${errorCode}&message=${encodeURIComponent(message)}`,\n\t\t);\n\t}\n};\n"],"mappings":"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"}