diff --git a/apps/server/docs/ai-context/README.md b/apps/server/docs/ai-context/README.md index fece4ebc7..da07cd42c 100644 --- a/apps/server/docs/ai-context/README.md +++ b/apps/server/docs/ai-context/README.md @@ -25,6 +25,8 @@ - 计费链路专项说明,重点看 Flux / Stripe / outbox / Redis Streams - `observability-conventions.md` - traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界 +- `auth-and-oidc.md` + - 认证与 OIDC Provider 架构、登录流程、trusted clients、踩坑记录 ## 快速结论 @@ -45,3 +47,4 @@ - 改配置默认值、Redis key 命名、HTTP route 命名:先看 `config-and-naming-conventions.md` - 改扣费、充值、Stripe:先看 `billing-architecture.md` - 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md` +- 改认证、OIDC、登录流程:先看 `auth-and-oidc.md` diff --git a/apps/server/docs/ai-context/auth-and-oidc.md b/apps/server/docs/ai-context/auth-and-oidc.md new file mode 100644 index 000000000..564a5cdfb --- /dev/null +++ b/apps/server/docs/ai-context/auth-and-oidc.md @@ -0,0 +1,255 @@ +# 认证与 OIDC Provider + +## 一句话总结 + +Server 通过 `better-auth` 同时充当**用户认证后端**和 **OIDC Provider(Authorization Server)**,为 Web、Electron Desktop、Capacitor Mobile 三个客户端提供 Authorization Code + PKCE 登录流程。所有客户端通过 OIDC session bridge 将 OIDC access token 换取 better-auth session token,使用 Bearer token 鉴权。 + +## 架构角色 + +``` +┌─────────────────────────────────┐ +│ 社交登录 IdP (Google, GitHub) │ +└──────────────┬──────────────────┘ + ↓ OAuth 2.0 +┌──────────────────────────────────────────────────┐ +│ AIRI Server (better-auth OIDC Provider) │ +│ │ +│ /api/auth/oauth2/authorize ← PKCE 授权 │ +│ /api/auth/oauth2/token ← Code 换 Token │ +│ /api/auth/oidc/session ← OIDC→Session 桥接 │ +│ /api/auth/oidc/electron-callback ← 回调中继页 │ +│ /api/auth/sign-in/social ← 社交登录入口 │ +│ /sign-in ← 登录选择页 │ +└──────────────┬──────────────────┬────────────────┘ + ↓ ↓ + ┌──────────┐ ┌──────────────┐ + │ Stage Web │ │ Stage Electron│ + │ /auth/ │ │ 127.0.0.1: │ + │ callback │ │ {port}/ │ + └──────────┘ │ callback │ + └──────────────┘ +``` + +## 核心组件 + +### Server 端 + +| 文件 | 职责 | +|------|------| +| `src/libs/auth.ts` | better-auth 配置:社交 provider、OIDC provider 插件、trusted clients 种子数据、session/cookie 策略 | +| `src/routes/auth/index.ts` | 所有鉴权路由的统一入口:sign-in 页、rate limiter、OIDC session bridge、electron callback、well-known metadata、better-auth catch-all | +| `src/routes/oidc/session.ts` | OIDC→Session 桥接端点:验证 OIDC access token → 创建 better-auth session → 返回 session token | +| `src/routes/oidc/electron-callback.ts` | Electron 回调中继页:服务端 HTML 页面通过 JS fetch() 将 auth code 转发到 Electron 本地 loopback | +| `src/utils/sign-in-page.ts` | 渲染 fallback HTML 登录页(Google/GitHub 按钮) | +| `src/utils/origin.ts` | 可信来源配置:`localhost`、`127.0.0.1`、`airi.moeru.ai`、`capacitor://localhost` | +| `src/libs/env.ts` | OIDC 相关环境变量定义(Valibot schema) | + +### Client 端 + +| 文件 | 职责 | +|------|------| +| `packages/stage-ui/src/libs/auth-oidc.ts` | OIDC 协议实现:构建 authorize URL、PKCE 生成、code 换 token、token 刷新、flow state 持久化 | +| `packages/stage-ui/src/libs/auth.ts` | 高层鉴权编排:`signInOIDC()` 发起登录、`bridgeOIDCTokens()` 桥接 token、`fetchSession()` 同步会话、自动刷新调度 | +| `packages/stage-ui/src/stores/auth.ts` | Pinia auth store:持久化 `user`、`session`、`token`、`refreshToken` 到 localStorage | +| `packages/stage-shared/src/auth/pkce.ts` | PKCE 工具函数:`generateCodeVerifier()`、`generateCodeChallenge()`、`generateState()` | +| `apps/stage-web/src/pages/auth/callback.vue` | Web 回调页:提取 code → 换 token → bridge → fetchSession → 跳转首页 | +| `apps/stage-web/src/pages/auth/sign-in.vue` | Web 登录页:调用 `signInOIDC()` 发起 OIDC 流程 | + +### Trusted Clients + +| Client | ID 环境变量 | redirect_uri | 类型 | +|--------|------------|--------------|------| +| Web | `OIDC_CLIENT_ID_WEB` | `https://airi.moeru.ai/auth/callback`, `http://localhost:5173/auth/callback` | web | +| Electron | `OIDC_CLIENT_ID_ELECTRON` | `{API_SERVER_URL}/api/auth/oidc/electron-callback`(服务端中继) | native | +| Mobile | `OIDC_CLIENT_ID_POCKET` | `capacitor://localhost/auth/callback` | native | + +### 环境变量 + +``` +# 社交 Provider +AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET +AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET + +# OIDC Trusted Clients(均 optional,不配则不注册) +# Web and Pocket are public clients (no secret, PKCE only) +OIDC_CLIENT_ID_WEB +OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON +OIDC_CLIENT_ID_POCKET +``` + +## Token 层次 + +| Token | 用途 | 存储位置 | 生命周期 | +|-------|------|---------|---------| +| Authorization Code | 一次性换 token | URL query param (`?code=`) | 极短,一次性 | +| OIDC Access Token | 换 session token(bridge) | 不持久化,仅在回调流程中使用 | 由 oauthProvider 控制 | +| OIDC Refresh Token | 刷新 access token | localStorage `auth/v1/refresh-token` | 长期 | +| Session Token | 实际的 API 鉴权凭证(Bearer) | localStorage `auth/v1/token` | 由 better-auth session 控制 | + +**为什么不直接用 OIDC access token?** 因为 better-auth 的 session 系统使用自己的 `session` 表,OIDC access token 存在 `oauth_access_token` 表中,两者不兼容。所有 API 路由通过 `sessionMiddleware` 查 `session` 表鉴权,所以需要 bridge 端点做转换。 + +**为什么不用 cookie?** 客户端和服务端跨域(如 `localhost:5173` vs `localhost:3000`),cookie 无法跨域传递。客户端 `credentials: 'omit'`,纯 Bearer token 鉴权。 + +## 登录流程 + +### Web 完整流程 + +``` +Client (localhost:5173) Server (localhost:3000) Social IdP + │ │ │ + 1. signInOIDC() │ │ + 构建 PKCE (verifier + challenge) │ │ + 存 sessionStorage │ │ + window.location → │ │ + │ │ │ + 2. GET /api/auth/oauth2/authorize │ │ + ?response_type=code │ │ + &client_id=airi-stage-web │ │ + &redirect_uri=localhost:5173/auth/callback │ │ + &code_challenge=xxx │ │ + &provider=github │ │ + │ │ │ + │ 3. 用户未登录 │ + │ 302 → /sign-in?...所有 OIDC 参数... │ + │ │ │ + │ 4. /sign-in 看到 provider=github │ + │ 重建 callbackURL = /api/auth/oauth2/authorize?... │ + │ 302 → /api/auth/sign-in/social │ + │ ?provider=github │ + │ &callbackURL={OIDC authorize URL} │ + │ │ │ + │ ──────── 302 to GitHub ───────────────► │ + │ │ 5. 用户授权 + │ │ ◄──────── callback ────────────────── │ + │ │ │ + │ 6. better-auth 创建 user + session(server cookie) │ + │ 302 → callbackURL(= OIDC authorize) │ + │ │ │ + │ 7. /api/auth/oauth2/authorize │ + │ 用户已有 session → 签发 authorization code │ + │ 302 → redirect_uri?code=xxx&state=xxx │ + │ │ │ + 8. /auth/callback │ │ + consumeFlowState() 恢复 PKCE │ │ + 验证 state 防 CSRF │ │ + │ │ │ + 9. POST /api/auth/oauth2/token ──────────────► │ │ + (code + code_verifier + client_id) │ │ + ◄──── { access_token, refresh_token } ────── │ │ + │ │ │ + 10. POST /api/auth/oidc/session ──────────────► │ │ + (Bearer: access_token) │ │ + ◄──── { token: session_token } ───────────── │ │ + │ │ │ + 11. GET /api/auth/get-session ─────────────────► │ │ + (Bearer: session_token) │ │ + ◄──── { user, session } ──────────────────── │ │ + │ │ │ + 12. 写入 authStore → 跳转首页 │ │ +``` + +**关键设计:callbackURL 传递 OIDC 参数** + +`/sign-in` 路由收到的 URL 包含所有 OIDC 授权参数(`response_type`、`client_id`、`redirect_uri`、`code_challenge` 等)。它将这些参数重建为完整的 OIDC authorize URL,作为 `callbackURL` 传给社交登录。社交登录完成后,用户被重定向回 OIDC authorize 端点,此时用户已有 server session,OIDC 流程继续签发 code。 + +### Electron 特殊处理 + +Electron 不使用自定义协议(`airi://`),而是在 main process 临时启动一个 HTTP server 监听 `127.0.0.1:{port}/callback`: +- 固定端口范围:19721-19725,按顺序尝试 +- 收到回调后立即关闭 server +- 5 分钟超时安全机制 + +**服务端回调中继**: + +Electron 的 OIDC redirect_uri 不再直接指向 loopback 端口,而是指向服务端的 `/api/auth/oidc/electron-callback`。这个端点返回一个 HTML 页面,页面通过 JS `fetch()` 将 auth code 转发到本地 loopback。 + +好处: +- 浏览器不显示 `http://127.0.0.1:19721/...` 这样的 URL +- 只需注册一个 redirect_uri(不再需要 5 个端口对应的 URL) +- Loopback server 需要设置 CORS `Access-Control-Allow-Origin: *` + +端口编码方式:loopback 端口编码在 `state` 参数中,格式为 `{port}:{originalState}`。中继页面提取端口后,将 code 和原始 state 通过 fetch 发送到 `http://127.0.0.1:{port}/callback`。 + +### OIDC→Session 桥接 + +better-auth 的 `oidcProvider` 插件发出的 OIDC access token 存在 `oauth_access_token` 表中,与 better-auth session(`session` 表)不兼容。所有客户端(Web、Electron、Mobile)登录后都需要调用 `POST /api/auth/oidc/session` 桥接端点: + +1. 验证 Bearer token 对应 `oauth_access_token` 表中的有效记录 +2. 验证 `clientId` 属于受信任的客户端集合(Web、Electron、Pocket 均可) +3. 验证 `accessTokenExpiresAt > now` +4. 通过 `(await auth.$context).internalAdapter.createSession(userId)` 创建 better-auth session +5. 缓存 `oidc_token → session_token` 映射(TTL 5 分钟,幂等) +6. TTL 过期后删除 `oauth_access_token` 行 + +安全措施: +- 客户端 ID 限制(仅受信任客户端) +- Token 过期检查 +- 幂等 + TTL 限制重放窗口 +- 统一 401 响应(防止信息泄漏) + +### 自动 Token 刷新 + +客户端在 OIDC token 生命周期 80% 时自动调用 `/api/auth/oauth2/token`(`grant_type=refresh_token`),刷新后重新 bridge 获取新 session token。页面重载后从 localStorage 恢复刷新调度: + +- `auth/v1/oidc-client-id` — 客户端 ID +- `auth/v1/oidc-client-secret` — 客户端 Secret +- `auth/v1/oidc-token-expiry` — Token 过期时间戳 + +### provider 参数直通 + +客户端在 authorize URL 中附带 `provider` 参数,server 的 `/sign-in` 路由会直接 302 到对应社交 provider,**跳过选择页**。没有 `provider` 参数时 fallback 到 HTML 选择页(兜底场景,如直接浏览器访问)。 + +## 路由注册顺序 + +Auth 路由集中在 `src/routes/auth/index.ts`,通过 `.route('/', authRoutes)` 挂载到根路径。路由注册顺序很重要: + +1. `GET /sign-in` — 登录选择页(或直接 302 到社交 provider) +2. `USE /api/auth/*` — rate limiter(IP 限流) +3. `.route('/api/auth/oidc/session')` — session bridge(在 catch-all 之前注册) +4. `.route('/api/auth/oidc/electron-callback')` — electron 回调中继 +5. `GET /.well-known/oauth-authorization-server/api/auth` — OAuth 2.1 AS metadata +6. `GET /api/auth/.well-known/openid-configuration` — OIDC discovery +7. `['POST', 'GET'] /api/auth/*` — **catch-all**,将所有其他请求转发给 `auth.handler()` + +自定义 OIDC 路由(`/api/auth/oidc/*`)注册在 catch-all 之前,所以不会被 better-auth 拦截。`/api/auth/oauth2/authorize` 和 `/api/auth/oauth2/token` 等标准端点由 catch-all 转发给 better-auth 内部处理。 + +## 踩坑记录 + +### better-auth redirect_uri 精确匹配 + +better-auth 的 OIDC 插件对 `redirect_uri` 做**精确字符串匹配**(`authorize.mjs`): + +```javascript +client.redirectUrls.find(url => url === ctx.query.redirect_uri) +``` + +RFC 8252 S7.3 要求 Authorization Server 对 loopback 地址允许任意端口,但 better-auth 不支持。因此 Electron 使用服务端中继 URL 作为 redirect_uri,绕过了端口匹配问题。 + +### better-auth cookie 与 Bearer 共存 + +better-auth client 默认 `credentials: "include"`,会同时发送 cookie。我们 override 为 `credentials: "omit"`,只使用 Bearer token 认证。见 `packages/stage-ui/src/libs/auth.ts` 的 NOTICE 注释。 + +### skipStateCookieCheck + +Capacitor 移动端无法正确处理 state cookie(系统浏览器和 WebView cookie jar 隔离),所以 better-auth 配置了 `skipStateCookieCheck: true`。PKCE 仍然提供 CSRF 防护。 + +### better-auth internalAdapter + +`(await auth.$context).internalAdapter.createSession(userId)` 是创建 session 的正确路径。`auth.api` 是 HTTP endpoint handlers 的集合,没有 `createSession` 方法。参考 better-auth admin 插件和 test-utils 的用法。注意 `createAuth()` 返回 `any`(TS2742),需要无类型安全地访问 `$context`。 + +### OIDC 流程中断:callbackURL 必须指回 authorize + +社交登录完成后,`callbackURL` 必须指向 `/api/auth/oauth2/authorize?...OIDC参数...`,否则用户会被重定向到服务端根路径,OIDC 授权码流程中断。`/sign-in` 路由从 URL query params 重建完整的 OIDC authorize URL 作为 `callbackURL`。 + +## 修改指南 + +- 新增 OIDC client → `src/libs/auth.ts` 的 `buildTrustedClientSeeds`,加环境变量到 `src/libs/env.ts` +- 改登录页 → `src/utils/sign-in-page.ts`(HTML),或 `src/routes/auth/index.ts` 的 `/sign-in` 路由 +- 改认证中间件 → `src/app.ts` 的 session middleware +- 改 trusted origins → `src/utils/origin.ts` +- 改桥接端点 → `src/routes/oidc/session.ts` +- 改回调中继 → `src/routes/oidc/electron-callback.ts` +- 改 Auth 路由结构 → `src/routes/auth/index.ts` +- 调试 OIDC 流程 → 检查 `/sign-in` 的 callbackURL 是否正确重建,以及 `oidc_login_prompt` cookie +- Client 端登录逻辑 → `packages/stage-ui/src/libs/auth.ts` 和 `packages/stage-ui/src/libs/auth-oidc.ts` +- Electron 认证回调处理 → `apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts` diff --git a/apps/server/docs/ai-context/observability-conventions.md b/apps/server/docs/ai-context/observability-conventions.md index 4a436ce56..42d56f35c 100644 --- a/apps/server/docs/ai-context/observability-conventions.md +++ b/apps/server/docs/ai-context/observability-conventions.md @@ -8,7 +8,7 @@ - 不能映射到标准字段、但确实属于 AIRI 业务语义的字段,统一放到 `airi.*` 命名空间下。 - 不要新增新的顶级前缀,例如 `llm.*`、`gateway.*`、`telegram.*` 之类的 attribute key。 - span name、event name、metric name 不等于 attribute key;是否迁移它们要单独评估兼容性。 -- 代码里不要继续散落新的 observability key 字符串字面量;统一从 [packages/server-shared/src/observability.ts](/Users/luoling8192/Git/moeru-ai/airi/packages/server-shared/src/observability.ts) 引用。 +- 代码里不要继续散落新的 observability key 字符串字面量;统一从 [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts) 引用。 ## 标准字段优先级 @@ -114,7 +114,7 @@ Redis 相关优先复用 instrumentation 自动产生的标准属性,不要重 ### 当前已落地的 dashboard 例子 -[apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json](/Users/luoling8192/Git/moeru-ai/airi/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json) 已经按以下方式查询: +[apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json](/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json) 已经按以下方式查询: - Request rate by model: `gen_ai_request_model` - Request rate by operation: `gen_ai_operation_name` + `airi_gen_ai_operation_kind` @@ -152,8 +152,8 @@ span name 目前允许保留业务可读格式,例如: ## 当前参考实现 -- [packages/server-shared/src/observability.ts](/Users/luoling8192/Git/moeru-ai/airi/packages/server-shared/src/observability.ts) -- [apps/server/src/routes/v1completions.ts](/Users/luoling8192/Git/moeru-ai/airi/apps/server/src/routes/v1completions.ts) -- [apps/server/src/libs/otel.ts](/Users/luoling8192/Git/moeru-ai/airi/apps/server/src/libs/otel.ts) -- [services/telegram-bot/src/llm/actions.ts](/Users/luoling8192/Git/moeru-ai/airi/services/telegram-bot/src/llm/actions.ts) -- [services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts](/Users/luoling8192/Git/moeru-ai/airi/services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts) +- [packages/server-shared/src/observability.ts](/packages/server-shared/src/observability.ts) +- [apps/server/src/routes/v1completions.ts](/apps/server/src/routes/v1completions.ts) +- [apps/server/src/libs/otel.ts](/apps/server/src/libs/otel.ts) +- [services/telegram-bot/src/llm/actions.ts](/services/telegram-bot/src/llm/actions.ts) +- [services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts](/services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts) diff --git a/apps/server/docs/ai-context/transport-and-routes.md b/apps/server/docs/ai-context/transport-and-routes.md index b0fc242ad..65cfeda49 100644 --- a/apps/server/docs/ai-context/transport-and-routes.md +++ b/apps/server/docs/ai-context/transport-and-routes.md @@ -37,20 +37,24 @@ ## 路由到服务映射 -### `/api/auth/*` +### `/api/auth/*` 及 `/sign-in` 实现位置: -- 路由注册:`src/app.ts` -- 实际处理:`auth.handler(c.req.raw)` -- 服务工厂:`src/libs/auth.ts` +- 路由入口:`src/routes/auth/index.ts`(通过 `.route('/')` 挂载到根路径) +- OIDC session bridge:`src/routes/oidc/session.ts` +- Electron 回调中继:`src/routes/oidc/electron-callback.ts` +- better-auth 配置:`src/libs/auth.ts` +- 登录页渲染:`src/utils/sign-in-page.ts` 特点: -- 基于 `better-auth` -- 开启 email/password、Google、GitHub -- Bearer plugin 已启用 -- `/api/auth/*` 有独立 IP 限流,每分钟 20 次 +- 基于 `better-auth` + `oauthProvider` 插件 +- 开启 email/password、Google、GitHub 社交登录 +- Bearer plugin + JWT plugin 已启用 +- `/api/auth/*` 有独立 IP 限流 +- 自定义 OIDC 路由(`/api/auth/oidc/*`)注册在 catch-all 之前 +- 详见 `auth-and-oidc.md` ### `/api/v1/characters` diff --git a/apps/server/drizzle/0008_gray_xavin.sql b/apps/server/drizzle/0008_gray_xavin.sql new file mode 100644 index 000000000..baab8e23f --- /dev/null +++ b/apps/server/drizzle/0008_gray_xavin.sql @@ -0,0 +1,90 @@ +CREATE TABLE "jwks" ( + "id" text PRIMARY KEY NOT NULL, + "public_key" text NOT NULL, + "private_key" text NOT NULL, + "created_at" timestamp NOT NULL, + "expires_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "oauth_access_token" ( + "id" text PRIMARY KEY NOT NULL, + "token" text, + "client_id" text NOT NULL, + "session_id" text, + "user_id" text, + "reference_id" text, + "refresh_id" text, + "expires_at" timestamp, + "created_at" timestamp, + "scopes" text[] NOT NULL, + CONSTRAINT "oauth_access_token_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "oauth_client" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "client_secret" text, + "disabled" boolean DEFAULT false, + "skip_consent" boolean, + "enable_end_session" boolean, + "subject_type" text, + "scopes" text[], + "user_id" text, + "created_at" timestamp, + "updated_at" timestamp, + "name" text, + "uri" text, + "icon" text, + "contacts" text[], + "tos" text, + "policy" text, + "software_id" text, + "software_version" text, + "software_statement" text, + "redirect_uris" text[] NOT NULL, + "post_logout_redirect_uris" text[], + "token_endpoint_auth_method" text, + "grant_types" text[], + "response_types" text[], + "public" boolean, + "type" text, + "require_pkce" boolean, + "reference_id" text, + "metadata" jsonb, + CONSTRAINT "oauth_client_client_id_unique" UNIQUE("client_id") +); +--> statement-breakpoint +CREATE TABLE "oauth_consent" ( + "id" text PRIMARY KEY NOT NULL, + "client_id" text NOT NULL, + "user_id" text, + "reference_id" text, + "scopes" text[] NOT NULL, + "created_at" timestamp, + "updated_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "oauth_refresh_token" ( + "id" text PRIMARY KEY NOT NULL, + "token" text NOT NULL, + "client_id" text NOT NULL, + "session_id" text, + "user_id" text NOT NULL, + "reference_id" text, + "expires_at" timestamp, + "created_at" timestamp, + "revoked" timestamp, + "auth_time" timestamp, + "scopes" text[] NOT NULL +); +--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_refresh_id_oauth_refresh_token_id_fk" FOREIGN KEY ("refresh_id") REFERENCES "public"."oauth_refresh_token"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD CONSTRAINT "oauth_client_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_client_id_oauth_client_client_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."oauth_client"("client_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "oauth_refresh_token" ADD CONSTRAINT "oauth_refresh_token_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0008_snapshot.json b/apps/server/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..1563a083a --- /dev/null +++ b/apps/server/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2864 @@ +{ + "id": "e77aa17f-3071-42f9-948b-8b9ac9d0b457", + "prevId": "661da871-23b9-4c28-bddd-1c5ef856ac99", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_transaction": { + "name": "flux_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flux_tx_user_id_idx": { + "name": "flux_tx_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_created_at_idx": { + "name": "flux_tx_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_user_request_uniq": { + "name": "flux_tx_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flux_transaction_user_id_user_id_fk": { + "name": "flux_transaction_user_id_user_id_fk", + "tableFrom": "flux_transaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_flux_user_id_user_id_fk": { + "name": "user_flux_user_id_user_id_fk", + "tableFrom": "user_flux", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_provider_configs_owner_id_user_id_fk": { + "name": "user_provider_configs_owner_id_user_id_fk", + "tableFrom": "user_provider_configs", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_checkout_session_user_id_user_id_fk": { + "name": "stripe_checkout_session_user_id_user_id_fk", + "tableFrom": "stripe_checkout_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_customer_user_id_user_id_fk": { + "name": "stripe_customer_user_id_user_id_fk", + "tableFrom": "stripe_customer", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_invoice_user_id_user_id_fk": { + "name": "stripe_invoice_user_id_user_id_fk", + "tableFrom": "stripe_invoice", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_subscription_user_id_user_id_fk": { + "name": "stripe_subscription_user_id_user_id_fk", + "tableFrom": "stripe_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_user_id_user_id_fk": { + "name": "user_character_bookmarks_user_id_user_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_user_id_user_id_fk": { + "name": "user_character_likes_user_id_user_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 12cea9e47..b49c6b333 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1774632446757, "tag": "0007_red_nicolaos", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1775032828818, + "tag": "0008_gray_xavin", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/server/package.json b/apps/server/package.json index bcd9cfe2d..cfd601198 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@better-auth/drizzle-adapter": "^1.5.6", + "@better-auth/oauth-provider": "catalog:", "@dotenvx/dotenvx": "^1.57.2", "@electric-sql/pglite": "catalog:", "@guiiai/logg": "catalog:", diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts new file mode 100644 index 000000000..5c4c66b97 --- /dev/null +++ b/apps/server/src/app.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' + +import { buildApp } from './app' + +function createTestDeps() { + const authServerMetadata = { + issuer: 'http://localhost:3000/api/auth', + authorization_endpoint: 'http://localhost:3000/api/auth/oauth2/authorize', + token_endpoint: 'http://localhost:3000/api/auth/oauth2/token', + } + + const openIdConfig = { + issuer: 'http://localhost:3000/api/auth', + jwks_uri: 'http://localhost:3000/api/auth/jwks', + authorization_endpoint: 'http://localhost:3000/api/auth/oauth2/authorize', + token_endpoint: 'http://localhost:3000/api/auth/oauth2/token', + } + + const auth = { + api: { + getSession: vi.fn(async () => null), + getOAuthServerConfig: vi.fn(async () => authServerMetadata), + getOpenIdConfig: vi.fn(async () => openIdConfig), + }, + handler: vi.fn(async () => new Response('not-found', { status: 404 })), + } as any + + const redisSubscriber = { + on: vi.fn(), + subscribe: vi.fn(async () => 1), + unsubscribe: vi.fn(async () => 0), + } + + const redis = { + duplicate: vi.fn(() => redisSubscriber), + publish: vi.fn(async () => 0), + } + + const deps = { + auth, + db: {} as any, + characterService: {} as any, + chatService: {} as any, + providerService: {} as any, + fluxService: {} as any, + fluxTransactionService: {} as any, + stripeService: {} as any, + billingService: {} as any, + billingMq: {} as any, + configKV: { + getOrThrow: vi.fn(async (key: string) => { + switch (key) { + case 'AUTH_RATE_LIMIT_MAX': + return 20 + case 'AUTH_RATE_LIMIT_WINDOW_SEC': + return 60 + default: + throw new Error(`Unexpected config key: ${key}`) + } + }), + } as any, + redis: redis as any, + env: { + API_SERVER_URL: 'http://localhost:3000', + } as any, + otel: null, + } + + return { + deps, + auth, + authServerMetadata, + openIdConfig, + redis, + } +} + +describe('app well-known metadata routes', () => { + it('serves oauth authorization server metadata at the root well-known path', async () => { + const { deps, auth, authServerMetadata } = createTestDeps() + const { app } = await buildApp(deps) + + const res = await app.request('/.well-known/oauth-authorization-server/api/auth') + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + expect(await res.json()).toEqual(authServerMetadata) + expect(auth.api.getOAuthServerConfig).toHaveBeenCalledTimes(1) + expect(auth.api.getOpenIdConfig).not.toHaveBeenCalled() + }) + + it('serves openid configuration at the issuer-appended well-known path', async () => { + const { deps, auth, openIdConfig } = createTestDeps() + const { app } = await buildApp(deps) + + const res = await app.request('/api/auth/.well-known/openid-configuration') + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + expect(await res.json()).toEqual(openIdConfig) + expect(auth.api.getOpenIdConfig).toHaveBeenCalledTimes(1) + expect(auth.api.getOAuthServerConfig).not.toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 0db4d1ccd..849cdf018 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,5 +1,6 @@ import type Redis from 'ioredis' +import type { Database } from './libs/db' import type { Env } from './libs/env' import type { MqService } from './libs/mq' import type { OtelInstance } from './libs/otel' @@ -25,15 +26,16 @@ import { cors } from 'hono/cors' import { logger as honoLogger } from 'hono/logger' import { createLoggLogger, injeca, lifecycle } from 'injeca' -import { createAuth } from './libs/auth' +import { createAuth, getTrustedClientSeedSummaries, seedTrustedClients } from './libs/auth' import { createDrizzle, migrateDatabase } from './libs/db' import { parsedEnv } from './libs/env' import { initializeExternalDependency } from './libs/external-dependency' import { emitOtelLog, initOtel } from './libs/otel' import { createRedis } from './libs/redis' +import { resolveRequestAuth } from './libs/request-auth' import { sessionMiddleware } from './middlewares/auth' import { otelMiddleware } from './middlewares/otel' -import { rateLimiter } from './middlewares/rate-limit' +import { createAuthRoutes } from './routes/auth' import { createCharacterRoutes } from './routes/characters' import { createChatWsHandlers } from './routes/chat-ws' import { createChatRoutes } from './routes/chats' @@ -56,6 +58,7 @@ import { getTrustedOrigin } from './utils/origin' interface AppDeps { auth: ReturnType + db: Database characterService: CharacterService chatService: ChatService providerService: ProviderService @@ -70,7 +73,7 @@ interface AppDeps { otel: OtelInstance | null } -async function buildApp(deps: AppDeps) { +export async function buildApp(deps: AppDeps) { const logger = useLogger('app').useGlobalConfig() const app = new Hono() @@ -106,9 +109,11 @@ async function buildApp(deps: AppDeps) { if (!token) { throw createUnauthorizedError('Missing token') } - const session = await deps.auth.api.getSession({ - headers: new Headers({ Authorization: `Bearer ${token}` }), - }) + const session = await resolveRequestAuth( + deps.auth, + deps.db, + new Headers({ Authorization: `Bearer ${token}` }), + ) if (!session?.user) { throw createUnauthorizedError('Invalid token') } @@ -116,7 +121,7 @@ async function buildApp(deps: AppDeps) { })) const builtApp = app - .use('*', sessionMiddleware(deps.auth)) + .use('*', sessionMiddleware(deps.auth, deps.db)) .use('*', async (c, next) => { // Skip global body limit for ASR transcription route (has its own 25MB limit) if (c.req.path === '/api/v1/openai/audio/transcriptions') { @@ -149,47 +154,15 @@ async function buildApp(deps: AppDeps) { .on('GET', '/health', c => c.json({ status: 'ok' })) /** - * Auth routes are handled by the auth instance directly, - * Powered by better-auth. - * Rate limited by IP: 20 requests per minute. + * Auth routes: sign-in page, OIDC session bridge, electron callback + * relay, well-known metadata, and better-auth catch-all. */ - .use('/api/auth/*', rateLimiter({ - max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'), - windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'), - keyGenerator: c => c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown', + .route('/', await createAuthRoutes({ + auth: deps.auth, + db: deps.db, + env: deps.env, + configKV: deps.configKV, })) - .on(['POST', 'GET'], '/api/auth/*', async (c) => { - const response: Response = await deps.auth.handler(c.req.raw) - - // NOTICE: On OAuth callback redirects, the bearer plugin adds the session - // token to the `set-auth-token` header. But browsers don't expose headers - // from 302 redirects to JS. We append the token to the Location URL's - // fragment (#) so the client can extract it. Fragments are never sent to - // the server, so they won't leak into CDN/proxy logs or Referer headers. - if (response.status === 302) { - const token = response.headers.get('set-auth-token') - const location = response.headers.get('location') - if (token && location) { - try { - const url = new URL(location) - url.hash = `auth_token=${encodeURIComponent(token)}` - const headers = new Headers(response.headers) - headers.set('location', url.toString()) - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }) - } - catch (error) { - // If URL parsing fails, return the original response - logger.withError(error).warn('Failed to parse redirect URL, cannot append auth_token', { location }) - } - } - } - - return response - }) /** * Character routes are handled by the character service. @@ -318,7 +291,20 @@ export async function createApp() { const auth = injeca.provide('services:auth', { dependsOn: { db, env: parsedEnv, otel }, - build: ({ dependsOn }) => createAuth(dependsOn.db, dependsOn.env, dependsOn.otel?.auth), + build: async ({ dependsOn }) => { + // Seed trusted OIDC clients into DB so FK constraints on oauth_access_token are satisfied + await seedTrustedClients(dependsOn.db, dependsOn.env) + const trustedClients = getTrustedClientSeedSummaries(dependsOn.env) + logger.withField('apiServerUrl', dependsOn.env.API_SERVER_URL).log('OIDC startup configuration') + for (const client of trustedClients) { + logger.withFields({ + clientId: client.clientId, + clientName: client.name, + redirectUris: client.redirectUris.join(', '), + }).log('OIDC trusted client ready') + } + return createAuth(dependsOn.db, dependsOn.env, dependsOn.otel?.auth) + }, }) const characterService = injeca.provide('services:characters', { @@ -381,6 +367,7 @@ export async function createApp() { }) const { app, injectWebSocket } = await buildApp({ auth: resolved.auth, + db: resolved.db, characterService: resolved.characterService, chatService: resolved.chatService, providerService: resolved.providerService, diff --git a/apps/server/src/libs/auth.test.ts b/apps/server/src/libs/auth.test.ts new file mode 100644 index 000000000..e6ca7a125 --- /dev/null +++ b/apps/server/src/libs/auth.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it, vi } from 'vitest' + +import { ensureDynamicFirstPartyRedirectUri, seedTrustedClients } from './auth' + +function createMockDb(existingRowsByCall: unknown[][] = []) { + const limit = vi.fn() + for (const rows of existingRowsByCall) { + limit.mockResolvedValueOnce(rows) + } + + const capturedValues: any[] = [] + const values = vi.fn(async (value: any) => { + capturedValues.push(value) + }) + + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit, + })), + })), + })), + insert: vi.fn(() => ({ + values, + })), + } + + return { db, limit, values, capturedValues } +} + +describe('seedTrustedClients', () => { + it('seeds trusted first-party clients with explicit oauth metadata', async () => { + const { db, values, capturedValues } = createMockDb([[], [], []]) + + await seedTrustedClients(db as any, { + API_SERVER_URL: 'http://localhost:3000', + } as any) + + expect(values).toHaveBeenCalledTimes(3) + + // Web — public client (no secret, PKCE only) + const webClient = capturedValues[0] + if (!webClient) + throw new Error('Expected web client seed insert') + + expect(webClient.clientId).toBe('airi-stage-web') + expect(webClient.clientSecret).toBeNull() + expect(webClient.public).toBe(true) + // Includes default URIs + derived from API_SERVER_URL (localhost:3000) + expect(webClient.redirectUris).toEqual([ + 'https://airi.moeru.ai/auth/callback', + 'http://localhost:5173/auth/callback', + 'http://localhost:4173/auth/callback', + 'http://localhost:3000/auth/callback', + ]) + expect(webClient.scopes).toEqual(['openid', 'profile', 'email', 'offline_access']) + expect(webClient.grantTypes).toEqual(['authorization_code', 'refresh_token']) + expect(webClient.responseTypes).toEqual(['code']) + expect(webClient.tokenEndpointAuthMethod).toBe('none') + expect(webClient.requirePKCE).toBe(true) + expect(webClient.skipConsent).toBe(true) + + // Electron — public native client (PKCE only) + const electronClient = capturedValues[1] + if (!electronClient) + throw new Error('Expected electron client seed insert') + + expect(electronClient.clientId).toBe('airi-stage-electron') + expect(electronClient.clientSecret).toBeNull() + expect(electronClient.public).toBe(true) + expect(electronClient.tokenEndpointAuthMethod).toBe('none') + expect(electronClient.redirectUris).toEqual([ + 'http://localhost:3000/api/auth/oidc/electron-callback', + ]) + + // Mobile — public client (no secret, PKCE only) + const pocketClient = capturedValues[2] + if (!pocketClient) + throw new Error('Expected pocket client seed insert') + + expect(pocketClient.clientId).toBe('airi-stage-pocket') + expect(pocketClient.clientSecret).toBeNull() + expect(pocketClient.public).toBe(true) + expect(pocketClient.tokenEndpointAuthMethod).toBe('none') + expect(pocketClient.redirectUris).toEqual([ + 'capacitor://localhost/auth/callback', + ]) + }) + + it('updates existing clients to match current config', async () => { + const setCalls: any[] = [] + const set = vi.fn((vals: any) => { + setCalls.push(vals) + return { where: vi.fn() } + }) + + const { db, values } = createMockDb([ + [{ clientId: 'airi-stage-web' }], + [], + [], + ]); + (db as any).update = vi.fn(() => ({ set })) + + await seedTrustedClients(db as any, { + API_SERVER_URL: 'http://localhost:3000', + } as any) + + expect(values).toHaveBeenCalledTimes(2) + expect(set).toHaveBeenCalledTimes(1) + expect(setCalls[0].public).toBe(true) + expect(setCalls[0].tokenEndpointAuthMethod).toBe('none') + expect(setCalls[0].clientSecret).toBeNull() + }) +}) + +describe('ensureDynamicFirstPartyRedirectUri', () => { + it('appends a trusted web callback redirect URI discovered from the authorize request', async () => { + const setCalls: any[] = [] + const updateWhere = vi.fn() + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([ + { redirectUris: ['https://airi.moeru.ai/auth/callback'] }, + ]), + })), + })), + })), + update: vi.fn(() => ({ + set: vi.fn((value: any) => { + setCalls.push(value) + return { where: updateWhere } + }), + })), + } + + await ensureDynamicFirstPartyRedirectUri( + db as any, + new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fpreview.kwaa.workers.dev%2Fauth%2Fcallback'), + ) + + expect(setCalls).toHaveLength(1) + expect(setCalls[0].redirectUris).toEqual([ + 'https://airi.moeru.ai/auth/callback', + 'https://preview.kwaa.workers.dev/auth/callback', + ]) + expect(updateWhere).toHaveBeenCalledTimes(1) + }) + + it('appends a same-origin electron relay redirect URI discovered from the authorize request', async () => { + const setCalls: any[] = [] + const updateWhere = vi.fn() + const db = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([ + { redirectUris: ['https://api.airi.build/api/auth/oidc/electron-callback'] }, + ]), + })), + })), + })), + update: vi.fn(() => ({ + set: vi.fn((value: any) => { + setCalls.push(value) + return { where: updateWhere } + }), + })), + } + + await ensureDynamicFirstPartyRedirectUri( + db as any, + new Request('https://airi-server-dev.up.railway.app/api/auth/oauth2/authorize?client_id=airi-stage-electron&redirect_uri=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fauth%2Foidc%2Felectron-callback'), + ) + + expect(setCalls).toHaveLength(1) + expect(setCalls[0].redirectUris).toEqual([ + 'https://api.airi.build/api/auth/oidc/electron-callback', + 'https://airi-server-dev.up.railway.app/api/auth/oidc/electron-callback', + ]) + expect(updateWhere).toHaveBeenCalledTimes(1) + }) + + it('ignores untrusted or non-callback redirect URIs', async () => { + const db = { + select: vi.fn(), + update: vi.fn(), + } + + await ensureDynamicFirstPartyRedirectUri( + db as any, + new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fevil.example%2Fauth%2Fcallback'), + ) + + await ensureDynamicFirstPartyRedirectUri( + db as any, + new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fairi.moeru.ai%2Fother-path'), + ) + + await ensureDynamicFirstPartyRedirectUri( + db as any, + new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-electron&redirect_uri=https%3A%2F%2Fother.example%2Fapi%2Fauth%2Foidc%2Felectron-callback'), + ) + + expect(db.select).not.toHaveBeenCalled() + expect(db.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 4265f2b62..5766e93e2 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -2,15 +2,298 @@ import type { Database } from './db' import type { Env } from './env' import type { AuthMetrics } from './otel' +import { Buffer } from 'node:buffer' + +import { oauthProvider } from '@better-auth/oauth-provider' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { createAuthMiddleware } from 'better-auth/api' -import { bearer } from 'better-auth/plugins' +import { bearer, jwt } from 'better-auth/plugins' +import { eq } from 'drizzle-orm' -import { getAuthTrustedOrigins } from '../utils/origin' +import { getAuthTrustedOrigins, getTrustedOrigin } from '../utils/origin' import * as authSchema from '../schemas/accounts' +interface TrustedClientSeed { + clientId: string + /** Omit for public clients — only confidential clients need a secret. */ + clientSecret?: string + name: string + type: 'web' | 'native' + /** Public clients rely solely on PKCE; confidential clients use client_secret + PKCE. */ + public: boolean + redirectUris: string[] + scopes: string[] + grantTypes: string[] + responseTypes: string[] + tokenEndpointAuthMethod: 'none' | 'client_secret_post' + requirePKCE: boolean + skipConsent: boolean +} + +export interface TrustedClientSeedSummary { + clientId: string + name: string + redirectUris: string[] +} + +const OIDC_SCOPES = ['openid', 'profile', 'email', 'offline_access'] as const +const OIDC_GRANT_TYPES = ['authorization_code', 'refresh_token'] as const +const OIDC_RESPONSE_TYPES = ['code'] as const +export const OIDC_CLIENT_ID_WEB = 'airi-stage-web' +export const OIDC_CLIENT_ID_ELECTRON = 'airi-stage-electron' +export const OIDC_CLIENT_ID_POCKET = 'airi-stage-pocket' + +const DEFAULT_WEB_REDIRECT_URIS = [ + 'https://airi.moeru.ai/auth/callback', + 'http://localhost:5173/auth/callback', + 'http://localhost:4173/auth/callback', +] + +/** + * Build redirect URIs for the web OIDC client. + * Includes the default set plus any derived from API_SERVER_URL for + * colocated dev/preview deployments. + */ +function buildWebRedirectUris(env: Env): string[] { + const uris = new Set(DEFAULT_WEB_REDIRECT_URIS) + + // If API_SERVER_URL has a different origin (e.g. a dev branch deployment), + // add its /auth/callback so OIDC redirect validation passes. + try { + const apiOrigin = new URL(env.API_SERVER_URL).origin + const derived = `${apiOrigin}/auth/callback` + if (!uris.has(derived)) + uris.add(derived) + } + catch { + // Invalid API_SERVER_URL — skip + } + + return [...uris] +} + +function buildTrustedWebRedirectUri(redirectUri: string): string | null { + try { + const parsed = new URL(redirectUri) + if (parsed.pathname !== '/auth/callback') + return null + + const trustedOrigin = getTrustedOrigin(parsed.origin) + if (!trustedOrigin) + return null + + return `${trustedOrigin}/auth/callback` + } + catch { + return null + } +} + +function buildTrustedElectronRedirectUri(request: Request, redirectUri: string): string | null { + try { + const requestUrl = new URL(request.url) + const parsed = new URL(redirectUri) + + if (parsed.origin !== requestUrl.origin) + return null + + if (parsed.pathname !== '/api/auth/oidc/electron-callback') + return null + + return `${requestUrl.origin}/api/auth/oidc/electron-callback` + } + catch { + return null + } +} + +/** + * Build the list of first-party OIDC clients to seed into the database. + */ +function buildTrustedClientSeeds(env: Env): TrustedClientSeed[] { + const clients: TrustedClientSeed[] = [] + clients.push({ + clientId: OIDC_CLIENT_ID_WEB, + name: 'AIRI Stage Web', + type: 'web', + public: true, + redirectUris: buildWebRedirectUris(env), + scopes: [...OIDC_SCOPES], + grantTypes: [...OIDC_GRANT_TYPES], + responseTypes: [...OIDC_RESPONSE_TYPES], + tokenEndpointAuthMethod: 'none', + requirePKCE: true, + skipConsent: true, + }) + + // Electron desktop app — public client (installed app, PKCE only). + // The binary is user-controlled, so a bundled client_secret would only be + // obfuscation, not a meaningful confidentiality boundary. + clients.push({ + clientId: OIDC_CLIENT_ID_ELECTRON, + name: 'AIRI Stage Desktop', + type: 'native', + public: true, + redirectUris: [ + `${env.API_SERVER_URL}/api/auth/oidc/electron-callback`, + ], + scopes: [...OIDC_SCOPES], + grantTypes: [...OIDC_GRANT_TYPES], + responseTypes: [...OIDC_RESPONSE_TYPES], + tokenEndpointAuthMethod: 'none', + requirePKCE: true, + skipConsent: true, + }) + + // Capacitor mobile app — public client (no secret, PKCE only). + // Same reasoning as Web: native WebView cannot safely store secrets. + clients.push({ + clientId: OIDC_CLIENT_ID_POCKET, + name: 'AIRI Stage Mobile', + type: 'native', + public: true, + redirectUris: [ + 'capacitor://localhost/auth/callback', + ], + scopes: [...OIDC_SCOPES], + grantTypes: [...OIDC_GRANT_TYPES], + responseTypes: [...OIDC_RESPONSE_TYPES], + tokenEndpointAuthMethod: 'none', + requirePKCE: true, + skipConsent: true, + }) + + return clients +} + +export function getTrustedClientSeedSummaries(env: Env): TrustedClientSeedSummary[] { + return buildTrustedClientSeeds(env).map(seed => ({ + clientId: seed.clientId, + name: seed.name, + redirectUris: [...seed.redirectUris], + })) +} + +export function getTrustedOIDCClientIds(): string[] { + return [OIDC_CLIENT_ID_WEB, OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_ID_POCKET] +} + +export async function ensureDynamicFirstPartyRedirectUri( + db: Database, + request: Request, +): Promise { + const url = new URL(request.url) + const clientId = url.searchParams.get('client_id') + const redirectUri = url.searchParams.get('redirect_uri') + + if (!clientId || !redirectUri) + return + + let normalizedRedirectUri: string | null = null + + switch (clientId) { + case OIDC_CLIENT_ID_WEB: + normalizedRedirectUri = buildTrustedWebRedirectUri(redirectUri) + break + case OIDC_CLIENT_ID_ELECTRON: + normalizedRedirectUri = buildTrustedElectronRedirectUri(request, redirectUri) + break + } + + if (!normalizedRedirectUri) + return + + const [existing] = await db + .select({ redirectUris: authSchema.oauthClient.redirectUris }) + .from(authSchema.oauthClient) + .where(eq(authSchema.oauthClient.clientId, clientId)) + .limit(1) + + if (!existing?.redirectUris || existing.redirectUris.includes(normalizedRedirectUri)) + return + + await db.update(authSchema.oauthClient) + .set({ + redirectUris: [...existing.redirectUris, normalizedRedirectUri], + updatedAt: new Date(), + }) + .where(eq(authSchema.oauthClient.clientId, clientId)) +} + +/** + * Hash a client secret the same way oauthProvider does internally. + * + * NOTICE: oauthProvider defaults to `storeClientSecret: "hashed"` when + * the JWT plugin is enabled (our config). The internal hasher is + * `SHA-256(secret) → base64url(no padding)`. We replicate this so that + * secrets seeded via raw INSERT match what the plugin expects during + * token exchange validation. + */ +async function hashClientSecret(secret: string): Promise { + const hash = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(secret), + ) + // base64url encode without padding — matches @better-auth/utils/base64 + return Buffer.from(hash).toString('base64url') +} + +/** + * Ensure trusted OIDC clients exist in the `oauth_client` table. + * The oauthProvider plugin's `cachedTrustedClients` caches DB lookups, but + * the `oauth_access_token` table has a FK to `oauth_client.client_id`. + * Without a matching row, token INSERT fails with a constraint violation. + * + * Secrets are hashed before storage to match oauthProvider's default + * `storeClientSecret: "hashed"` mode. + */ +export async function seedTrustedClients(db: Database, env: Env): Promise { + const seeds = buildTrustedClientSeeds(env) + if (seeds.length === 0) + return + + for (const seed of seeds) { + const existing = await db + .select({ clientId: authSchema.oauthClient.clientId }) + .from(authSchema.oauthClient) + .where(eq(authSchema.oauthClient.clientId, seed.clientId)) + .limit(1) + + const values = { + clientSecret: seed.clientSecret ? await hashClientSecret(seed.clientSecret) : null, + name: seed.name, + type: seed.type, + public: seed.public, + redirectUris: seed.redirectUris, + scopes: seed.scopes, + grantTypes: seed.grantTypes, + responseTypes: seed.responseTypes, + tokenEndpointAuthMethod: seed.tokenEndpointAuthMethod, + requirePKCE: seed.requirePKCE, + skipConsent: seed.skipConsent, + updatedAt: new Date(), + } + + if (existing.length > 0) { + // Update existing client to match current config (e.g. public ↔ confidential change) + await db.update(authSchema.oauthClient) + .set(values) + .where(eq(authSchema.oauthClient.clientId, seed.clientId)) + continue + } + + await db.insert(authSchema.oauthClient).values({ + id: crypto.randomUUID(), + clientId: seed.clientId, + ...values, + disabled: false, + createdAt: new Date(), + }) + } +} + // NOTICE: return type uses `any` to avoid TS2742 — betterAuth's inferred type // references internal pnpm paths (@better-auth/core) that aren't directly accessible @@ -23,8 +306,24 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) }, }), + // NOTICE: disabledPaths prevents better-auth's built-in /token route from + // conflicting with oauthProvider's /oauth2/token endpoint. + disabledPaths: ['/token'], + plugins: [ bearer(), + jwt(), + oauthProvider({ + loginPage: '/sign-in', + consentPage: '/oauth/authorize', + scopes: [...OIDC_SCOPES], + validAudiences: [env.API_SERVER_URL], + // NOTICE: do not enable cachedTrustedClients here. + // The oauth-provider plugin caches the full oauth_client row in-process, + // including redirectUris. We mutate redirectUris at runtime for trusted + // first-party clients, so caching would leave the current process with a + // stale redirect allowlist and cause invalid_redirect failures until restart. + }), ], emailAndPassword: { @@ -32,6 +331,10 @@ export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null) }, session: { + // NOTICE: oauthProvider's oauth_access_token table has a FK to the session + // table. Without DB-backed sessions the FK INSERT fails when issuing tokens. + storeSessionInDatabase: true, + // NOTICE: keep a short-lived signed session cache cookie so follow-up // session reads avoid hitting the database on every request. cookieCache: { diff --git a/apps/server/src/libs/env.test.ts b/apps/server/src/libs/env.test.ts new file mode 100644 index 000000000..a04b3de48 --- /dev/null +++ b/apps/server/src/libs/env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' + +import { parseEnv } from './env' + +describe('parseEnv', () => { + it('parses the required auth and infrastructure environment variables', () => { + const env = parseEnv({ + DATABASE_URL: 'postgres://example', + REDIS_URL: 'redis://example', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + }) + + expect(env.DATABASE_URL).toBe('postgres://example') + expect(env.REDIS_URL).toBe('redis://example') + }) +}) diff --git a/apps/server/src/libs/request-auth.test.ts b/apps/server/src/libs/request-auth.test.ts new file mode 100644 index 000000000..c89e820dd --- /dev/null +++ b/apps/server/src/libs/request-auth.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest' + +import { resolveRequestAuth, revokeOIDCAccessToken } from './request-auth' + +function createSelectDb(rows: unknown[]) { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue(rows), + })), + })), + })), + } +} + +describe('resolveRequestAuth', () => { + it('returns the better-auth session when it is already available', async () => { + const authSession = { + user: { id: 'user-1', email: 'user@example.com', name: 'User', emailVerified: true, image: null, createdAt: new Date(), updatedAt: new Date() }, + session: { id: 'session-1', userId: 'user-1', token: 'session-token', createdAt: new Date(), updatedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), ipAddress: null, userAgent: null }, + } + + const auth = { + api: { + getSession: vi.fn().mockResolvedValue(authSession), + }, + } + + const db = createSelectDb([]) + const result = await resolveRequestAuth( + auth, + db as any, + new Headers({ Authorization: 'Bearer ignored' }), + ) + + expect(result).toBe(authSession) + expect(db.select).not.toHaveBeenCalled() + }) + + it('falls back to a trusted OIDC access token when no better-auth session exists', async () => { + const createdAt = new Date('2026-04-02T10:00:00.000Z') + const expiresAt = new Date('2026-04-02T11:00:00.000Z') + const user = { + id: 'user-1', + email: 'user@example.com', + name: 'User', + emailVerified: true, + image: null, + createdAt, + updatedAt: createdAt, + } + + const auth = { + api: { + getSession: vi.fn().mockResolvedValue(null), + }, + $context: Promise.resolve({ + internalAdapter: { + findUserById: vi.fn().mockResolvedValue(user), + }, + }), + } + + const db = createSelectDb([{ + id: 'oauth-token-row', + userId: 'user-1', + sessionId: null, + createdAt, + expiresAt, + }]) + + const result = await resolveRequestAuth( + auth, + db as any, + new Headers({ Authorization: 'Bearer oidc-access-token' }), + ) + + expect(result).toEqual({ + user, + session: { + id: 'oauth-token-row', + userId: 'user-1', + token: 'oidc-access-token', + createdAt, + updatedAt: createdAt, + expiresAt, + ipAddress: null, + userAgent: null, + }, + }) + }) +}) + +describe('revokeOIDCAccessToken', () => { + it('revokes the related refresh token and removes access tokens in one transaction', async () => { + const deleteWhere = vi.fn() + const updateWhere = vi.fn() + const transaction = vi.fn(async (callback: (tx: any) => Promise) => { + await callback({ + delete: vi.fn(() => ({ where: deleteWhere })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ where: updateWhere })), + })), + }) + }) + + const db = { + ...createSelectDb([{ + id: 'access-row-1', + refreshId: 'refresh-row-1', + }]), + transaction, + } + + const revoked = await revokeOIDCAccessToken( + db as any, + 'oidc-access-token', + ) + + expect(revoked).toBe(true) + expect(transaction).toHaveBeenCalledTimes(1) + expect(deleteWhere).toHaveBeenCalledTimes(2) + expect(updateWhere).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/server/src/libs/request-auth.ts b/apps/server/src/libs/request-auth.ts new file mode 100644 index 000000000..349d10565 --- /dev/null +++ b/apps/server/src/libs/request-auth.ts @@ -0,0 +1,136 @@ +import type auth from '../scripts/auth' +import type { Database } from './db' + +import { Buffer } from 'node:buffer' + +import { and, eq, gt, inArray } from 'drizzle-orm' + +import { oauthAccessToken, oauthRefreshToken } from '../schemas/accounts' +import { getTrustedOIDCClientIds } from './auth' + +export interface RequestAuthSession { + user: typeof auth.$Infer.Session.user + session: typeof auth.$Infer.Session.session +} + +async function hashToken(token: string): Promise { + const hash = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(token), + ) + return Buffer.from(hash).toString('base64url') +} + +function readBearerToken(headers: Headers): string | null { + const authorization = headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) + return null + + const token = authorization.slice(7).trim() + return token.length > 0 ? token : null +} + +export async function resolveOIDCAccessTokenAuth( + auth: any, + db: Database, + accessToken: string, +): Promise { + const hashedToken = await hashToken(accessToken) + const now = new Date() + const [tokenRow] = await db + .select({ + id: oauthAccessToken.id, + userId: oauthAccessToken.userId, + sessionId: oauthAccessToken.sessionId, + createdAt: oauthAccessToken.createdAt, + expiresAt: oauthAccessToken.expiresAt, + }) + .from(oauthAccessToken) + .where( + and( + eq(oauthAccessToken.token, hashedToken), + inArray(oauthAccessToken.clientId, getTrustedOIDCClientIds()), + gt(oauthAccessToken.expiresAt, now), + ), + ) + .limit(1) + + if (!tokenRow?.userId || !tokenRow.createdAt || !tokenRow.expiresAt) + return null + + const ctx = await auth.$context + const user = await ctx.internalAdapter.findUserById(tokenRow.userId) + if (!user) + return null + + return { + user, + session: { + id: tokenRow.sessionId ?? tokenRow.id, + token: accessToken, + userId: tokenRow.userId, + createdAt: tokenRow.createdAt, + updatedAt: tokenRow.createdAt, + expiresAt: tokenRow.expiresAt, + ipAddress: null, + userAgent: null, + }, + } +} + +export async function resolveRequestAuth( + auth: any, + db: Database, + headers: Headers, +): Promise { + const session = await auth.api.getSession({ headers }) + if (session?.user && session?.session) + return session + + const accessToken = readBearerToken(headers) + if (!accessToken) + return null + + return await resolveOIDCAccessTokenAuth(auth, db, accessToken) +} + +export async function revokeOIDCAccessToken( + db: Database, + accessToken: string, +): Promise { + const hashedToken = await hashToken(accessToken) + const now = new Date() + const [tokenRow] = await db + .select({ + id: oauthAccessToken.id, + refreshId: oauthAccessToken.refreshId, + }) + .from(oauthAccessToken) + .where( + and( + eq(oauthAccessToken.token, hashedToken), + inArray(oauthAccessToken.clientId, getTrustedOIDCClientIds()), + gt(oauthAccessToken.expiresAt, now), + ), + ) + .limit(1) + + if (!tokenRow) + return false + + await db.transaction(async (tx) => { + await tx.delete(oauthAccessToken) + .where(eq(oauthAccessToken.id, tokenRow.id)) + + if (tokenRow.refreshId) { + await tx.update(oauthRefreshToken) + .set({ revoked: now }) + .where(eq(oauthRefreshToken.id, tokenRow.refreshId)) + + await tx.delete(oauthAccessToken) + .where(eq(oauthAccessToken.refreshId, tokenRow.refreshId)) + } + }) + + return true +} diff --git a/apps/server/src/middlewares/auth.ts b/apps/server/src/middlewares/auth.ts index e19e6fec2..a7195dba3 100644 --- a/apps/server/src/middlewares/auth.ts +++ b/apps/server/src/middlewares/auth.ts @@ -1,10 +1,12 @@ import type { MiddlewareHandler } from 'hono' import type { createAuth } from '../libs/auth' +import type { Database } from '../libs/db' import type { HonoEnv } from '../types/hono' import { useLogger } from '@guiiai/logg' +import { resolveRequestAuth } from '../libs/request-auth' import { createUnauthorizedError } from '../utils/error' const logger = useLogger('auth') @@ -15,9 +17,23 @@ type AuthInstance = ReturnType * Session middleware injects the user and session into the Hono context. * It does not block unauthorized requests. */ -export function sessionMiddleware(auth: AuthInstance): MiddlewareHandler { +export function sessionMiddleware(auth: AuthInstance, db: Database): MiddlewareHandler { return async (c, next) => { - const session = await auth.api.getSession({ headers: c.req.raw.headers }) + // NOTICE: auth routes handle session lookup inside better-auth itself. + // Running the global session middleware on `/api/auth/*`, `/sign-in`, and + // the auth discovery endpoints duplicates the same session read and slows + // the OIDC login path (`authorize` → `token` → `get-session`) noticeably. + if ( + c.req.path === '/sign-in' + || c.req.path.startsWith('/api/auth/') + || c.req.path === '/.well-known/oauth-authorization-server/api/auth' + ) { + c.set('user', null) + c.set('session', null) + return await next() + } + + const session = await resolveRequestAuth(auth, db, c.req.raw.headers) if (!session) { c.set('user', null) diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts new file mode 100644 index 000000000..29e172112 --- /dev/null +++ b/apps/server/src/routes/auth/index.ts @@ -0,0 +1,106 @@ +import type { Database } from '../../libs/db' +import type { Env } from '../../libs/env' +import type { ConfigKVService } from '../../services/config-kv' +import type { HonoEnv } from '../../types/hono' + +import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider' +import { Hono } from 'hono' + +import { ensureDynamicFirstPartyRedirectUri } from '../../libs/auth' +import { rateLimiter } from '../../middlewares/rate-limit' +import { renderSignInPage } from '../../utils/sign-in-page' +import { createElectronCallbackRelay } from '../oidc/electron-callback' +import { createOIDCTokenAuthRoute } from '../oidc/token-auth' + +export interface AuthRoutesDeps { + auth: any // TODO: fix type + db: Database + env: Env + configKV: ConfigKVService +} + +/** + * All auth-related routes: sign-in page, rate-limited better-auth + * catch-all, OIDC session bridge, electron callback relay, and + * well-known metadata endpoints. + * + * Mounted at the root level because routes span multiple prefixes + * (`/sign-in`, `/api/auth/*`, `/.well-known/*`). + */ +export async function createAuthRoutes(deps: AuthRoutesDeps) { + return new Hono() + /** + * Minimal login page for the OIDC Provider flow. + * When an unauthenticated user hits /api/auth/oauth2/authorize, + * better-auth redirects here. 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. + */ + .on('GET', '/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-login redirect to authorize doesn't force + // another login — 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' || provider === 'github') { + const socialUrl = `${deps.env.API_SERVER_URL}/api/auth/sign-in/social?provider=${provider}&callbackURL=${encodeURIComponent(callbackURL)}` + return c.redirect(socialUrl) + } + + // Fallback: show the sign-in picker page (e.g. direct browser visit) + return c.html(renderSignInPage(deps.env.API_SERVER_URL, callbackURL)) + }) + + /** + * Auth routes are handled by the auth instance directly, + * Powered by better-auth. + * Rate limited by IP: 20 requests per minute. + */ + .use('/api/auth/*', rateLimiter({ + max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'), + windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'), + keyGenerator: c => c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown', + })) + .use('/api/auth/oauth2/authorize', async (c, next) => { + await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw) + await next() + }) + .route('/api/auth', createOIDCTokenAuthRoute(deps)) + /** + * Electron OIDC callback relay: serves an HTML page that forwards the + * 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()) + /** + * OAuth 2.1 Authorization Server metadata must live at the root-level + * well-known path with the issuer path inserted for non-root issuers. + */ + .on('GET', '/.well-known/oauth-authorization-server/api/auth', async (c) => { + return oauthProviderAuthServerMetadata(deps.auth)(c.req.raw) + }) + /** + * OpenID Connect discovery metadata uses path appending for issuers with + * paths, so `/api/auth` serves its own `/.well-known/openid-configuration`. + */ + .on('GET', '/api/auth/.well-known/openid-configuration', async (c) => { + return oauthProviderOpenIdConfigMetadata(deps.auth)(c.req.raw) + }) + .on(['POST', 'GET'], '/api/auth/*', async (c) => { + return deps.auth.handler(c.req.raw) as Promise + }) +} diff --git a/apps/server/src/routes/oidc/electron-callback.ts b/apps/server/src/routes/oidc/electron-callback.ts new file mode 100644 index 000000000..8d95291cf --- /dev/null +++ b/apps/server/src/routes/oidc/electron-callback.ts @@ -0,0 +1,194 @@ +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' + +/** + * Render an HTML relay page that forwards the OIDC authorization code + * to the Electron app's loopback server. + * + * 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. + * + * 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. + */ +export function createElectronCallbackRelay() { + 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(renderRelayPage({ code, state, error, errorDescription })) + }) +} + +// Regex patterns for escaping values in HTML/JS context +const RE_BACKSLASH = /\\/g +const RE_SINGLE_QUOTE = /'/g +const RE_LT = / s.replace(RE_BACKSLASH, '\\\\').replace(RE_SINGLE_QUOTE, '\\\'').replace(RE_LT, '\\x3c') + + return ` + + + + + Signing in — AIRI + + + +
+ +

Signing in…

+
+

Completing authentication

+ +
+ + +` +} diff --git a/apps/server/src/routes/oidc/token-auth.ts b/apps/server/src/routes/oidc/token-auth.ts new file mode 100644 index 000000000..284524f43 --- /dev/null +++ b/apps/server/src/routes/oidc/token-auth.ts @@ -0,0 +1,35 @@ +import type { Database } from '../../libs/db' +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' + +import { resolveRequestAuth, revokeOIDCAccessToken } from '../../libs/request-auth' + +export interface OIDCTokenAuthRouteDeps { + auth: any + db: Database +} + +export function createOIDCTokenAuthRoute(deps: OIDCTokenAuthRouteDeps) { + return new Hono() + .on(['GET', 'POST'], '/get-session', async (c) => { + const session = await resolveRequestAuth(deps.auth, deps.db, c.req.raw.headers) + return c.json(session) + }) + .post('/sign-out', async (c) => { + const authorization = c.req.header('authorization') + const accessToken = authorization?.startsWith('Bearer ') + ? authorization.slice(7).trim() + : null + + if (accessToken) { + await revokeOIDCAccessToken(deps.db, accessToken) + } + + return c.json({ success: true }) + }) + .get('/list-sessions', async (c) => { + const session = await resolveRequestAuth(deps.auth, deps.db, c.req.raw.headers) + return c.json(session ? [session.session] : []) + }) +} diff --git a/apps/server/src/schemas/accounts.ts b/apps/server/src/schemas/accounts.ts index b638d8438..33b78e160 100644 --- a/apps/server/src/schemas/accounts.ts +++ b/apps/server/src/schemas/accounts.ts @@ -1,5 +1,5 @@ import { relations } from 'drizzle-orm' -import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { boolean, index, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core' export const user = pgTable('user', { id: text('id').primaryKey(), @@ -73,16 +73,114 @@ export const verification = pgTable( table => [index('verification_identifier_idx').on(table.identifier)], ) +export const jwks = pgTable('jwks', { + id: text('id').primaryKey(), + publicKey: text('public_key').notNull(), + privateKey: text('private_key').notNull(), + createdAt: timestamp('created_at').notNull(), + expiresAt: timestamp('expires_at'), +}) + +export const oauthClient = pgTable('oauth_client', { + id: text('id').primaryKey(), + clientId: text('client_id').notNull().unique(), + clientSecret: text('client_secret'), + disabled: boolean('disabled').default(false), + skipConsent: boolean('skip_consent'), + enableEndSession: boolean('enable_end_session'), + subjectType: text('subject_type'), + scopes: text('scopes').array(), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at'), + name: text('name'), + uri: text('uri'), + icon: text('icon'), + contacts: text('contacts').array(), + tos: text('tos'), + policy: text('policy'), + softwareId: text('software_id'), + softwareVersion: text('software_version'), + softwareStatement: text('software_statement'), + redirectUris: text('redirect_uris').array().notNull(), + postLogoutRedirectUris: text('post_logout_redirect_uris').array(), + tokenEndpointAuthMethod: text('token_endpoint_auth_method'), + grantTypes: text('grant_types').array(), + responseTypes: text('response_types').array(), + public: boolean('public'), + type: text('type'), + requirePKCE: boolean('require_pkce'), + referenceId: text('reference_id'), + metadata: jsonb('metadata'), +}) + +export const oauthRefreshToken = pgTable('oauth_refresh_token', { + id: text('id').primaryKey(), + token: text('token').notNull(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => session.id, { + onDelete: 'set null', + }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + expiresAt: timestamp('expires_at'), + createdAt: timestamp('created_at'), + revoked: timestamp('revoked'), + authTime: timestamp('auth_time'), + scopes: text('scopes').array().notNull(), +}) + +export const oauthAccessToken = pgTable('oauth_access_token', { + id: text('id').primaryKey(), + token: text('token').unique(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => session.id, { + onDelete: 'set null', + }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + refreshId: text('refresh_id').references(() => oauthRefreshToken.id, { + onDelete: 'cascade', + }), + expiresAt: timestamp('expires_at'), + createdAt: timestamp('created_at'), + scopes: text('scopes').array().notNull(), +}) + +export const oauthConsent = pgTable('oauth_consent', { + id: text('id').primaryKey(), + clientId: text('client_id') + .notNull() + .references(() => oauthClient.clientId, { onDelete: 'cascade' }), + userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }), + referenceId: text('reference_id'), + scopes: text('scopes').array().notNull(), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at'), +}) + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account), + oauthClients: many(oauthClient), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), + oauthConsents: many(oauthConsent), })) -export const sessionRelations = relations(session, ({ one }) => ({ +export const sessionRelations = relations(session, ({ one, many }) => ({ user: one(user, { fields: [session.userId], references: [user.id], }), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), })) export const accountRelations = relations(account, ({ one }) => ({ @@ -91,3 +189,65 @@ export const accountRelations = relations(account, ({ one }) => ({ references: [user.id], }), })) + +export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({ + user: one(user, { + fields: [oauthClient.userId], + references: [user.id], + }), + oauthRefreshTokens: many(oauthRefreshToken), + oauthAccessTokens: many(oauthAccessToken), + oauthConsents: many(oauthConsent), +})) + +export const oauthRefreshTokenRelations = relations( + oauthRefreshToken, + ({ one, many }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthRefreshToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthRefreshToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthRefreshToken.userId], + references: [user.id], + }), + oauthAccessTokens: many(oauthAccessToken), + }), +) + +export const oauthAccessTokenRelations = relations( + oauthAccessToken, + ({ one }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthAccessToken.clientId], + references: [oauthClient.clientId], + }), + session: one(session, { + fields: [oauthAccessToken.sessionId], + references: [session.id], + }), + user: one(user, { + fields: [oauthAccessToken.userId], + references: [user.id], + }), + oauthRefreshToken: one(oauthRefreshToken, { + fields: [oauthAccessToken.refreshId], + references: [oauthRefreshToken.id], + }), + }), +) + +export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({ + oauthClient: one(oauthClient, { + fields: [oauthConsent.clientId], + references: [oauthClient.clientId], + }), + user: one(user, { + fields: [oauthConsent.userId], + references: [user.id], + }), +})) diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index 9332af50c..21af7f32a 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -9,29 +9,30 @@ function getOriginFromUrl(url: string): string | undefined { } } +const TRUSTED_EXACT_ORIGINS = [ + 'capacitor://localhost', // Capacitor mobile (iOS) + 'https://airi.moeru.ai', // Production +] + +const TRUSTED_ORIGIN_PATTERNS = [ + // Localhost dev (any port) + /^http:\/\/localhost(:\d+)?$/, + // Loopback interface for Electron OIDC callbacks (RFC 8252 S7.3) + /^http:\/\/127\.0\.0\.1(:\d+)?$/, + // Cloudflare Workers subdomains + /^https:\/\/.*\.kwaa\.workers\.dev$/, +] + export function getTrustedOrigin(origin: string): string { - // 1. Allow Dev (Localhost with any port) - if (!origin || origin.startsWith('http://localhost:')) { + if (!origin) return origin - } - // 2. Allow Capacitor mobile app origins (iOS: capacitor://, Android: http://localhost) - if (origin === 'capacitor://localhost') { + if (TRUSTED_EXACT_ORIGINS.includes(origin)) return origin - } - // 3. Allow Production (Exact Match) - if (origin === 'https://airi.moeru.ai') { + if (TRUSTED_ORIGIN_PATTERNS.some(pattern => pattern.test(origin))) return origin - } - // 4. Allow Dynamic Subdomains (Strict Regex) - // Matches: https://foo.kwaa.workers.dev - if (/^https:\/\/.*\.kwaa\.workers\.dev$/.test(origin)) { - return origin - } - - // Default: Block return '' } diff --git a/apps/server/src/utils/sign-in-page.ts b/apps/server/src/utils/sign-in-page.ts new file mode 100644 index 000000000..01fab8e81 --- /dev/null +++ b/apps/server/src/utils/sign-in-page.ts @@ -0,0 +1,173 @@ +// Regex patterns for escaping values in HTML/JS context +const RE_BACKSLASH = /\\/g +const RE_SINGLE_QUOTE = /'/g +const RE_LT = /` tags. + */ +export function renderSignInPage(baseUrl: string, callbackURL: string = '/'): string { + const signInEndpoint = `${baseUrl}/api/auth/sign-in/social` + // Escape callbackURL for safe embedding in a JS string literal inside HTML + const escapedCallbackURL = callbackURL + .replace(RE_BACKSLASH, '\\\\') + .replace(RE_SINGLE_QUOTE, '\\\'') + .replace(RE_LT, '\\x3c') + + return ` + + + + + Sign in — AIRI + + + +
+ +

Sign in to AIRI

+

Choose a provider to continue

+
+ + +
+

+ +
+ + +` +} diff --git a/apps/stage-tamagotchi/electron.vite.config.ts b/apps/stage-tamagotchi/electron.vite.config.ts index 741c8ad6e..3b041281c 100644 --- a/apps/stage-tamagotchi/electron.vite.config.ts +++ b/apps/stage-tamagotchi/electron.vite.config.ts @@ -206,6 +206,7 @@ export default defineConfig({ src: resolve(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src', 'pages'), exclude: base => [ ...base, + '**/settings/account/index.vue', '**/settings/connection/index.vue', '**/settings/data/index.vue', '**/settings/models/index.vue', diff --git a/apps/stage-tamagotchi/src/main/services/electron/auth.ts b/apps/stage-tamagotchi/src/main/services/electron/auth.ts new file mode 100644 index 000000000..359e65b35 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/auth.ts @@ -0,0 +1,184 @@ +import type { createContext } from '@moeru/eventa/adapters/electron/main' +import type { BrowserWindow } from 'electron' + +import { useLogg } from '@guiiai/logg' +import { defineInvokeHandler } from '@moeru/eventa' +import { errorMessageFrom } from '@moeru/std' +import { generateCodeChallenge, generateCodeVerifier, generateState } from '@proj-airi/stage-shared/auth' +import { shell } from 'electron' + +import { + electronAuthCallback, + electronAuthCallbackError, + electronAuthLogout, + electronAuthStartLogin, +} from '../../../shared/eventa' +import { startLoopbackServer } from './loopback-server' + +const log = useLogg('auth-service').useGlobalConfig() + +type MainContext = ReturnType['context'] + +// OIDC configuration for the Electron client. +const OIDC_CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron' +const OIDC_SCOPES = 'openid profile email offline_access' +const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://api.airi.build' +const OIDC_AUTHORIZE_PATH = '/api/auth/oauth2/authorize' +const OIDC_TOKEN_PATH = '/api/auth/oauth2/token' + +// Active loopback server cleanup handle +let closeLoopback: (() => void) | null = null +let loginInFlight = false +const authContexts = new Set() + +function emitAuthCallback(tokens: TokenExchangeResult): void { + for (const context of authContexts) { + context.emit(electronAuthCallback, tokens) + } +} + +function emitAuthError(error: string): void { + for (const context of authContexts) { + context.emit(electronAuthCallbackError, { error }) + } +} + +/** + * Create the auth service IPC handlers for a given window context. + */ +export function createAuthService(params: { + context: MainContext + window: BrowserWindow +}): void { + authContexts.add(params.context) + params.window.on('closed', () => { + authContexts.delete(params.context) + }) + + defineInvokeHandler(params.context, electronAuthStartLogin, async (_, options) => { + if (params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) { + return + } + + if (loginInFlight) { + log.withFields({ windowId: params.window.webContents.id }).warn('Replacing in-flight OIDC login attempt with a new request') + closeLoopback?.() + closeLoopback = null + loginInFlight = false + } + + loginInFlight = true + + try { + // Clean up any previous in-flight login + closeLoopback?.() + + const codeVerifier = generateCodeVerifier() + const codeChallenge = await generateCodeChallenge(codeVerifier) + const state = generateState() + + // Start loopback server to receive the callback + const loopback = await startLoopbackServer() + closeLoopback = loopback.close + + // Use the server-side relay as redirect_uri. The relay page serves HTML + // that forwards the authorization code to the loopback via JS fetch(). + // The loopback port is encoded in the state parameter as "{port}:{state}". + const redirectUri = `${SERVER_URL}/api/auth/oidc/electron-callback` + const stateWithPort = `${loopback.port}:${state}` + + // Build authorization URL + // NOTICE: prompt=login forces the authorization server to show the login + // page even if the system browser has an existing session cookie. Without + // this, the OIDC flow auto-completes silently using the stale cookie. + const url = new URL(OIDC_AUTHORIZE_PATH, SERVER_URL) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', OIDC_CLIENT_ID) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', OIDC_SCOPES) + url.searchParams.set('state', stateWithPort) + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('prompt', 'login') + + // Open system browser + await shell.openExternal(url.toString()) + + // Wait for the callback in the background + loopback.result + .then(async ({ code, state: returnedState }) => { + if (returnedState !== state) { + log.warn('State mismatch — possible CSRF attack') + emitAuthError('State mismatch') + return + } + + const tokens = await exchangeCode(code, codeVerifier, redirectUri) + emitAuthCallback(tokens) + log.log('OIDC token exchange successful') + }) + .catch((err) => { + log.withError(err).error('OIDC login failed') + emitAuthError(errorMessageFrom(err) ?? 'OIDC login failed') + }) + .finally(() => { + closeLoopback = null + loginInFlight = false + }) + } + catch (err) { + closeLoopback = null + loginInFlight = false + log.withError(err).error('Failed to start OIDC login flow') + emitAuthError(errorMessageFrom(err) ?? 'OIDC login failed') + } + }) + + defineInvokeHandler(params.context, electronAuthLogout, async (_, options) => { + if (params.window.webContents.id !== options?.raw.ipcMainEvent.sender.id) { + return + } + + closeLoopback?.() + closeLoopback = null + loginInFlight = false + }) +} + +// --- Internal helpers --- + +interface TokenExchangeResult { + accessToken: string + refreshToken?: string + idToken?: string + expiresIn: number +} + +async function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: OIDC_CLIENT_ID, + code_verifier: codeVerifier, + }) + + const response = await fetch(new URL(OIDC_TOKEN_PATH, SERVER_URL), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Token exchange failed (${response.status}): ${text}`) + } + + const data = await response.json() as Record + return { + accessToken: data.access_token as string, + refreshToken: data.refresh_token as string | undefined, + idToken: data.id_token as string | undefined, + expiresIn: data.expires_in as number, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts index 6b58098a7..6bd361777 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/index.ts @@ -1,4 +1,5 @@ export * from './app' +export * from './auth' export * from './auto-updater' export * from './powerMonitor' export * from './screen' diff --git a/apps/stage-tamagotchi/src/main/services/electron/loopback-server.ts b/apps/stage-tamagotchi/src/main/services/electron/loopback-server.ts new file mode 100644 index 000000000..fdc5cf159 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/loopback-server.ts @@ -0,0 +1,146 @@ +import http from 'node:http' + +import { useLogg } from '@guiiai/logg' + +const log = useLogg('loopback-server').useGlobalConfig() + +export interface LoopbackCallbackResult { + code: string + state: string +} + +/** + * Fixed ports for the loopback OIDC callback server. + * The server relay page (`/api/auth/oidc/electron-callback`) forwards the + * authorization code to `http://127.0.0.1:{port}/callback` via JS fetch(). + * The port is encoded in the `state` parameter, not in the redirect_uri. + * + * See RFC 8252 S7.3 for the loopback redirect pattern. + */ +const LOOPBACK_PORTS = [19721, 19722, 19723, 19724, 19725] + +/** + * Start a temporary HTTP server on 127.0.0.1 to receive an OIDC authorization + * callback. Tries ports from LOOPBACK_PORTS in order. The server handles + * exactly one request and then shuts down. + * + * This follows RFC 8252 S7.3 (Loopback Interface Redirection) and is the + * approach used by VS Code, GitHub Desktop, and Slack Desktop. + */ +export function startLoopbackServer(): Promise<{ + port: number + result: Promise + close: () => void +}> { + return new Promise((resolveStart, rejectStart) => { + let settled = false + let resultResolve: (value: LoopbackCallbackResult) => void + let resultReject: (reason: Error) => void + + const resultPromise = new Promise((resolve, reject) => { + resultResolve = resolve + resultReject = reject + }) + + const server = http.createServer((req, res) => { + if (settled) + return + + const url = new URL(req.url ?? '/', `http://127.0.0.1`) + + // CORS: the relay page on the server origin sends a cross-origin fetch() + // to the loopback. Allow all origins since this is a one-shot local server. + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + if (req.method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + + if (url.pathname !== '/callback') { + res.writeHead(404) + res.end('Not found') + return + } + + const error = url.searchParams.get('error') + if (error) { + const description = url.searchParams.get('error_description') ?? error + settled = true + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end('

Authentication failed

You can close this window.

') + resultReject!(new Error(description)) + server.close() + return + } + + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + + if (!code || !state) { + res.writeHead(400, { 'Content-Type': 'text/html' }) + res.end('

Missing parameters

') + return + } + + settled = true + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end('

Authentication successful!

You can close this window and return to the app.

') + resultResolve!({ code, state }) + server.close() + }) + + // Safety timeout: close after 5 minutes + const timeout = setTimeout(() => { + if (!settled) { + settled = true + resultReject!(new Error('Login timed out — no callback received')) + server.close() + } + }, 5 * 60 * 1000) + + server.on('close', () => clearTimeout(timeout)) + + // Try each port in order + let portIndex = 0 + + function tryListen(): void { + if (portIndex >= LOOPBACK_PORTS.length) { + rejectStart(new Error(`All loopback ports (${LOOPBACK_PORTS.join(', ')}) are in use`)) + return + } + + const port = LOOPBACK_PORTS[portIndex] + + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + log.withFields({ port }).log('Port in use, trying next') + portIndex++ + tryListen() + } + else { + rejectStart(err) + } + }) + + server.listen(port, '127.0.0.1', () => { + log.withFields({ port }).log('Loopback callback server started') + resolveStart({ + port, + result: resultPromise, + close: () => { + if (settled) + return + + settled = true + resultReject!(new Error('OIDC login attempt cancelled')) + server.close() + }, + }) + }) + } + + tryListen() + }) +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index c28dd6b55..502a9b33f 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -18,6 +18,7 @@ import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createOnboardingService } from '../../../services/airi/onboarding' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' +import { createAuthService } from '../../../services/electron/auth' import { toggleWindowShow } from '../../shared' import { setupBaseWindowElectronInvokes } from '../../shared/window' @@ -45,6 +46,7 @@ export async function setupMainWindowElectronInvokes(params: { createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) createMcpServersService({ context, manager: params.mcpStdioManager }) createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager }) + createAuthService({ context, window: params.window }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route)) diff --git a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts index 3df668cc3..5f52b4a7c 100644 --- a/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/onboarding/index.ts @@ -14,6 +14,7 @@ import icon from '../../../../resources/icon.png?asset' import { electronOnboardingClose } from '../../../shared/eventa' import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' import { createReusableWindow } from '../../libs/electron/window-manager' +import { createAuthService } from '../../services/electron/auth' import { toggleWindowShow } from '../shared' import { setupBaseWindowElectronInvokes } from '../shared/window' @@ -71,6 +72,7 @@ export function setupOnboardingWindowManager(params: { }) await setupBaseWindowElectronInvokes({ context, window: newWindow, i18n: params.i18n, serverChannel: params.serverChannel }) + createAuthService({ context, window: newWindow }) await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding')) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index b56e4f422..711c89156 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -15,6 +15,7 @@ import { electronOpenDevtoolsWindow, electronOpenSettingsDevtools } from '../../ import { createMcpServersService } from '../../../services/airi/mcp-servers' import { createWidgetsService } from '../../../services/airi/widgets' import { createAutoUpdaterService } from '../../../services/electron' +import { createAuthService } from '../../../services/electron/auth' import { setupBaseWindowElectronInvokes } from '../../shared/window' export async function setupSettingsWindowInvokes(params: { @@ -38,6 +39,7 @@ export async function setupSettingsWindowInvokes(params: { createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) createMcpServersService({ context, manager: params.mcpStdioManager }) + createAuthService({ context, window: params.settingsWindow }) defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenDevtoolsWindow, async (payload) => { diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index c7274b2c1..aed63b030 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -41,6 +41,7 @@ import { pluginProtocolListProviders, pluginProtocolListProvidersEventName, } from '../shared/eventa' +import { initializeElectronAuthCallbackBridge } from './bridges/electron-auth-callback' import { initializeStageThreeRuntimeTraceBridge } from './bridges/stage-three-runtime-trace' import { useServerChannelSettingsStore } from './stores/settings/server-channel' import { useStageWindowLifecycleStore } from './stores/stage-window-lifecycle' @@ -65,6 +66,7 @@ const settingsAudioDeviceStore = useSettingsAudioDevice() const context = useElectronEventaContext() usePerfTracerBridgeStore() initializeStageThreeRuntimeTraceBridge() +initializeElectronAuthCallbackBridge() void stageWindowLifecycleStore.initializeWindowLifecycleBridge() const getServerChannelConfig = useElectronEventaInvoke(electronGetServerChannelConfig) const listPlugins = useElectronEventaInvoke(electronPluginList) diff --git a/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts new file mode 100644 index 000000000..ac2bf9a21 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts @@ -0,0 +1,48 @@ +import { errorMessageFrom } from '@moeru/std' +import { getElectronEventaContext } from '@proj-airi/electron-vueuse' +import { fetchSession } from '@proj-airi/stage-ui/libs/auth' +import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' +import { toast } from 'vue-sonner' + +import { + electronAuthCallback, + electronAuthCallbackError, +} from '../../shared/eventa' + +/** + * Register auth callback listeners at the renderer service level so they + * persist for the window's lifetime, independent of any Vue component's + * mount/unmount lifecycle. + */ +export function initializeElectronAuthCallbackBridge() { + const context = getElectronEventaContext() + + context.on(electronAuthCallback, async (event) => { + const tokens = event.body + if (!tokens) + return + + try { + const authStore = useAuthStore() + authStore.token = tokens.accessToken + + if (tokens.refreshToken) { + authStore.refreshToken = tokens.refreshToken + } + + authStore.oidcClientId = import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-electron' + authStore.tokenExpiry = Date.now() + tokens.expiresIn * 1000 + authStore.scheduleTokenRefresh(tokens.expiresIn) + + await fetchSession() + } + catch (error) { + toast.error(errorMessageFrom(error) ?? 'Login failed') + } + }) + + context.on(electronAuthCallbackError, (event) => { + if (event.body) + toast.error(event.body.error) + }) +} diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue new file mode 100644 index 000000000..a701e3423 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/controls-island-auth-button.vue @@ -0,0 +1,183 @@ + + + diff --git a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue index a99bf9f72..1adc0c2a8 100644 --- a/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue +++ b/apps/stage-tamagotchi/src/renderer/components/stage-islands/controls-island/index.vue @@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n' import ControlButtonTooltip from './control-button-tooltip.vue' import ControlButton from './control-button.vue' +import ControlsIslandAuthButton from './controls-island-auth-button.vue' import ControlsIslandFadeOnHover from './controls-island-fade-on-hover.vue' import ControlsIslandHearingConfig from './controls-island-hearing-config.vue' import ControlsIslandProfilePicker from './controls-island-profile-picker.vue' @@ -135,6 +136,11 @@ function refreshWindow() { leave-to-class="opacity-0 translate-y-8 scale-90 blur-sm" >
+ +
diff --git a/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue b/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue index 604899aed..d846fb935 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/onboarding.vue @@ -2,14 +2,31 @@ import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse' import { OnboardingScreen, OnboardingStepAnalyticsNotice } from '@proj-airi/stage-ui/components' import { isPosthogAvailableInBuild } from '@proj-airi/stage-ui/stores/analytics' +import { useAuthStore } from '@proj-airi/stage-ui/stores/auth' import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding' import { useTheme } from '@proj-airi/ui' -import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { computed, watch } from 'vue' -import { electronOnboardingClose } from '../../shared/eventa' +import { electronAuthStartLogin, electronOnboardingClose } from '../../shared/eventa' +const authStore = useAuthStore() +const { needsLogin, isAuthenticated } = storeToRefs(authStore) const onboardingStore = useOnboardingStore() const { isDark } = useTheme() +const startLogin = useElectronEventaInvoke(electronAuthStartLogin) +const closeWindow = useElectronEventaInvoke(electronOnboardingClose) + +// The onboarding window is a separate Electron process with its own Pinia instance. +// When step-welcome sets needsLogin=true, we must invoke the IPC login from here +// since the controls-island watcher only exists in the main window. +watch(needsLogin, async (val) => { + if (val && !isAuthenticated.value) { + await startLogin() + needsLogin.value = false + await closeWindow() + } +}) const bgClass = computed(() => isDark.value ? 'bg-[#0f0f0f]' : 'bg-white') const extraSteps = computed(() => { @@ -18,8 +35,6 @@ const extraSteps = computed(() => { : [] }) -const closeWindow = useElectronEventaInvoke(electronOnboardingClose) - async function handleSkipped() { onboardingStore.markSetupSkipped() await closeWindow() diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/account/index.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/account/index.vue new file mode 100644 index 000000000..70750acd6 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/account/index.vue @@ -0,0 +1,40 @@ + + + + + +meta: + layout: settings + titleKey: settings.pages.account.title + subtitleKey: settings.title + descriptionKey: settings.pages.account.description + icon: i-solar:user-circle-bold-duotone + settingsEntry: false + order: 0 + stageTransition: + name: slide + diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 24a6b1cdd..7f5e4a5bd 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -262,6 +262,18 @@ export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Re export const electronOnboardingClose = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:close') export const electronOpenOnboarding = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:open') +// Auth — OIDC Authorization Code + PKCE flow via system browser +export interface ElectronAuthTokens { + accessToken: string + refreshToken?: string + idToken?: string + expiresIn: number +} +export const electronAuthStartLogin = defineInvokeEventa('eventa:invoke:electron:auth:start-login') +export const electronAuthCallback = defineEventa('eventa:event:electron:auth:callback') +export const electronAuthCallbackError = defineEventa<{ error: string }>('eventa:event:electron:auth:callback-error') +export const electronAuthLogout = defineInvokeEventa('eventa:invoke:electron:auth:logout') + export const i18nSetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:set-locale') export const i18nGetLocale = defineInvokeEventa('eventa:invoke:electron:i18n:get-locale') diff --git a/apps/stage-web/package.json b/apps/stage-web/package.json index e07dfe28c..95ff0bf8a 100644 --- a/apps/stage-web/package.json +++ b/apps/stage-web/package.json @@ -32,6 +32,7 @@ "@proj-airi/pipelines-audio": "workspace:^", "@proj-airi/server-sdk": "workspace:^", "@proj-airi/stage-layouts": "workspace:^", + "@proj-airi/stage-pages": "workspace:^", "@proj-airi/stage-shared": "workspace:^", "@proj-airi/stage-ui": "workspace:^", "@proj-airi/stage-ui-three": "workspace:^", diff --git a/apps/stage-web/src/pages/auth/callback.vue b/apps/stage-web/src/pages/auth/callback.vue new file mode 100644 index 000000000..e2a73227a --- /dev/null +++ b/apps/stage-web/src/pages/auth/callback.vue @@ -0,0 +1,63 @@ + + + diff --git a/apps/stage-web/src/pages/auth/login.vue b/apps/stage-web/src/pages/auth/sign-in.vue similarity index 79% rename from apps/stage-web/src/pages/auth/login.vue rename to apps/stage-web/src/pages/auth/sign-in.vue index 5067a3c47..706ef47bf 100644 --- a/apps/stage-web/src/pages/auth/login.vue +++ b/apps/stage-web/src/pages/auth/sign-in.vue @@ -3,7 +3,8 @@ import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth' import { LoginDrawer } from '@proj-airi/stage-ui/components/auth' import { useBreakpoints } from '@proj-airi/stage-ui/composables' -import { fetchSession, signIn } from '@proj-airi/stage-ui/libs/auth' +import { fetchSession, signInOIDC } from '@proj-airi/stage-ui/libs/auth' +import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from '@proj-airi/stage-ui/libs/auth-config' import { Button } from '@proj-airi/ui' import { onMounted, ref, watch } from 'vue' import { useRouter } from 'vue-router' @@ -21,7 +22,11 @@ const loading = ref>({ async function handleSignIn(provider: OAuthProvider) { loading.value[provider] = true try { - await signIn(provider) + await signInOIDC({ + clientId: OIDC_CLIENT_ID, + redirectUri: OIDC_REDIRECT_URI, + provider, + }) } catch (error) { toast.error(error instanceof Error ? error.message : 'An unknown error occurred') @@ -32,6 +37,15 @@ async function handleSignIn(provider: OAuthProvider) { } onMounted(() => { + // Check URL for error from failed OAuth callback + const url = new URL(window.location.href) + const error = url.searchParams.get('error') + if (error) { + toast.error(error === 'auth_failed' ? 'Authentication failed. Please try again.' : error) + url.searchParams.delete('error') + window.history.replaceState(null, '', url.pathname) + } + fetchSession() .then((authenticated) => { if (authenticated || !isDesktop.value) { diff --git a/apps/stage-web/src/pages/settings/account/index.vue b/apps/stage-web/src/pages/settings/account/index.vue new file mode 100644 index 000000000..1449340af --- /dev/null +++ b/apps/stage-web/src/pages/settings/account/index.vue @@ -0,0 +1,36 @@ + + + + + +meta: + layout: settings + titleKey: settings.pages.account.title + subtitleKey: settings.title + descriptionKey: settings.pages.account.description + icon: i-solar:user-circle-bold-duotone + settingsEntry: false + order: 0 + stageTransition: + name: slide + diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index c9a563dc4..4fc2c0212 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -130,6 +130,15 @@ live2d: microphone: Microphone models: Model pages: + account: + title: Account + description: View your profile and manage your account + notLoggedIn: Sign in to view your account and access all features + login: Log in + logout: Log out + fluxBalance: Flux Balance + viewFluxDetails: View details + signedInAs: Signed in as card: activate: Activate active: Active diff --git a/packages/i18n/src/locales/en/tamagotchi/stage.yaml b/packages/i18n/src/locales/en/tamagotchi/stage.yaml index 5171efb6d..9d511a777 100644 --- a/packages/i18n/src/locales/en/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/stage.yaml @@ -25,6 +25,10 @@ docs: 'close': Close 'expand': Expand 'collapse': Collapse + 'login': Sign in + 'logging-in': Signing in... + 'account': Account + 'logout': Sign out 'status-island': connected: WebSocket connected disconnected: WebSocket disconnected diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index 512d2efd0..6fbf0f311 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -123,6 +123,15 @@ live2d: microphone: 麦克风 models: 模型 pages: + account: + title: 账户 + description: 查看您的个人资料和管理账户 + notLoggedIn: 登录以查看您的账户并使用所有功能 + login: 登录 + logout: 退出登录 + fluxBalance: Flux 余额 + viewFluxDetails: 查看详情 + signedInAs: 已登录为 card: activate: 激活 active: 已激活 diff --git a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml index b092289ce..03fa6ad06 100644 --- a/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/tamagotchi/stage.yaml @@ -25,6 +25,10 @@ docs: 'close': 关闭 'expand': 展开 'collapse': 折叠 + 'login': 登录 + 'logging-in': 登录中... + 'account': 账户 + 'logout': 退出登录 'status-island': connected: WebSocket 已连接 disconnected: WebSocket 已断开 diff --git a/packages/stage-pages/src/pages/settings/account/account-settings-page.vue b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue new file mode 100644 index 000000000..8a5479e0e --- /dev/null +++ b/packages/stage-pages/src/pages/settings/account/account-settings-page.vue @@ -0,0 +1,119 @@ + + + diff --git a/packages/stage-shared/package.json b/packages/stage-shared/package.json index 4ba7f4f9b..ef2449527 100644 --- a/packages/stage-shared/package.json +++ b/packages/stage-shared/package.json @@ -16,6 +16,7 @@ }, "exports": { ".": "./src/index.ts", + "./auth": "./src/auth/index.ts", "./beat-sync": "./src/beat-sync/index.ts", "./electron-renderer": "./src/electron-renderer.d.ts", "./composables": "./src/composables/index.ts" diff --git a/packages/stage-shared/src/auth/index.ts b/packages/stage-shared/src/auth/index.ts new file mode 100644 index 000000000..651ddd04d --- /dev/null +++ b/packages/stage-shared/src/auth/index.ts @@ -0,0 +1 @@ +export { base64UrlEncode, generateCodeChallenge, generateCodeVerifier, generateState } from './pkce' diff --git a/packages/stage-shared/src/auth/pkce.test.ts b/packages/stage-shared/src/auth/pkce.test.ts new file mode 100644 index 000000000..d8a8b9d1a --- /dev/null +++ b/packages/stage-shared/src/auth/pkce.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { base64UrlEncode, generateCodeChallenge, generateCodeVerifier, generateState } from './pkce' + +describe('base64UrlEncode', () => { + it('encodes to URL-safe base64 without padding', () => { + const input = new Uint8Array([72, 101, 108, 108, 111]) // "Hello" + const result = base64UrlEncode(input) + expect(result).toBe('SGVsbG8') + expect(result).not.toMatch(/[+/=]/) + }) + + it('replaces + with - and / with _', () => { + const input = new Uint8Array([251, 255, 254]) + const result = base64UrlEncode(input) + expect(result).not.toMatch(/[+/=]/) + }) +}) + +describe('generateCodeVerifier', () => { + it('returns a URL-safe string of expected length range', () => { + const verifier = generateCodeVerifier() + expect(verifier.length).toBeGreaterThanOrEqual(43) + expect(verifier.length).toBeLessThanOrEqual(128) + expect(verifier).not.toMatch(/[+/=]/) + }) + + it('generates unique values', () => { + const a = generateCodeVerifier() + const b = generateCodeVerifier() + expect(a).not.toBe(b) + }) +}) + +describe('generateCodeChallenge', () => { + it('produces a valid S256 challenge from a known verifier', async () => { + // RFC 7636 Appendix B test vector + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const challenge = await generateCodeChallenge(verifier) + expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM') + }) +}) + +describe('generateState', () => { + it('returns a URL-safe string', () => { + const state = generateState() + expect(state.length).toBeGreaterThan(0) + expect(state).not.toMatch(/[+/=]/) + }) + + it('generates unique values', () => { + const a = generateState() + const b = generateState() + expect(a).not.toBe(b) + }) +}) diff --git a/packages/stage-shared/src/auth/pkce.ts b/packages/stage-shared/src/auth/pkce.ts new file mode 100644 index 000000000..ba52b4e1a --- /dev/null +++ b/packages/stage-shared/src/auth/pkce.ts @@ -0,0 +1,46 @@ +const BASE64_PLUS = /\+/g +const BASE64_SLASH = /\//g +const BASE64_TRAILING_EQ = /=+$/ + +/** + * Encode a byte array as a URL-safe base64 string (no padding). + * Works in both Browser and Node.js (Electron main). + */ +export function base64UrlEncode(buffer: Uint8Array): string { + let binary = '' + for (const byte of buffer) + binary += String.fromCharCode(byte) + + return btoa(binary) + .replace(BASE64_PLUS, '-') + .replace(BASE64_SLASH, '_') + .replace(BASE64_TRAILING_EQ, '') +} + +/** + * Generate a cryptographically random code verifier (RFC 7636 S4.1). + * 43-128 characters from the unreserved URL character set. + */ +export function generateCodeVerifier(length = 64): string { + const array = new Uint8Array(length) + crypto.getRandomValues(array) + return base64UrlEncode(array) +} + +/** + * Derive a S256 code challenge from a code verifier (RFC 7636 S4.2). + */ +export async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier) + const digest = await crypto.subtle.digest('SHA-256', data) + return base64UrlEncode(new Uint8Array(digest)) +} + +/** + * Generate a cryptographically random state parameter for CSRF protection. + */ +export function generateState(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return base64UrlEncode(array) +} diff --git a/packages/stage-shared/vitest.config.ts b/packages/stage-shared/vitest.config.ts new file mode 100644 index 000000000..647f39364 --- /dev/null +++ b/packages/stage-shared/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}) diff --git a/packages/stage-ui/src/components/auth/LoginDrawer.vue b/packages/stage-ui/src/components/auth/LoginDrawer.vue index 08480d0dc..ec4203f96 100644 --- a/packages/stage-ui/src/components/auth/LoginDrawer.vue +++ b/packages/stage-ui/src/components/auth/LoginDrawer.vue @@ -7,7 +7,8 @@ import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } import { ref } from 'vue' import { toast } from 'vue-sonner' -import { fetchSession, signIn } from '../../libs/auth' +import { signInOIDC } from '../../libs/auth' +import { OIDC_CLIENT_ID, OIDC_REDIRECT_URI } from '../../libs/auth-config' const open = defineModel('open', { required: true }) @@ -22,8 +23,11 @@ const loading = ref>({ async function handleSignIn(provider: OAuthProvider) { loading.value[provider] = true try { - await signIn(provider) - await fetchSession() + await signInOIDC({ + clientId: OIDC_CLIENT_ID, + redirectUri: OIDC_REDIRECT_URI, + provider, + }) } catch (error) { toast.error(error instanceof Error ? error.message : 'An unknown error occurred') diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue index c1d867cbb..544790f0d 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue @@ -2,7 +2,6 @@ import type { OnboardingStepNextHandler } from './types' import { all } from '@proj-airi/i18n' -import { isStageTamagotchi } from '@proj-airi/stage-shared' import { Button, FieldCombobox } from '@proj-airi/ui' import { storeToRefs } from 'pinia' import { computed } from 'vue' @@ -90,7 +89,6 @@ function handleLocalSetup() {