refactor(server): split auth route helpers
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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=')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user