mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
feat(auth): OIDC (#1531)
This commit is contained in:
@@ -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 Provider(Authorization Server)**,为 Web、Electron Desktop、Capacitor Mobile 三个客户端提供 Authorization Code + PKCE 登录流程。所有客户端通过 OIDC session bridge 将 OIDC access token 换取 better-auth session token,使用 Bearer token 鉴权。
|
||||
|
||||
## 架构角色
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 社交登录 IdP (Google, GitHub) │
|
||||
└──────────────┬──────────────────┘
|
||||
↓ OAuth 2.0
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ AIRI Server (better-auth OIDC Provider) │
|
||||
│ │
|
||||
│ /api/auth/oauth2/authorize ← PKCE 授权 │
|
||||
│ /api/auth/oauth2/token ← Code 换 Token │
|
||||
│ /api/auth/oidc/session ← OIDC→Session 桥接 │
|
||||
│ /api/auth/oidc/electron-callback ← 回调中继页 │
|
||||
│ /api/auth/sign-in/social ← 社交登录入口 │
|
||||
│ /sign-in ← 登录选择页 │
|
||||
└──────────────┬──────────────────┬────────────────┘
|
||||
↓ ↓
|
||||
┌──────────┐ ┌──────────────┐
|
||||
│ Stage Web │ │ Stage Electron│
|
||||
│ /auth/ │ │ 127.0.0.1: │
|
||||
│ callback │ │ {port}/ │
|
||||
└──────────┘ │ callback │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### Server 端
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/libs/auth.ts` | better-auth 配置:社交 provider、OIDC provider 插件、trusted clients 种子数据、session/cookie 策略 |
|
||||
| `src/routes/auth/index.ts` | 所有鉴权路由的统一入口:sign-in 页、rate limiter、OIDC session bridge、electron callback、well-known metadata、better-auth catch-all |
|
||||
| `src/routes/oidc/session.ts` | OIDC→Session 桥接端点:验证 OIDC access token → 创建 better-auth session → 返回 session token |
|
||||
| `src/routes/oidc/electron-callback.ts` | Electron 回调中继页:服务端 HTML 页面通过 JS fetch() 将 auth code 转发到 Electron 本地 loopback |
|
||||
| `src/utils/sign-in-page.ts` | 渲染 fallback HTML 登录页(Google/GitHub 按钮) |
|
||||
| `src/utils/origin.ts` | 可信来源配置:`localhost`、`127.0.0.1`、`airi.moeru.ai`、`capacitor://localhost` |
|
||||
| `src/libs/env.ts` | OIDC 相关环境变量定义(Valibot schema) |
|
||||
|
||||
### Client 端
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `packages/stage-ui/src/libs/auth-oidc.ts` | OIDC 协议实现:构建 authorize URL、PKCE 生成、code 换 token、token 刷新、flow state 持久化 |
|
||||
| `packages/stage-ui/src/libs/auth.ts` | 高层鉴权编排:`signInOIDC()` 发起登录、`bridgeOIDCTokens()` 桥接 token、`fetchSession()` 同步会话、自动刷新调度 |
|
||||
| `packages/stage-ui/src/stores/auth.ts` | Pinia auth store:持久化 `user`、`session`、`token`、`refreshToken` 到 localStorage |
|
||||
| `packages/stage-shared/src/auth/pkce.ts` | PKCE 工具函数:`generateCodeVerifier()`、`generateCodeChallenge()`、`generateState()` |
|
||||
| `apps/stage-web/src/pages/auth/callback.vue` | Web 回调页:提取 code → 换 token → bridge → fetchSession → 跳转首页 |
|
||||
| `apps/stage-web/src/pages/auth/sign-in.vue` | Web 登录页:调用 `signInOIDC()` 发起 OIDC 流程 |
|
||||
|
||||
### Trusted Clients
|
||||
|
||||
| Client | ID 环境变量 | redirect_uri | 类型 |
|
||||
|--------|------------|--------------|------|
|
||||
| Web | `OIDC_CLIENT_ID_WEB` | `https://airi.moeru.ai/auth/callback`, `http://localhost:5173/auth/callback` | web |
|
||||
| Electron | `OIDC_CLIENT_ID_ELECTRON` | `{API_SERVER_URL}/api/auth/oidc/electron-callback`(服务端中继) | native |
|
||||
| Mobile | `OIDC_CLIENT_ID_POCKET` | `capacitor://localhost/auth/callback` | native |
|
||||
|
||||
### 环境变量
|
||||
|
||||
```
|
||||
# 社交 Provider
|
||||
AUTH_GOOGLE_CLIENT_ID, AUTH_GOOGLE_CLIENT_SECRET
|
||||
AUTH_GITHUB_CLIENT_ID, AUTH_GITHUB_CLIENT_SECRET
|
||||
|
||||
# OIDC Trusted Clients(均 optional,不配则不注册)
|
||||
# Web and Pocket are public clients (no secret, PKCE only)
|
||||
OIDC_CLIENT_ID_WEB
|
||||
OIDC_CLIENT_ID_ELECTRON, OIDC_CLIENT_SECRET_ELECTRON
|
||||
OIDC_CLIENT_ID_POCKET
|
||||
```
|
||||
|
||||
## Token 层次
|
||||
|
||||
| Token | 用途 | 存储位置 | 生命周期 |
|
||||
|-------|------|---------|---------|
|
||||
| Authorization Code | 一次性换 token | URL query param (`?code=`) | 极短,一次性 |
|
||||
| OIDC Access Token | 换 session token(bridge) | 不持久化,仅在回调流程中使用 | 由 oauthProvider 控制 |
|
||||
| OIDC Refresh Token | 刷新 access token | localStorage `auth/v1/refresh-token` | 长期 |
|
||||
| Session Token | 实际的 API 鉴权凭证(Bearer) | localStorage `auth/v1/token` | 由 better-auth session 控制 |
|
||||
|
||||
**为什么不直接用 OIDC access token?** 因为 better-auth 的 session 系统使用自己的 `session` 表,OIDC access token 存在 `oauth_access_token` 表中,两者不兼容。所有 API 路由通过 `sessionMiddleware` 查 `session` 表鉴权,所以需要 bridge 端点做转换。
|
||||
|
||||
**为什么不用 cookie?** 客户端和服务端跨域(如 `localhost:5173` vs `localhost:3000`),cookie 无法跨域传递。客户端 `credentials: 'omit'`,纯 Bearer token 鉴权。
|
||||
|
||||
## 登录流程
|
||||
|
||||
### Web 完整流程
|
||||
|
||||
```
|
||||
Client (localhost:5173) Server (localhost:3000) Social IdP
|
||||
│ │ │
|
||||
1. signInOIDC() │ │
|
||||
构建 PKCE (verifier + challenge) │ │
|
||||
存 sessionStorage │ │
|
||||
window.location → │ │
|
||||
│ │ │
|
||||
2. GET /api/auth/oauth2/authorize │ │
|
||||
?response_type=code │ │
|
||||
&client_id=airi-stage-web │ │
|
||||
&redirect_uri=localhost:5173/auth/callback │ │
|
||||
&code_challenge=xxx │ │
|
||||
&provider=github │ │
|
||||
│ │ │
|
||||
│ 3. 用户未登录 │
|
||||
│ 302 → /sign-in?...所有 OIDC 参数... │
|
||||
│ │ │
|
||||
│ 4. /sign-in 看到 provider=github │
|
||||
│ 重建 callbackURL = /api/auth/oauth2/authorize?... │
|
||||
│ 302 → /api/auth/sign-in/social │
|
||||
│ ?provider=github │
|
||||
│ &callbackURL={OIDC authorize URL} │
|
||||
│ │ │
|
||||
│ ──────── 302 to GitHub ───────────────► │
|
||||
│ │ 5. 用户授权
|
||||
│ │ ◄──────── callback ────────────────── │
|
||||
│ │ │
|
||||
│ 6. better-auth 创建 user + session(server cookie) │
|
||||
│ 302 → callbackURL(= OIDC authorize) │
|
||||
│ │ │
|
||||
│ 7. /api/auth/oauth2/authorize │
|
||||
│ 用户已有 session → 签发 authorization code │
|
||||
│ 302 → redirect_uri?code=xxx&state=xxx │
|
||||
│ │ │
|
||||
8. /auth/callback │ │
|
||||
consumeFlowState() 恢复 PKCE │ │
|
||||
验证 state 防 CSRF │ │
|
||||
│ │ │
|
||||
9. POST /api/auth/oauth2/token ──────────────► │ │
|
||||
(code + code_verifier + client_id) │ │
|
||||
◄──── { access_token, refresh_token } ────── │ │
|
||||
│ │ │
|
||||
10. POST /api/auth/oidc/session ──────────────► │ │
|
||||
(Bearer: access_token) │ │
|
||||
◄──── { token: session_token } ───────────── │ │
|
||||
│ │ │
|
||||
11. GET /api/auth/get-session ─────────────────► │ │
|
||||
(Bearer: session_token) │ │
|
||||
◄──── { user, session } ──────────────────── │ │
|
||||
│ │ │
|
||||
12. 写入 authStore → 跳转首页 │ │
|
||||
```
|
||||
|
||||
**关键设计:callbackURL 传递 OIDC 参数**
|
||||
|
||||
`/sign-in` 路由收到的 URL 包含所有 OIDC 授权参数(`response_type`、`client_id`、`redirect_uri`、`code_challenge` 等)。它将这些参数重建为完整的 OIDC authorize URL,作为 `callbackURL` 传给社交登录。社交登录完成后,用户被重定向回 OIDC authorize 端点,此时用户已有 server session,OIDC 流程继续签发 code。
|
||||
|
||||
### Electron 特殊处理
|
||||
|
||||
Electron 不使用自定义协议(`airi://`),而是在 main process 临时启动一个 HTTP server 监听 `127.0.0.1:{port}/callback`:
|
||||
- 固定端口范围:19721-19725,按顺序尝试
|
||||
- 收到回调后立即关闭 server
|
||||
- 5 分钟超时安全机制
|
||||
|
||||
**服务端回调中继**:
|
||||
|
||||
Electron 的 OIDC redirect_uri 不再直接指向 loopback 端口,而是指向服务端的 `/api/auth/oidc/electron-callback`。这个端点返回一个 HTML 页面,页面通过 JS `fetch()` 将 auth code 转发到本地 loopback。
|
||||
|
||||
好处:
|
||||
- 浏览器不显示 `http://127.0.0.1:19721/...` 这样的 URL
|
||||
- 只需注册一个 redirect_uri(不再需要 5 个端口对应的 URL)
|
||||
- Loopback server 需要设置 CORS `Access-Control-Allow-Origin: *`
|
||||
|
||||
端口编码方式:loopback 端口编码在 `state` 参数中,格式为 `{port}:{originalState}`。中继页面提取端口后,将 code 和原始 state 通过 fetch 发送到 `http://127.0.0.1:{port}/callback`。
|
||||
|
||||
### OIDC→Session 桥接
|
||||
|
||||
better-auth 的 `oidcProvider` 插件发出的 OIDC access token 存在 `oauth_access_token` 表中,与 better-auth session(`session` 表)不兼容。所有客户端(Web、Electron、Mobile)登录后都需要调用 `POST /api/auth/oidc/session` 桥接端点:
|
||||
|
||||
1. 验证 Bearer token 对应 `oauth_access_token` 表中的有效记录
|
||||
2. 验证 `clientId` 属于受信任的客户端集合(Web、Electron、Pocket 均可)
|
||||
3. 验证 `accessTokenExpiresAt > now`
|
||||
4. 通过 `(await auth.$context).internalAdapter.createSession(userId)` 创建 better-auth session
|
||||
5. 缓存 `oidc_token → session_token` 映射(TTL 5 分钟,幂等)
|
||||
6. TTL 过期后删除 `oauth_access_token` 行
|
||||
|
||||
安全措施:
|
||||
- 客户端 ID 限制(仅受信任客户端)
|
||||
- Token 过期检查
|
||||
- 幂等 + TTL 限制重放窗口
|
||||
- 统一 401 响应(防止信息泄漏)
|
||||
|
||||
### 自动 Token 刷新
|
||||
|
||||
客户端在 OIDC token 生命周期 80% 时自动调用 `/api/auth/oauth2/token`(`grant_type=refresh_token`),刷新后重新 bridge 获取新 session token。页面重载后从 localStorage 恢复刷新调度:
|
||||
|
||||
- `auth/v1/oidc-client-id` — 客户端 ID
|
||||
- `auth/v1/oidc-client-secret` — 客户端 Secret
|
||||
- `auth/v1/oidc-token-expiry` — Token 过期时间戳
|
||||
|
||||
### provider 参数直通
|
||||
|
||||
客户端在 authorize URL 中附带 `provider` 参数,server 的 `/sign-in` 路由会直接 302 到对应社交 provider,**跳过选择页**。没有 `provider` 参数时 fallback 到 HTML 选择页(兜底场景,如直接浏览器访问)。
|
||||
|
||||
## 路由注册顺序
|
||||
|
||||
Auth 路由集中在 `src/routes/auth/index.ts`,通过 `.route('/', authRoutes)` 挂载到根路径。路由注册顺序很重要:
|
||||
|
||||
1. `GET /sign-in` — 登录选择页(或直接 302 到社交 provider)
|
||||
2. `USE /api/auth/*` — rate limiter(IP 限流)
|
||||
3. `.route('/api/auth/oidc/session')` — session bridge(在 catch-all 之前注册)
|
||||
4. `.route('/api/auth/oidc/electron-callback')` — electron 回调中继
|
||||
5. `GET /.well-known/oauth-authorization-server/api/auth` — OAuth 2.1 AS metadata
|
||||
6. `GET /api/auth/.well-known/openid-configuration` — OIDC discovery
|
||||
7. `['POST', 'GET'] /api/auth/*` — **catch-all**,将所有其他请求转发给 `auth.handler()`
|
||||
|
||||
自定义 OIDC 路由(`/api/auth/oidc/*`)注册在 catch-all 之前,所以不会被 better-auth 拦截。`/api/auth/oauth2/authorize` 和 `/api/auth/oauth2/token` 等标准端点由 catch-all 转发给 better-auth 内部处理。
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
### better-auth redirect_uri 精确匹配
|
||||
|
||||
better-auth 的 OIDC 插件对 `redirect_uri` 做**精确字符串匹配**(`authorize.mjs`):
|
||||
|
||||
```javascript
|
||||
client.redirectUrls.find(url => url === ctx.query.redirect_uri)
|
||||
```
|
||||
|
||||
RFC 8252 S7.3 要求 Authorization Server 对 loopback 地址允许任意端口,但 better-auth 不支持。因此 Electron 使用服务端中继 URL 作为 redirect_uri,绕过了端口匹配问题。
|
||||
|
||||
### better-auth cookie 与 Bearer 共存
|
||||
|
||||
better-auth client 默认 `credentials: "include"`,会同时发送 cookie。我们 override 为 `credentials: "omit"`,只使用 Bearer token 认证。见 `packages/stage-ui/src/libs/auth.ts` 的 NOTICE 注释。
|
||||
|
||||
### skipStateCookieCheck
|
||||
|
||||
Capacitor 移动端无法正确处理 state cookie(系统浏览器和 WebView cookie jar 隔离),所以 better-auth 配置了 `skipStateCookieCheck: true`。PKCE 仍然提供 CSRF 防护。
|
||||
|
||||
### better-auth internalAdapter
|
||||
|
||||
`(await auth.$context).internalAdapter.createSession(userId)` 是创建 session 的正确路径。`auth.api` 是 HTTP endpoint handlers 的集合,没有 `createSession` 方法。参考 better-auth admin 插件和 test-utils 的用法。注意 `createAuth()` 返回 `any`(TS2742),需要无类型安全地访问 `$context`。
|
||||
|
||||
### OIDC 流程中断:callbackURL 必须指回 authorize
|
||||
|
||||
社交登录完成后,`callbackURL` 必须指向 `/api/auth/oauth2/authorize?...OIDC参数...`,否则用户会被重定向到服务端根路径,OIDC 授权码流程中断。`/sign-in` 路由从 URL query params 重建完整的 OIDC authorize URL 作为 `callbackURL`。
|
||||
|
||||
## 修改指南
|
||||
|
||||
- 新增 OIDC client → `src/libs/auth.ts` 的 `buildTrustedClientSeeds`,加环境变量到 `src/libs/env.ts`
|
||||
- 改登录页 → `src/utils/sign-in-page.ts`(HTML),或 `src/routes/auth/index.ts` 的 `/sign-in` 路由
|
||||
- 改认证中间件 → `src/app.ts` 的 session middleware
|
||||
- 改 trusted origins → `src/utils/origin.ts`
|
||||
- 改桥接端点 → `src/routes/oidc/session.ts`
|
||||
- 改回调中继 → `src/routes/oidc/electron-callback.ts`
|
||||
- 改 Auth 路由结构 → `src/routes/auth/index.ts`
|
||||
- 调试 OIDC 流程 → 检查 `/sign-in` 的 callbackURL 是否正确重建,以及 `oidc_login_prompt` cookie
|
||||
- Client 端登录逻辑 → `packages/stage-ui/src/libs/auth.ts` 和 `packages/stage-ui/src/libs/auth-oidc.ts`
|
||||
- Electron 认证回调处理 → `apps/stage-tamagotchi/src/renderer/bridges/electron-auth-callback.ts`
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -57,6 +57,13 @@
|
||||
"when": 1774632446757,
|
||||
"tag": "0007_red_nicolaos",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1775032828818,
|
||||
"tag": "0008_gray_xavin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -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] : [])
|
||||
})
|
||||
}
|
||||
@@ -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],
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+183
@@ -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')
|
||||
|
||||
|
||||
@@ -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>
|
||||
+16
-2
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+57
-31
@@ -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
@@ -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
|
||||
|
||||
@@ -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/**"]
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user