loginbase 1.0.0 → 1.2.0

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
@@ -3,7 +3,7 @@
3
3
  > The shared login foundation for my apps — email OTP + social OAuth + session management.
4
4
  > Cloudflare Workers server library + Kotlin Multiplatform client.
5
5
 
6
- 多 App 共用的登录底座:邮箱验证码登录、社交 OAuth(GitHub 等)、会话管理(refresh 轮换 + 重用检测 + 丢回执救活)。服务端库跑在各 App 自己的 Cloudflare Worker 里(数据在各自 D1,不做中心化账号),客户端库以 Kotlin Multiplatform 提供。
6
+ 多 App 共用的登录底座:邮箱验证码登录、社交 OAuth(GitHub 等)、会话管理(refresh 轮换 + 重用检测 + 丢回执救活)。服务端库跑在各 App 自己的 Cloudflare Worker 里(数据在各自 D1,不做中心化账号),客户端库以 Kotlin Multiplatform 提供,住在姊妹仓 [HarlonWang/loginbase-kt](https://github.com/HarlonWang/loginbase-kt)(第 4 步新建)。**协议契约 [docs/protocol.md](docs/protocol.md) 只住本仓,是两端的唯一权威。**
7
7
 
8
8
  ## 由来
9
9
 
@@ -11,13 +11,12 @@
11
11
  - 服务端实现的母本是 Tono-Server 已在生产验证的邮箱验证码登录(含全套测试),本仓库是它的抽取 + 泛化
12
12
  - 路线选择(公共库而非中心化服务)与仓库设计见 [docs/design.md](docs/design.md),命名记录见 [docs/naming.md](docs/naming.md)
13
13
 
14
- ## 结构(monorepo:一份协议,两个产物)
14
+ ## 结构(两个仓库:一份协议,两个产物)
15
15
 
16
16
  ```
17
- loginbase/
17
+ loginbase/ # 本仓:TS 服务端库 + 协议契约
18
18
  ├── src/ # TS 服务端库(Hono sub-app 工厂),从 Tono-Server 平移
19
19
  ├── test/ # vitest
20
- ├── kotlin/ # KMP 客户端库(独立 gradle 工程)
21
20
  ├── docs/
22
21
  │ ├── design.md # 路线与仓库设计决策
23
22
  │ ├── server-design.md # 服务端技术方案(公共 API/协议草案/会话模型/平移策略)
@@ -26,20 +25,25 @@ loginbase/
26
25
  │ ├── protocol.md # API 契约(唯一权威)——代码平移时落笔
27
26
  │ └── logto-替换方案-调研.md # 背景调研(自 TrendingProjects 迁入)
28
27
  └── README.md
28
+
29
+ loginbase-kt/ # 姊妹仓:KMP 客户端库(独立 gradle 工程、独立 CI 与版本线)
29
30
  ```
30
31
 
31
32
  ## 产物坐标
32
33
 
33
34
  ```
34
- 仓库 HarlonWang/loginbase
35
- npm loginbase (npm registry,tag 触发 CI 发布)
36
- Maven wang.harlon:loginbase-kt Maven Central,tag 触发 CI 发布)
35
+ 服务端仓 HarlonWang/loginbase (含 protocol.md,协议唯一权威)
36
+ 客户端仓 HarlonWang/loginbase-kt
37
+ npm loginbase npm registry,本仓 tag 触发 CI 发布)
38
+ Maven wang.harlon:loginbase-kt (Maven Central,客户端仓 tag 触发 CI 发布)
37
39
  Kotlin 包 wang.harlon.loginbase
38
40
  ```
39
41
 
42
+ 两仓各自独立版本线,tag 为裸版本号;协议兼容关系由 `protocol.md` 版本历史 + 客户端仓 README 的对齐声明表达,不靠版本号相等表达。
43
+
40
44
  ## 状态与路线
41
45
 
42
- **落地第 1 步已完成**(2026-08-12):`loginbase@0.1.1` 上线 npm(trusted publishing + provenance),Tono-Server 已切换依赖并部署生产(其 67 测试全绿即抽取验收)。下一步 = 第 2 步钩子化。落地顺序:
46
+ **落地第 1~3 步已完成**(2026-08-13):`loginbase@1.1.0` 上线 npmTono-Server(钩子化)与 github-ai-trending-api(双轨迁移:loginbase 优先 + Logto fallback)均已部署生产并通过分层验收。下一步 = 第 4 步 KMP 客户端。落地顺序:
43
47
 
44
48
  1. 从 Tono-Server 平移服务端代码与测试,Tono-Server 改为依赖本包(其现有测试即抽取验收)
45
49
  2. 钩子化(`onVerified` 用户回调)+ zh/en 邮件模板 + github-oauth 可选插件
@@ -50,4 +54,4 @@ Kotlin 包 wang.harlon.loginbase
50
54
  ## 设计红线
51
55
 
52
56
  - 依赖最小集:服务端 hono + jose(+ zod-validator),客户端 ktor + kotlinx-serialization + multiplatform-settings。auth 库是供应链攻击的最高价值目标,每加一个依赖都要过一遍这个念头
53
- - 协议变更必须服务端 + 客户端 + `docs/protocol.md` 同一个 commit;单一版本线,一个 tag 同时锁定两端
57
+ - 协议变更:服务端实现 + `docs/protocol.md` 同一个 commit,并在 `loginbase-kt` 仓开跟进 issue,客户端版本落地前不关(2026-08-13 由 monorepo 三位一体改判,理由见 design.md)
package/dist/body.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 请求体字段取字符串。
3
+ *
4
+ * JSON 里客户端可以塞任意类型,而 `(body.x ?? "").trim()` 遇到数字/对象/数组会抛
5
+ * TypeError——把一个本该 400 的坏请求变成 500:日志噪音,且给客户端「服务端故障、
6
+ * 可重试」的错误信号(实际重试多少次都一样)。TS 的字段类型只在编译期成立,
7
+ * 运行时边界必须自己兜(见 design.md 技术选型第 1 条)。
8
+ *
9
+ * 非字符串一律当空串,让各端点照常走自己的 400 分支。
10
+ */
11
+ export declare function trimmedField(value: unknown): string;
package/dist/body.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 请求体字段取字符串。
3
+ *
4
+ * JSON 里客户端可以塞任意类型,而 `(body.x ?? "").trim()` 遇到数字/对象/数组会抛
5
+ * TypeError——把一个本该 400 的坏请求变成 500:日志噪音,且给客户端「服务端故障、
6
+ * 可重试」的错误信号(实际重试多少次都一样)。TS 的字段类型只在编译期成立,
7
+ * 运行时边界必须自己兜(见 design.md 技术选型第 1 条)。
8
+ *
9
+ * 非字符串一律当空串,让各端点照常走自己的 400 分支。
10
+ */
11
+ export function trimmedField(value) {
12
+ return typeof value === "string" ? value.trim() : "";
13
+ }
package/dist/config.d.ts CHANGED
@@ -3,6 +3,25 @@ export interface VerifiedIdentity {
3
3
  email: string;
4
4
  provider: "email" | "github";
5
5
  providerUserId?: string;
6
+ /**
7
+ * OAuth provider 的公开档案(GitHub = /user 响应裁剪到建档白名单:
8
+ * id/login/name/email/avatar_url/bio/html_url/company/location/blog/
9
+ * twitter_username/created_at),供 App 建档取资料;email provider 恒缺省。
10
+ * 认证视图的敏感字段(私有统计、2FA 状态等)在库边界内剔除。
11
+ */
12
+ providerProfile?: unknown;
13
+ /**
14
+ * OAuth provider 的 access token(1.2.0 起)。库**只透传、不存储、不再分发**——
15
+ * 第三方 API 凭据是各 App 的产品决策,不属登录底座(见 docs/design.md 定位边界)。
16
+ * GitHub OAuth App 的 token 不过期,用户撤销授权才失效。email provider 恒缺省。
17
+ */
18
+ providerAccessToken?: string;
19
+ /**
20
+ * provider 返回的全部**已验证**邮箱,归一化小写(1.2.0 起)。`email` 是其中
21
+ * 选定的主锚点(primary+verified 优先);App 可用整个列表做账号对账——GitHub 上
22
+ * 挂了多个验证邮箱、其中之一才是 App 账号邮箱的用户,只比对 `email` 会被误判为新人。
23
+ */
24
+ verifiedEmails?: string[];
6
25
  requestMeta: {
7
26
  ip?: string;
8
27
  userAgent?: string;
@@ -14,6 +33,42 @@ export interface VerifiedResult {
14
33
  user?: unknown;
15
34
  }
16
35
  export type OnVerified = (identity: VerifiedIdentity) => Promise<VerifiedResult> | VerifiedResult;
36
+ /**
37
+ * onLinked:**已登录用户绑定第二身份**的语义出口(1.2.0 起)。
38
+ * 与 onVerified 方向相反——onVerified **返回** userId(用外部身份换会话),
39
+ * onLinked **接收** userId(用已有会话认领外部身份);不建会话、不发 token。
40
+ */
41
+ export interface LinkedIdentity {
42
+ /** 当前登录用户,来自 link/start 的 Bearer 校验并存于 state;绝不接受客户端传入 */
43
+ userId: string;
44
+ provider: "github";
45
+ providerUserId: string;
46
+ /**
47
+ * link 时 userId 已确定,email 只是附加信息——provider 未给也**不构成失败**
48
+ * (与 onVerified 不同:那里 email 是账号锚点,缺了就无法找号建号)。
49
+ */
50
+ email?: string;
51
+ verifiedEmails?: string[];
52
+ providerProfile?: unknown;
53
+ providerAccessToken?: string;
54
+ requestMeta: {
55
+ ip?: string;
56
+ userAgent?: string;
57
+ };
58
+ }
59
+ /**
60
+ * 冲突用返回值而非抛错表达:「该外部身份已属他人」是预期业务结果,不是异常
61
+ * (抛错留给真异常 → `internal`)。库不裁决冲突——拒绝/换绑/重复绑定是否幂等
62
+ * 全由 App 决定,库只把 `reason` 映射成回跳参数。
63
+ * `reason` 须为 `[A-Za-z0-9_]{1,64}`,否则回落为 `internal`(回跳 URL 卫生)。
64
+ */
65
+ export type LinkResult = {
66
+ ok: true;
67
+ } | {
68
+ ok: false;
69
+ reason: string;
70
+ };
71
+ export type OnLinked = (identity: LinkedIdentity) => Promise<LinkResult> | LinkResult;
17
72
  export type OnEvent = (event: Record<string, unknown>) => void;
18
73
  export interface GithubSocialConfig {
19
74
  clientId: string;
@@ -25,6 +80,12 @@ export interface GithubSocialConfig {
25
80
  allowedRedirects: string[];
26
81
  /** GitHub OAuth App 注册的回调地址;缺省由请求 origin + basePath 推导 */
27
82
  callbackUrl?: string;
83
+ /**
84
+ * authorize 的 scope,默认 `user:email`——取 primary+verified 邮箱的最小集。
85
+ * 需要额外能力的消费方自配,如 TrendingAI 的 `"user:email public_repo"`
86
+ * (star 写操作要求 public_repo)。scope 越大授权页越吓人,按需给。
87
+ */
88
+ scope?: string;
28
89
  }
29
90
  export interface LoginConfig {
30
91
  db: D1Database;
@@ -40,6 +101,8 @@ export interface LoginConfig {
40
101
  refreshTtlMs?: number | null;
41
102
  };
42
103
  onVerified: OnVerified;
104
+ /** 提供后才启用 link 端点(未提供 → 404 not_configured,默认关闭、显式启用) */
105
+ onLinked?: OnLinked;
43
106
  onEvent?: OnEvent;
44
107
  socials?: {
45
108
  github?: GithubSocialConfig;
package/dist/handler.js CHANGED
@@ -9,6 +9,7 @@ import { createSession, hashRefreshToken, findSession, rotateSession, revokeFami
9
9
  import { signAccessToken, ACCESS_TTL_SECONDS } from "./token.js";
10
10
  import { createAuthMiddleware } from "./middleware.js";
11
11
  import { logEvent } from "./log.js";
12
+ import { trimmedField } from "./body.js";
12
13
  import { registerGithubOauth } from "./plugins/github.js";
13
14
  export function createAuthApp(getConfig, basePath) {
14
15
  const authMiddleware = createAuthMiddleware(getConfig);
@@ -25,7 +26,7 @@ export function createAuthApp(getConfig, basePath) {
25
26
  const body = await c.req
26
27
  .json()
27
28
  .catch(() => ({}));
28
- const raw = (body.email ?? "").trim().toLowerCase();
29
+ const raw = trimmedField(body.email).toLowerCase();
29
30
  if (!/^\S+@\S+\.\S+$/.test(raw)) {
30
31
  return c.json({ error: "invalid_email" }, 400);
31
32
  }
@@ -49,8 +50,8 @@ export function createAuthApp(getConfig, basePath) {
49
50
  const body = await c.req
50
51
  .json()
51
52
  .catch(() => ({}));
52
- const email = (body.email ?? "").trim().toLowerCase();
53
- const code = (body.code ?? "").trim();
53
+ const email = trimmedField(body.email).toLowerCase();
54
+ const code = trimmedField(body.code);
54
55
  const stored = await readCode(cfg(c).kv, email);
55
56
  if (!stored)
56
57
  return c.json({ error: "code_expired" }, 400);
@@ -96,7 +97,7 @@ export function createAuthApp(getConfig, basePath) {
96
97
  const body = await c.req
97
98
  .json()
98
99
  .catch(() => ({}));
99
- const token = (body.refreshToken ?? "").trim();
100
+ const token = trimmedField(body.refreshToken);
100
101
  if (!token) {
101
102
  return c.json({ error: "invalid_refresh_token", reason: "missing_token" }, 401);
102
103
  }
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface Login<TEnv> {
15
15
  }>;
16
16
  }
17
17
  export declare function createLogin<TEnv = unknown>(resolve: (env: TEnv) => LoginConfig, options?: CreateLoginOptions): Login<TEnv>;
18
- export type { LoginConfig, CreateLoginOptions, VerifiedIdentity, VerifiedResult, OnVerified, OnEvent, GithubSocialConfig, } from "./config.js";
18
+ export type { LoginConfig, CreateLoginOptions, VerifiedIdentity, VerifiedResult, OnVerified, LinkedIdentity, LinkResult, OnLinked, OnEvent, GithubSocialConfig, } from "./config.js";
19
19
  export type { AuthVariables } from "./middleware.js";
20
20
  export { createAuthMiddleware } from "./middleware.js";
21
21
  export * from "./code.js";
@@ -1,6 +1,6 @@
1
1
  import type { Hono } from "hono";
2
2
  import type { LoginConfig } from "../config.js";
3
- import type { AuthVariables } from "../middleware.js";
3
+ import { type AuthVariables } from "../middleware.js";
4
4
  export declare function registerGithubOauth<TEnv>(auth: Hono<{
5
5
  Variables: AuthVariables;
6
6
  }>, getConfig: (env: TEnv) => LoginConfig, basePath: string): void;
@@ -1,3 +1,5 @@
1
+ import { trimmedField } from "../body.js";
2
+ import { createAuthMiddleware } from "../middleware.js";
1
3
  import { generateRefreshToken as randomToken } from "../session.js";
2
4
  import { createSession } from "../session.js";
3
5
  import { signAccessToken, ACCESS_TTL_SECONDS } from "../token.js";
@@ -7,6 +9,9 @@ const GITHUB_API_USER = "https://api.github.com/user";
7
9
  const GITHUB_API_EMAILS = "https://api.github.com/user/emails";
8
10
  const STATE_TTL_SECONDS = 600;
9
11
  const OTC_TTL_SECONDS = 60;
12
+ const DEFAULT_SCOPE = "user:email";
13
+ // App 自定义的冲突 reason 会进回跳 URL,限制字符集防畸形/敏感内容外泄
14
+ const REASON_PATTERN = /^[A-Za-z0-9_]{1,64}$/;
10
15
  // 结构化校验而非字符串前缀:startsWith("https://example.com") 会被
11
16
  // https://example.com.evil.com 绕过(开放重定向 → otc 泄露给攻击者域)。
12
17
  // scheme + host 精确匹配,path 只允许白名单条目的前缀扩展。
@@ -34,9 +39,91 @@ function redirectAllowed(redirect, github) {
34
39
  function withParam(url, key, value) {
35
40
  return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
36
41
  }
42
+ // providerProfile 只透传建档需要的公开档案白名单——GET /user 的认证视图含
43
+ // two_factor_authentication、私有仓库统计等敏感字段,不该流出库边界。
44
+ const PROFILE_FIELDS = [
45
+ "id",
46
+ "login",
47
+ "name",
48
+ "email",
49
+ "avatar_url",
50
+ "bio",
51
+ "html_url",
52
+ "company",
53
+ "location",
54
+ "blog",
55
+ "twitter_username",
56
+ "created_at",
57
+ ];
58
+ function publicProfile(user) {
59
+ const out = {};
60
+ for (const key of PROFILE_FIELDS) {
61
+ if (user[key] !== undefined)
62
+ out[key] = user[key];
63
+ }
64
+ return out;
65
+ }
66
+ function buildAuthorizeUrl(gh, callbackUrl, state) {
67
+ const url = new URL(GITHUB_AUTHORIZE);
68
+ url.searchParams.set("client_id", gh.clientId);
69
+ url.searchParams.set("redirect_uri", callbackUrl);
70
+ url.searchParams.set("scope", gh.scope ?? DEFAULT_SCOPE);
71
+ url.searchParams.set("state", state);
72
+ return url.toString();
73
+ }
74
+ /**
75
+ * 换 token → 取用户 → 取邮箱。login 与 link 两条流程共用(callback 是同一个端点:
76
+ * GitHub OAuth App 的回调地址注册在 GitHub 侧,多一个即多一处配置漂移)。
77
+ * 返回 null = 换码或取用户失败,调用方回跳 oauth_failed。
78
+ */
79
+ async function fetchGithubIdentity(gh, code) {
80
+ // server-side 换 token:client_secret 只在此出现,客户端不可见
81
+ const tokenRes = await fetch(GITHUB_TOKEN, {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
84
+ body: JSON.stringify({
85
+ client_id: gh.clientId,
86
+ client_secret: gh.clientSecret,
87
+ code,
88
+ }),
89
+ });
90
+ const tokenBody = tokenRes.ok
91
+ ? (await tokenRes.json().catch(() => null))
92
+ : null;
93
+ const ghToken = tokenBody?.access_token;
94
+ if (!ghToken)
95
+ return null;
96
+ // GitHub API 要求 User-Agent
97
+ const ghHeaders = {
98
+ Authorization: `Bearer ${ghToken}`,
99
+ Accept: "application/vnd.github+json",
100
+ "User-Agent": "loginbase",
101
+ };
102
+ const userRes = await fetch(GITHUB_API_USER, { headers: ghHeaders });
103
+ if (!userRes.ok)
104
+ return null;
105
+ const ghUser = (await userRes.json());
106
+ const emailsRes = await fetch(GITHUB_API_EMAILS, { headers: ghHeaders });
107
+ const emails = emailsRes.ok
108
+ ? (await emailsRes.json())
109
+ : [];
110
+ const normalize = (e) => e.trim().toLowerCase();
111
+ const verifiedEmails = emails.filter((e) => e.verified).map((e) => normalize(e.email));
112
+ const email = emails.find((e) => e.primary && e.verified)?.email ??
113
+ emails.find((e) => e.verified)?.email;
114
+ return {
115
+ ghUser,
116
+ ghToken,
117
+ ...(email ? { email: normalize(email) } : {}),
118
+ verifiedEmails,
119
+ };
120
+ }
37
121
  export function registerGithubOauth(auth, getConfig, basePath) {
38
122
  const cfg = (c) => getConfig(c.env);
39
123
  const github = (c) => cfg(c).socials?.github;
124
+ const authMiddleware = createAuthMiddleware(getConfig);
125
+ const callbackUrlFor = (c, gh) => gh.callbackUrl ??
126
+ `${new URL(c.req.url).origin}${basePath}/oauth/github/callback`;
40
127
  auth.get("/oauth/github/start", async (c) => {
41
128
  const gh = github(c);
42
129
  if (!gh)
@@ -50,14 +137,28 @@ export function registerGithubOauth(auth, getConfig, basePath) {
50
137
  await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
51
138
  expirationTtl: STATE_TTL_SECONDS,
52
139
  });
53
- const callbackUrl = gh.callbackUrl ??
54
- `${new URL(c.req.url).origin}${basePath}/oauth/github/callback`;
55
- const url = new URL(GITHUB_AUTHORIZE);
56
- url.searchParams.set("client_id", gh.clientId);
57
- url.searchParams.set("redirect_uri", callbackUrl);
58
- url.searchParams.set("scope", "user:email");
59
- url.searchParams.set("state", state);
60
- return c.redirect(url.toString(), 302);
140
+ return c.redirect(buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state), 302);
141
+ });
142
+ // 已登录用户绑定第二身份。**必须是 POST**:浏览器导航带不了 Authorization 头,
143
+ // 故不能像 login 那样 GET 直接 302——客户端先换 authorizeUrl,再自己打开浏览器。
144
+ auth.post("/oauth/github/link/start", authMiddleware, async (c) => {
145
+ const gh = github(c);
146
+ // onLinked 未提供 = 该能力未启用(默认关闭、显式启用)
147
+ if (!gh || !cfg(c).onLinked)
148
+ return c.json({ error: "not_configured" }, 404);
149
+ const body = await c.req
150
+ .json()
151
+ .catch(() => ({}));
152
+ const redirect = trimmedField(body.redirect);
153
+ if (!redirect || !redirectAllowed(redirect, gh)) {
154
+ return c.json({ error: "invalid_redirect" }, 400);
155
+ }
156
+ const state = randomToken();
157
+ const record = { redirect, mode: "link", userId: c.get("userId") };
158
+ await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
159
+ expirationTtl: STATE_TTL_SECONDS,
160
+ });
161
+ return c.json({ authorizeUrl: buildAuthorizeUrl(gh, callbackUrlFor(c, gh), state) }, 200);
61
162
  });
62
163
  auth.get("/oauth/github/callback", async (c) => {
63
164
  const gh = github(c);
@@ -72,51 +173,48 @@ export function registerGithubOauth(auth, getConfig, basePath) {
72
173
  return c.json({ error: "invalid_state" }, 400);
73
174
  }
74
175
  await cfg(c).kv.delete(stateKey); // 单次使用,验证即焚
75
- const { redirect } = JSON.parse(rawState);
76
- // server-side token:client_secret 只在此出现,客户端不可见
77
- const tokenRes = await fetch(GITHUB_TOKEN, {
78
- method: "POST",
79
- headers: { "Content-Type": "application/json", Accept: "application/json" },
80
- body: JSON.stringify({
81
- client_id: gh.clientId,
82
- client_secret: gh.clientSecret,
83
- code,
84
- }),
85
- });
86
- const tokenBody = tokenRes.ok
87
- ? (await tokenRes.json().catch(() => null))
88
- : null;
89
- const ghToken = tokenBody?.access_token;
90
- if (!ghToken)
176
+ const { redirect, mode, userId } = JSON.parse(rawState);
177
+ const identity = await fetchGithubIdentity(gh, code);
178
+ if (!identity)
91
179
  return c.redirect(withParam(redirect, "error", "oauth_failed"), 302);
92
- // GitHub API 要求 User-Agent
93
- const ghHeaders = {
94
- Authorization: `Bearer ${ghToken}`,
95
- Accept: "application/vnd.github+json",
96
- "User-Agent": "loginbase",
180
+ const { ghUser, ghToken, email, verifiedEmails } = identity;
181
+ const userAgent = c.req.header("User-Agent") ?? undefined;
182
+ const ip = c.req.header("CF-Connecting-IP") ?? undefined;
183
+ const common = {
184
+ provider: "github",
185
+ providerUserId: String(ghUser.id),
186
+ providerProfile: publicProfile(ghUser), // GitHub /user 公开档案白名单字段
187
+ providerAccessToken: ghToken,
188
+ verifiedEmails,
189
+ requestMeta: { ip, userAgent },
97
190
  };
98
- const userRes = await fetch(GITHUB_API_USER, { headers: ghHeaders });
99
- if (!userRes.ok)
100
- return c.redirect(withParam(redirect, "error", "oauth_failed"), 302);
101
- const ghUser = (await userRes.json());
102
- const emailsRes = await fetch(GITHUB_API_EMAILS, { headers: ghHeaders });
103
- const emails = emailsRes.ok
104
- ? (await emailsRes.json())
105
- : [];
106
- const email = emails.find((e) => e.primary && e.verified)?.email ??
107
- emails.find((e) => e.verified)?.email;
191
+ // ---- link 分支:认领身份,不建会话、不发 token ----
192
+ if (mode === "link") {
193
+ const onLinked = cfg(c).onLinked;
194
+ // userId link/start 写入,缺失只可能是载荷被篡改/降级,保守失败
195
+ if (!onLinked || !userId) {
196
+ return c.redirect(withParam(redirect, "error", "internal"), 302);
197
+ }
198
+ let result;
199
+ try {
200
+ // email 在此可缺省:userId 已定,邮箱只是附加信息(与 login 分支的关键差别)
201
+ result = await onLinked({ userId, ...common, ...(email ? { email } : {}) });
202
+ }
203
+ catch {
204
+ return c.redirect(withParam(redirect, "error", "internal"), 302);
205
+ }
206
+ if (!result.ok) {
207
+ const reason = REASON_PATTERN.test(result.reason) ? result.reason : "internal";
208
+ return c.redirect(withParam(redirect, "error", reason), 302);
209
+ }
210
+ return c.redirect(withParam(redirect, "linked", "github"), 302);
211
+ }
212
+ // ---- login 分支:email 是账号锚点,缺了就无法找号建号 ----
108
213
  if (!email)
109
214
  return c.redirect(withParam(redirect, "error", "oauth_no_email"), 302);
110
- const userAgent = c.req.header("User-Agent") ?? undefined;
111
- const ip = c.req.header("CF-Connecting-IP") ?? undefined;
112
215
  let verified;
113
216
  try {
114
- verified = await cfg(c).onVerified({
115
- email: email.trim().toLowerCase(),
116
- provider: "github",
117
- providerUserId: String(ghUser.id),
118
- requestMeta: { ip, userAgent },
119
- });
217
+ verified = await cfg(c).onVerified({ email, ...common });
120
218
  }
121
219
  catch {
122
220
  return c.redirect(withParam(redirect, "error", "internal"), 302);
@@ -148,7 +246,7 @@ export function registerGithubOauth(auth, getConfig, basePath) {
148
246
  const body = await c.req
149
247
  .json()
150
248
  .catch(() => ({}));
151
- const otc = (body.otc ?? "").trim();
249
+ const otc = trimmedField(body.otc);
152
250
  const key = `oauth:otc:${otc}`;
153
251
  const raw = otc ? await cfg(c).kv.get(key) : null;
154
252
  if (!raw)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loginbase",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Shared login foundation for Cloudflare Workers: email OTP + social OAuth + session management, as a Hono sub-app factory.",
5
5
  "type": "module",
6
6
  "license": "MIT",