feat(auth): migrate auth ui to unique domain

This commit is contained in:
RainbowBird
2026-06-09 00:15:34 +08:00
parent ff7c18694a
commit 57ba232432
38 changed files with 682 additions and 234 deletions
@@ -0,0 +1,68 @@
name: Cloudflare Pages (Auth UI)
on:
push:
branches:
- 'main'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy - ui-server-auth
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
environment:
name: Auth Production
url: https://auth.airi.build/ui/
steps:
- uses: actions/checkout@v6
# Turborepo
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: pnpm
# NOTICE:
#
# Keep the wrangler setup consistent with the stage-web deployment
# workflow. cloudflare/wrangler-action expects a usable wrangler binary
# when running repository-local pnpm workspaces.
- run: pnpm i -g wrangler@4
- run: pnpm install --frozen-lockfile
- run: pnpm run build:packages
- name: Build ui-server-auth
run: pnpm -F @proj-airi/ui-server-auth run build
env:
VITE_ENABLE_POSTHOG: 'true'
VITE_SERVER_URL: 'https://api.airi.build'
- run: |
CI=true pnpm install -g @posthog/cli
pnpm exec posthog-cli sourcemap inject --directory ./apps/ui-server-auth/dist
pnpm exec posthog-cli sourcemap upload --directory ./apps/ui-server-auth/dist
env:
POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_ENV_ID }}
POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_TOKEN }}
continue-on-error: true
- uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.AUTH_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.AUTH_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=main
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
@@ -12,7 +12,7 @@ concurrency:
cancel-in-progress: true
jobs:
deploy:
deploy-stage-web:
name: Deploy - stage-web (server-dev)
runs-on: ubuntu-latest
permissions:
@@ -83,3 +83,50 @@ jobs:
echo "preview_url=$preview_url" >> "$GITHUB_OUTPUT"
echo "Preview URL: $preview_url"
deploy-auth-ui:
name: Deploy - ui-server-auth (server-dev)
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
environment:
name: AuthServerDev
url: https://server-dev.airi-server-auth.pages.dev/ui/
steps:
- uses: actions/checkout@v6
# Turborepo
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-auth-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-auth-
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/*
cache: pnpm
- run: pnpm i -g wrangler@4
- run: pnpm install --frozen-lockfile
- run: pnpm run build:packages
- name: Build ui-server-auth
run: pnpm -F @proj-airi/ui-server-auth run build
env:
VITE_SERVER_URL: 'https://airi-server-dev.up.railway.app'
- name: Wrangler Pages Deploy
uses: cloudflare/wrangler-action@v3.14.1
with:
apiToken: ${{ secrets.AUTH_CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.AUTH_CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./apps/ui-server-auth/dist --project-name=moeru-ai-airi-auth --branch=server-dev
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Print preview URL
run: |
echo "Preview URL: https://server-dev.airi-server-auth.pages.dev/ui/"
+16 -24
View File
@@ -13,33 +13,15 @@ concurrency:
jobs:
deploy:
name: Deploy - ${{ matrix.app_name }}
name: Deploy - stage-web
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
strategy:
matrix:
include:
- app_name: stage-web
environment_name: Production
environment_url: https://airi.moeru.ai/docs/
cloudflare_pages_id: moeru-ai-airi
dist_directory: ./apps/stage-web/dist
command: |
pnpm -F @proj-airi/stage-web run build
pnpm -F @proj-airi/docs run build:base
mv ./docs/.vitepress/dist ./apps/stage-web/dist/docs
cp ./apps/stage-web/dist/docs/sitemap.xml ./apps/stage-web/dist/sitemap.xml
pnpm -F @proj-airi/stage-ui run story:build
mv ./packages/stage-ui/.histoire/dist ./apps/stage-web/dist/ui
environment:
name: ${{ matrix.environment_name }}
url: ${{ matrix.environment_url }}
name: Production
url: https://airi.moeru.ai/docs/
steps:
- uses: actions/checkout@v6
@@ -72,18 +54,28 @@ jobs:
- run: pnpm i -g wrangler@4
- run: pnpm install --frozen-lockfile
- run: pnpm run build:packages
- run: ${{ matrix.command }}
- name: Build stage-web
run: |
pnpm -F @proj-airi/stage-web run build
pnpm -F @proj-airi/docs run build:base
mv ./docs/.vitepress/dist ./apps/stage-web/dist/docs
cp ./apps/stage-web/dist/docs/sitemap.xml ./apps/stage-web/dist/sitemap.xml
pnpm -F @proj-airi/stage-ui run story:build
mv ./packages/stage-ui/.histoire/dist ./apps/stage-web/dist/ui
env:
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_REGION: ${{ secrets.S3_REGION }}
VITE_ENABLE_POSTHOG: 'true'
VITE_SERVER_URL: 'https://api.airi.build'
WARP_DRIVE_PUBLIC_BASE: ${{ secrets.WARP_DRIVE_PUBLIC_BASE }}
- run: |
CI=true pnpm install -g @posthog/cli
pnpm exec posthog-cli sourcemap inject --directory ${{ matrix.dist_directory }}
pnpm exec posthog-cli sourcemap upload --directory ${{ matrix.dist_directory }}
pnpm exec posthog-cli sourcemap inject --directory ./apps/stage-web/dist
pnpm exec posthog-cli sourcemap upload --directory ./apps/stage-web/dist
env:
POSTHOG_CLI_ENV_ID: ${{ secrets.POSTHOG_ENV_ID }}
POSTHOG_CLI_TOKEN: ${{ secrets.POSTHOG_TOKEN }}
+1 -3
View File
@@ -11,8 +11,7 @@ COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile --ignore-scripts
RUN pnpm -r -F @proj-airi/ui-server-auth^... -F @proj-airi/ui-admin^... --if-present run build
RUN pnpm -F @proj-airi/ui-server-auth run build
RUN pnpm -r -F @proj-airi/ui-admin^... --if-present run build
RUN pnpm -F @proj-airi/ui-admin run build
FROM node:24-alpine
@@ -24,7 +23,6 @@ RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY apps/server apps/server
COPY --from=ui-assets /app/apps/server/public/ui-server-auth apps/server/public/ui-server-auth
COPY --from=ui-assets /app/apps/server/public/ui-admin apps/server/public/ui-admin
COPY packages/server-schema packages/server-schema
COPY packages/server-sdk-shared packages/server-sdk-shared
+10
View File
@@ -26,6 +26,16 @@ For local observability infrastructure, use:
docker compose -f apps/server/docker-compose.otel.yml up -d
```
## `AUTH_UI_URL`
`apps/ui-server-auth` is deployed separately from the server image. The API server still owns the historical `/auth/*` entrypoints and redirects them to **`AUTH_UI_URL`**.
Default:
`AUTH_UI_URL=https://auth.airi.build/ui`
Set this when previewing or deploying auth UI to a different Cloudflare URL.
## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev)
When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `apps/server/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example:
@@ -46,7 +46,7 @@ Out:
- **Resend SDK**:使用官方 `resend` npm 包。错误处理走 `errorMessageFrom``@moeru/std`);失败时抛 `ApiError(502, 'email/send_failed', ...)` 让 Better Auth 把错传回前端。
- **触发邮件的位置**Better Auth 的 hook 是 server 内部回调,不是 HTTP 路由——跨实例时只有处理该次 sign-in/up 的实例会触发,不会重复。
- **Verify / reset 链接 URL**:链接落地页不放 `apps/server`,而是放 `apps/ui-server-auth``API_SERVER_URL` 是 server 自身(如 `https://airi-api.moeru.ai`),ui-server-auth 通常是另一域(如 `https://auth.airi.moeru.ai`);两者要么同源(dev)要么通过 trustedOrigins 已经互信。链接组装规则:
- **Verify / reset 链接 URL**:链接落地页不放 `apps/server`,而是放独立部署的 `apps/ui-server-auth``API_SERVER_URL` 是 server 自身(如 `https://api.airi.build`),ui-server-auth 是同站点的另一域(如 `https://auth.airi.build/ui`);两者通过 trustedOrigins 互信。链接组装规则:
- Verify email`<UI_BASE>/verify-email?token=<token>`
- Reset password`<UI_BASE>/reset-password?token=<token>`
-`getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 `<UI_BASE>`,避免硬编码。
@@ -39,13 +39,14 @@ What this proves:
- `clearedFlux=500` confirms the Flux handler picked up the actual balance.
- `count=0` for providers / characters / chats reflects the test user not having those records — empty soft-delete is a valid no-op.
## Known gotcha — UI dist staleness
## Known gotcha — standalone UI deploy staleness
`apps/server/public/ui-server-auth/` is a build artifact (Vite `outDir`). New
pages added under `apps/ui-server-auth/src/pages/` only show up after
running `pnpm -F @proj-airi/ui-server-auth build`. Symptom of forgetting:
the success page returns `200` with the SPA HTML but renders blank because
vue-router never registered the route. Re-build → fixes.
`apps/ui-server-auth/dist/` is deployed independently from the server image.
New pages added under `apps/ui-server-auth/src/pages/` only show up after
running `pnpm -F @proj-airi/ui-server-auth build` and deploying the Cloudflare
Workers Static Assets project. Symptom of forgetting: the success page returns
`200` with the SPA HTML but renders blank because vue-router never registered
the route. Re-build and re-deploy the auth UI → fixes.
## What is verified
@@ -61,13 +61,13 @@ Two follow-up issues surfaced and were fixed in the same session:
1. From `/sign-in`, click "Forgot password?" → `/forgot-password`.
2. Submit the registered email. Expect `POST /api/auth/request-password-reset` returns 200, an email arrives ("Reset your Project AIRI password").
3. Click the email link. Expect server validates and 302s to `${UI}/auth/reset-password?token=<token>`.
3. Click the email link. Expect server validates and 302s to `${UI}/ui/reset-password?token=<token>`.
4. Submit a new password. Expect `POST /api/auth/reset-password?token=...` returns 200; UI shows "Password updated".
5. Sign in with the new password and confirm session is issued.
### Path 3 — OIDC-bridged sign-in
1. Open a stage app (e.g. `apps/stage-web`) → triggers OIDC `/oauth2/authorize` → bounces to `ui-server-auth /sign-in?...`.
1. Open a stage app (e.g. `apps/stage-web`) → triggers OIDC `/oauth2/authorize` → bounces to `ui-server-auth /ui/sign-in?...`.
2. Submit email + password against the verified user. Expect session cookie set; browser redirects to the OIDC continuation URL; stage app yields `code` → token exchange.
3. Stage app shows a signed-in state.
@@ -82,4 +82,4 @@ Treat the email-auth feature as **partially shipped**. Sign-up + verify-email is
- Email i18n (only English).
- Resend bounce / complaint webhook ingestion.
- Email send audit log in `request_log`.
- dev/prod served-from parity (dev runs Vite at `:5174`; prod expects ui-server-auth dist under `apps/server/public/ui-server-auth`).
- dev/prod served-from parity (dev runs Vite at `:5174`; prod expects ui-server-auth to be deployed from `apps/ui-server-auth/dist` via Cloudflare Workers Static Assets).
+1 -3
View File
@@ -10,8 +10,7 @@ COPY . .
RUN pnpm install --frozen-lockfile --ignore-scripts
RUN pnpm -r -F @proj-airi/ui-server-auth^... -F @proj-airi/ui-admin^... --if-present run build
RUN pnpm -F @proj-airi/ui-server-auth run build
RUN pnpm -r -F @proj-airi/ui-admin^... --if-present run build
RUN pnpm -F @proj-airi/ui-admin run build
FROM node:24-alpine
@@ -22,7 +21,6 @@ RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY --from=ui-assets /app/apps/server/public/ui-server-auth apps/server/public/ui-server-auth
COPY --from=ui-assets /app/apps/server/public/ui-admin apps/server/public/ui-admin
COPY apps/server apps/server
COPY packages/server-schema packages/server-schema
-1
View File
@@ -4,7 +4,6 @@ dockerfilePath = "/apps/server/production/railway/Dockerfile"
watchPatterns = [
"apps/server/**",
"apps/ui-admin/**",
"apps/ui-server-auth/**",
"packages/**",
"pnpm-lock.yaml"
]
+4 -6
View File
@@ -423,12 +423,10 @@ export function createAuth(
},
}),
oauthProvider({
// Keep loginPage inside the ui-server-auth vue-router base (`/auth/`)
// so the OIDC redirect lands on a URL the SPA router actually owns.
// Without the prefix the address bar stays on bare `/sign-in`, which
// is outside vue-router's history base — SPA-internal `router.push`
// later jumps to `/auth/...`, and a refresh of the bare URL would
// fall through to the global 404.
// Keep loginPage on the server-owned historical `/auth/*` entrypoint.
// The server redirects it to standalone ui-server-auth (`/ui/*` in
// production), while Better Auth still gets a stable relative path for
// oauth-provider's OIDC redirect query construction.
loginPage: '/auth/sign-in',
consentPage: '/oauth/authorize',
scopes: [...OIDC_SCOPES],
+5
View File
@@ -80,6 +80,11 @@ const EnvSchema = object({
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
// Standalone auth UI base URL. The server keeps `/auth/*` as the historical
// entrypoint and redirects those requests here after ui-server-auth moved out
// of the server image.
AUTH_UI_URL: optional(string(), 'https://auth.airi.build/ui'),
// Canonical user-facing web app origin. Used as the Stripe redirect base
// (success_url / cancel_url / portal return_url) when a request has no trusted
// browser origin — notably the Electron desktop renderer, which loads from
+1
View File
@@ -41,6 +41,7 @@ describe('parseEnv', () => {
expect(env.DATABASE_URL).toBe('postgres://example')
expect(env.REDIS_URL).toBe('redis://example')
expect(env.AUTH_UI_URL).toBe('https://auth.airi.build/ui')
expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([])
})
+3 -4
View File
@@ -16,10 +16,9 @@ type AuthInstance = ReturnType<typeof createAuth>
export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandler<HonoEnv> {
return async (c, next) => {
// NOTICE: auth routes handle session lookup inside better-auth itself,
// and the ui-server-auth SPA bundle (HTML/JS/CSS + SPA routes like
// `/auth/sign-in`, `/auth/verify-email`, …) doesn't need a session
// attached either. Running the global session middleware on `/api/auth/*`,
// `/auth/*`, and the auth discovery endpoints duplicates the same session
// and `/auth/*` only redirects to the standalone ui-server-auth deployment.
// Running the global session middleware on `/api/auth/*`, `/auth/*`,
// and the auth discovery endpoints duplicates the same session
// read and slows the OIDC login path (`authorize` → `token` →
// `get-session`) noticeably.
//
+1 -1
View File
@@ -83,7 +83,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) {
* authorization code to the Electron loopback server via JS fetch().
* This avoids navigating the browser to http://127.0.0.1:{port}.
*/
.route('/api/auth/oidc/electron-callback', createElectronCallbackRelay())
.route('/api/auth/oidc/electron-callback', createElectronCallbackRelay(deps.env))
/**
* OAuth 2.1 Authorization Server metadata must live at the root-level
* well-known path with the issuer path inserted for non-root issuers.
@@ -41,7 +41,11 @@ async function buildRoutes(currentUser: SessionUser) {
api: { getSession: vi.fn(async () => sessionFor(currentUser)) },
} as any,
db: {} as any, // userinfo path never queries the DB
env: { API_SERVER_URL: 'http://localhost:3000', ADDITIONAL_TRUSTED_ORIGINS: [] } as any,
env: {
API_SERVER_URL: 'http://localhost:3000',
AUTH_UI_URL: 'https://auth.airi.build/ui',
ADDITIONAL_TRUSTED_ORIGINS: [],
} as any,
configKV: createConfigKV(),
rateLimitMetrics: null,
}
@@ -91,7 +95,7 @@ describe('oidc /oauth2/userinfo ban guard', () => {
})
describe('auth UI routes', () => {
it('redirects sign-in provider shortcut before the SPA fallback', async () => {
it('redirects sign-in provider shortcut to the standalone auth UI', 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')
@@ -99,14 +103,15 @@ describe('auth UI routes', () => {
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=')
expect(location).toBe('https://auth.airi.build/ui/sign-in?provider=github&client_id=stage-web&prompt=login&redirect_uri=http%3A%2F%2Flocalhost%3A5173%2Fauth%2Fcallback')
})
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=')
it('redirects Electron OIDC callback queries to the standalone auth UI relay', async () => {
const { routes } = await buildRoutes({ id: 'uid_ok', email: 'ok@example.com', banned: false, banExpires: null })
const res = await routes.request('/api/auth/oidc/electron-callback?code=sample-code&state=43123%3Aopaque-state')
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('https://auth.airi.build/ui/api/auth/oidc/electron-callback?code=sample-code&state=43123%3Aopaque-state')
})
})
@@ -1,38 +1,29 @@
import type { Env } from '../../../libs/env'
import type { HonoEnv } from '../../../types/hono'
import { Hono } from 'hono'
import { renderServerAuthUiHtml } from '../../../utils/server-auth-ui'
import { buildAuthUiUrl } from '../../../utils/auth-ui'
/**
* Render an HTML relay page that forwards the OIDC authorization code
* to the Electron app's loopback server.
* Redirects the Electron OIDC callback to the standalone auth UI relay page.
*
* The page first tries a background fetch() for the cleanest UX. If the browser
* blocks cross-origin loopback fetches, it falls back to a top-level navigation
* and also exposes a manual localhost link so the user can complete the flow.
* Use when:
* - The API origin remains the registered Electron redirect URI, but the relay
* UI bundle is deployed separately from the server image.
*
* Expects:
* The loopback port is encoded in the `state` parameter as a prefix:
* `{port}:{originalState}`. The relay page extracts the port, reconstructs
* the original state, and forwards both `code` and `state` to the loopback.
*
* Returns:
* - A redirect preserving the OIDC callback query string.
*/
export function createElectronCallbackRelay() {
export function createElectronCallbackRelay(env: Env) {
return new Hono<HonoEnv>()
.get('/', (c) => {
const code = c.req.query('code') ?? ''
const state = c.req.query('state') ?? ''
const error = c.req.query('error') ?? ''
const errorDescription = c.req.query('error_description') ?? ''
return c.html(renderServerAuthUiHtml({
apiServerUrl: new URL(c.req.url).origin,
currentUrl: c.req.url,
oidcCallback: {
code,
error,
errorDescription,
state,
},
}))
const request = new URL(c.req.url)
return c.redirect(buildAuthUiUrl(env.AUTH_UI_URL, '/api/auth/oidc/electron-callback', request.search))
})
}
+8 -75
View File
@@ -1,97 +1,30 @@
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/
import { buildAuthUiRedirectUrl, SERVER_AUTH_UI_BASE_PATH } from '../../utils/auth-ui'
export interface AuthUiRoutesDeps {
/** Server environment used to build OIDC callback URLs and UI config. */
/** Server environment carrying the standalone auth UI URL. */
env: Env
}
/**
* Creates routes for the server-hosted auth UI bundle.
* Creates routes that redirect historical server auth UI URLs to the
* standalone auth UI deployment.
*
* Use when:
* - Mounting auth pages before `/api/auth/*` catch-all routes.
*
* Expects:
* - The ui-server-auth dist exists under `getServerAuthUiDistDir()`.
* - `env.AUTH_UI_URL` points to the public standalone auth UI base.
*
* Returns:
* - Root-mounted Hono routes for `/auth/*`.
* - Root-mounted redirects 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,
}))
})
.get(SERVER_AUTH_UI_BASE_PATH, c => c.redirect(buildAuthUiRedirectUrl(deps.env.AUTH_UI_URL, c.req.url, deps.env.API_SERVER_URL)))
.get(`${SERVER_AUTH_UI_BASE_PATH}/*`, c => c.redirect(buildAuthUiRedirectUrl(deps.env.AUTH_UI_URL, c.req.url, deps.env.API_SERVER_URL)))
}
+97
View File
@@ -0,0 +1,97 @@
export const SERVER_AUTH_UI_BASE_PATH = '/auth'
export const AUTH_UI_API_SERVER_URL_QUERY_PARAM = 'api_server_url'
export const DEFAULT_AUTH_UI_URL = 'https://auth.airi.build/ui'
export const SERVER_DEV_API_SERVER_URL = 'https://airi-server-dev.up.railway.app'
export const SERVER_DEV_AUTH_UI_URL = 'https://server-dev.airi-server-auth.pages.dev/ui'
/**
* Builds an absolute URL inside the externally hosted auth UI.
*
* Use when:
* - Redirecting server-owned auth UI entrypoints to the standalone
* `apps/ui-server-auth` deployment.
* - Preserving query parameters from OIDC, verification, or reset flows.
*
* Expects:
* - `authUiUrl` is the public auth UI base, usually ending in `/ui`.
* - `path` is the route path within the auth UI router.
*
* Returns:
* - An absolute URL with the auth UI base path, normalized path, and search.
*/
export function buildAuthUiUrl(authUiUrl: string, path: string, search = ''): string {
const target = new URL(authUiUrl)
const basePath = target.pathname.replace(/\/+$/, '')
const routePath = path.startsWith('/') ? path : `/${path}`
target.pathname = `${basePath}${routePath}`
target.search = search
target.hash = ''
return target.toString()
}
/**
* Resolves the standalone auth UI base for the active server environment.
*
* Use when:
* - The server redirects historical `/auth/*` entrypoints to the standalone UI.
* - The server-dev Railway deployment needs the matching Cloudflare Pages
* branch without changing the production auth domain.
*
* Expects:
* - `authUiUrl` is the configured auth UI base URL.
* - `apiServerUrl` is the configured API server URL.
*
* Returns:
* - The configured auth UI URL, except for the server-dev default pairing where
* the matching Pages branch URL is returned.
*/
export function resolveAuthUiUrl(authUiUrl: string, apiServerUrl: string): string {
try {
const authUi = new URL(authUiUrl)
const defaultAuthUi = new URL(DEFAULT_AUTH_UI_URL)
const apiServer = new URL(apiServerUrl)
const authUiBase = `${authUi.origin}${authUi.pathname.replace(/\/+$/, '')}`
const defaultAuthUiBase = `${defaultAuthUi.origin}${defaultAuthUi.pathname.replace(/\/+$/, '')}`
if (authUiBase === defaultAuthUiBase && apiServer.origin === SERVER_DEV_API_SERVER_URL) {
return SERVER_DEV_AUTH_UI_URL
}
}
catch {
return authUiUrl
}
return authUiUrl
}
/**
* Maps a server `/auth/*` request to the standalone auth UI.
*
* Use when:
* - The server keeps owning the historical `/auth/*` entrypoint but no longer
* packages the auth UI bundle.
*
* Expects:
* - `requestUrl` is the incoming server URL.
* - `authUiUrl` points to the standalone auth UI base path.
*
* Returns:
* - The external auth UI URL preserving route suffix and query string.
*/
export function buildAuthUiRedirectUrl(authUiUrl: string, requestUrl: string, apiServerUrl?: string): string {
const request = new URL(requestUrl)
const suffix = request.pathname === SERVER_AUTH_UI_BASE_PATH
? '/'
: request.pathname.slice(SERVER_AUTH_UI_BASE_PATH.length) || '/'
const resolvedAuthUiUrl = apiServerUrl ? resolveAuthUiUrl(authUiUrl, apiServerUrl) : authUiUrl
const target = new URL(buildAuthUiUrl(resolvedAuthUiUrl, suffix, request.search))
if (apiServerUrl) {
const apiServer = new URL(apiServerUrl)
target.searchParams.set(AUTH_UI_API_SERVER_URL_QUERY_PARAM, apiServer.origin)
}
return target.toString()
}
+2
View File
@@ -13,6 +13,8 @@ const TRUSTED_EXACT_ORIGINS = [
'capacitor://localhost', // Capacitor mobile (iOS)
'ai.moeru.airi-pocket://links', // Android deep link
'https://airi.moeru.ai', // Production
'https://auth.airi.build', // Standalone auth UI
'https://server-dev.airi-server-auth.pages.dev', // Server-dev standalone auth UI
]
// NOTICE:
-55
View File
@@ -1,55 +0,0 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
export const SERVER_AUTH_UI_BASE_PATH = '/auth'
const SERVER_AUTH_UI_DIST_DIR = fileURLToPath(new URL('../../public/ui-server-auth', import.meta.url))
const SERVER_AUTH_UI_INDEX_HTML_PATH = fileURLToPath(new URL('../../public/ui-server-auth/index.html', import.meta.url))
const RE_HTML_LT = /</g
const RE_HTML_GT = />/g
const RE_HTML_AMP = /&/g
const RE_UNICODE_LINE_SEPARATOR = /\u2028/g
const RE_UNICODE_PARAGRAPH_SEPARATOR = /\u2029/g
let cachedIndexHtml: string | null = null
export interface ServerAuthUiContext {
apiServerUrl: string
currentUrl: string
oidcCallback?: {
code: string
error: string
errorDescription: string
state: string
}
}
export function getServerAuthUiDistDir(): string {
return SERVER_AUTH_UI_DIST_DIR
}
export function renderServerAuthUiHtml(context: ServerAuthUiContext): string {
const indexHtml = getServerAuthUiIndexHtml()
if (!indexHtml.includes('__AIRI_SERVER_AUTH_CONTEXT__'))
throw new Error('ui-server-auth index.html is missing __AIRI_SERVER_AUTH_CONTEXT__ placeholder')
return indexHtml.replace('__AIRI_SERVER_AUTH_CONTEXT__', serializeInlineJson(context))
}
function getServerAuthUiIndexHtml(): string {
if (cachedIndexHtml !== null)
return cachedIndexHtml
cachedIndexHtml = readFileSync(SERVER_AUTH_UI_INDEX_HTML_PATH, 'utf8')
return cachedIndexHtml
}
function serializeInlineJson(value: unknown): string {
return JSON.stringify(value)
.replace(RE_HTML_LT, '\\u003c')
.replace(RE_HTML_GT, '\\u003e')
.replace(RE_HTML_AMP, '\\u0026')
.replace(RE_UNICODE_LINE_SEPARATOR, '\\u2028')
.replace(RE_UNICODE_PARAGRAPH_SEPARATOR, '\\u2029')
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { buildAuthUiRedirectUrl, buildAuthUiUrl, resolveAuthUiUrl } from '../auth-ui'
describe('auth UI URL helpers', () => {
it('builds auth UI URLs under the configured auth base path', () => {
expect(buildAuthUiUrl('https://auth.airi.build/ui', '/sign-in', '?client_id=web')).toBe(
'https://auth.airi.build/ui/sign-in?client_id=web',
)
})
it('maps server /auth requests to the standalone auth UI while preserving queries', () => {
expect(buildAuthUiRedirectUrl(
'https://auth.airi.build/ui/',
'https://api.airi.build/auth/verify-email?verified=true',
)).toBe('https://auth.airi.build/ui/verify-email?verified=true')
})
it('adds the API server origin for standalone auth UI cross-environment redirects', () => {
expect(buildAuthUiRedirectUrl(
'https://auth-preview.example/ui/',
'https://airi-server-dev.up.railway.app/auth/sign-in?client_id=airi-stage-web&api_server_url=https%3A%2F%2Fevil.example',
'https://airi-server-dev.up.railway.app/api/auth',
)).toBe(
'https://auth-preview.example/ui/sign-in?client_id=airi-stage-web&api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app',
)
})
it('routes server-dev default auth UI redirects to the matching Pages branch', () => {
expect(buildAuthUiRedirectUrl(
'https://auth.airi.build/ui',
'https://airi-server-dev.up.railway.app/auth/sign-in?client_id=airi-stage-web',
'https://airi-server-dev.up.railway.app',
)).toBe(
'https://server-dev.airi-server-auth.pages.dev/ui/sign-in?client_id=airi-stage-web&api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app',
)
})
it('keeps an explicitly configured auth UI URL for server-dev', () => {
expect(resolveAuthUiUrl(
'https://auth-preview.example/ui',
'https://airi-server-dev.up.railway.app',
)).toBe('https://auth-preview.example/ui')
})
})
@@ -12,6 +12,11 @@ describe('origin utils', () => {
expect(getTrustedOrigin('https://127.0.0.1:5273')).toBe('https://127.0.0.1:5273')
})
it('allows the standalone auth UI origin', () => {
expect(getTrustedOrigin('https://auth.airi.build')).toBe('https://auth.airi.build')
expect(getTrustedOrigin('https://server-dev.airi-server-auth.pages.dev')).toBe('https://server-dev.airi-server-auth.pages.dev')
})
it('rejects private LAN Vite dev origins unless listed in ADDITIONAL_TRUSTED_ORIGINS', () => {
expect(getTrustedOrigin('https://10.0.0.129:5273')).toBe('')
expect(getTrustedOrigin('https://198.18.0.1:5273')).toBe('')
+45
View File
@@ -0,0 +1,45 @@
# AIRI Server Auth UI
Auth UI for the hosted AIRI server. It is a Vue/Vite app deployed separately from `apps/server` and used for Better Auth sign-in, email verification, password reset, profile, and Electron OIDC callback relay flows.
## Use When
- Building user-facing auth pages backed by server `/api/auth/*` endpoints.
- Updating login, sign-up, verification, reset-password, account profile, or Electron auth relay UX.
- Deploying the auth surface to Cloudflare Workers Static Assets.
## Do Not Use When
- Building the main stage app sign-in callback pages that consume OIDC tokens.
- Adding admin-only operational pages. Those belong in `apps/ui-admin`.
## Commands
```sh
pnpm -F @proj-airi/ui-server-auth dev
pnpm -F @proj-airi/ui-server-auth typecheck
pnpm -F @proj-airi/ui-server-auth build
```
## Deployment
`pnpm -F @proj-airi/ui-server-auth build` writes to `apps/ui-server-auth/dist`. Vue Router owns `/ui/*`, while Vite assets are served from root `/assets/*` so Cloudflare Pages can serve static files without rewriting nested asset paths. Cloudflare Pages uses `public/_redirects` to route `/ui/*` back to the SPA HTML.
The production GitHub Actions workflow deploys this app to the Cloudflare Pages project `moeru-ai-airi-auth` with separate auth-account credentials:
```sh
AUTH_CLOUDFLARE_ACCOUNT_ID=...
AUTH_CLOUDFLARE_API_TOKEN=...
```
`apps/ui-server-auth/wrangler.toml` remains available for Workers Static Assets deployments, but production CI uses Cloudflare Pages direct upload.
Production expects:
```sh
VITE_SERVER_URL=https://api.airi.build
```
The server redirects historical `/auth/*` URLs to `AUTH_UI_URL`, which defaults to `https://auth.airi.build/ui`.
The `server-dev` workflow deploys a Cloudflare Pages branch build at `https://server-dev.moeru-ai-airi-auth.pages.dev/ui/` with `VITE_SERVER_URL=https://airi-server-dev.up.railway.app`. Set the server-dev API environment variable `AUTH_UI_URL=https://server-dev.moeru-ai-airi-auth.pages.dev/ui` when the full dev auth redirect chain should stay on server-dev.
+4
View File
@@ -0,0 +1,4 @@
/auth /ui/ 302
/auth/* /ui/:splat 302
/ui /index.html 200
/ui/* /index.html 200
+3 -2
View File
@@ -14,6 +14,7 @@ import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { AUTH_UI_ROUTER_BASE_PATH } from './modules/auth-ui-base'
import { i18n } from './modules/i18n'
import '@proj-airi/font-chillroundm/index.css'
@@ -29,9 +30,9 @@ const routeRecords = setupLayouts(routes as RouteRecordRaw[])
let router: Router
if (isEnvTruthy(import.meta.env.VITE_APP_TARGET_HUGGINGFACE_SPACE))
router = createRouter({ routes: routeRecords, history: createWebHashHistory('/auth/') })
router = createRouter({ routes: routeRecords, history: createWebHashHistory(AUTH_UI_ROUTER_BASE_PATH) })
else
router = createRouter({ routes: routeRecords, history: createWebHistory('/auth/') })
router = createRouter({ routes: routeRecords, history: createWebHistory(AUTH_UI_ROUTER_BASE_PATH) })
router.beforeEach((to, from) => {
if (to.path !== from.path)
@@ -0,0 +1,56 @@
export const AUTH_UI_BASE_PATH = '/ui'
export const AUTH_UI_ROUTER_BASE_PATH = `${AUTH_UI_BASE_PATH}/`
/**
* Normalizes auth UI route paths under the deployed `/ui` router base.
*
* Before:
* - "/sign-in"
* - "/auth/sign-in"
*
* After:
* - "/ui/sign-in"
* - "/ui/sign-in"
*/
export function buildAuthUiPath(path = '/'): string {
const routePath = normalizeRoutePath(path)
return routePath === '/'
? AUTH_UI_ROUTER_BASE_PATH
: `${AUTH_UI_BASE_PATH}${routePath}`
}
/**
* Builds an absolute auth UI URL on the current browser origin.
*
* Use when:
* - Passing Better Auth callback URLs that must land back in ui-server-auth.
* - Navigating from auth UI helper modules without depending on Vue Router.
*
* Expects:
* - The caller runs in a browser context.
*
* Returns:
* - An absolute URL under the `/ui` router base.
*/
export function buildCurrentOriginAuthUiUrl(path = '/'): string {
return `${window.location.origin}${buildAuthUiPath(path)}`
}
function normalizeRoutePath(path: string): string {
if (!path || path === '/')
return '/'
if (path === '/auth' || path.startsWith('/auth?') || path.startsWith('/auth#'))
return `/${path.slice('/auth'.length)}`
if (path === AUTH_UI_BASE_PATH || path.startsWith(`${AUTH_UI_BASE_PATH}?`) || path.startsWith(`${AUTH_UI_BASE_PATH}#`))
return `/${path.slice(AUTH_UI_BASE_PATH.length)}`
if (path.startsWith('/auth/'))
return path.slice('/auth'.length)
if (path.startsWith(`${AUTH_UI_BASE_PATH}/`))
return path.slice(AUTH_UI_BASE_PATH.length)
return path.startsWith('/') ? path : `/${path}`
}
@@ -0,0 +1,42 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { getServerAuthBootstrapContext, resolveStandaloneServerAuthContext } from './server-auth-context'
describe('ui-server-auth bootstrap context', () => {
it('uses the trusted API server origin carried by standalone server redirects', () => {
expect(resolveStandaloneServerAuthContext(
'https://auth.airi.build/ui/sign-in?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fauth&client_id=airi-stage-web',
'https://api.airi.build',
)).toEqual({
apiServerUrl: 'https://airi-server-dev.up.railway.app',
currentUrl: 'https://auth.airi.build/ui/sign-in?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fauth&client_id=airi-stage-web',
})
})
it('ignores untrusted API server origins from crafted standalone auth URLs', () => {
expect(resolveStandaloneServerAuthContext(
'https://auth.airi.build/ui/sign-in?api_server_url=https%3A%2F%2Fevil.example&client_id=airi-stage-web',
'https://api.airi.build',
)).toBeNull()
})
it('allows localhost API origins for local development', () => {
expect(resolveStandaloneServerAuthContext(
'http://localhost:5173/ui/sign-in?api_server_url=http%3A%2F%2F127.0.0.1%3A3000',
'https://api.airi.build',
)?.apiServerUrl).toBe('http://127.0.0.1:3000')
})
it('falls back to the standalone query context when the static placeholder script is still present', () => {
document.body.innerHTML = '<script id="airi-server-auth-context" type="application/json">__AIRI_SERVER_AUTH_CONTEXT__</script>'
window.history.replaceState(
null,
'',
'/ui/sign-in?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app',
)
expect(getServerAuthBootstrapContext()?.apiServerUrl).toBe('https://airi-server-dev.up.railway.app')
})
})
@@ -12,6 +12,19 @@ export interface ServerAuthBootstrapContext {
}
const SCRIPT_ID = 'airi-server-auth-context'
const API_SERVER_URL_QUERY_PARAM = 'api_server_url'
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
'https://api.airi.build',
'https://airi-server-dev.up.railway.app',
]
const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [
/^http:\/\/localhost(:\d+)?$/,
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
/^https:\/\/localhost(:\d+)?$/,
/^https:\/\/127\.0\.0\.1(:\d+)?$/,
]
let cachedContext: ServerAuthBootstrapContext | null | undefined
@@ -21,7 +34,7 @@ export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | nu
const element = document.getElementById(SCRIPT_ID)
if (!element) {
cachedContext = null
cachedContext = resolveStandaloneServerAuthContext(window.location.href, SERVER_URL)
return cachedContext
}
@@ -35,7 +48,58 @@ export function getServerAuthBootstrapContext(): ServerAuthBootstrapContext | nu
return cachedContext
}
catch {
cachedContext = null
cachedContext = resolveStandaloneServerAuthContext(window.location.href, SERVER_URL)
return cachedContext
}
}
/**
* Resolves API-server context carried by server redirects into static auth UI.
*
* Use when:
* - The standalone auth UI serves more than one AIRI environment from the same
* Pages deployment, such as production `auth.airi.build` handling server-dev
* OIDC redirects.
*
* Expects:
* - The server-owned `/auth/*` redirect sets `api_server_url`.
* - Only known AIRI API origins and localhost development origins are accepted.
*
* Returns:
* - A bootstrap context using the trusted API origin, or null when no trusted
* override is present.
*/
export function resolveStandaloneServerAuthContext(currentUrl: string, fallbackApiServerUrl: string): ServerAuthBootstrapContext | null {
const url = new URL(currentUrl)
const apiServerUrl = normalizeTrustedApiServerUrl(
url.searchParams.get(API_SERVER_URL_QUERY_PARAM),
)
if (!apiServerUrl)
return null
return {
apiServerUrl: apiServerUrl ?? fallbackApiServerUrl,
currentUrl,
}
}
function normalizeTrustedApiServerUrl(value: string | null): string | null {
if (!value)
return null
try {
const origin = new URL(value).origin
if (TRUSTED_STANDALONE_API_SERVER_ORIGINS.includes(origin))
return origin
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin)))
return origin
return null
}
catch {
return null
}
}
@@ -5,7 +5,7 @@ import { createServerSignInContext, requestSocialSignInRedirect } from './sign-i
describe('ui-server-auth sign-in flow helpers', () => {
it('rebuilds the OIDC callback URL without provider and prompt query params', () => {
expect(createServerSignInContext(
'https://auth.airi.test/sign-in?client_id=airi-stage-web&provider=github&prompt=login&response_type=code&scope=openid',
'https://auth.airi.test/sign-in?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app&client_id=airi-stage-web&provider=github&prompt=login&response_type=code&scope=openid',
'https://api.airi.test',
)).toEqual({
callbackURL: 'https://api.airi.test/api/auth/oauth2/authorize?client_id=airi-stage-web&response_type=code&scope=openid',
@@ -58,6 +58,24 @@ describe('ui-server-auth sign-in flow helpers', () => {
})
})
it('normalizes standalone UI redirects under the deployed /ui base', () => {
expect(createServerSignInContext(
'https://auth.airi.test/ui/sign-in?redirect=%2Fprofile%3Ftab%3Dsecurity',
'https://api.airi.test',
)).toEqual({
callbackURL: 'https://auth.airi.test/ui/profile?tab=security',
requestedProvider: null,
})
expect(createServerSignInContext(
'https://auth.airi.test/ui/sign-in?redirect=%2Fauth%2Freset-password%3Ftoken%3Dold-link',
'https://api.airi.test',
)).toEqual({
callbackURL: 'https://auth.airi.test/ui/reset-password?token=old-link',
requestedProvider: null,
})
})
it('posts the selected provider and callback URL to the social sign-in endpoint', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => {
return new Response(JSON.stringify({ url: 'https://accounts.example.test/oauth/google' }), {
+4 -2
View File
@@ -1,6 +1,7 @@
import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth'
import { extractAuthError } from './auth-fetch'
import { buildAuthUiPath } from './auth-ui-base'
export interface ServerSignInContext {
callbackURL: string
@@ -23,6 +24,7 @@ export function createServerSignInContext(currentUrl: string, apiServerUrl: stri
oidcParams.delete('provider')
oidcParams.delete('redirect')
oidcParams.delete('prompt')
oidcParams.delete('api_server_url')
// NOTICE:
// Only synthesize an OIDC authorize callback when the page query genuinely
@@ -56,10 +58,10 @@ function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null):
if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//'))
return null
if (redirect.startsWith('/admin') || redirect.startsWith('/auth'))
if (redirect.startsWith('/admin'))
return `${currentUrl.origin}${redirect}`
return `${currentUrl.origin}/auth${redirect}`
return `${currentUrl.origin}${buildAuthUiPath(redirect)}`
}
export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise<string> {
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Button } from '@proj-airi/ui'
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<main
:class="[
'min-h-screen w-full',
'flex items-center justify-center',
'bg-neutral-50 px-6 py-10 text-neutral-900',
'dark:bg-neutral-950 dark:text-neutral-50',
]"
>
<section
:class="[
'w-full max-w-sm',
'flex flex-col items-center gap-6 text-center',
]"
>
<div
:class="[
'flex flex-col items-center gap-3',
]"
>
<p
:class="[
'text-sm font-medium text-neutral-500 uppercase tracking-wide',
'dark:text-neutral-400',
]"
>
404
</p>
<h1
:class="[
'text-2xl font-semibold',
]"
>
Page not found
</h1>
<p
:class="[
'text-sm leading-6 text-neutral-600',
'dark:text-neutral-300',
]"
>
This auth page does not exist.
</p>
</div>
<div
:class="[
'flex flex-wrap items-center justify-center gap-3',
]"
>
<Button @click="router.push('/sign-in')">
Sign in
</Button>
<Button variant="secondary" @click="router.go(-1)">
Go back
</Button>
</div>
</section>
</main>
</template>
@@ -4,6 +4,7 @@ import { Button, FieldInput } from '@proj-airi/ui'
import { reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { describeAuthError, requestPasswordReset } from '../modules/email-password'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@@ -14,7 +15,7 @@ const apiServerUrl = bootstrapContext?.apiServerUrl ?? SERVER_URL
// Reset link must redirect back into ui-server-auth itself. Use the current
// origin so dev (localhost) and prod (auth.airi) both resolve correctly.
const resetRedirect = `${window.location.origin}/auth/reset-password`
const resetRedirect = buildCurrentOriginAuthUiUrl('/reset-password')
const form = reactive({ email: '' })
const errorMessage = shallowRef<string | null>(null)
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
// Empty placeholder. The route below redirects /auth/ to /auth/profile so a
// directly-typed `/auth/` URL or a post-sign-in callback that fell back to
// Empty placeholder. The route below redirects /ui/ to /ui/profile so a
// directly-typed `/ui/` URL or a post-sign-in callback that fell back to
// the auth-UI root lands on a usable page instead of an empty RouterView.
</script>
+6 -5
View File
@@ -8,6 +8,7 @@ import { computed, reactive, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import {
checkEmail,
describeAuthError,
@@ -46,10 +47,10 @@ const signInContext = computed(() => createServerSignInContext(currentUrl, apiSe
// Outside an OIDC flow signInContext.callbackURL is bare `/` which Better Auth
// resolves against the API server origin (404). Fall back to the UI root so
// the user lands somewhere useful the `/auth/` index route redirects to
// `/auth/profile` so this is not the dead-end empty RouterView it once was.
const uiHomeURL = `${window.location.origin}/auth/`
const verifySuccessURL = `${window.location.origin}/auth/verify-email?verified=true`
// the user lands somewhere useful the `/ui/` index route redirects to
// `/ui/profile` so this is not the dead-end empty RouterView it once was.
const uiHomeURL = buildCurrentOriginAuthUiUrl()
const verifySuccessURL = buildCurrentOriginAuthUiUrl('/verify-email?verified=true')
const effectiveCallbackURL = computed(() =>
signInContext.value.callbackURL === '/' ? uiHomeURL : signInContext.value.callbackURL,
@@ -186,7 +187,7 @@ async function handleEmailSignIn(event: Event) {
if (result.requiresVerification) {
// Existing-but-unverified accounts that started from /oauth2/authorize
// must carry the OIDC continuation through verification. Without it the
// verify-email tab would resume to /auth/profile after the cookie lands
// verify-email tab would resume to /ui/profile after the cookie lands
// and the upstream stage app never receives its auth code/tokens.
await router.push({
path: '/verify-email',
@@ -5,6 +5,7 @@ import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
const { t } = useI18n()
@@ -40,7 +41,7 @@ const continueURL = computed(() => {
// NOTICE:
// Cross-tab signal between the verification-success tab (the one opened from
// the email link) and the original "check your inbox" tab. Both tabs live on
// the same origin (/auth/...), so BroadcastChannel works without setup.
// the same origin (/ui/...), so BroadcastChannel works without setup.
//
// Why not poll /get-session every 2s? An abandoned pending tab would burn
// 1800 requests/hour for no reason, and the request volume scales with time
@@ -73,7 +74,7 @@ async function resumeIfSessionReady(): Promise<boolean> {
// Same-tab navigation preserves sessionStorage on the destination origin,
// so the original PKCE flowState saved by the OIDC client is still
// available when /auth/callback runs.
window.location.href = continueURL.value || `${window.location.origin}/auth/`
window.location.href = continueURL.value || buildCurrentOriginAuthUiUrl()
return true
}
catch {
+2 -2
View File
@@ -13,7 +13,7 @@ import VueRouter from 'vue-router/vite'
import { defineConfig } from 'vite'
export default defineConfig({
base: '/auth/',
base: '/',
optimizeDeps: {
exclude: [
// Internal Packages
@@ -45,7 +45,7 @@ export default defineConfig({
},
build: {
emptyOutDir: true,
outDir: resolve(join(import.meta.dirname, '..', 'server', 'public', 'ui-server-auth')),
outDir: resolve(join(import.meta.dirname, 'dist')),
sourcemap: true,
},
worker: {
+7
View File
@@ -0,0 +1,7 @@
name = "moeru-ai-airi-auth"
compatibility_date = "2025-04-01"
preview_urls = true
[assets]
directory = "./dist"
not_found_handling = "single-page-application"