diff --git a/apps/server/src/routes/auth/email-identifier.ts b/apps/server/src/routes/auth/email-identifier.ts new file mode 100644 index 000000000..67b4c73b7 --- /dev/null +++ b/apps/server/src/routes/auth/email-identifier.ts @@ -0,0 +1,69 @@ +import type { Database } from '../../libs/db' + +import { and, eq } from 'drizzle-orm' + +import { account, user } from '../../schemas/accounts' +import { createBadRequestError } from '../../utils/error' + +// NOTICE: +// Loose RFC-5322-ish regex used to fail fast on obviously malformed input. +// Authoritative validation happens in better-auth on sign-in/sign-up; +// this is just a pre-flight gate for the email-first identifier step so we +// avoid hitting the DB with garbage. +const EMAIL_SHAPE_RE = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/ + +export interface CheckEmailIdentifierDeps { + /** Database used to inspect user and credential-account rows. */ + db: Database +} + +export interface CheckEmailIdentifierResult { + /** Whether a user row exists for the normalized email. */ + exists: boolean + /** Whether the matching user can sign in with email and password. */ + hasPassword: boolean +} + +/** + * Checks whether an email belongs to an existing credential-capable account. + * + * Use when: + * - The auth UI needs to choose between password sign-in, account creation, + * or social-provider guidance. + * + * Expects: + * - Raw body shape from `/api/auth/check-email`. + * + * Returns: + * - Existence and credential-account flags for the normalized email. + */ +export async function checkEmailIdentifier( + deps: CheckEmailIdentifierDeps, + body: { email?: unknown } | null, +): Promise { + const raw = typeof body?.email === 'string' ? body.email.trim() : '' + const email = raw.toLowerCase() + + if (!email || !EMAIL_SHAPE_RE.test(email)) + throw createBadRequestError('Invalid email', 'INVALID_EMAIL') + + const [matched] = await deps.db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1) + + if (!matched) + return { exists: false, hasPassword: false } + + const [credential] = await deps.db + .select({ id: account.id }) + .from(account) + .where(and( + eq(account.userId, matched.id), + eq(account.providerId, 'credential'), + )) + .limit(1) + + return { exists: true, hasPassword: !!credential } +} diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 957b7097c..030ee9e30 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -6,27 +6,16 @@ import type { ConfigKVService } from '../../services/adapters/config-kv' import type { HonoEnv } from '../../types/hono' import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider' -import { serveStatic } from '@hono/node-server/serve-static' -import { and, eq } from 'drizzle-orm' import { Hono } from 'hono' import { ensureDynamicFirstPartyRedirectUri } from '../../libs/auth' import { isUserBannedNow, resolveSessionIgnoringBan } from '../../libs/request-auth' import { rateLimiter } from '../../middlewares/rate-limit' -import { account, user } from '../../schemas/accounts' -import { createBadRequestError, createForbiddenError } from '../../utils/error' -import { getServerAuthUiDistDir, renderServerAuthUiHtml, SERVER_AUTH_UI_BASE_PATH } from '../../utils/server-auth-ui' +import { createForbiddenError } from '../../utils/error' +import { checkEmailIdentifier } from './email-identifier' import { createElectronCallbackRelay } from './oidc/electron-callback' import { createOIDCTokenAuthRoute } from './oidc/token-auth' - -// NOTICE: -// Loose RFC-5322-ish regex used to fail fast on obviously malformed input. -// Authoritative validation happens in better-auth on sign-in/sign-up; -// this is just a pre-flight gate for the email-first identifier step so we -// avoid hitting the DB with garbage. -const EMAIL_SHAPE_RE = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/ - -const RE_SERVER_AUTH_UI_BASE_PATH = /^\/auth/ +import { createAuthUiRoutes } from './ui-routes' export interface AuthRoutesDeps { auth: AuthInstance @@ -55,74 +44,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { } return new Hono() - .use(`${SERVER_AUTH_UI_BASE_PATH}/*`, serveStatic({ - root: getServerAuthUiDistDir(), - rewriteRequestPath: (path: string) => path.replace(RE_SERVER_AUTH_UI_BASE_PATH, ''), - })) - /** - * Login page for the OIDC Provider flow, served under the ui-server-auth - * vue-router base (`/auth/sign-in`). When an unauthenticated - * user hits `/api/auth/oauth2/authorize`, better-auth redirects here - * because of `oauthProvider({ loginPage })`. After the user signs in via - * a social provider, the social callback redirects to `callbackURL`, - * which points back to the OIDC authorize endpoint. - * - * If a `provider` query parameter is present (e.g. `?provider=github`), - * skip the picker page and redirect directly to the social provider. - * - * Registered BEFORE the SPA `/auth/*` wildcard fallback so - * the provider shortcut gets a chance to short-circuit. Hono matches - * routes in registration order — specific path before wildcard wins. - */ - .on('GET', `${SERVER_AUTH_UI_BASE_PATH}/sign-in`, (c) => { - const provider = c.req.query('provider') - - // Reconstruct the OIDC authorize URL from query params so the flow - // resumes after social login. The oauthProvider plugin appends all - // authorization request params when redirecting to loginPage. - const url = new URL(c.req.url) - const oidcParams = new URLSearchParams(url.searchParams) - oidcParams.delete('provider') - // Strip prompt so the post-sign-in redirect to authorize doesn't force - // another sign-in — prompt=login should only apply on the first pass. - oidcParams.delete('prompt') - - const callbackURL = oidcParams.toString() - ? `${deps.env.API_SERVER_URL}/api/auth/oauth2/authorize?${oidcParams.toString()}` - : '/' - - if (!!provider && ['google', 'github'].includes(provider)) { - const socialUrl = `${deps.env.API_SERVER_URL}/api/auth/sign-in/social?provider=${provider}&callbackURL=${encodeURIComponent(callbackURL)}` - return c.redirect(socialUrl) - } - - return c.html(renderServerAuthUiHtml({ - apiServerUrl: deps.env.API_SERVER_URL, - currentUrl: c.req.url, - })) - }) - /** - * SPA fallback for the ui-server-auth bundle. - * - * vue-router runs with `createWebHistory('/auth/')`, so any - * client-side route — `/auth/verify-email`, - * `/auth/forgot-password`, `/auth/reset-password`, - * etc. — appears in the URL bar but has no matching file in the dist. - * Without this handler, deep-link hits (verification email links, page - * refresh on a SPA route, copy-pasted URLs) fall through `serveStatic` - * to the global 404 JSON. - * - * Mounted AFTER the static middleware so real assets under - * `/auth/assets/...` still resolve to the file on disk; - * `serveStatic` short-circuits on hits and only calls through on misses. - */ - .on('GET', `${SERVER_AUTH_UI_BASE_PATH}/*`, (c) => { - return c.html(renderServerAuthUiHtml({ - apiServerUrl: deps.env.API_SERVER_URL, - currentUrl: c.req.url, - })) - }) - + .route('/', createAuthUiRoutes({ env: deps.env })) /** * Auth routes are handled by the auth instance directly, * Powered by better-auth. @@ -197,31 +119,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { */ .on('POST', '/api/auth/check-email', async (c) => { const body = await c.req.json().catch(() => null) as { email?: unknown } | null - const raw = typeof body?.email === 'string' ? body.email.trim() : '' - const email = raw.toLowerCase() - - if (!email || !EMAIL_SHAPE_RE.test(email)) - throw createBadRequestError('Invalid email', 'INVALID_EMAIL') - - const [matched] = await deps.db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, email)) - .limit(1) - - if (!matched) - return c.json({ exists: false, hasPassword: false }) - - const [credential] = await deps.db - .select({ id: account.id }) - .from(account) - .where(and( - eq(account.userId, matched.id), - eq(account.providerId, 'credential'), - )) - .limit(1) - - return c.json({ exists: true, hasPassword: !!credential }) + return c.json(await checkEmailIdentifier({ db: deps.db }, body)) }) .on(['POST', 'GET'], '/api/auth/*', async (c) => { return handleAuthRequest(c.req.raw) diff --git a/apps/server/src/routes/auth/oidc-userinfo-ban.test.ts b/apps/server/src/routes/auth/oidc-userinfo-ban.test.ts index 4e7a58489..db0ca36e8 100644 --- a/apps/server/src/routes/auth/oidc-userinfo-ban.test.ts +++ b/apps/server/src/routes/auth/oidc-userinfo-ban.test.ts @@ -89,3 +89,24 @@ describe('oidc /oauth2/userinfo ban guard', () => { expect(handler).toHaveBeenCalledTimes(1) }) }) + +describe('auth UI routes', () => { + it('redirects sign-in provider shortcut before the SPA fallback', async () => { + const { routes } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null }) + + const res = await routes.request('/auth/sign-in?provider=github&client_id=stage-web&prompt=login&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fauth%2Fcallback') + + expect(res.status).toBe(302) + + const location = res.headers.get('location') + expect(location).toContain('http://localhost:3000/api/auth/sign-in/social?provider=github') + expect(location).toContain('callbackURL=') + + const redirect = new URL(location!) + const callbackURL = redirect.searchParams.get('callbackURL') + expect(callbackURL).toContain('/api/auth/oauth2/authorize?') + expect(callbackURL).toContain('client_id=stage-web') + expect(callbackURL).not.toContain('provider=') + expect(callbackURL).not.toContain('prompt=') + }) +}) diff --git a/apps/server/src/routes/auth/ui-routes.ts b/apps/server/src/routes/auth/ui-routes.ts new file mode 100644 index 000000000..9d009fcd7 --- /dev/null +++ b/apps/server/src/routes/auth/ui-routes.ts @@ -0,0 +1,97 @@ +import type { Env } from '../../libs/env' +import type { HonoEnv } from '../../types/hono' + +import { serveStatic } from '@hono/node-server/serve-static' +import { Hono } from 'hono' + +import { getServerAuthUiDistDir, renderServerAuthUiHtml, SERVER_AUTH_UI_BASE_PATH } from '../../utils/server-auth-ui' + +const RE_SERVER_AUTH_UI_BASE_PATH = /^\/auth/ + +export interface AuthUiRoutesDeps { + /** Server environment used to build OIDC callback URLs and UI config. */ + env: Env +} + +/** + * Creates routes for the server-hosted auth UI bundle. + * + * Use when: + * - Mounting auth pages before `/api/auth/*` catch-all routes. + * + * Expects: + * - The ui-server-auth dist exists under `getServerAuthUiDistDir()`. + * + * Returns: + * - Root-mounted Hono routes for `/auth/*`. + */ +export function createAuthUiRoutes(deps: AuthUiRoutesDeps) { + return new Hono() + .use(`${SERVER_AUTH_UI_BASE_PATH}/*`, serveStatic({ + root: getServerAuthUiDistDir(), + rewriteRequestPath: (path: string) => path.replace(RE_SERVER_AUTH_UI_BASE_PATH, ''), + })) + /** + * Login page for the OIDC Provider flow, served under the ui-server-auth + * vue-router base (`/auth/sign-in`). When an unauthenticated + * user hits `/api/auth/oauth2/authorize`, better-auth redirects here + * because of `oauthProvider({ loginPage })`. After the user signs in via + * a social provider, the social callback redirects to `callbackURL`, + * which points back to the OIDC authorize endpoint. + * + * If a `provider` query parameter is present (e.g. `?provider=github`), + * skip the picker page and redirect directly to the social provider. + * + * Registered BEFORE the SPA `/auth/*` wildcard fallback so + * the provider shortcut gets a chance to short-circuit. Hono matches + * routes in registration order — specific path before wildcard wins. + */ + .on('GET', `${SERVER_AUTH_UI_BASE_PATH}/sign-in`, (c) => { + const provider = c.req.query('provider') + + // Reconstruct the OIDC authorize URL from query params so the flow + // resumes after social login. The oauthProvider plugin appends all + // authorization request params when redirecting to loginPage. + const url = new URL(c.req.url) + const oidcParams = new URLSearchParams(url.searchParams) + oidcParams.delete('provider') + // Strip prompt so the post-sign-in redirect to authorize doesn't force + // another sign-in — prompt=login should only apply on the first pass. + oidcParams.delete('prompt') + + const callbackURL = oidcParams.toString() + ? `${deps.env.API_SERVER_URL}/api/auth/oauth2/authorize?${oidcParams.toString()}` + : '/' + + if (!!provider && ['google', 'github'].includes(provider)) { + const socialUrl = `${deps.env.API_SERVER_URL}/api/auth/sign-in/social?provider=${provider}&callbackURL=${encodeURIComponent(callbackURL)}` + return c.redirect(socialUrl) + } + + return c.html(renderServerAuthUiHtml({ + apiServerUrl: deps.env.API_SERVER_URL, + currentUrl: c.req.url, + })) + }) + /** + * SPA fallback for the ui-server-auth bundle. + * + * vue-router runs with `createWebHistory('/auth/')`, so any + * client-side route — `/auth/verify-email`, + * `/auth/forgot-password`, `/auth/reset-password`, + * etc. — appears in the URL bar but has no matching file in the dist. + * Without this handler, deep-link hits (verification email links, page + * refresh on a SPA route, copy-pasted URLs) fall through `serveStatic` + * to the global 404 JSON. + * + * Mounted AFTER the static middleware so real assets under + * `/auth/assets/...` still resolve to the file on disk; + * `serveStatic` short-circuits on hits and only calls through on misses. + */ + .on('GET', `${SERVER_AUTH_UI_BASE_PATH}/*`, (c) => { + return c.html(renderServerAuthUiHtml({ + apiServerUrl: deps.env.API_SERVER_URL, + currentUrl: c.req.url, + })) + }) +}