refactor(server): split auth route helpers

This commit is contained in:
RainbowBird
2026-06-03 13:48:19 +08:00
parent f5b051d969
commit a795806e81
4 changed files with 192 additions and 107 deletions
@@ -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<CheckEmailIdentifierResult> {
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 }
}
+5 -107
View File
@@ -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<HonoEnv>()
.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)
@@ -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=')
})
})
+97
View File
@@ -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<HonoEnv>()
.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,
}))
})
}