feat(auth): migrate auth ui to unique domain
This commit is contained in:
@@ -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/"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,6 @@ dockerfilePath = "/apps/server/production/railway/Dockerfile"
|
||||
watchPatterns = [
|
||||
"apps/server/**",
|
||||
"apps/ui-admin/**",
|
||||
"apps/ui-server-auth/**",
|
||||
"packages/**",
|
||||
"pnpm-lock.yaml"
|
||||
]
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
/auth /ui/ 302
|
||||
/auth/* /ui/:splat 302
|
||||
/ui /index.html 200
|
||||
/ui/* /index.html 200
|
||||
@@ -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' }), {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user