loginbase 0.1.0 → 1.0.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 +1 -1
- package/dist/config.d.ts +39 -1
- package/dist/email.d.ts +12 -0
- package/dist/email.js +11 -15
- package/dist/handler.d.ts +2 -2
- package/dist/handler.js +46 -67
- package/dist/index.d.ts +11 -11
- package/dist/index.js +10 -10
- package/dist/middleware.d.ts +1 -1
- package/dist/plugins/github.d.ts +6 -0
- package/dist/plugins/github.js +159 -0
- package/dist/session.d.ts +4 -2
- package/dist/session.js +8 -8
- package/dist/templates/en.d.ts +2 -0
- package/dist/templates/en.js +15 -0
- package/dist/templates/zh.d.ts +2 -0
- package/dist/templates/zh.js +14 -0
- package/dist/token.d.ts +1 -1
- package/dist/token.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Kotlin 包 wang.harlon.loginbase
|
|
|
39
39
|
|
|
40
40
|
## 状态与路线
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
**落地第 1 步已完成**(2026-08-12):`loginbase@0.1.1` 上线 npm(trusted publishing + provenance),Tono-Server 已切换依赖并部署生产(其 67 测试全绿即抽取验收)。下一步 = 第 2 步钩子化。落地顺序:
|
|
43
43
|
|
|
44
44
|
1. 从 Tono-Server 平移服务端代码与测试,Tono-Server 改为依赖本包(其现有测试即抽取验收)
|
|
45
45
|
2. 钩子化(`onVerified` 用户回调)+ zh/en 邮件模板 + github-oauth 可选插件
|
package/dist/config.d.ts
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
|
-
import type { EmailConfig } from "./email";
|
|
1
|
+
import type { EmailConfig } from "./email.js";
|
|
2
|
+
export interface VerifiedIdentity {
|
|
3
|
+
email: string;
|
|
4
|
+
provider: "email" | "github";
|
|
5
|
+
providerUserId?: string;
|
|
6
|
+
requestMeta: {
|
|
7
|
+
ip?: string;
|
|
8
|
+
userAgent?: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export interface VerifiedResult {
|
|
12
|
+
userId: string;
|
|
13
|
+
isNewUser?: boolean;
|
|
14
|
+
user?: unknown;
|
|
15
|
+
}
|
|
16
|
+
export type OnVerified = (identity: VerifiedIdentity) => Promise<VerifiedResult> | VerifiedResult;
|
|
17
|
+
export type OnEvent = (event: Record<string, unknown>) => void;
|
|
18
|
+
export interface GithubSocialConfig {
|
|
19
|
+
clientId: string;
|
|
20
|
+
clientSecret: string;
|
|
21
|
+
/**
|
|
22
|
+
* OAuth 回跳 deepLink 白名单,如 ["trendingai://auth"]。
|
|
23
|
+
* 结构化匹配:scheme + host 精确一致,path 允许前缀扩展(防开放重定向)。
|
|
24
|
+
*/
|
|
25
|
+
allowedRedirects: string[];
|
|
26
|
+
/** GitHub OAuth App 注册的回调地址;缺省由请求 origin + basePath 推导 */
|
|
27
|
+
callbackUrl?: string;
|
|
28
|
+
}
|
|
2
29
|
export interface LoginConfig {
|
|
3
30
|
db: D1Database;
|
|
4
31
|
kv: KVNamespace;
|
|
5
32
|
jwt: {
|
|
6
33
|
secret: string;
|
|
34
|
+
/** access token TTL,默认 3600s */
|
|
35
|
+
accessTtlSeconds?: number;
|
|
7
36
|
};
|
|
8
37
|
email: EmailConfig;
|
|
38
|
+
session?: {
|
|
39
|
+
/** refresh 会话滑动过期(每次轮换重新起算);null/缺省 = 不过期 */
|
|
40
|
+
refreshTtlMs?: number | null;
|
|
41
|
+
};
|
|
42
|
+
onVerified: OnVerified;
|
|
43
|
+
onEvent?: OnEvent;
|
|
44
|
+
socials?: {
|
|
45
|
+
github?: GithubSocialConfig;
|
|
46
|
+
};
|
|
9
47
|
}
|
|
10
48
|
export interface CreateLoginOptions {
|
|
11
49
|
/** 路由前缀,默认 "/auth"。静态选项(Hono app 构建期即需要),不进 env-依赖的 config。 */
|
package/dist/email.d.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
|
+
export interface EmailTemplates {
|
|
2
|
+
subject(code: string): string;
|
|
3
|
+
html(code: string): string;
|
|
4
|
+
text(code: string): string;
|
|
5
|
+
}
|
|
1
6
|
export interface EmailConfig {
|
|
2
7
|
resendApiKey: string;
|
|
3
8
|
from: string;
|
|
9
|
+
/** 品牌名,注入内置模板的标题与正文;不影响 templates 整体覆盖 */
|
|
10
|
+
brand?: string;
|
|
11
|
+
/** 内置模板语言,默认 "en" */
|
|
12
|
+
locale?: "en" | "zh";
|
|
13
|
+
/** 整体覆盖模板;提供时 brand/locale 不生效 */
|
|
14
|
+
templates?: EmailTemplates;
|
|
4
15
|
}
|
|
16
|
+
export declare function resolveTemplates(config: EmailConfig): EmailTemplates;
|
|
5
17
|
export declare function sendCodeEmail(config: EmailConfig, email: string, code: string): Promise<void>;
|
package/dist/email.js
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
import { enTemplates } from "./templates/en.js";
|
|
2
|
+
import { zhTemplates } from "./templates/zh.js";
|
|
1
3
|
const RESEND_ENDPOINT = "https://api.resend.com/emails";
|
|
4
|
+
export function resolveTemplates(config) {
|
|
5
|
+
if (config.templates)
|
|
6
|
+
return config.templates;
|
|
7
|
+
return config.locale === "zh" ? zhTemplates(config.brand) : enTemplates(config.brand);
|
|
8
|
+
}
|
|
2
9
|
export async function sendCodeEmail(config, email, code) {
|
|
10
|
+
const templates = resolveTemplates(config);
|
|
3
11
|
const body = {
|
|
4
12
|
from: config.from,
|
|
5
13
|
to: [email],
|
|
6
|
-
subject:
|
|
7
|
-
html:
|
|
8
|
-
text:
|
|
14
|
+
subject: templates.subject(code),
|
|
15
|
+
html: templates.html(code),
|
|
16
|
+
text: templates.text(code),
|
|
9
17
|
};
|
|
10
18
|
const res = await fetch(RESEND_ENDPOINT, {
|
|
11
19
|
method: "POST",
|
|
@@ -20,15 +28,3 @@ export async function sendCodeEmail(config, email, code) {
|
|
|
20
28
|
throw new Error(`Resend failed: ${res.status} ${msg}`);
|
|
21
29
|
}
|
|
22
30
|
}
|
|
23
|
-
function renderHtml(code) {
|
|
24
|
-
return `
|
|
25
|
-
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding:24px;">
|
|
26
|
-
<h2 style="margin:0 0 16px 0;">Your Tono verification code</h2>
|
|
27
|
-
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
|
|
28
|
-
<p style="color:#666;font-size:14px;">This code expires in 10 minutes. If you didn't request it, you can ignore this email.</p>
|
|
29
|
-
</div>
|
|
30
|
-
`;
|
|
31
|
-
}
|
|
32
|
-
function renderText(code) {
|
|
33
|
-
return `Your Tono verification code is ${code}. It expires in 10 minutes.`;
|
|
34
|
-
}
|
package/dist/handler.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { type AuthVariables } from "./middleware";
|
|
2
|
-
import type { LoginConfig } from "./config";
|
|
1
|
+
import { type AuthVariables } from "./middleware.js";
|
|
2
|
+
import type { LoginConfig } from "./config.js";
|
|
3
3
|
export declare function createAuthApp<TEnv>(getConfig: (env: TEnv) => LoginConfig, basePath: string): import("hono/hono-base").HonoBase<{
|
|
4
4
|
Variables: AuthVariables;
|
|
5
5
|
}, import("hono/types").BlankSchema, string, string>;
|
package/dist/handler.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// 第 2 步钩子化(onVerified)时移回 Tono。
|
|
1
|
+
// 母本为 Tono-Server src/auth/handler.ts(第 1 步平移);第 2 步钩子化:
|
|
2
|
+
// 用户语义(users upsert / 试用 / /me)经 onVerified 移回 App 侧,
|
|
3
|
+
// 事件出口接 onEvent,refreshTtlMs / accessTtlSeconds 可配生效。
|
|
5
4
|
import { Hono } from "hono";
|
|
6
|
-
import { generateCode, storeCode, readCode, deleteCode, incrementAttempts, MAX_ATTEMPTS, } from "./code";
|
|
7
|
-
import { sendCodeEmail } from "./email";
|
|
8
|
-
import { checkSendRateLimit, recordSend } from "./rate_limit";
|
|
9
|
-
import { createSession, hashRefreshToken, findSession, rotateSession, revokeFamily, tryRescueSession, revokeSession, revokeAllForUser, } from "./session";
|
|
10
|
-
import { signAccessToken } from "./token";
|
|
11
|
-
import { createAuthMiddleware } from "./middleware";
|
|
12
|
-
import { logEvent } from "./log";
|
|
13
|
-
|
|
14
|
-
const TRIAL_PERIOD_MS = 90 * 24 * 60 * 60 * 1000;
|
|
5
|
+
import { generateCode, storeCode, readCode, deleteCode, incrementAttempts, MAX_ATTEMPTS, } from "./code.js";
|
|
6
|
+
import { sendCodeEmail } from "./email.js";
|
|
7
|
+
import { checkSendRateLimit, recordSend } from "./rate_limit.js";
|
|
8
|
+
import { createSession, hashRefreshToken, findSession, rotateSession, revokeFamily, tryRescueSession, revokeSession, revokeAllForUser, } from "./session.js";
|
|
9
|
+
import { signAccessToken, ACCESS_TTL_SECONDS } from "./token.js";
|
|
10
|
+
import { createAuthMiddleware } from "./middleware.js";
|
|
11
|
+
import { logEvent } from "./log.js";
|
|
12
|
+
import { registerGithubOauth } from "./plugins/github.js";
|
|
15
13
|
export function createAuthApp(getConfig, basePath) {
|
|
16
14
|
const authMiddleware = createAuthMiddleware(getConfig);
|
|
17
15
|
const auth = new Hono().basePath(basePath);
|
|
18
16
|
const cfg = (c) => getConfig(c.env);
|
|
17
|
+
const emit = (c) => cfg(c).onEvent ?? logEvent;
|
|
18
|
+
const accessTtl = (c) => cfg(c).jwt.accessTtlSeconds ?? ACCESS_TTL_SECONDS;
|
|
19
|
+
const sessionExpiry = (c) => {
|
|
20
|
+
const ttl = cfg(c).session?.refreshTtlMs ?? null;
|
|
21
|
+
// == null 而非 truthiness:0 是数值语义的「立即过期」,不是「不过期」
|
|
22
|
+
return ttl == null ? null : Date.now() + ttl;
|
|
23
|
+
};
|
|
19
24
|
auth.post("/code/send", async (c) => {
|
|
20
25
|
const body = await c.req
|
|
21
26
|
.json()
|
|
@@ -58,41 +63,33 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
58
63
|
return c.json({ error: "invalid_code" }, 400);
|
|
59
64
|
}
|
|
60
65
|
await deleteCode(cfg(c).kv, email);
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
isNewUser = true;
|
|
66
|
+
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
67
|
+
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
68
|
+
// 用户语义(建号/试用/档案)全部在 App 侧钩子内完成;
|
|
69
|
+
// 钩子失败 → 500(码已焚,重新发码),会话在钩子成功后才创建。
|
|
70
|
+
let verified;
|
|
71
|
+
try {
|
|
72
|
+
verified = await cfg(c).onVerified({
|
|
73
|
+
email,
|
|
74
|
+
provider: "email",
|
|
75
|
+
requestMeta: { ip, userAgent },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return c.json({ error: "internal" }, 500);
|
|
76
80
|
}
|
|
77
|
-
const userAgent = c.req.header("User-Agent") ?? null;
|
|
78
|
-
const ip = c.req.header("CF-Connecting-IP") ?? null;
|
|
79
81
|
const { sessionId, refreshToken } = await createSession(cfg(c).db, {
|
|
80
|
-
userId:
|
|
81
|
-
userAgent
|
|
82
|
-
ip
|
|
82
|
+
userId: verified.userId,
|
|
83
|
+
userAgent,
|
|
84
|
+
ip,
|
|
85
|
+
expiresAt: sessionExpiry(c),
|
|
83
86
|
});
|
|
84
|
-
const accessToken = await signAccessToken(cfg(c).jwt.secret,
|
|
87
|
+
const accessToken = await signAccessToken(cfg(c).jwt.secret, verified.userId, sessionId, accessTtl(c));
|
|
85
88
|
return c.json({
|
|
86
89
|
accessToken,
|
|
87
90
|
refreshToken,
|
|
88
|
-
user: {
|
|
89
|
-
|
|
90
|
-
email: user.email,
|
|
91
|
-
isPro: user.pro_expires_at != null && user.pro_expires_at > now,
|
|
92
|
-
proExpiresAt: user.pro_expires_at,
|
|
93
|
-
createdAt: user.created_at,
|
|
94
|
-
},
|
|
95
|
-
isNewUser,
|
|
91
|
+
...(verified.user !== undefined ? { user: verified.user } : {}),
|
|
92
|
+
...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
|
|
96
93
|
});
|
|
97
94
|
});
|
|
98
95
|
auth.post("/refresh", async (c) => {
|
|
@@ -112,21 +109,21 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
112
109
|
// 已轮换的 token 被再次提交:可能是"丢回执的诚实重试",先尝试救活。
|
|
113
110
|
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
114
111
|
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
115
|
-
const rescue = await tryRescueSession(cfg(c).db, row, { userAgent, ip });
|
|
112
|
+
const rescue = await tryRescueSession(cfg(c).db, row, { userAgent, ip }, sessionExpiry(c));
|
|
116
113
|
if (rescue.status === "rescued") {
|
|
117
|
-
|
|
114
|
+
emit(c)({
|
|
118
115
|
event: "refresh",
|
|
119
116
|
outcome: "rescued",
|
|
120
117
|
userId: row.user_id,
|
|
121
118
|
familyId: row.family_id,
|
|
122
119
|
ip: ip ?? null,
|
|
123
120
|
});
|
|
124
|
-
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, rescue.session.sessionId);
|
|
121
|
+
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, rescue.session.sessionId, accessTtl(c));
|
|
125
122
|
return c.json({ accessToken, refreshToken: rescue.session.refreshToken });
|
|
126
123
|
}
|
|
127
124
|
// 救活不成立 → 判定真重用,撤销整条会话链。guardrail 与 not_eligible 分开记录。
|
|
128
125
|
await revokeFamily(cfg(c).db, row.family_id);
|
|
129
|
-
|
|
126
|
+
emit(c)({
|
|
130
127
|
event: "refresh",
|
|
131
128
|
outcome: rescue.status === "guardrail" ? "guardrail_revoked" : "reuse_revoked",
|
|
132
129
|
userId: row.user_id,
|
|
@@ -140,32 +137,13 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
140
137
|
}
|
|
141
138
|
const userAgent = c.req.header("User-Agent") ?? undefined;
|
|
142
139
|
const ip = c.req.header("CF-Connecting-IP") ?? undefined;
|
|
143
|
-
const next = await rotateSession(cfg(c).db, sessionId, { userAgent, ip });
|
|
140
|
+
const next = await rotateSession(cfg(c).db, sessionId, { userAgent, ip }, sessionExpiry(c));
|
|
144
141
|
if (!next) {
|
|
145
142
|
return c.json({ error: "invalid_refresh_token", reason: "rotate_failed" }, 401);
|
|
146
143
|
}
|
|
147
|
-
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, next.sessionId);
|
|
144
|
+
const accessToken = await signAccessToken(cfg(c).jwt.secret, row.user_id, next.sessionId, accessTtl(c));
|
|
148
145
|
return c.json({ accessToken, refreshToken: next.refreshToken });
|
|
149
146
|
});
|
|
150
|
-
// 返回当前登录用户的最新信息(含实时 Pro 状态),供客户端在前台/启动时刷新本地缓存,
|
|
151
|
-
// 无需重新登录即可感知服务端 pro_expires_at 的变化(续费 / 到期 / 手动赠送)。
|
|
152
|
-
auth.get("/me", authMiddleware, async (c) => {
|
|
153
|
-
const userId = c.get("userId");
|
|
154
|
-
const row = await cfg(c)
|
|
155
|
-
.db.prepare("SELECT id, email, pro_expires_at, created_at FROM users WHERE id = ?")
|
|
156
|
-
.bind(userId)
|
|
157
|
-
.first();
|
|
158
|
-
if (!row)
|
|
159
|
-
return c.json({ error: "user_not_found" }, 404);
|
|
160
|
-
const now = Date.now();
|
|
161
|
-
return c.json({
|
|
162
|
-
id: row.id,
|
|
163
|
-
email: row.email,
|
|
164
|
-
isPro: row.pro_expires_at != null && row.pro_expires_at > now,
|
|
165
|
-
proExpiresAt: row.pro_expires_at,
|
|
166
|
-
createdAt: row.created_at,
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
147
|
auth.delete("/sessions", authMiddleware, async (c) => {
|
|
170
148
|
const sessionId = c.get("sessionId");
|
|
171
149
|
if (!sessionId)
|
|
@@ -178,5 +156,6 @@ export function createAuthApp(getConfig, basePath) {
|
|
|
178
156
|
await revokeAllForUser(cfg(c).db, userId);
|
|
179
157
|
return c.body(null, 204);
|
|
180
158
|
});
|
|
159
|
+
registerGithubOauth(auth, getConfig, basePath);
|
|
181
160
|
return auth;
|
|
182
161
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Hono } from "hono";
|
|
2
2
|
import type { MiddlewareHandler } from "hono";
|
|
3
|
-
import { type CreateLoginOptions, type LoginConfig } from "./config";
|
|
4
|
-
import { type AuthVariables } from "./middleware";
|
|
3
|
+
import { type CreateLoginOptions, type LoginConfig } from "./config.js";
|
|
4
|
+
import { type AuthVariables } from "./middleware.js";
|
|
5
5
|
export interface Login<TEnv> {
|
|
6
6
|
/** Hono 实例(已含 basePath)。Hono 消费方:app.route("/", login.app) */
|
|
7
7
|
app: Hono<{
|
|
@@ -15,12 +15,12 @@ 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 } from "./config";
|
|
19
|
-
export type { AuthVariables } from "./middleware";
|
|
20
|
-
export { createAuthMiddleware } from "./middleware";
|
|
21
|
-
export * from "./code";
|
|
22
|
-
export * from "./email";
|
|
23
|
-
export * from "./rate_limit";
|
|
24
|
-
export * from "./session";
|
|
25
|
-
export * from "./token";
|
|
26
|
-
export { logEvent } from "./log";
|
|
18
|
+
export type { LoginConfig, CreateLoginOptions, VerifiedIdentity, VerifiedResult, OnVerified, OnEvent, GithubSocialConfig, } from "./config.js";
|
|
19
|
+
export type { AuthVariables } from "./middleware.js";
|
|
20
|
+
export { createAuthMiddleware } from "./middleware.js";
|
|
21
|
+
export * from "./code.js";
|
|
22
|
+
export * from "./email.js";
|
|
23
|
+
export * from "./rate_limit.js";
|
|
24
|
+
export * from "./session.js";
|
|
25
|
+
export * from "./token.js";
|
|
26
|
+
export { logEvent } from "./log.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { memoizeResolver } from "./config";
|
|
2
|
-
import { createAuthApp } from "./handler";
|
|
3
|
-
import { createAuthMiddleware } from "./middleware";
|
|
1
|
+
import { memoizeResolver } from "./config.js";
|
|
2
|
+
import { createAuthApp } from "./handler.js";
|
|
3
|
+
import { createAuthMiddleware } from "./middleware.js";
|
|
4
4
|
export function createLogin(resolve, options = {}) {
|
|
5
5
|
const getConfig = memoizeResolver(resolve);
|
|
6
6
|
const app = createAuthApp(getConfig, options.basePath ?? "/auth");
|
|
@@ -10,12 +10,12 @@ export function createLogin(resolve, options = {}) {
|
|
|
10
10
|
middleware: createAuthMiddleware(getConfig),
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
-
export { createAuthMiddleware } from "./middleware";
|
|
13
|
+
export { createAuthMiddleware } from "./middleware.js";
|
|
14
14
|
// 底层模块出口:verifyAccessToken 供裸 Worker 的 requireAuth 双轨;
|
|
15
15
|
// session/code 等出口供消费方测试工具(如 Tono test/helpers)复用。
|
|
16
|
-
export * from "./code";
|
|
17
|
-
export * from "./email";
|
|
18
|
-
export * from "./rate_limit";
|
|
19
|
-
export * from "./session";
|
|
20
|
-
export * from "./token";
|
|
21
|
-
export { logEvent } from "./log";
|
|
16
|
+
export * from "./code.js";
|
|
17
|
+
export * from "./email.js";
|
|
18
|
+
export * from "./rate_limit.js";
|
|
19
|
+
export * from "./session.js";
|
|
20
|
+
export * from "./token.js";
|
|
21
|
+
export { logEvent } from "./log.js";
|
package/dist/middleware.d.ts
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import type { LoginConfig } from "../config.js";
|
|
3
|
+
import type { AuthVariables } from "../middleware.js";
|
|
4
|
+
export declare function registerGithubOauth<TEnv>(auth: Hono<{
|
|
5
|
+
Variables: AuthVariables;
|
|
6
|
+
}>, getConfig: (env: TEnv) => LoginConfig, basePath: string): void;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { generateRefreshToken as randomToken } from "../session.js";
|
|
2
|
+
import { createSession } from "../session.js";
|
|
3
|
+
import { signAccessToken, ACCESS_TTL_SECONDS } from "../token.js";
|
|
4
|
+
const GITHUB_AUTHORIZE = "https://github.com/login/oauth/authorize";
|
|
5
|
+
const GITHUB_TOKEN = "https://github.com/login/oauth/access_token";
|
|
6
|
+
const GITHUB_API_USER = "https://api.github.com/user";
|
|
7
|
+
const GITHUB_API_EMAILS = "https://api.github.com/user/emails";
|
|
8
|
+
const STATE_TTL_SECONDS = 600;
|
|
9
|
+
const OTC_TTL_SECONDS = 60;
|
|
10
|
+
// 结构化校验而非字符串前缀:startsWith("https://example.com") 会被
|
|
11
|
+
// https://example.com.evil.com 绕过(开放重定向 → otc 泄露给攻击者域)。
|
|
12
|
+
// scheme + host 精确匹配,path 只允许白名单条目的前缀扩展。
|
|
13
|
+
function redirectAllowed(redirect, github) {
|
|
14
|
+
let target;
|
|
15
|
+
try {
|
|
16
|
+
target = new URL(redirect);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return github.allowedRedirects.some((allowed) => {
|
|
22
|
+
let base;
|
|
23
|
+
try {
|
|
24
|
+
base = new URL(allowed);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return (target.protocol === base.protocol &&
|
|
30
|
+
target.host === base.host &&
|
|
31
|
+
target.pathname.startsWith(base.pathname));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function withParam(url, key, value) {
|
|
35
|
+
return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
|
|
36
|
+
}
|
|
37
|
+
export function registerGithubOauth(auth, getConfig, basePath) {
|
|
38
|
+
const cfg = (c) => getConfig(c.env);
|
|
39
|
+
const github = (c) => cfg(c).socials?.github;
|
|
40
|
+
auth.get("/oauth/github/start", async (c) => {
|
|
41
|
+
const gh = github(c);
|
|
42
|
+
if (!gh)
|
|
43
|
+
return c.json({ error: "not_configured" }, 404);
|
|
44
|
+
const redirect = c.req.query("redirect") ?? "";
|
|
45
|
+
if (!redirect || !redirectAllowed(redirect, gh)) {
|
|
46
|
+
return c.json({ error: "invalid_redirect" }, 400);
|
|
47
|
+
}
|
|
48
|
+
const state = randomToken();
|
|
49
|
+
const record = { redirect };
|
|
50
|
+
await cfg(c).kv.put(`oauth:state:${state}`, JSON.stringify(record), {
|
|
51
|
+
expirationTtl: STATE_TTL_SECONDS,
|
|
52
|
+
});
|
|
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);
|
|
61
|
+
});
|
|
62
|
+
auth.get("/oauth/github/callback", async (c) => {
|
|
63
|
+
const gh = github(c);
|
|
64
|
+
if (!gh)
|
|
65
|
+
return c.json({ error: "not_configured" }, 404);
|
|
66
|
+
const code = c.req.query("code") ?? "";
|
|
67
|
+
const state = c.req.query("state") ?? "";
|
|
68
|
+
const stateKey = `oauth:state:${state}`;
|
|
69
|
+
const rawState = state ? await cfg(c).kv.get(stateKey) : null;
|
|
70
|
+
if (!code || !rawState) {
|
|
71
|
+
// state 无效即回跳地址不可信,只能就地报错
|
|
72
|
+
return c.json({ error: "invalid_state" }, 400);
|
|
73
|
+
}
|
|
74
|
+
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)
|
|
91
|
+
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",
|
|
97
|
+
};
|
|
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;
|
|
108
|
+
if (!email)
|
|
109
|
+
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
|
+
let verified;
|
|
113
|
+
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
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return c.redirect(withParam(redirect, "error", "internal"), 302);
|
|
123
|
+
}
|
|
124
|
+
const refreshTtlMs = cfg(c).session?.refreshTtlMs ?? null;
|
|
125
|
+
const { sessionId, refreshToken } = await createSession(cfg(c).db, {
|
|
126
|
+
userId: verified.userId,
|
|
127
|
+
userAgent,
|
|
128
|
+
ip,
|
|
129
|
+
expiresAt: refreshTtlMs == null ? null : Date.now() + refreshTtlMs,
|
|
130
|
+
});
|
|
131
|
+
const accessToken = await signAccessToken(cfg(c).jwt.secret, verified.userId, sessionId, cfg(c).jwt.accessTtlSeconds ?? ACCESS_TTL_SECONDS);
|
|
132
|
+
const otc = randomToken();
|
|
133
|
+
const payload = {
|
|
134
|
+
accessToken,
|
|
135
|
+
refreshToken,
|
|
136
|
+
...(verified.isNewUser !== undefined ? { isNewUser: verified.isNewUser } : {}),
|
|
137
|
+
...(verified.user !== undefined ? { user: verified.user } : {}),
|
|
138
|
+
};
|
|
139
|
+
await cfg(c).kv.put(`oauth:otc:${otc}`, JSON.stringify(payload), {
|
|
140
|
+
expirationTtl: OTC_TTL_SECONDS,
|
|
141
|
+
});
|
|
142
|
+
return c.redirect(withParam(redirect, "otc", otc), 302);
|
|
143
|
+
});
|
|
144
|
+
auth.post("/oauth/exchange", async (c) => {
|
|
145
|
+
const gh = github(c);
|
|
146
|
+
if (!gh)
|
|
147
|
+
return c.json({ error: "not_configured" }, 404);
|
|
148
|
+
const body = await c.req
|
|
149
|
+
.json()
|
|
150
|
+
.catch(() => ({}));
|
|
151
|
+
const otc = (body.otc ?? "").trim();
|
|
152
|
+
const key = `oauth:otc:${otc}`;
|
|
153
|
+
const raw = otc ? await cfg(c).kv.get(key) : null;
|
|
154
|
+
if (!raw)
|
|
155
|
+
return c.json({ error: "invalid_otc" }, 400);
|
|
156
|
+
await cfg(c).kv.delete(key); // 单次使用,兑换即焚
|
|
157
|
+
return c.json(JSON.parse(raw), 200);
|
|
158
|
+
});
|
|
159
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface CreateSessionInput {
|
|
|
16
16
|
userId: string;
|
|
17
17
|
userAgent?: string;
|
|
18
18
|
ip?: string;
|
|
19
|
+
/** 会话过期时刻(epoch ms);缺省/null = 不过期 */
|
|
20
|
+
expiresAt?: number | null;
|
|
19
21
|
}
|
|
20
22
|
export interface CreateSessionOutput {
|
|
21
23
|
sessionId: string;
|
|
@@ -29,7 +31,7 @@ export declare function findSession(db: D1Database, sessionId: string): Promise<
|
|
|
29
31
|
export declare function rotateSession(db: D1Database, oldSessionId: string, meta: {
|
|
30
32
|
userAgent?: string;
|
|
31
33
|
ip?: string;
|
|
32
|
-
}): Promise<CreateSessionOutput | null>;
|
|
34
|
+
}, expiresAt?: number | null): Promise<CreateSessionOutput | null>;
|
|
33
35
|
export declare const RESCUE_WINDOW_MS: number;
|
|
34
36
|
export declare const RESCUE_LIMIT = 3;
|
|
35
37
|
export type RescueResult = {
|
|
@@ -43,7 +45,7 @@ export type RescueResult = {
|
|
|
43
45
|
export declare function tryRescueSession(db: D1Database, revoked: SessionRow, meta: {
|
|
44
46
|
userAgent?: string;
|
|
45
47
|
ip?: string;
|
|
46
|
-
}): Promise<RescueResult>;
|
|
48
|
+
}, expiresAt?: number | null): Promise<RescueResult>;
|
|
47
49
|
export declare function revokeFamily(db: D1Database, familyId: string): Promise<void>;
|
|
48
50
|
export declare function revokeSession(db: D1Database, sessionId: string): Promise<void>;
|
|
49
51
|
export declare function revokeAllForUser(db: D1Database, userId: string): Promise<void>;
|
package/dist/session.js
CHANGED
|
@@ -21,8 +21,8 @@ export async function createSession(db, input) {
|
|
|
21
21
|
const now = Date.now();
|
|
22
22
|
await db
|
|
23
23
|
.prepare(`INSERT INTO sessions (id, user_id, family_id, expires_at, created_at, last_used_at, user_agent, ip, revoked_at, replaced_by_id)
|
|
24
|
-
VALUES (?, ?, ?,
|
|
25
|
-
.bind(sessionId, input.userId, familyId, now, now, input.userAgent ?? null, input.ip ?? null)
|
|
24
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)`)
|
|
25
|
+
.bind(sessionId, input.userId, familyId, input.expiresAt ?? null, now, now, input.userAgent ?? null, input.ip ?? null)
|
|
26
26
|
.run();
|
|
27
27
|
return { sessionId, refreshToken, familyId };
|
|
28
28
|
}
|
|
@@ -32,7 +32,7 @@ export async function findSession(db, sessionId) {
|
|
|
32
32
|
.bind(sessionId)
|
|
33
33
|
.first();
|
|
34
34
|
}
|
|
35
|
-
export async function rotateSession(db, oldSessionId, meta) {
|
|
35
|
+
export async function rotateSession(db, oldSessionId, meta, expiresAt = null) {
|
|
36
36
|
const old = await findSession(db, oldSessionId);
|
|
37
37
|
if (!old)
|
|
38
38
|
return null;
|
|
@@ -42,8 +42,8 @@ export async function rotateSession(db, oldSessionId, meta) {
|
|
|
42
42
|
await db.batch([
|
|
43
43
|
db
|
|
44
44
|
.prepare(`INSERT INTO sessions (id, user_id, family_id, expires_at, created_at, last_used_at, user_agent, ip, revoked_at, replaced_by_id)
|
|
45
|
-
VALUES (?, ?, ?,
|
|
46
|
-
.bind(newId, old.user_id, old.family_id, now, now, meta.userAgent ?? old.user_agent, meta.ip ?? old.ip),
|
|
45
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)`)
|
|
46
|
+
.bind(newId, old.user_id, old.family_id, expiresAt, now, now, meta.userAgent ?? old.user_agent, meta.ip ?? old.ip),
|
|
47
47
|
db
|
|
48
48
|
.prepare("UPDATE sessions SET revoked_at = ?, replaced_by_id = ? WHERE id = ? AND revoked_at IS NULL")
|
|
49
49
|
.bind(now, newId, oldSessionId),
|
|
@@ -65,7 +65,7 @@ async function recentRescueCount(db, familyId, since) {
|
|
|
65
65
|
// 丢回执诚实重试的救活:presented 的 token 已被轮换(revoked),但它的直接后继
|
|
66
66
|
// 仍 active(会话链未被第二方推进)—— 说明客户端没收到上次轮换的回执、手里只剩旧
|
|
67
67
|
// token。此时从后继再轮换一次、发一套可用的新证,避免误把整条链当成 token 重用杀掉。
|
|
68
|
-
export async function tryRescueSession(db, revoked, meta) {
|
|
68
|
+
export async function tryRescueSession(db, revoked, meta, expiresAt = null) {
|
|
69
69
|
if (revoked.replaced_by_id === null)
|
|
70
70
|
return { status: "not_eligible" };
|
|
71
71
|
const successor = await findSession(db, revoked.replaced_by_id);
|
|
@@ -80,8 +80,8 @@ export async function tryRescueSession(db, revoked, meta) {
|
|
|
80
80
|
await db.batch([
|
|
81
81
|
db
|
|
82
82
|
.prepare(`INSERT INTO sessions (id, user_id, family_id, expires_at, created_at, last_used_at, user_agent, ip, revoked_at, replaced_by_id, rescued_at)
|
|
83
|
-
VALUES (?, ?, ?,
|
|
84
|
-
.bind(newId, successor.user_id, successor.family_id, now, now, meta.userAgent ?? successor.user_agent, meta.ip ?? successor.ip, now),
|
|
83
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)`)
|
|
84
|
+
.bind(newId, successor.user_id, successor.family_id, expiresAt, now, now, meta.userAgent ?? successor.user_agent, meta.ip ?? successor.ip, now),
|
|
85
85
|
db
|
|
86
86
|
.prepare("UPDATE sessions SET revoked_at = ?, replaced_by_id = ? WHERE id = ? AND revoked_at IS NULL")
|
|
87
87
|
.bind(now, newId, successor.id),
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// en 模板即 Tono 生产模板的 brand 参数化:brand="Tono" 时输出与平移前逐字节一致。
|
|
2
|
+
export function enTemplates(brand) {
|
|
3
|
+
const title = brand ? `Your ${brand} verification code` : "Your verification code";
|
|
4
|
+
return {
|
|
5
|
+
subject: (code) => `${title}: ${code}`,
|
|
6
|
+
html: (code) => `
|
|
7
|
+
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding:24px;">
|
|
8
|
+
<h2 style="margin:0 0 16px 0;">${title}</h2>
|
|
9
|
+
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
|
|
10
|
+
<p style="color:#666;font-size:14px;">This code expires in 10 minutes. If you didn't request it, you can ignore this email.</p>
|
|
11
|
+
</div>
|
|
12
|
+
`,
|
|
13
|
+
text: (code) => `${title} is ${code}. It expires in 10 minutes.`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function zhTemplates(brand) {
|
|
2
|
+
const title = brand ? `${brand} 登录验证码` : "登录验证码";
|
|
3
|
+
return {
|
|
4
|
+
subject: (code) => `${title}:${code}`,
|
|
5
|
+
html: (code) => `
|
|
6
|
+
<div style="font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;padding:24px;">
|
|
7
|
+
<h2 style="margin:0 0 16px 0;">${title}</h2>
|
|
8
|
+
<p style="font-size:32px;letter-spacing:8px;font-weight:700;margin:16px 0;">${code}</p>
|
|
9
|
+
<p style="color:#666;font-size:14px;">验证码 10 分钟内有效。如果这不是你的操作,请忽略这封邮件。</p>
|
|
10
|
+
</div>
|
|
11
|
+
`,
|
|
12
|
+
text: (code) => `你的${title}是 ${code},10 分钟内有效。`,
|
|
13
|
+
};
|
|
14
|
+
}
|
package/dist/token.d.ts
CHANGED
|
@@ -4,5 +4,5 @@ export interface AccessPayload extends JWTPayload {
|
|
|
4
4
|
sub: string;
|
|
5
5
|
sid: string;
|
|
6
6
|
}
|
|
7
|
-
export declare function signAccessToken(secret: string, userId: string, sessionId: string): Promise<string>;
|
|
7
|
+
export declare function signAccessToken(secret: string, userId: string, sessionId: string, ttlSeconds?: number): Promise<string>;
|
|
8
8
|
export declare function verifyAccessToken(secret: string, token: string): Promise<AccessPayload>;
|
package/dist/token.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { SignJWT, jwtVerify } from "jose";
|
|
2
2
|
export const ACCESS_TTL_SECONDS = 3600;
|
|
3
|
-
export async function signAccessToken(secret, userId, sessionId) {
|
|
3
|
+
export async function signAccessToken(secret, userId, sessionId, ttlSeconds = ACCESS_TTL_SECONDS) {
|
|
4
4
|
const key = new TextEncoder().encode(secret);
|
|
5
5
|
return new SignJWT({ sub: userId, sid: sessionId })
|
|
6
6
|
.setProtectedHeader({ alg: "HS256" })
|
|
7
7
|
.setIssuedAt()
|
|
8
|
-
.setExpirationTime(`${
|
|
8
|
+
.setExpirationTime(`${ttlSeconds}s`)
|
|
9
9
|
.sign(key);
|
|
10
10
|
}
|
|
11
11
|
export async function verifyAccessToken(secret, token) {
|
package/package.json
CHANGED