@homurajs/auth 26.10.14 → 26.10.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,57 +1,134 @@
1
1
  # @homurajs/auth
2
2
 
3
- 认证:通用标准 OAuth2 (Authorization Code Flow) 客户端 + 自管 session(HS256 短 JWT + opaque refresh rotate + 族吊销)。
3
+ 多凭证身份认证:OAuth2 客户端 + TextPassword(scrypt)+ WebAuthn(可选)+ 自管 session(HS256 短 JWT + opaque refresh rotate + 族吊销)。
4
4
 
5
- session store 有两版实现,接口语义严格对齐,按数据方言选:
5
+ ## 架构
6
6
 
7
- | 实现 | 用于 | 说明 |
8
- | ------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
9
- | `createDrizzleSessionStore(db)` | `sqlite` / `pg` | 原生 SQL,`UPDATE...WHERE used_at IS NULL RETURNING` 做原子抢占 |
10
- | `createRestSessionStore(rest)` | `rest`(qw-pages) | PostgREST:`PATCH ?token_hash=eq&expires_at=gt&used_at=is.null` + `return=representation`,返回空数组即抢占失败 |
7
+ **身份模型(ADR-022)**:`users` 表是本地身份锚点,三种凭证都通过 `user_id` 关联:
8
+
9
+ - `passwords`:1:1 users,scrypt 哈希(Node 内置 crypto,零依赖,ADR-023)
10
+ - `oauth2_identity_links`:1:N users,UNIQUE(provider, provider_sub)
11
+ - `webauthn_credentials`:1:N users,PK = credentialId base64url
12
+
13
+ **session.sub = local user ID**(Breaking:不再是 IdP subject)。Identity Resolver 在 OAuth2 callback 时桥接 IdP sub → local user,JIT 默认开启(首次登录自动建户),可配 false 拒绝未知身份。
14
+
15
+ **SDK 不渲染 HTML**:所有路由只返回 JSON + cookies,登录 UI 由 App 自行实现。
16
+
17
+ ## 存储实现
18
+
19
+ 所有 store 都有双版本,接口语义严格对齐,按数据方言选:
20
+
21
+ | 实现 | 用于 | 说明 |
22
+ | ----------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
23
+ | `createDrizzleSessionStore(db)` | `sqlite` / `pg` | 原生 SQL,`UPDATE...WHERE used_at IS NULL RETURNING` 做原子抢占 |
24
+ | `createRestSessionStore(rest)` | `rest`(qw-pages) | PostgREST:`PATCH ?token_hash=eq&expires_at=gt&used_at=is.null` + `return=representation`,返回空数组即抢占失败 |
25
+ | `createDrizzleUserStore(db)` | `sqlite` / `pg` | users 表 CRUD |
26
+ | `createRestUserStore(rest)` | `rest` | PostgREST |
27
+ | `createDrizzleOAuth2LinkStore(db)` | `sqlite` / `pg` | oauth2_identity_links 表 CRUD |
28
+ | `createRestOAuth2LinkStore(rest)` | `rest` | PostgREST |
29
+ | `createDrizzlePasswordStore(db)` | `sqlite` / `pg` | passwords 表 CRUD |
30
+ | `createRestPasswordStore(rest)` | `rest` | PostgREST |
31
+ | `createDrizzleWebauthnCredentialStore(db)` | `sqlite` / `pg` | webauthn_credentials 表 CRUD |
32
+ | `createRestWebauthnCredentialStore(rest)` | `rest` | PostgREST |
33
+
34
+ ## 使用示例
35
+
36
+ ### OAuth2 + Identity Resolver
11
37
 
12
38
  ```ts
13
- // OAuth 预设来自 @homurajs/3p(结构化契约,鸭子类型互操作)
14
39
  import { dingtalkOAuth } from '@homurajs/3p/dingtalk'
15
40
  import {
16
41
  OAuth2Client,
17
42
  SessionManager,
18
43
  createDrizzleSessionStore,
19
- createRestSessionStore,
44
+ createDrizzleUserStore,
45
+ createDrizzleOAuth2LinkStore,
46
+ createDefaultIdentityResolver,
47
+ createMemoryTtlKv,
20
48
  } from '@homurajs/auth'
21
- import { createMemoryTtlKv } from '@homurajs/core'
49
+ import { createAuthRoutes, sessionMiddleware } from '@homurajs/auth/hono'
22
50
 
23
- const preset = dingtalkOAuth({ orgId: 'my_org' }) // 对接 ../oauth2-dingtalk
51
+ const preset = dingtalkOAuth({ orgId: 'my_org' })
24
52
  const client = new OAuth2Client({
25
53
  clientId: '...',
26
54
  clientSecret: '...',
27
- redirectUri: 'https://app.example/auth/callback',
28
- stateKv: createMemoryTtlKv(), // SQL 单机够用;多实例换 createDbTtlKv;qw-pages 换 createRestTtlKv
29
- ...preset, // endpoints + mapUser
55
+ redirectUri: 'https://app.example/auth/oauth2/callback',
56
+ stateKv: createMemoryTtlKv(),
57
+ ...preset,
30
58
  })
31
59
 
32
- const store = isRest
33
- ? createRestSessionStore(rest) // qw-pages
34
- : createDrizzleSessionStore(db) // standalone
35
- const sessions = new SessionManager(
36
- { secret: SESSION_SECRET /*>=32B*/, accessTokenTtlSec: 7200, refreshTokenTtlSec: 30 * 24 * 3600 },
37
- store,
38
- )
60
+ const userStore = createDrizzleUserStore(db)
61
+ const linkStore = createDrizzleOAuth2LinkStore(db)
62
+ const identityResolver = createDefaultIdentityResolver(userStore, linkStore, { jit: true })
63
+
64
+ const sessions = new SessionManager({ secret: SESSION_SECRET }, createDrizzleSessionStore(db))
39
65
 
40
- // Hono 集成
41
- import { createAuthRoutes, sessionMiddleware, requireAuth } from '@homurajs/auth/hono'
42
66
  app.use('*', sessionMiddleware(sessions, { secure }))
43
- app.route('/', createAuthRoutes({ client, sessions, secure }))
44
- app.use('/api/*', requireAuth)
45
- app.get('/api/me', (c) => c.json(c.get('auth')))
67
+ app.route('/', createAuthRoutes({
68
+ sessions,
69
+ oauth2: { client, identityResolver, provider: 'dingtalk' },
70
+ }))
46
71
  ```
47
72
 
48
- **`secure` 必须按形态传**:qw-pages 在平台网关后、对外是 https,`secure` 应为 `true`,否则 cookie 不带 `Secure`。模板里由 `adapter.trustProxy()` 接线(standalone 由 `TRUST_PROXY=1` 控制)。
73
+ ### TextPassword
74
+
75
+ ```ts
76
+ import { PasswordProvider, createDrizzlePasswordStore } from '@homurajs/auth'
77
+
78
+ const passwordProvider = new PasswordProvider({
79
+ userStore,
80
+ passwordStore: createDrizzlePasswordStore(db),
81
+ })
82
+
83
+ app.route('/', createAuthRoutes({
84
+ sessions,
85
+ password: passwordProvider,
86
+ allowRegister: true, // 默认 true,可配 false 禁止自助注册
87
+ }))
88
+ ```
89
+
90
+ 路由:
91
+ - `POST /auth/password/register` → session cookies + 200 JSON
92
+ - `POST /auth/password/login` → session cookies + 200 JSON
93
+ - `POST /auth/password/change` → 需已登录 → 200 JSON
94
+ - `POST /auth/password/delete` → 需已登录 → 200 JSON
95
+
96
+ ### WebAuthn(可选)
97
+
98
+ ```ts
99
+ import { WebAuthProvider, createDrizzleWebauthnCredentialStore } from '@homurajs/auth/webauthn'
100
+ import { createWebAuthnRoutes } from '@homurajs/auth/webauthn'
101
+
102
+ const webauthn = new WebAuthProvider({
103
+ rpName: 'My App',
104
+ rpID: 'app.example.com',
105
+ origin: 'https://app.example.com',
106
+ userStore,
107
+ credentialStore: createDrizzleWebauthnCredentialStore(db),
108
+ challengeKv: createMemoryTtlKv(),
109
+ })
110
+
111
+ app.route('/', createWebAuthnRoutes({ webauthn, sessions }))
112
+ ```
113
+
114
+ `@simplewebauthn/server` 是 optional peer dep,不用 WebAuthn 的 App 无需安装。
115
+
116
+ ## 子路径导出
117
+
118
+ - `@homurajs/auth`:核心(OAuth2Client, SessionManager, PasswordProvider, IdentityResolver, 各 store 工厂)
119
+ - `@homurajs/auth/hono`:Hono 路由(createAuthRoutes, createPasswordRoutes, sessionMiddleware, requireAuth)
120
+ - `@homurajs/auth/tables`:表定义(pgAuthTables, sqliteAuthTables,含 5 张表)
121
+ - `@homurajs/auth/webauthn`:WebAuthn(WebAuthProvider, createWebAuthnRoutes, credential store 工厂)
122
+
123
+ ## 注意事项
49
124
 
50
- **qw-pages 上的两个前置条件**:
125
+ **`secure` 必须按形态传**:qw-pages 在平台网关后、对外是 https,`secure` 应为 `true`,否则 cookie 不带 `Secure`。模板里由 `adapter.trustProxy()` 接线。
51
126
 
52
- 1. `refresh_tokens` 表与 anon RLS policy 必须在部署期用平台受控迁移建好(App 无 DDL 权限)。缺 policy 时 PostgREST 是静默失败——`SELECT` 返回 `200 []`、`INSERT` 返回 401 + `42501`。
53
- 2. `SESSION_SECRET` 不能用仓库里的公开缺省值(模板 `loadConfig` 会在 Pages/生产形态下直接拒绝启动)。密钥经 `config/homura.config.env` 随产物携带——实测产物不可被公网读取。
127
+ **qw-pages 前置条件**:所有 auth 表(users / passwords / oauth2_identity_links / webauthn_credentials / refresh_tokens)与 anon RLS policy 必须在部署期用平台受控迁移建好(App 无 DDL 权限)。缺 policy 时 PostgREST 是静默失败。
54
128
 
55
- `consume` 的原子抢占语义已在真实 PostgREST 上验证:三路并发抢占恰好一个赢家、过期 token 不会被误报为复用、族吊销只命中未消费行。
129
+ **Breaking(2026-10-14)**:
130
+ - session.sub 从 IdP subject 改为 local user ID(现有 refresh token 全部失效,重登即恢复)
131
+ - OAuth2 路径从 `/auth/login` + `/auth/callback` 迁移到 `/auth/oauth2/authorize` + `/auth/oauth2/callback`
132
+ - `createAuthRoutes` 签名变更:`client` → `oauth2: { client, identityResolver, provider }`
56
133
 
57
- 上游契约与 state 语义见 `docs/specs/module-oauth2`;session 状态机见 `module-session`。使用示范见模板 `src/server/app.ts`(buildAuth)与 `src/server/routes/api.ts`(`c.get('auth')`)。
134
+ `consume` 的原子抢占语义已在真实 PostgREST 上验证。详见 `docs/specs/module-oauth2`、`module-session`、`module-password`、`module-webauthn`、ADR-022、ADR-023。使用示范见模板 `src/server/app.ts`(buildAuth)。
package/dist/hono.d.ts CHANGED
@@ -1,29 +1,68 @@
1
- import { l as OAuth2Client, n as SessionManager, t as SessionClaims } from "./manager-_5q5sluE.js";
1
+ import { o as SessionClaims, s as SessionManager } from "./types-C-SlEMFJ.js";
2
+ import { d as OAuth2Client, s as IdentityResolver, t as PasswordProvider } from "./provider-BkUYQ703.js";
2
3
  import { Hono, MiddlewareHandler } from "hono";
3
4
  //#region src/hono/routes.d.ts
4
- interface AuthPaths {
5
- login?: string;
6
- callback?: string;
7
- refresh?: string;
8
- logout?: string;
5
+ interface OAuth2Inject {
6
+ client: OAuth2Client;
7
+ identityResolver: IdentityResolver;
8
+ /** OAuth2 provider 标识(如 'dingtalk') */
9
+ provider: string;
9
10
  }
10
11
  interface AuthRoutesOptions {
11
- client: OAuth2Client;
12
12
  sessions: SessionManager;
13
- paths?: AuthPaths;
13
+ /** OAuth2 能力(不传则不挂载 OAuth2 路由) */
14
+ oauth2?: OAuth2Inject;
15
+ /** Password 能力(不传则不挂载 Password 路由) */
16
+ password?: PasswordProvider;
17
+ /** 是否允许自助注册(默认 true) */
18
+ allowRegister?: boolean;
14
19
  /** cookie Secure(qw-pages/https 传 true) */
15
20
  secure?: boolean;
16
- /** 登录成功后的缺省跳转(returnTo 优先) */
21
+ /** 登录成功后的缺省跳转 */
17
22
  defaultReturnTo?: string;
18
23
  }
19
24
  /**
20
- * GET /auth/login → 302 authorize(returnTo via query)
21
- * GET /auth/callback → 建 session 写双 cookie → 302 returnTo
22
- * POST /auth/refresh → rotate(读 refresh cookie)
23
- * POST /auth/logout → 吊销 session + 清 cookie
25
+ * 组合 auth 路由:OAuth2 + Password(按配置)+ refresh + logout。
24
26
  */
25
27
  export declare function createAuthRoutes(opts: AuthRoutesOptions): Hono;
26
28
  //#endregion
29
+ //#region src/hono/oauth2-routes.d.ts
30
+ interface OAuth2Paths {
31
+ authorize?: string;
32
+ callback?: string;
33
+ }
34
+ interface OAuth2RoutesOptions {
35
+ client: OAuth2Client;
36
+ sessions: SessionManager;
37
+ /** OAuth2 回调时调用:IdP sub → local user */
38
+ identityResolver: IdentityResolver;
39
+ /** OAuth2 provider 标识(如 'dingtalk'),用于 identity link */
40
+ provider: string;
41
+ paths?: OAuth2Paths;
42
+ /** cookie Secure(qw-pages/https 传 true) */
43
+ secure?: boolean;
44
+ /** 登录成功后的缺省跳转(returnTo 优先) */
45
+ defaultReturnTo?: string;
46
+ }
47
+ export declare function createOAuth2Routes(opts: OAuth2RoutesOptions): Hono;
48
+ //#endregion
49
+ //#region src/hono/password-routes.d.ts
50
+ interface PasswordPaths {
51
+ register?: string;
52
+ login?: string;
53
+ change?: string;
54
+ delete?: string;
55
+ }
56
+ interface PasswordRoutesOptions {
57
+ password: PasswordProvider;
58
+ sessions: SessionManager;
59
+ /** 是否允许自助注册(默认 true) */
60
+ allowRegister?: boolean;
61
+ paths?: PasswordPaths;
62
+ secure?: boolean;
63
+ }
64
+ export declare function createPasswordRoutes(opts: PasswordRoutesOptions): Hono;
65
+ //#endregion
27
66
  //#region src/hono/middleware.d.ts
28
67
  interface SessionMiddlewareOptions {
29
68
  /** 允许 Authorization: Bearer(默认 true) */
@@ -37,7 +76,14 @@ interface SessionMiddlewareOptions {
37
76
  * 无身份不报错(交给 requireAuth 决定 401/302)。
38
77
  */
39
78
  export declare function sessionMiddleware(sessions: SessionManager, opts?: SessionMiddlewareOptions): MiddlewareHandler;
40
- /** 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login */
79
+ /**
80
+ * 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login。
81
+ *
82
+ * ⚠ loginPath 缺省 = OAuth2 授权入口 `/auth/oauth2/authorize`(旧 `/auth/login` 已废弃,
83
+ * 见 ADR-022)。SDK 不渲染登录页,故缺省指向唯一内置的 GET 登录入口;
84
+ * 纯密码认证或自带登录 UI 的 App 必须显式传 `loginPath` 指向自己的登录页,
85
+ * 否则页面请求会 302 到一个不存在的路径(被 SPA fallback 判成 404 JSON)。
86
+ */
41
87
  export declare function requireAuth(opts?: {
42
88
  loginPath?: string;
43
89
  }): MiddlewareHandler;
@@ -47,4 +93,4 @@ declare module 'hono' {
47
93
  }
48
94
  }
49
95
  //#endregion
50
- export type { AuthPaths, AuthRoutesOptions, SessionClaims, SessionMiddlewareOptions };
96
+ export type { AuthRoutesOptions, OAuth2Inject, OAuth2Paths, OAuth2RoutesOptions, PasswordPaths, PasswordRoutesOptions, SessionClaims, SessionMiddlewareOptions };
package/dist/hono.js CHANGED
@@ -1,24 +1,25 @@
1
+ import { n as UsernameTakenError, r as IdentityNotLinkedError } from "./provider-DMtwLgGf.js";
1
2
  import { Hono } from "hono";
2
3
  import { getCookie, setCookie } from "hono/cookie";
3
- //#region src/hono/routes.ts
4
+ //#region src/hono/oauth2-routes.ts
4
5
  /**
5
- * GET /auth/login → 302 authorize(returnTo via query)
6
- * GET /auth/callback → 建 session 写双 cookie → 302 returnTo
7
- * POST /auth/refresh → rotate(读 refresh cookie)
8
- * POST /auth/logout → 吊销 session + 清 cookie
6
+ * OAuth2 路由(新路径统一到 /auth/oauth2/*)。
7
+ *
8
+ * GET /auth/oauth2/authorize → 302 到 IdP authorize URL
9
+ * GET /auth/oauth2/callback → identity resolver → session → 302 returnTo
10
+ *
11
+ * 旧路径 /auth/login + /auth/callback 已废弃(Breaking change)。
9
12
  */
10
- function createAuthRoutes(opts) {
11
- const { client, sessions } = opts;
13
+ function createOAuth2Routes(opts) {
14
+ const { client, sessions, identityResolver, provider } = opts;
12
15
  const paths = {
13
- login: opts.paths?.login ?? "/auth/login",
14
- callback: opts.paths?.callback ?? "/auth/callback",
15
- refresh: opts.paths?.refresh ?? "/auth/refresh",
16
- logout: opts.paths?.logout ?? "/auth/logout"
16
+ authorize: opts.paths?.authorize ?? "/auth/oauth2/authorize",
17
+ callback: opts.paths?.callback ?? "/auth/oauth2/callback"
17
18
  };
18
19
  const secure = opts.secure ?? false;
19
20
  const names = sessions.cookies;
20
21
  const app = new Hono();
21
- app.get(paths.login, async (c) => {
22
+ app.get(paths.authorize, async (c) => {
22
23
  const returnTo = c.req.query("returnTo");
23
24
  const url = await client.authorizeUrl(returnTo);
24
25
  return c.redirect(url);
@@ -28,11 +29,20 @@ function createAuthRoutes(opts) {
28
29
  const state = c.req.query("state");
29
30
  if (!code) return c.json({ error: "missing_code" }, 400);
30
31
  try {
31
- const { user, returnTo } = await client.callback({
32
+ const { user: idpUser, returnTo } = await client.callback({
32
33
  code,
33
34
  state: state ?? void 0
34
35
  });
35
- const tokens = await sessions.createSession(user);
36
+ const localUser = await identityResolver.resolve(provider, idpUser.sub, {
37
+ nickname: idpUser.nickname,
38
+ avatarUrl: idpUser.avatarUrl
39
+ });
40
+ const authUser = {
41
+ sub: localUser.id,
42
+ nickname: localUser.nickname ?? idpUser.nickname,
43
+ avatarUrl: localUser.avatarUrl ?? idpUser.avatarUrl
44
+ };
45
+ const tokens = await sessions.createSession(authUser);
36
46
  setCookie(c, names.access, tokens.accessToken, {
37
47
  httpOnly: true,
38
48
  sameSite: "Lax",
@@ -49,13 +59,271 @@ function createAuthRoutes(opts) {
49
59
  });
50
60
  return c.redirect(returnTo || opts.defaultReturnTo || "/");
51
61
  } catch (e) {
62
+ if (e instanceof IdentityNotLinkedError) return c.json({
63
+ error: "identity_not_linked",
64
+ message: e.message
65
+ }, 403);
52
66
  return c.json({
53
67
  error: "oauth_failed",
54
68
  message: e.message
55
69
  }, 401);
56
70
  }
57
71
  });
58
- app.post(paths.refresh, async (c) => {
72
+ return app;
73
+ }
74
+ //#endregion
75
+ //#region src/hono/password-routes.ts
76
+ /**
77
+ * 密码认证路由(JSON API,不渲染页面)。
78
+ *
79
+ * POST /auth/password/register → 注册(username + password)→ session cookies + 200 JSON
80
+ * POST /auth/password/login → 登录 → session cookies + 200 JSON
81
+ * POST /auth/password/change → 改密码(需已登录)→ 200 JSON
82
+ * POST /auth/password/delete → 删密码(需已登录)→ 200 JSON
83
+ */
84
+ /**
85
+ * 跨方言唯一约束冲突判定(username 并发注册的兜底)。
86
+ *
87
+ * ⚠ 必须走 cause 链:drizzle 把底层驱动错误包成 DrizzleQueryError,其顶层 message 是
88
+ * "Failed query: INSERT ...",**不含** UNIQUE 字样——只看顶层 message 会漏判(曾导致
89
+ * 重复用户名注册回落 400 而非 409)。真正的信号在 cause:
90
+ * - sqlite:cause.errcode === 2067(SQLITE_CONSTRAINT_UNIQUE),cause.message 含 "UNIQUE constraint failed"
91
+ * - pg:cause.code === '23505'(unique_violation),message 含 "duplicate key"
92
+ * 逐层向下最多走 5 层,命中任一信号即判定为唯一冲突。
93
+ */
94
+ function isUniqueViolation(e) {
95
+ let cur = e;
96
+ for (let depth = 0; cur && depth < 5; depth++) {
97
+ if (cur.errcode === 2067) return true;
98
+ if (cur.code === "23505") return true;
99
+ if (typeof cur.message === "string" && /unique constraint|duplicate key|unique violation/i.test(cur.message)) return true;
100
+ cur = cur.cause;
101
+ }
102
+ return false;
103
+ }
104
+ function createPasswordRoutes(opts) {
105
+ const { password, sessions } = opts;
106
+ const allowRegister = opts.allowRegister ?? true;
107
+ const paths = {
108
+ register: opts.paths?.register ?? "/auth/password/register",
109
+ login: opts.paths?.login ?? "/auth/password/login",
110
+ change: opts.paths?.change ?? "/auth/password/change",
111
+ delete: opts.paths?.delete ?? "/auth/password/delete"
112
+ };
113
+ const secure = opts.secure ?? false;
114
+ const names = sessions.cookies;
115
+ const app = new Hono();
116
+ app.post(paths.register, async (c) => {
117
+ if (!allowRegister) return c.json({
118
+ error: "registration_disabled",
119
+ message: "自助注册已关闭"
120
+ }, 403);
121
+ let body;
122
+ try {
123
+ body = await c.req.json();
124
+ } catch {
125
+ return c.json({ error: "invalid_body" }, 400);
126
+ }
127
+ if (!body.username || !body.password) return c.json({
128
+ error: "missing_fields",
129
+ message: "需要 username 和 password"
130
+ }, 400);
131
+ if (body.password.length < 8) return c.json({
132
+ error: "weak_password",
133
+ message: "密码至少 8 位"
134
+ }, 400);
135
+ try {
136
+ const user = await password.register({
137
+ username: body.username,
138
+ password: body.password,
139
+ nickname: body.nickname
140
+ });
141
+ const authUser = {
142
+ sub: user.id,
143
+ nickname: user.nickname ?? void 0,
144
+ avatarUrl: user.avatarUrl ?? void 0
145
+ };
146
+ const tokens = await sessions.createSession(authUser);
147
+ setCookie(c, names.access, tokens.accessToken, {
148
+ httpOnly: true,
149
+ sameSite: "Lax",
150
+ secure,
151
+ path: "/",
152
+ maxAge: sessions.accessTokenTtlSec
153
+ });
154
+ setCookie(c, names.refresh, tokens.refreshToken, {
155
+ httpOnly: true,
156
+ sameSite: "Lax",
157
+ secure,
158
+ path: "/",
159
+ maxAge: sessions.refreshTokenTtlSec
160
+ });
161
+ return c.json({
162
+ ok: true,
163
+ user: {
164
+ id: user.id,
165
+ username: user.username,
166
+ nickname: user.nickname
167
+ }
168
+ });
169
+ } catch (e) {
170
+ if (e instanceof UsernameTakenError || isUniqueViolation(e)) return c.json({
171
+ error: "username_taken",
172
+ message: "用户名已被注册"
173
+ }, 409);
174
+ return c.json({
175
+ error: "register_failed",
176
+ message: e.message
177
+ }, 400);
178
+ }
179
+ });
180
+ app.post(paths.login, async (c) => {
181
+ let body;
182
+ try {
183
+ body = await c.req.json();
184
+ } catch {
185
+ return c.json({ error: "invalid_body" }, 400);
186
+ }
187
+ if (!body.username || !body.password) return c.json({
188
+ error: "missing_fields",
189
+ message: "需要 username 和 password"
190
+ }, 400);
191
+ const user = await password.verify(body.username, body.password);
192
+ if (!user) return c.json({
193
+ error: "invalid_credentials",
194
+ message: "用户名或密码错误"
195
+ }, 401);
196
+ const authUser = {
197
+ sub: user.id,
198
+ nickname: user.nickname ?? void 0,
199
+ avatarUrl: user.avatarUrl ?? void 0
200
+ };
201
+ const tokens = await sessions.createSession(authUser);
202
+ setCookie(c, names.access, tokens.accessToken, {
203
+ httpOnly: true,
204
+ sameSite: "Lax",
205
+ secure,
206
+ path: "/",
207
+ maxAge: sessions.accessTokenTtlSec
208
+ });
209
+ setCookie(c, names.refresh, tokens.refreshToken, {
210
+ httpOnly: true,
211
+ sameSite: "Lax",
212
+ secure,
213
+ path: "/",
214
+ maxAge: sessions.refreshTokenTtlSec
215
+ });
216
+ return c.json({
217
+ ok: true,
218
+ user: {
219
+ id: user.id,
220
+ username: user.username,
221
+ nickname: user.nickname
222
+ }
223
+ });
224
+ });
225
+ app.post(paths.change, async (c) => {
226
+ const claims = c.get("auth");
227
+ if (!claims) return c.json({ error: "unauthorized" }, 401);
228
+ let body;
229
+ try {
230
+ body = await c.req.json();
231
+ } catch {
232
+ return c.json({ error: "invalid_body" }, 400);
233
+ }
234
+ if (!body.oldPassword || !body.newPassword) return c.json({
235
+ error: "missing_fields",
236
+ message: "需要 oldPassword 和 newPassword"
237
+ }, 400);
238
+ if (body.newPassword.length < 8) return c.json({
239
+ error: "weak_password",
240
+ message: "密码至少 8 位"
241
+ }, 400);
242
+ try {
243
+ await password.changePassword(claims.sub, body.oldPassword, body.newPassword);
244
+ await sessions.revokeUser(claims.sub);
245
+ setCookie(c, names.access, "", {
246
+ httpOnly: true,
247
+ secure,
248
+ path: "/",
249
+ maxAge: 0
250
+ });
251
+ setCookie(c, names.refresh, "", {
252
+ httpOnly: true,
253
+ secure,
254
+ path: "/",
255
+ maxAge: 0
256
+ });
257
+ return c.json({
258
+ ok: true,
259
+ reloginRequired: true
260
+ });
261
+ } catch (e) {
262
+ return c.json({
263
+ error: "change_failed",
264
+ message: e.message
265
+ }, 400);
266
+ }
267
+ });
268
+ app.post(paths.delete, async (c) => {
269
+ const claims = c.get("auth");
270
+ if (!claims) return c.json({ error: "unauthorized" }, 401);
271
+ try {
272
+ await password.deletePassword(claims.sub);
273
+ return c.json({ ok: true });
274
+ } catch (e) {
275
+ return c.json({
276
+ error: "delete_failed",
277
+ message: e.message
278
+ }, 400);
279
+ }
280
+ });
281
+ return app;
282
+ }
283
+ //#endregion
284
+ //#region src/hono/routes.ts
285
+ /**
286
+ * 主路由组合器 —— 按配置的能力(OAuth2 / Password)组装路由。
287
+ *
288
+ * createAuthRoutes(opts) 根据 opts 提供的 capability 自动组合:
289
+ * - opts.oauth2(OAuth2Client + IdentityResolver + provider)→ /auth/oauth2/*
290
+ * - opts.password(PasswordProvider)→ /auth/password/*
291
+ * - 无条件挂载:/auth/refresh、/auth/logout
292
+ *
293
+ * WebAuthn 路由**不在**此组合器内——消费者从 @homurajs/auth/webauthn 取
294
+ * createWebAuthnRoutes 自行挂载。
295
+ *
296
+ * ⚠ Breaking:旧 /auth/login + /auth/callback 路径已迁移到 /auth/oauth2/*。
297
+ */
298
+ /**
299
+ * 组合 auth 路由:OAuth2 + Password(按配置)+ refresh + logout。
300
+ */
301
+ function createAuthRoutes(opts) {
302
+ const { sessions } = opts;
303
+ const secure = opts.secure ?? false;
304
+ const names = sessions.cookies;
305
+ const app = new Hono();
306
+ if (opts.oauth2) {
307
+ const oauth2App = createOAuth2Routes({
308
+ client: opts.oauth2.client,
309
+ sessions,
310
+ identityResolver: opts.oauth2.identityResolver,
311
+ provider: opts.oauth2.provider,
312
+ secure,
313
+ defaultReturnTo: opts.defaultReturnTo
314
+ });
315
+ app.route("/", oauth2App);
316
+ }
317
+ if (opts.password) {
318
+ const passwordApp = createPasswordRoutes({
319
+ password: opts.password,
320
+ sessions,
321
+ allowRegister: opts.allowRegister,
322
+ secure
323
+ });
324
+ app.route("/", passwordApp);
325
+ }
326
+ app.post("/auth/refresh", async (c) => {
59
327
  const refreshToken = getCookie(c, names.refresh);
60
328
  if (!refreshToken) return c.json({ error: "no_refresh_token" }, 401);
61
329
  const rotated = await sessions.refresh(refreshToken);
@@ -76,7 +344,7 @@ function createAuthRoutes(opts) {
76
344
  });
77
345
  return c.json({ ok: true });
78
346
  });
79
- app.post(paths.logout, async (c) => {
347
+ app.post("/auth/logout", async (c) => {
80
348
  const claims = c.get("auth");
81
349
  if (claims?.sid) await sessions.revokeSession(claims.sid);
82
350
  setCookie(c, names.access, "", {
@@ -140,9 +408,16 @@ function sessionMiddleware(sessions, opts) {
140
408
  await next();
141
409
  };
142
410
  }
143
- /** 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login */
411
+ /**
412
+ * 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login。
413
+ *
414
+ * ⚠ loginPath 缺省 = OAuth2 授权入口 `/auth/oauth2/authorize`(旧 `/auth/login` 已废弃,
415
+ * 见 ADR-022)。SDK 不渲染登录页,故缺省指向唯一内置的 GET 登录入口;
416
+ * 纯密码认证或自带登录 UI 的 App 必须显式传 `loginPath` 指向自己的登录页,
417
+ * 否则页面请求会 302 到一个不存在的路径(被 SPA fallback 判成 404 JSON)。
418
+ */
144
419
  function requireAuth(opts) {
145
- const loginPath = opts?.loginPath ?? "/auth/login";
420
+ const loginPath = opts?.loginPath ?? "/auth/oauth2/authorize";
146
421
  return async (c, next) => {
147
422
  if (c.get("auth")) {
148
423
  await next();
@@ -154,4 +429,4 @@ function requireAuth(opts) {
154
429
  };
155
430
  }
156
431
  //#endregion
157
- export { createAuthRoutes, requireAuth, sessionMiddleware };
432
+ export { createAuthRoutes, createOAuth2Routes, createPasswordRoutes, requireAuth, sessionMiddleware };