feat(auth): OIDC (#1531)

This commit is contained in:
RainbowBird
2026-04-02 04:24:04 +08:00
committed by RainbowBird
parent cb7240fe1d
commit f1fe161bc0
61 changed files with 6329 additions and 174 deletions
+3
View File
@@ -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`
@@ -0,0 +1,255 @@
# 认证与 OIDC Provider
## 一句话总结
Server 通过 `better-auth` 同时充当**用户认证后端**和 **OIDC ProviderAuthorization 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 tokenbridge | 不持久化,仅在回调流程中使用 | 由 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 + sessionserver 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 sessionOIDC 流程继续签发 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 limiterIP 限流)
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`
@@ -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)
@@ -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`
+90
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -57,6 +57,13 @@
"when": 1774632446757,
"tag": "0007_red_nicolaos",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1775032828818,
"tag": "0008_gray_xavin",
"breakpoints": true
}
]
}
}
+1
View File
@@ -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:",
+104
View File
@@ -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()
})
})
+34 -47
View File
@@ -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<typeof createAuth>
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<HonoEnv>()
@@ -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,
+210
View File
@@ -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()
})
})
+305 -2
View File
@@ -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<void> {
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<string> {
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<void> {
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: {
+19
View File
@@ -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')
})
})
+126
View File
@@ -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<void>) => {
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)
})
})
+136
View File
@@ -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<string> {
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<RequestAuthSession | null> {
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<RequestAuthSession | null> {
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<boolean> {
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
}
+18 -2
View File
@@ -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<typeof createAuth>
* Session middleware injects the user and session into the Hono context.
* It does not block unauthorized requests.
*/
export function sessionMiddleware(auth: AuthInstance): MiddlewareHandler<HonoEnv> {
export function sessionMiddleware(auth: AuthInstance, db: Database): MiddlewareHandler<HonoEnv> {
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)
+106
View File
@@ -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<HonoEnv>()
/**
* 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<Response>
})
}
@@ -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<HonoEnv>()
.get('/', (c) => {
const code = c.req.query('code') ?? ''
const state = c.req.query('state') ?? ''
const error = c.req.query('error') ?? ''
const errorDescription = c.req.query('error_description') ?? ''
return c.html(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 = /</g
function renderRelayPage(params: {
code: string
state: string
error: string
errorDescription: string
}): string {
// Escape values for safe embedding in HTML/JS
const esc = (s: string) => s.replace(RE_BACKSLASH, '\\\\').replace(RE_SINGLE_QUOTE, '\\\'').replace(RE_LT, '\\x3c')
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Signing in — AIRI</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
color: #111827;
-webkit-font-smoothing: antialiased;
}
.card {
background: #ffffff;
border: 1px solid #f3f4f6;
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03), 0 20px 25px -5px rgba(0, 0, 0, 0.05);
}
.logo {
width: 48px;
height: 48px;
margin: 0 auto 24px;
background: #111827;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 20px;
letter-spacing: -0.5px;
}
h1 { font-size: 24px; font-weight: 700; margin-bottom: 12px; letter-spacing: -0.025em; }
.status { font-size: 15px; color: #6b7280; line-height: 1.5; }
.status.error { color: #ef4444; background: #fef2f2; padding: 12px; border-radius: 8px; border: 1px solid #fecaca; margin-top: 16px; }
.status.success { color: #059669; background: #ecfdf5; padding: 12px; border-radius: 8px; border: 1px solid #a7f3d0; margin-top: 16px; }
.link {
display: inline-block;
margin-top: 24px;
color: #4f46e5;
text-decoration: none;
font-weight: 500;
font-size: 14px;
transition: color 0.2s;
word-break: break-all;
}
.link:hover { color: #4338ca; text-decoration: underline; }
.link[hidden] { display: none; }
.spinner {
width: 32px; height: 32px;
border: 3px solid #f3f4f6;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 24px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-color-scheme: dark) {
body { background: #030712; color: #f9fafb; }
.card { background: #111827; border-color: #1f2937; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); }
.logo { background: #ffffff; color: #111827; }
.status { color: #9ca3af; }
.status.error { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; }
.status.success { background: #064e3b; border-color: #065f46; color: #6ee7b7; }
.link { color: #818cf8; }
.link:hover { color: #a5b4fc; }
.spinner { border-color: #374151; border-top-color: #818cf8; }
}
</style>
</head>
<body>
<div class="card">
<div class="logo">Ai</div>
<h1 id="title">Signing in…</h1>
<div class="spinner" id="spinner"></div>
<p class="status" id="status">Completing authentication</p>
<a id="manual-link" class="link" hidden rel="noreferrer">If AIRI does not open automatically, click here</a>
</div>
<script>
(function() {
var code = '${esc(params.code)}';
var fullState = '${esc(params.state)}';
var error = '${esc(params.error)}';
var errorDesc = '${esc(params.errorDescription)}';
var titleEl = document.getElementById('title');
var statusEl = document.getElementById('status');
var spinnerEl = document.getElementById('spinner');
var manualLinkEl = document.getElementById('manual-link');
function done(ok, msg) {
spinnerEl.style.display = 'none';
titleEl.textContent = ok ? 'Signed in!' : 'Sign-in failed';
statusEl.textContent = msg;
statusEl.className = 'status ' + (ok ? 'success' : 'error');
}
function revealManualLink(url, text) {
manualLinkEl.href = url;
manualLinkEl.textContent = text;
manualLinkEl.hidden = false;
}
if (error) {
done(false, errorDesc || error);
return;
}
// State format: "{port}:{originalState}"
var sep = fullState.indexOf(':');
if (sep === -1) {
done(false, 'Invalid state parameter');
return;
}
var port = fullState.substring(0, sep);
var originalState = fullState.substring(sep + 1);
// Send the code and state to the Electron loopback server
var url = 'http://127.0.0.1:' + port + '/callback?code=' + encodeURIComponent(code) + '&state=' + encodeURIComponent(originalState);
revealManualLink(url, 'If AIRI does not open automatically, click here');
fetch(url)
.then(function() {
done(true, 'You can close this tab and return to AIRI.');
})
.catch(function() {
done(false, 'Trying to open AIRI directly…');
setTimeout(function() {
window.location.replace(url);
}, 150);
setTimeout(function() {
done(false, 'Could not reach AIRI automatically. Use the link below to continue.');
}, 1000);
});
})();
</script>
</body>
</html>`
}
+35
View File
@@ -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<HonoEnv>()
.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] : [])
})
}
+162 -2
View File
@@ -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],
}),
}))
+17 -16
View File
@@ -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 ''
}
+173
View File
@@ -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 = /</g
/**
* Render a minimal sign-in page for the OIDC Provider flow.
*
* When the oidcProvider plugin redirects an unauthenticated user here,
* they choose a social provider. After authentication, the social
* callback redirects to callbackURL, which points back to the OIDC
* authorize endpoint so the authorization code flow can complete.
*
* NOTICE: better-auth's `/api/auth/sign-in/social` is a POST endpoint
* that expects JSON body `{ provider, callbackURL }` and returns a
* redirect URL in JSON. We use fetch + redirect in JS, not `<a>` 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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — AIRI</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
color: #111827;
-webkit-font-smoothing: antialiased;
}
.card {
background: #ffffff;
border: 1px solid #f3f4f6;
border-radius: 24px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
text-align: center;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03), 0 20px 25px -5px rgba(0, 0, 0, 0.05);
}
.logo {
width: 48px;
height: 48px;
margin: 0 auto 24px;
background: #111827;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 20px;
letter-spacing: -0.5px;
}
h1 { font-size: 24px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.025em; }
.subtitle { font-size: 15px; color: #6b7280; margin-bottom: 32px; line-height: 1.5; }
.buttons { display: flex; flex-direction: column; gap: 12px; }
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 24px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #ffffff;
color: #374151;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.btn:hover { background: #f9fafb; border-color: #d1d5db; transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); }
.btn:active { transform: translateY(0); box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
.btn svg { width: 20px; height: 20px; flex-shrink: 0; }
.footer { margin-top: 32px; font-size: 13px; color: #9ca3af; line-height: 1.5; }
.footer a { color: #6b7280; text-decoration: none; transition: color 0.2s; }
.footer a:hover { color: #374151; text-decoration: underline; }
.error { margin-top: 16px; font-size: 14px; color: #ef4444; display: none; padding: 12px; background: #fef2f2; border-radius: 8px; border: 1px solid #fecaca; }
@media (prefers-color-scheme: dark) {
body { background: #030712; color: #f9fafb; }
.card { background: #111827; border-color: #1f2937; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.2); }
.logo { background: #ffffff; color: #111827; }
.subtitle { color: #9ca3af; }
.btn { background: #1f2937; border-color: #374151; color: #e5e7eb; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); }
.btn:hover { background: #374151; border-color: #4b5563; }
.footer { color: #6b7280; }
.footer a { color: #9ca3af; }
.footer a:hover { color: #e5e7eb; }
.error { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; }
}
</style>
</head>
<body>
<div class="card">
<div class="logo">Ai</div>
<h1>Sign in to AIRI</h1>
<p class="subtitle">Choose a provider to continue</p>
<div class="buttons">
<button class="btn" onclick="signIn('google', this)">
<svg viewBox="0 0 24 24" fill="none"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Google
</button>
<button class="btn" onclick="signIn('github', this)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/></svg>
GitHub
</button>
</div>
<p class="error" id="error"></p>
<p class="footer">
By continuing, you agree to our
<a href="https://airi.moeru.ai/docs/en/about/terms">Terms</a> and
<a href="https://airi.moeru.ai/docs/en/about/privacy">Privacy Policy</a>.
</p>
</div>
<script>
async function signIn(provider, btn) {
var errorEl = document.getElementById('error');
errorEl.style.display = 'none';
btn.disabled = true;
try {
var res = await fetch('${signInEndpoint}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider: provider,
callbackURL: '${escapedCallbackURL}'
}),
credentials: 'include',
redirect: 'manual'
});
// better-auth returns { url, redirect } for social sign-in
if (res.type === 'opaqueredirect' || res.status === 302) {
window.location.href = res.headers.get('location') || '/';
return;
}
var data = await res.json();
if (data.url) {
window.location.href = data.url;
} else if (data.error) {
throw new Error(data.error.message || data.error);
} else {
throw new Error('Unexpected response');
}
} catch (e) {
errorEl.textContent = e.message || 'Sign in failed';
errorEl.style.display = 'block';
btn.disabled = false;
}
}
</script>
</body>
</html>`
}
@@ -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',
@@ -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<typeof createContext>['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<MainContext>()
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<TokenExchangeResult> {
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<string, unknown>
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,
}
}
@@ -1,4 +1,5 @@
export * from './app'
export * from './auth'
export * from './auto-updater'
export * from './powerMonitor'
export * from './screen'
@@ -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<LoopbackCallbackResult>
close: () => void
}> {
return new Promise((resolveStart, rejectStart) => {
let settled = false
let resultResolve: (value: LoopbackCallbackResult) => void
let resultReject: (reason: Error) => void
const resultPromise = new Promise<LoopbackCallbackResult>((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('<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>')
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('<html><body><h2>Missing parameters</h2></body></html>')
return
}
settled = true
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>')
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()
})
}
@@ -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))
@@ -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'))
@@ -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) => {
@@ -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)
@@ -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)
})
}
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
electronAuthCallback,
electronAuthCallbackError,
electronAuthStartLogin,
electronOpenSettings,
} from '../../../../shared/eventa'
const props = defineProps<{
buttonStyle?: string
iconClass?: string
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const { isAuthenticated, user, needsLogin, credits } = storeToRefs(authStore)
const context = useElectronEventaContext()
const startLogin = useElectronEventaInvoke(electronAuthStartLogin)
const openSettings = useElectronEventaInvoke(electronOpenSettings)
const loggingIn = ref(false)
const userName = computed(() => user.value?.name)
const userAvatar = computed(() => user.value?.image)
function handleClick() {
if (isAuthenticated.value) {
openSettings({ route: '/settings/account' })
}
else {
doLogin()
}
}
function doLogin() {
loggingIn.value = true
startLogin()
}
// Clear loading state on callback or error from main process.
// No cleanup needed — this component lives for the window's lifetime.
context.value.on(electronAuthCallback, () => {
loggingIn.value = false
})
context.value.on(electronAuthCallbackError, () => {
loggingIn.value = false
})
// React to needsLogin from other components (e.g. onboarding)
watch(needsLogin, (val) => {
if (val && !isAuthenticated.value) {
doLogin()
needsLogin.value = false
}
})
// Clear loading when authenticated
watch(isAuthenticated, (val) => {
if (val)
loggingIn.value = false
})
</script>
<template>
<!-- Logging in state -->
<div v-if="loggingIn && !isAuthenticated" flex="~ col gap-1.5" mb-1.5>
<div
flex="~ items-center gap-3"
rounded-xl px-3 py-2.5
bg="black/5 dark:white/5"
>
<div
:class="[
'size-4 shrink-0',
'i-svg-spinners:ring-resize',
'text-primary-500 dark:text-primary-400',
]"
/>
<span text="sm neutral-500 dark:neutral-400" truncate>
{{ t('tamagotchi.stage.controls-island.logging-in') }}
</span>
</div>
</div>
<!-- Authenticated state -->
<div v-else-if="isAuthenticated" flex="~ col gap-1.5" mb-1.5>
<button
type="button"
:class="[
'flex min-w-0 items-center gap-2.5',
'rounded-xl px-2.5 py-2',
'bg-transparent hover:bg-black/5 dark:hover:bg-white/5',
'transition-colors duration-200',
'cursor-pointer border-none outline-none',
'w-full text-left',
props.buttonStyle,
]"
@click="handleClick"
>
<div
:class="[
'size-8 shrink-0 overflow-hidden rounded-full',
'bg-primary-100 dark:bg-primary-900/40',
'flex items-center justify-center',
]"
>
<img
v-if="userAvatar"
:src="userAvatar"
:alt="userName ?? ''"
class="size-full object-cover"
>
<div v-else i-solar:user-check-rounded-bold class="size-4 text-primary-500 dark:text-primary-400" />
</div>
<div class="min-w-0 flex flex-1 flex-col items-start gap-0.5">
<span
:class="[
'w-full truncate',
'text-sm font-semibold',
'text-neutral-800 dark:text-neutral-200',
]"
>
{{ userName }}
</span>
<!-- Flux balance: horizontal pill -->
<div
:class="[
'flex items-center gap-1',
'rounded-md px-1.5 py-0.5',
'bg-primary-500/12 dark:bg-primary-400/12',
'text-[10px] font-semibold',
'text-primary-600 dark:text-primary-400',
]"
>
<div
:class="[
'i-solar:battery-charge-bold-duotone',
'size-3 shrink-0',
]"
/>
<span class="whitespace-nowrap leading-tight">{{ credits }} Flux</span>
</div>
</div>
</button>
</div>
<!-- Not authenticated state -->
<div v-else mb-1.5>
<button
type="button"
:class="[
'flex items-center gap-2.5',
'w-full rounded-xl px-3 py-2.5',
'bg-primary-500/10 hover:bg-primary-500/20',
'dark:bg-primary-400/10 dark:hover:bg-primary-400/20',
'transition-colors duration-200',
'cursor-pointer border-none outline-none',
'text-left',
props.buttonStyle,
]"
@click="handleClick"
>
<div
i-solar:login-3-bold-duotone
:class="[
props.iconClass ?? 'size-4.5',
'shrink-0 text-primary-500 dark:text-primary-400',
]"
/>
<span text="sm primary-600 dark:primary-400" font-medium>
{{ t('tamagotchi.stage.controls-island.login') }}
</span>
</button>
</div>
</template>
@@ -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"
>
<div v-if="expanded" border="1 neutral-200 dark:neutral-800" mb-2 flex flex-col gap-1 rounded-2xl p-2 backdrop-blur-xl class="bg-neutral-100/80 shadow-2xl shadow-black/20 dark:bg-neutral-900/80">
<ControlsIslandAuthButton
:button-style="adjustStyleClasses.button"
:icon-class="adjustStyleClasses.icon"
/>
<div grid grid-cols-3 gap-2>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="openSettings({ route: '/settings' })">
@@ -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()
@@ -0,0 +1,40 @@
<script setup lang="ts">
import AccountSettingsPage from '@proj-airi/stage-pages/pages/settings/account/account-settings-page.vue'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { signOut } from '@proj-airi/stage-ui/libs/auth'
import { useRouter } from 'vue-router'
import { electronAuthLogout, electronAuthStartLogin } from '../../../../shared/eventa'
const router = useRouter()
const startLogin = useElectronEventaInvoke(electronAuthStartLogin)
const logout = useElectronEventaInvoke(electronAuthLogout)
async function handleLogin() {
await startLogin()
}
async function handleLogout() {
await signOut()
await logout()
router.push('/settings')
}
</script>
<template>
<AccountSettingsPage @login="handleLogin" @logout="handleLogout" />
</template>
<route lang="yaml">
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
</route>
@@ -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<void>('eventa:invoke:electron:auth:start-login')
export const electronAuthCallback = defineEventa<ElectronAuthTokens>('eventa:event:electron:auth:callback')
export const electronAuthCallbackError = defineEventa<{ error: string }>('eventa:event:electron:auth:callback-error')
export const electronAuthLogout = defineInvokeEventa<void>('eventa:invoke:electron:auth:logout')
export const i18nSetLocale = defineInvokeEventa<void, Locale>('eventa:invoke:electron:i18n:set-locale')
export const i18nGetLocale = defineInvokeEventa<Locale>('eventa:invoke:electron:i18n:get-locale')
+1
View File
@@ -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:^",
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { applyOIDCTokens, fetchSession } from '@proj-airi/stage-ui/libs/auth'
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const error = ref<string | null>(null)
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const errorParam = url.searchParams.get('error')
if (errorParam) {
error.value = url.searchParams.get('error_description') ?? errorParam
return
}
if (!code || !state) {
error.value = 'Missing authorization code or state'
return
}
const persisted = consumeFlowState()
if (!persisted) {
error.value = 'Missing OIDC flow state — please try logging in again'
return
}
try {
const tokens = await exchangeCodeForTokens(code, persisted.flowState, persisted.params, state)
await applyOIDCTokens(tokens, persisted.params.clientId)
await fetchSession()
router.replace('/')
}
catch (err) {
error.value = err instanceof Error ? err.message : 'Token exchange failed'
}
})
</script>
<template>
<div :class="['min-h-screen', 'flex flex-col items-center justify-center']">
<div v-if="error" :class="['max-w-md', 'text-center']">
<div :class="['text-lg font-semibold', 'text-red-600 dark:text-red-400']">
Authentication failed
</div>
<div :class="['mt-2', 'text-sm text-gray-500']">
{{ error }}
</div>
<a href="/auth/sign-in" :class="['mt-4 inline-block', 'text-sm underline']">
Try again
</a>
</div>
<div v-else :class="['text-center']">
<div :class="['text-lg']">
Signing in...
</div>
</div>
</div>
</template>
@@ -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<Record<OAuthProvider, boolean>>({
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) {
@@ -0,0 +1,36 @@
<script setup lang="ts">
import AccountSettingsPage from '@proj-airi/stage-pages/pages/settings/account/account-settings-page.vue'
import { signOut } from '@proj-airi/stage-ui/libs/auth'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
function handleLogin() {
authStore.needsLogin = true
}
async function handleLogout() {
await signOut()
router.push('/settings')
}
</script>
<template>
<AccountSettingsPage @login="handleLogin" @logout="handleLogout" />
</template>
<route lang="yaml">
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
</route>
@@ -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
@@ -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
@@ -123,6 +123,15 @@ live2d:
microphone: 麦克风
models: 模型
pages:
account:
title: 账户
description: 查看您的个人资料和管理账户
notLoggedIn: 登录以查看您的账户并使用所有功能
login: 登录
logout: 退出登录
fluxBalance: Flux 余额
viewFluxDetails: 查看详情
signedInAs: 已登录为
card:
activate: 激活
active: 已激活
@@ -25,6 +25,10 @@ docs:
'close': 关闭
'expand': 展开
'collapse': 折叠
'login': 登录
'logging-in': 登录中...
'account': 账户
'logout': 退出登录
'status-island':
connected: WebSocket 已连接
disconnected: WebSocket 已断开
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const emit = defineEmits<{
login: []
logout: []
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const { isAuthenticated, user, credits } = storeToRefs(authStore)
const userName = computed(() => user.value?.name ?? '')
const userEmail = computed(() => user.value?.email ?? null)
const userAvatar = computed(() => user.value?.image ?? null)
</script>
<template>
<div :class="['flex flex-col gap-6', 'p-4']">
<template v-if="isAuthenticated">
<div :class="['flex flex-col items-center gap-3', 'rounded-xl p-6', 'bg-neutral-50 dark:bg-neutral-900']">
<div :class="['size-20 rounded-full overflow-hidden', 'bg-neutral-200 dark:bg-neutral-700', 'flex items-center justify-center']">
<img
v-if="userAvatar"
:src="userAvatar"
:alt="userName"
:class="['size-full object-cover']"
>
<div
v-else
:class="['i-solar:user-circle-bold-duotone', 'size-12 text-neutral-400']"
/>
</div>
<div :class="['flex flex-col items-center gap-1']">
<span :class="['text-sm text-neutral-500 dark:text-neutral-400']">
{{ t('settings.pages.account.signedInAs') }}
</span>
<h2 :class="['text-lg font-semibold']">
{{ userName }}
</h2>
<p
v-if="userEmail"
:class="['text-sm text-neutral-500 dark:text-neutral-400']"
>
{{ userEmail }}
</p>
</div>
</div>
<RouterLink
to="/settings/flux"
:class="[
'flex items-center justify-between',
'rounded-xl p-4',
'border border-neutral-200 dark:border-neutral-800',
'hover:bg-neutral-50 dark:hover:bg-neutral-800/50',
'transition-colors',
'no-underline text-inherit',
]"
>
<div :class="['flex items-center gap-3']">
<div :class="['i-solar:battery-charge-bold-duotone', 'size-6 text-primary-500']" />
<div :class="['flex flex-col']">
<span :class="['text-sm font-medium']">
{{ t('settings.pages.account.fluxBalance') }}
</span>
<span :class="['text-2xl font-bold']">
{{ credits }}
</span>
</div>
</div>
<div :class="['flex items-center gap-1', 'text-sm text-neutral-500 dark:text-neutral-400']">
<span>{{ t('settings.pages.account.viewFluxDetails') }}</span>
<div :class="['i-solar:alt-arrow-right-linear', 'size-4']" />
</div>
</RouterLink>
<button
:class="[
'mt-4 w-full rounded-lg py-2.5 px-4',
'text-sm font-medium',
'text-red-600 dark:text-red-400',
'bg-red-500/10 hover:bg-red-500/20',
'border border-red-200 dark:border-red-800/50',
'transition-colors cursor-pointer',
]"
@click="emit('logout')"
>
{{ t('settings.pages.account.logout') }}
</button>
</template>
<template v-else>
<div :class="['flex flex-col items-center gap-6', 'rounded-xl p-8', 'bg-neutral-50 dark:bg-neutral-900']">
<div :class="['i-solar:user-circle-bold-duotone', 'size-16 text-neutral-300 dark:text-neutral-600']" />
<p :class="['text-sm text-neutral-500 dark:text-neutral-400', 'text-center max-w-xs']">
{{ t('settings.pages.account.notLoggedIn') }}
</p>
<button
:class="[
'rounded-lg py-2.5 px-6',
'text-sm font-medium',
'text-white',
'bg-primary-500 hover:bg-primary-600',
'transition-colors cursor-pointer',
]"
@click="emit('login')"
>
{{ t('settings.pages.account.login') }}
</button>
</div>
</template>
</div>
</template>
+1
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
export { base64UrlEncode, generateCodeChallenge, generateCodeVerifier, generateState } from './pkce'
@@ -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)
})
})
+46
View File
@@ -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<string> {
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)
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
})
@@ -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<boolean>('open', { required: true })
@@ -22,8 +23,11 @@ const loading = ref<Record<OAuthProvider, boolean>>({
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')
@@ -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() {
</div>
<div :class="['flex', 'flex-col', 'gap-3', 'md:flex-row']">
<Button
v-if="!isStageTamagotchi()"
v-motion
:initial="{ opacity: 0 }"
:enter="{ opacity: 1 }"
@@ -106,7 +104,7 @@ function handleLocalSetup() {
:enter="{ opacity: 1 }"
:duration="500"
:delay="250"
:variant="isStageTamagotchi() ? 'primary' : 'secondary'"
variant="secondary"
:label="t('settings.dialogs.onboarding.setupWithoutSigningIn')"
:class="['flex-1']"
@click="handleLocalSetup"
@@ -0,0 +1,5 @@
// Centralized OIDC client configuration for the web platform.
// Electron and Pocket have their own configs due to different client IDs and redirect strategies.
export const OIDC_CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID || 'airi-stage-web'
export const OIDC_REDIRECT_URI = `${window.location.origin}/auth/callback`
+171
View File
@@ -0,0 +1,171 @@
import { generateCodeChallenge, generateCodeVerifier, generateState } from '@proj-airi/stage-shared/auth'
import { SERVER_URL } from './server'
// OIDC Authorization Code + PKCE client for all platforms.
const OIDC_AUTHORIZE_PATH = '/api/auth/oauth2/authorize'
const OIDC_TOKEN_PATH = '/api/auth/oauth2/token'
export interface OIDCFlowParams {
clientId: string
redirectUri: string
scopes?: string[]
/**
* Client secret required when the OIDC client is registered as
* confidential on the server. Omit for public clients.
*/
clientSecret?: string
/** Social provider hint — skips the server-side picker page. */
provider?: 'google' | 'github'
}
export interface OIDCFlowState {
codeVerifier: string
state: string
}
/**
* Build the full authorization URL and return the PKCE state that must be
* persisted until the callback arrives.
*/
export async function buildAuthorizationURL(
params: OIDCFlowParams,
): Promise<{ url: string, flowState: OIDCFlowState }> {
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const state = generateState()
const scopes = params.scopes ?? ['openid', 'profile', 'email', 'offline_access']
const url = new URL(OIDC_AUTHORIZE_PATH, SERVER_URL)
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', params.clientId)
url.searchParams.set('redirect_uri', params.redirectUri)
url.searchParams.set('scope', scopes.join(' '))
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', codeChallenge)
url.searchParams.set('code_challenge_method', 'S256')
if (params.provider)
url.searchParams.set('provider', params.provider)
return {
url: url.toString(),
flowState: { codeVerifier, state },
}
}
export interface TokenResponse {
access_token: string
token_type: string
expires_in: number
refresh_token?: string
id_token?: string
scope?: string
}
/**
* Exchange an authorization code for tokens (RFC 6749 S4.1.3).
* Pure function does NOT write to any store. Caller is responsible
* for persisting the returned tokens.
*/
export async function exchangeCodeForTokens(
code: string,
flowState: OIDCFlowState,
params: OIDCFlowParams,
returnedState: string,
): Promise<TokenResponse> {
if (returnedState !== flowState.state)
throw new Error('OIDC state mismatch — possible CSRF attack')
const bodyParams: Record<string, string> = {
grant_type: 'authorization_code',
code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
code_verifier: flowState.codeVerifier,
}
// Confidential clients must send the secret during token exchange.
if (params.clientSecret)
bodyParams.client_secret = params.clientSecret
const body = new URLSearchParams(bodyParams)
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 error = await response.text()
throw new Error(`Token exchange failed: ${response.status} ${error}`)
}
return await response.json()
}
/**
* Refresh an access token using a refresh token (RFC 6749 S6).
* Pure function returns new tokens without writing to any store.
*/
export async function refreshAccessToken(
clientId: string,
refreshToken: string,
clientSecret?: string,
): Promise<TokenResponse> {
const params: Record<string, string> = {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
}
if (clientSecret)
params.client_secret = clientSecret
const body = new URLSearchParams(params)
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)
throw new Error(`Token refresh failed: ${response.status}`)
return await response.json()
}
// Session storage keys for PKCE flow state (survives page navigation during OAuth)
const FLOW_STATE_KEY = 'auth/v1/oidc-flow-state'
const FLOW_PARAMS_KEY = 'auth/v1/oidc-flow-params'
/**
* Persist OIDC flow state before navigating to the authorization server.
*/
export function persistFlowState(flowState: OIDCFlowState, params: OIDCFlowParams): void {
sessionStorage.setItem(FLOW_STATE_KEY, JSON.stringify(flowState))
sessionStorage.setItem(FLOW_PARAMS_KEY, JSON.stringify(params))
}
/**
* Retrieve and clear persisted OIDC flow state after callback.
*/
export function consumeFlowState(): { flowState: OIDCFlowState, params: OIDCFlowParams } | null {
const flowStateRaw = sessionStorage.getItem(FLOW_STATE_KEY)
const paramsRaw = sessionStorage.getItem(FLOW_PARAMS_KEY)
if (!flowStateRaw || !paramsRaw)
return null
sessionStorage.removeItem(FLOW_STATE_KEY)
sessionStorage.removeItem(FLOW_PARAMS_KEY)
return {
flowState: JSON.parse(flowStateRaw),
params: JSON.parse(paramsRaw),
}
}
+58 -36
View File
@@ -1,6 +1,9 @@
import type { OIDCFlowParams, TokenResponse } from './auth-oidc'
import { createAuthClient } from 'better-auth/vue'
import { useAuthStore } from '../stores/auth'
import { buildAuthorizationURL, persistFlowState } from './auth-oidc'
import { SERVER_URL } from './server'
export type OAuthProvider = 'google' | 'github'
@@ -21,20 +24,11 @@ export const authClient = createAuthClient({
// (config.mjs L40), which causes cookies to be sent alongside the Authorization
// header. We override with "omit" so only the Bearer token is used for auth.
// This works because restOfFetchOptions is spread AFTER the default (L47).
// OAuth flow delivers the token via URL query param (`auth_token`) instead.
credentials: 'omit',
auth: {
type: 'Bearer',
token: () => getAuthToken() ?? '',
},
// Capture session token from bearer plugin's `set-auth-token` response header
// (returned on sign-in/sign-up API calls that aren't redirects).
onResponse(context) {
const token = context.response.headers.get('set-auth-token')
if (token) {
useAuthStore().token = token
}
},
},
})
@@ -44,37 +38,39 @@ export function initializeAuth() {
if (initialized)
return
// Pick up auth_token from OAuth callback redirect URL
extractTokenFromURL()
// NOTICE: OIDC callback is handled by the dedicated callback page
// (e.g. /auth/callback). initializeAuth() only restores existing
// sessions and refresh schedules — it does NOT consume the code.
fetchSession().catch(() => {})
// Restore OIDC token refresh scheduling from persisted state
const authStore = useAuthStore()
authStore.restoreRefreshSchedule()
authStore.onTokenRefreshed(async (accessToken) => {
authStore.token = accessToken
await fetchSession()
})
initialized = true
}
/**
* After OAuth callback, the server appends `#auth_token=<token>` to the
* redirect URL. Fragments are never sent to the server, avoiding leakage
* into CDN/proxy logs or Referer headers. Extract it, persist, and clean.
* Persist OIDC tokens locally and schedule refresh.
*/
function extractTokenFromURL() {
const hash = window.location.hash.slice(1) // remove leading '#'
if (!hash)
return
const params = new URLSearchParams(hash)
const token = params.get('auth_token')
if (!token)
return
// Persist through the Pinia store ref so reactive consumers (e.g.
// needsOnboarding) observe the change immediately. Writing to the
// useLocalStorage ref updates both the Vue reactivity system and
// the underlying localStorage entry in one step.
export async function applyOIDCTokens(tokens: TokenResponse, clientId: string): Promise<void> {
const authStore = useAuthStore()
authStore.token = decodeURIComponent(token)
authStore.token = tokens.access_token
if (tokens.refresh_token)
authStore.refreshToken = tokens.refresh_token
// Clean the fragment from the URL to avoid leaking it in browser history
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`)
// Persist client info for refresh after page reload
authStore.oidcClientId = clientId
if (tokens.expires_in)
authStore.tokenExpiry = Date.now() + tokens.expires_in * 1000
authStore.scheduleTokenRefresh(tokens.expires_in)
}
export async function fetchSession() {
@@ -91,6 +87,8 @@ export async function fetchSession() {
authStore.user = null
authStore.session = null
authStore.token = null
authStore.refreshToken = null
authStore.clearOIDCState()
return false
}
@@ -99,17 +97,41 @@ export async function listSessions() {
}
export async function signOut() {
await authClient.signOut()
const authStore = useAuthStore()
authStore.clearOIDCState()
// NOTICE: Server signOut is wrapped in try/catch so that local state cleanup
// always runs regardless of server errors (e.g. network unreachable). User
// intent to log out is respected even if token revocation fails server-side.
try {
await authClient.signOut()
}
catch {
// Swallow — local cleanup below ensures the user is logged out client-side.
}
authStore.user = null
authStore.session = null
authStore.token = null
authStore.refreshToken = null
}
export async function signIn(provider: OAuthProvider) {
return await authClient.signIn.social({
/**
* Initiate OIDC Authorization Code + PKCE login flow.
* Builds the authorization URL, persists PKCE state, and navigates.
*/
export async function signInOIDC(params: OIDCFlowParams) {
const { provider, ...oidcParams } = params
const { url, flowState } = await buildAuthorizationURL(oidcParams)
persistFlowState(flowState, params)
if (!provider) {
window.location.href = url
return
}
await authClient.signIn.social({
provider,
callbackURL: window.location.origin,
callbackURL: url.toString(),
})
}
+125 -8
View File
@@ -1,11 +1,13 @@
import type { Session, User } from 'better-auth'
import { StorageSerializers, useLocalStorage, whenever } from '@vueuse/core'
import { isStageTamagotchi } from '@proj-airi/stage-shared'
import { StorageSerializers, useLocalStorage, useTimeoutFn, whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { client } from '../composables/api'
import { useBreakpoints } from '../composables/use-breakpoints'
import { refreshAccessToken } from '../libs/auth-oidc'
/**
* Auth store holds identity state and credits.
@@ -20,9 +22,15 @@ export const useAuthStore = defineStore('auth', () => {
})
const session = useLocalStorage<Session | null>('auth/v1/session', null, { serializer: StorageSerializers.object })
const token = useLocalStorage<string | null>('auth/v1/token', null)
const refreshToken = useLocalStorage<string | null>('auth/v1/refresh-token', null)
const isAuthenticated = computed(() => !!user.value && !!session.value)
const userId = computed(() => user.value?.id ?? 'local')
// --- OIDC token refresh state ---
// Persisted so refresh scheduling survives page reloads.
const oidcClientId = useLocalStorage<string | null>('auth/v1/oidc-client-id', null)
const tokenExpiry = useLocalStorage<number | null>('auth/v1/oidc-token-expiry', null)
const credits = useLocalStorage<number>('user/v1/flux', 0)
// For controlling the login drawer on mobile
@@ -30,12 +38,18 @@ export const useAuthStore = defineStore('auth', () => {
const { isMobile } = useBreakpoints()
whenever(needsLogin, () => {
if (isMobile.value) {
// On mobile, LoginDrawer handles it via v-model
if (isMobile.value)
return
}
// On Electron, auth is triggered via IPC from controls-island-auth-button.
// Setting needsLogin is a no-op in Electron — the button listens directly.
if (isStageTamagotchi())
return
// On web desktop, redirect to login page
// TODO: type safe, import `useRouter` from router.ts
window.location.href = '/auth/login'
window.location.href = '/auth/sign-in'
})
// Reset status when changing the window viewport
@@ -73,18 +87,112 @@ export const useAuthStore = defineStore('auth', () => {
watch(isAuthenticated, async (val, oldVal) => {
if (val && !oldVal) {
for (const hook of authenticatedHooks) {
try { await hook() }
catch (e) { console.error('auth hook error', e) }
try {
await hook()
}
catch (e) {
console.error('auth hook error', e)
}
}
}
if (!val && oldVal) {
for (const hook of logoutHooks) {
try { await hook() }
catch (e) { console.error('logout hook error', e) }
try {
await hook()
}
catch (e) {
console.error('logout hook error', e)
}
}
}
})
// --- OIDC token refresh scheduling ---
// Uses useTimeoutFn for automatic cleanup on store teardown.
// The delay ref is updated by scheduleTokenRefresh before calling start().
const refreshDelayMs = ref(0)
type TokenRefreshedHook = (accessToken: string) => void | Promise<void>
const tokenRefreshedHooks: TokenRefreshedHook[] = []
const { start: startRefreshTimer, stop: stopRefreshTimer } = useTimeoutFn(
async () => {
if (!refreshToken.value || !oidcClientId.value)
return
try {
const tokens = await refreshAccessToken(oidcClientId.value, refreshToken.value)
token.value = tokens.access_token
if (tokens.refresh_token)
refreshToken.value = tokens.refresh_token
if (tokens.expires_in) {
tokenExpiry.value = Date.now() + tokens.expires_in * 1000
scheduleTokenRefresh(tokens.expires_in)
}
for (const hook of tokenRefreshedHooks) {
try {
await hook(tokens.access_token)
}
catch (e) {
console.error('token refresh hook error', e)
}
}
}
catch {
user.value = null
session.value = null
token.value = null
refreshToken.value = null
oidcClientId.value = null
tokenExpiry.value = null
}
},
refreshDelayMs,
{ immediate: false },
)
function scheduleTokenRefresh(expiresInSeconds: number): void {
stopRefreshTimer()
// Refresh at 80% of lifetime
refreshDelayMs.value = expiresInSeconds * 0.8 * 1000
startRefreshTimer()
}
/**
* Restore refresh scheduling from persisted state after page reload.
*/
function restoreRefreshSchedule(): void {
if (!refreshToken.value || !oidcClientId.value)
return
if (tokenExpiry.value) {
const remainingMs = tokenExpiry.value - Date.now()
if (remainingMs > 0) {
scheduleTokenRefresh(remainingMs / 1000)
return
}
}
// Token already expired or no expiry info — refresh immediately
scheduleTokenRefresh(0)
}
function onTokenRefreshed(hook: TokenRefreshedHook) {
tokenRefreshedHooks.push(hook)
return () => {
const idx = tokenRefreshedHooks.indexOf(hook)
if (idx >= 0)
tokenRefreshedHooks.splice(idx, 1)
}
}
function clearOIDCState(): void {
stopRefreshTimer()
oidcClientId.value = null
tokenExpiry.value = null
}
const updateCredits = async () => {
if (!isAuthenticated.value)
return
@@ -111,11 +219,20 @@ export const useAuthStore = defineStore('auth', () => {
userId,
session,
token,
refreshToken,
isAuthenticated,
credits,
updateCredits,
needsLogin,
onAuthenticated,
onLogout,
// OIDC token refresh
oidcClientId,
tokenExpiry,
scheduleTokenRefresh,
restoreRefreshSchedule,
clearOIDCState,
onTokenRefreshed,
}
})
+1 -1
View File
@@ -43,7 +43,7 @@ export const useOnboardingStore = defineStore('onboarding', () => {
})
// Check if first-time setup should be shown
const skipOnboardingPath = ['/auth/login']
const skipOnboardingPath = ['/auth/sign-in', '/auth/callback']
const needsOnboarding = computed(() =>
!authStore.isAuthenticated
&& !authStore.token
+57 -31
View File
@@ -6,6 +6,9 @@ settings:
catalogs:
default:
'@better-auth/oauth-provider':
specifier: 1.5.6
version: 1.5.6
'@capacitor/android':
specifier: ^8.2.0
version: 8.2.0
@@ -514,7 +517,10 @@ importers:
dependencies:
'@better-auth/drizzle-adapter':
specifier: ^1.5.6
version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))
version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))
'@better-auth/oauth-provider':
specifier: 'catalog:'
version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)))(better-call@1.3.2(zod@4.3.6))
'@dotenvx/dotenvx':
specifier: ^1.57.2
version: 1.57.2
@@ -629,7 +635,7 @@ importers:
devDependencies:
'@better-auth/cli':
specifier: ^1.4.21
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3))
version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3))
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
@@ -1517,6 +1523,9 @@ importers:
'@proj-airi/stage-layouts':
specifier: workspace:^
version: link:../../packages/stage-layouts
'@proj-airi/stage-pages':
specifier: workspace:^
version: link:../../packages/stage-pages
'@proj-airi/stage-shared':
specifier: workspace:^
version: link:../../packages/stage-shared
@@ -4482,6 +4491,15 @@ packages:
mongodb:
optional: true
'@better-auth/oauth-provider@1.5.6':
resolution: {integrity: sha512-DSkdC4GLwUiDwwd4hxQZa+aqzFoMXQV0Im3k4igzGIW6VB2S0p0CIR+YCOxJP7lAP+1eepxvjTfG07sTXzu2Bw==}
peerDependencies:
'@better-auth/core': 1.5.6
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.6
better-call: 1.3.2
'@better-auth/prisma-adapter@1.5.6':
resolution: {integrity: sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==}
peerDependencies:
@@ -18405,13 +18423,13 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3))':
'@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/preset-react': 7.28.5(@babel/core@7.29.0)
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))
'@better-auth/utils': 0.3.0
'@clack/prompts': 0.11.0
'@mrleebo/prisma-ast': 0.13.1
@@ -18488,14 +18506,12 @@ snapshots:
nanostores: 1.1.1
zod: 4.3.6
'@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)':
'@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)':
dependencies:
'@better-auth/utils': 0.3.1
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@opentelemetry/api': 1.9.1
'@opentelemetry/semantic-conventions': 1.40.0
'@standard-schema/spec': 1.1.0
better-call: 1.1.8(zod@4.3.6)
better-call: 1.3.2(zod@4.3.6)
jose: 6.1.3
kysely: 0.28.14
nanostores: 1.1.1
@@ -18514,46 +18530,56 @@ snapshots:
nanostores: 1.1.1
zod: 4.3.6
'@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))':
'@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))':
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
optionalDependencies:
drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)
'@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)':
'@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)':
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
optionalDependencies:
kysely: 0.28.14
'@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
'@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
'@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0)':
'@better-auth/oauth-provider@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)))(better-call@1.3.2(zod@4.3.6))':
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3))
better-call: 1.3.2(zod@4.3.6)
jose: 6.1.3
zod: 4.3.6
'@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0)':
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
optionalDependencies:
'@prisma/client': 5.22.0
'@better-auth/telemetry@1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))':
'@better-auth/telemetry@1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))':
dependencies:
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))':
'@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))':
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
@@ -24710,7 +24736,7 @@ snapshots:
better-auth@1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)):
dependencies:
'@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))
'@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))
'@better-auth/utils': 0.3.0
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
@@ -24734,12 +24760,12 @@ snapshots:
better-auth@1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)):
dependencies:
'@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)
'@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))
'@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)
'@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0)
'@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))
'@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))
'@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)
'@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)
'@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0)
'@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.1.1
+1 -1
View File
@@ -19,7 +19,6 @@ overrides:
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
patchedDependencies:
'@mediapipe/tasks-vision': patches/@mediapipe__tasks-vision.patch
crossws@0.4.4: patches/crossws@0.4.4.patch
@@ -28,6 +27,7 @@ patchedDependencies:
pixi-live2d-display: patches/pixi-live2d-display.patch
srvx: patches/srvx.patch
catalog:
'@better-auth/oauth-provider': 1.5.6
'@capacitor/android': ^8.2.0
'@capacitor/cli': ^8.2.0
'@capacitor/core': ^8.2.0
+3
View File
@@ -4,6 +4,9 @@
"build": {
"outputs": ["dist/**"]
},
"@proj-airi/server-schema#build": {
"cache": false
},
"@proj-airi/electron-vueuse#build": {
"dependsOn": ["@proj-airi/electron-eventa#build"],
"outputs": ["dist/**"]
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
'packages/plugin-sdk',
'packages/server-runtime',
'packages/server-sdk',
'packages/stage-shared',
'packages/stage-ui',
'packages/vite-plugin-warpdrive',
],