From 57ba2324324f3fd68144a785ee4f4764aa227614 Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 9 Jun 2026 00:15:34 +0800 Subject: [PATCH] feat(auth): migrate auth ui to unique domain --- .../workflows/deploy-cloudflare-auth-ui.yml | 68 +++++++++++++ .../deploy-cloudflare-workers-dev-server.yml | 49 +++++++++- .../workflows/deploy-cloudflare-workers.yml | 40 +++----- apps/server/Dockerfile | 4 +- apps/server/README.md | 10 ++ .../docs/ai-context/email-auth-resend.md | 2 +- .../verifications/account-deletion.md | 13 +-- .../ai-context/verifications/email-auth.md | 6 +- apps/server/production/railway/Dockerfile | 4 +- apps/server/railway.toml | 1 - apps/server/src/libs/auth.ts | 10 +- apps/server/src/libs/env.ts | 5 + apps/server/src/libs/tests/env.test.ts | 1 + apps/server/src/middlewares/auth.ts | 7 +- apps/server/src/routes/auth/index.ts | 2 +- .../src/routes/auth/oidc-userinfo-ban.test.ts | 25 +++-- .../src/routes/auth/oidc/electron-callback.ts | 35 +++---- apps/server/src/routes/auth/ui-routes.ts | 83 ++-------------- apps/server/src/utils/auth-ui.ts | 97 +++++++++++++++++++ apps/server/src/utils/origin.ts | 2 + apps/server/src/utils/server-auth-ui.ts | 55 ----------- apps/server/src/utils/tests/auth-ui.test.ts | 45 +++++++++ apps/server/src/utils/tests/origin.test.ts | 5 + apps/ui-server-auth/README.md | 45 +++++++++ apps/ui-server-auth/public/_redirects | 4 + apps/ui-server-auth/src/main.ts | 5 +- .../src/modules/auth-ui-base.ts | 56 +++++++++++ .../src/modules/server-auth-context.test.ts | 42 ++++++++ .../src/modules/server-auth-context.ts | 68 ++++++++++++- .../src/modules/sign-in.test.ts | 20 +++- apps/ui-server-auth/src/modules/sign-in.ts | 6 +- apps/ui-server-auth/src/pages/[...all].vue | 67 +++++++++++++ .../src/pages/forgot-password.vue | 3 +- apps/ui-server-auth/src/pages/index.vue | 4 +- apps/ui-server-auth/src/pages/sign-in.vue | 11 ++- .../ui-server-auth/src/pages/verify-email.vue | 5 +- apps/ui-server-auth/vite.config.ts | 4 +- apps/ui-server-auth/wrangler.toml | 7 ++ 38 files changed, 682 insertions(+), 234 deletions(-) create mode 100644 .github/workflows/deploy-cloudflare-auth-ui.yml create mode 100644 apps/server/src/utils/auth-ui.ts delete mode 100644 apps/server/src/utils/server-auth-ui.ts create mode 100644 apps/server/src/utils/tests/auth-ui.test.ts create mode 100644 apps/ui-server-auth/README.md create mode 100644 apps/ui-server-auth/public/_redirects create mode 100644 apps/ui-server-auth/src/modules/auth-ui-base.ts create mode 100644 apps/ui-server-auth/src/modules/server-auth-context.test.ts create mode 100644 apps/ui-server-auth/src/pages/[...all].vue create mode 100644 apps/ui-server-auth/wrangler.toml diff --git a/.github/workflows/deploy-cloudflare-auth-ui.yml b/.github/workflows/deploy-cloudflare-auth-ui.yml new file mode 100644 index 000000000..67a3ebc12 --- /dev/null +++ b/.github/workflows/deploy-cloudflare-auth-ui.yml @@ -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 }} diff --git a/.github/workflows/deploy-cloudflare-workers-dev-server.yml b/.github/workflows/deploy-cloudflare-workers-dev-server.yml index 27059f7a0..680630270 100644 --- a/.github/workflows/deploy-cloudflare-workers-dev-server.yml +++ b/.github/workflows/deploy-cloudflare-workers-dev-server.yml @@ -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/" diff --git a/.github/workflows/deploy-cloudflare-workers.yml b/.github/workflows/deploy-cloudflare-workers.yml index c2f34591a..26e9c8a32 100644 --- a/.github/workflows/deploy-cloudflare-workers.yml +++ b/.github/workflows/deploy-cloudflare-workers.yml @@ -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 }} diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 459800698..1225e8892 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -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 diff --git a/apps/server/README.md b/apps/server/README.md index 7e1b8e1ca..aca0cc98f 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -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: diff --git a/apps/server/docs/ai-context/email-auth-resend.md b/apps/server/docs/ai-context/email-auth-resend.md index 885b71178..1e7e32220 100644 --- a/apps/server/docs/ai-context/email-auth-resend.md +++ b/apps/server/docs/ai-context/email-auth-resend.md @@ -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:`/verify-email?token=` - Reset password:`/reset-password?token=` - 由 `getAuthTrustedOrigins(request)` 第一个匹配的 origin 决定 ``,避免硬编码。 diff --git a/apps/server/docs/ai-context/verifications/account-deletion.md b/apps/server/docs/ai-context/verifications/account-deletion.md index 5d032e8ab..32eb2cfd8 100644 --- a/apps/server/docs/ai-context/verifications/account-deletion.md +++ b/apps/server/docs/ai-context/verifications/account-deletion.md @@ -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 diff --git a/apps/server/docs/ai-context/verifications/email-auth.md b/apps/server/docs/ai-context/verifications/email-auth.md index 261701b2c..ff9a51452 100644 --- a/apps/server/docs/ai-context/verifications/email-auth.md +++ b/apps/server/docs/ai-context/verifications/email-auth.md @@ -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=`. +3. Click the email link. Expect server validates and 302s to `${UI}/ui/reset-password?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). diff --git a/apps/server/production/railway/Dockerfile b/apps/server/production/railway/Dockerfile index e8bad51b6..78a003428 100644 --- a/apps/server/production/railway/Dockerfile +++ b/apps/server/production/railway/Dockerfile @@ -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 diff --git a/apps/server/railway.toml b/apps/server/railway.toml index ac2603747..1e0f24420 100644 --- a/apps/server/railway.toml +++ b/apps/server/railway.toml @@ -4,7 +4,6 @@ dockerfilePath = "/apps/server/production/railway/Dockerfile" watchPatterns = [ "apps/server/**", "apps/ui-admin/**", - "apps/ui-server-auth/**", "packages/**", "pnpm-lock.yaml" ] diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index c155117c4..d7dd1c8f4 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -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], diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index b514f16df..4f15e8b4a 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -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 diff --git a/apps/server/src/libs/tests/env.test.ts b/apps/server/src/libs/tests/env.test.ts index 6a593cc5e..25f86c59a 100644 --- a/apps/server/src/libs/tests/env.test.ts +++ b/apps/server/src/libs/tests/env.test.ts @@ -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([]) }) diff --git a/apps/server/src/middlewares/auth.ts b/apps/server/src/middlewares/auth.ts index 7d7aa30d7..7caee0711 100644 --- a/apps/server/src/middlewares/auth.ts +++ b/apps/server/src/middlewares/auth.ts @@ -16,10 +16,9 @@ type AuthInstance = ReturnType export function sessionMiddleware(auth: AuthInstance, env: Env): MiddlewareHandler { 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. // diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index 030ee9e30..b92e4f4f0 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -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. 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 db0ca36e8..74b3ec724 100644 --- a/apps/server/src/routes/auth/oidc-userinfo-ban.test.ts +++ b/apps/server/src/routes/auth/oidc-userinfo-ban.test.ts @@ -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') }) }) diff --git a/apps/server/src/routes/auth/oidc/electron-callback.ts b/apps/server/src/routes/auth/oidc/electron-callback.ts index 07005b3ac..1efc0a17a 100644 --- a/apps/server/src/routes/auth/oidc/electron-callback.ts +++ b/apps/server/src/routes/auth/oidc/electron-callback.ts @@ -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() .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)) }) } diff --git a/apps/server/src/routes/auth/ui-routes.ts b/apps/server/src/routes/auth/ui-routes.ts index 9d009fcd7..eedd6d68e 100644 --- a/apps/server/src/routes/auth/ui-routes.ts +++ b/apps/server/src/routes/auth/ui-routes.ts @@ -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() - .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))) } diff --git a/apps/server/src/utils/auth-ui.ts b/apps/server/src/utils/auth-ui.ts new file mode 100644 index 000000000..5026c0694 --- /dev/null +++ b/apps/server/src/utils/auth-ui.ts @@ -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() +} diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index 557006451..05b21f7d1 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -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: diff --git a/apps/server/src/utils/server-auth-ui.ts b/apps/server/src/utils/server-auth-ui.ts deleted file mode 100644 index 4e5160940..000000000 --- a/apps/server/src/utils/server-auth-ui.ts +++ /dev/null @@ -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_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') -} diff --git a/apps/server/src/utils/tests/auth-ui.test.ts b/apps/server/src/utils/tests/auth-ui.test.ts new file mode 100644 index 000000000..0caf7cedb --- /dev/null +++ b/apps/server/src/utils/tests/auth-ui.test.ts @@ -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') + }) +}) diff --git a/apps/server/src/utils/tests/origin.test.ts b/apps/server/src/utils/tests/origin.test.ts index 790fd6950..f62bb7153 100644 --- a/apps/server/src/utils/tests/origin.test.ts +++ b/apps/server/src/utils/tests/origin.test.ts @@ -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('') diff --git a/apps/ui-server-auth/README.md b/apps/ui-server-auth/README.md new file mode 100644 index 000000000..30273f88d --- /dev/null +++ b/apps/ui-server-auth/README.md @@ -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. diff --git a/apps/ui-server-auth/public/_redirects b/apps/ui-server-auth/public/_redirects new file mode 100644 index 000000000..060b172e1 --- /dev/null +++ b/apps/ui-server-auth/public/_redirects @@ -0,0 +1,4 @@ +/auth /ui/ 302 +/auth/* /ui/:splat 302 +/ui /index.html 200 +/ui/* /index.html 200 diff --git a/apps/ui-server-auth/src/main.ts b/apps/ui-server-auth/src/main.ts index d6ef28fc6..0ee4aed3d 100644 --- a/apps/ui-server-auth/src/main.ts +++ b/apps/ui-server-auth/src/main.ts @@ -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) diff --git a/apps/ui-server-auth/src/modules/auth-ui-base.ts b/apps/ui-server-auth/src/modules/auth-ui-base.ts new file mode 100644 index 000000000..2a69da3db --- /dev/null +++ b/apps/ui-server-auth/src/modules/auth-ui-base.ts @@ -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}` +} diff --git a/apps/ui-server-auth/src/modules/server-auth-context.test.ts b/apps/ui-server-auth/src/modules/server-auth-context.test.ts new file mode 100644 index 000000000..e006e96ba --- /dev/null +++ b/apps/ui-server-auth/src/modules/server-auth-context.test.ts @@ -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 = '' + 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') + }) +}) diff --git a/apps/ui-server-auth/src/modules/server-auth-context.ts b/apps/ui-server-auth/src/modules/server-auth-context.ts index 3afd52ec2..957e35afd 100644 --- a/apps/ui-server-auth/src/modules/server-auth-context.ts +++ b/apps/ui-server-auth/src/modules/server-auth-context.ts @@ -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 + } +} diff --git a/apps/ui-server-auth/src/modules/sign-in.test.ts b/apps/ui-server-auth/src/modules/sign-in.test.ts index 346d36227..91d2b9e58 100644 --- a/apps/ui-server-auth/src/modules/sign-in.test.ts +++ b/apps/ui-server-auth/src/modules/sign-in.test.ts @@ -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(async () => { return new Response(JSON.stringify({ url: 'https://accounts.example.test/oauth/google' }), { diff --git a/apps/ui-server-auth/src/modules/sign-in.ts b/apps/ui-server-auth/src/modules/sign-in.ts index d50e56124..5771f70b2 100644 --- a/apps/ui-server-auth/src/modules/sign-in.ts +++ b/apps/ui-server-auth/src/modules/sign-in.ts @@ -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 { diff --git a/apps/ui-server-auth/src/pages/[...all].vue b/apps/ui-server-auth/src/pages/[...all].vue new file mode 100644 index 000000000..955e10a18 --- /dev/null +++ b/apps/ui-server-auth/src/pages/[...all].vue @@ -0,0 +1,67 @@ + + + diff --git a/apps/ui-server-auth/src/pages/forgot-password.vue b/apps/ui-server-auth/src/pages/forgot-password.vue index d22c3e560..e7ad08376 100644 --- a/apps/ui-server-auth/src/pages/forgot-password.vue +++ b/apps/ui-server-auth/src/pages/forgot-password.vue @@ -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(null) diff --git a/apps/ui-server-auth/src/pages/index.vue b/apps/ui-server-auth/src/pages/index.vue index 52b36f50e..68b21aca9 100644 --- a/apps/ui-server-auth/src/pages/index.vue +++ b/apps/ui-server-auth/src/pages/index.vue @@ -1,6 +1,6 @@ diff --git a/apps/ui-server-auth/src/pages/sign-in.vue b/apps/ui-server-auth/src/pages/sign-in.vue index 33557350e..3536c5168 100644 --- a/apps/ui-server-auth/src/pages/sign-in.vue +++ b/apps/ui-server-auth/src/pages/sign-in.vue @@ -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', diff --git a/apps/ui-server-auth/src/pages/verify-email.vue b/apps/ui-server-auth/src/pages/verify-email.vue index 127679d56..c0e24b6d5 100644 --- a/apps/ui-server-auth/src/pages/verify-email.vue +++ b/apps/ui-server-auth/src/pages/verify-email.vue @@ -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 { // 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 { diff --git a/apps/ui-server-auth/vite.config.ts b/apps/ui-server-auth/vite.config.ts index 1810fe1c9..52623ab56 100644 --- a/apps/ui-server-auth/vite.config.ts +++ b/apps/ui-server-auth/vite.config.ts @@ -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: { diff --git a/apps/ui-server-auth/wrangler.toml b/apps/ui-server-auth/wrangler.toml new file mode 100644 index 000000000..3dd7edde8 --- /dev/null +++ b/apps/ui-server-auth/wrangler.toml @@ -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"