@homurajs/auth 0.0.0-stage → 26.10.13
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 +30 -2
- package/dist/hono.d.ts +50 -0
- package/dist/hono.js +157 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +246 -0
- package/dist/manager-DUAs-ASy.d.ts +176 -0
- package/dist/tables.d.ts +237 -0
- package/dist/tables.js +33 -0
- package/package.json +52 -3
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @homurajs/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
认证:通用标准 OAuth2 (Authorization Code Flow) 客户端 + 自管 session(HS256 短 JWT + opaque refresh rotate + 族吊销)。
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// OAuth 预设来自 @homurajs/3p(结构化契约,鸭子类型互操作)
|
|
7
|
+
import { dingtalkOAuth } from '@homurajs/3p/dingtalk'
|
|
8
|
+
import { OAuth2Client, SessionManager } from '@homurajs/auth'
|
|
9
|
+
import { createMemoryTtlKv } from '@homurajs/core'
|
|
10
|
+
|
|
11
|
+
const preset = dingtalkOAuth({ orgId: 'my_org' }) // 对接 ../oauth2-dingtalk
|
|
12
|
+
const client = new OAuth2Client({
|
|
13
|
+
clientId: '...', clientSecret: '...', redirectUri: 'https://app.example/auth/callback',
|
|
14
|
+
stateKv: createMemoryTtlKv(), // 多实例部署换 createDbTtlKv
|
|
15
|
+
...preset, // endpoints + mapUser
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const sessions = new SessionManager(
|
|
19
|
+
{ secret: SESSION_SECRET /*>=32B*/, accessTokenTtlSec: 7200, refreshTokenTtlSec: 30*24*3600 },
|
|
20
|
+
createDrizzleSessionStore(db),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
// Hono 集成
|
|
24
|
+
import { createAuthRoutes, sessionMiddleware, requireAuth } from '@homurajs/auth/hono'
|
|
25
|
+
app.use('*', sessionMiddleware(sessions))
|
|
26
|
+
app.route('/', createAuthRoutes({ client, sessions }))
|
|
27
|
+
app.use('/api/*', requireAuth)
|
|
28
|
+
app.get('/api/me', (c) => c.json(c.get('auth')))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
上游契约与 state 语义见 `docs/specs/module-oauth2`;session 状态机见 `module-session`。使用示范见模板 `src/server/app.ts`(buildAuth)与 `src/server/routes/api.ts`(c.get('auth'))。
|
package/dist/hono.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { n as SessionManager, s as OAuth2Client, t as SessionClaims } from "./manager-DUAs-ASy.js";
|
|
2
|
+
import { Hono, MiddlewareHandler } from "hono";
|
|
3
|
+
//#region src/hono/routes.d.ts
|
|
4
|
+
interface AuthPaths {
|
|
5
|
+
login?: string;
|
|
6
|
+
callback?: string;
|
|
7
|
+
refresh?: string;
|
|
8
|
+
logout?: string;
|
|
9
|
+
}
|
|
10
|
+
interface AuthRoutesOptions {
|
|
11
|
+
client: OAuth2Client;
|
|
12
|
+
sessions: SessionManager;
|
|
13
|
+
paths?: AuthPaths;
|
|
14
|
+
/** cookie Secure(qw-pages/https 传 true) */
|
|
15
|
+
secure?: boolean;
|
|
16
|
+
/** 登录成功后的缺省跳转(returnTo 优先) */
|
|
17
|
+
defaultReturnTo?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
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
|
|
24
|
+
*/
|
|
25
|
+
export declare function createAuthRoutes(opts: AuthRoutesOptions): Hono;
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/hono/middleware.d.ts
|
|
28
|
+
interface SessionMiddlewareOptions {
|
|
29
|
+
/** 允许 Authorization: Bearer(默认 true) */
|
|
30
|
+
bearer?: boolean;
|
|
31
|
+
/** cookie Secure 属性(qw-pages/https 传 true;本地 http dev 传 false) */
|
|
32
|
+
secure?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* cookie 优先、Authorization: Bearer 兼容;
|
|
36
|
+
* access 过期且 refresh cookie 有效 → 透明 rotate 续期;c.set('auth', claims)。
|
|
37
|
+
* 无身份不报错(交给 requireAuth 决定 401/302)。
|
|
38
|
+
*/
|
|
39
|
+
export declare function sessionMiddleware(sessions: SessionManager, opts?: SessionMiddlewareOptions): MiddlewareHandler;
|
|
40
|
+
/** 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login */
|
|
41
|
+
export declare function requireAuth(opts?: {
|
|
42
|
+
loginPath?: string;
|
|
43
|
+
}): MiddlewareHandler;
|
|
44
|
+
declare module 'hono' {
|
|
45
|
+
interface ContextVariableMap {
|
|
46
|
+
auth: SessionClaims;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export type { AuthPaths, AuthRoutesOptions, SessionClaims, SessionMiddlewareOptions };
|
package/dist/hono.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getCookie, setCookie } from "hono/cookie";
|
|
3
|
+
//#region src/hono/routes.ts
|
|
4
|
+
/**
|
|
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
|
|
9
|
+
*/
|
|
10
|
+
function createAuthRoutes(opts) {
|
|
11
|
+
const { client, sessions } = opts;
|
|
12
|
+
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"
|
|
17
|
+
};
|
|
18
|
+
const secure = opts.secure ?? false;
|
|
19
|
+
const names = sessions.cookies;
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
app.get(paths.login, async (c) => {
|
|
22
|
+
const returnTo = c.req.query("returnTo");
|
|
23
|
+
const url = await client.authorizeUrl(returnTo);
|
|
24
|
+
return c.redirect(url);
|
|
25
|
+
});
|
|
26
|
+
app.get(paths.callback, async (c) => {
|
|
27
|
+
const code = c.req.query("code");
|
|
28
|
+
const state = c.req.query("state");
|
|
29
|
+
if (!code) return c.json({ error: "missing_code" }, 400);
|
|
30
|
+
try {
|
|
31
|
+
const { user, returnTo } = await client.callback({
|
|
32
|
+
code,
|
|
33
|
+
state: state ?? void 0
|
|
34
|
+
});
|
|
35
|
+
const tokens = await sessions.createSession(user);
|
|
36
|
+
setCookie(c, names.access, tokens.accessToken, {
|
|
37
|
+
httpOnly: true,
|
|
38
|
+
sameSite: "Lax",
|
|
39
|
+
secure,
|
|
40
|
+
path: "/",
|
|
41
|
+
maxAge: sessions.accessTokenTtlSec
|
|
42
|
+
});
|
|
43
|
+
setCookie(c, names.refresh, tokens.refreshToken, {
|
|
44
|
+
httpOnly: true,
|
|
45
|
+
sameSite: "Lax",
|
|
46
|
+
secure,
|
|
47
|
+
path: "/",
|
|
48
|
+
maxAge: sessions.refreshTokenTtlSec
|
|
49
|
+
});
|
|
50
|
+
return c.redirect(returnTo || opts.defaultReturnTo || "/");
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return c.json({
|
|
53
|
+
error: "oauth_failed",
|
|
54
|
+
message: e.message
|
|
55
|
+
}, 401);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
app.post(paths.refresh, async (c) => {
|
|
59
|
+
const refreshToken = getCookie(c, names.refresh);
|
|
60
|
+
if (!refreshToken) return c.json({ error: "no_refresh_token" }, 401);
|
|
61
|
+
const rotated = await sessions.refresh(refreshToken);
|
|
62
|
+
if (!rotated) return c.json({ error: "invalid_refresh_token" }, 401);
|
|
63
|
+
setCookie(c, names.access, rotated.accessToken, {
|
|
64
|
+
httpOnly: true,
|
|
65
|
+
sameSite: "Lax",
|
|
66
|
+
secure,
|
|
67
|
+
path: "/",
|
|
68
|
+
maxAge: sessions.accessTokenTtlSec
|
|
69
|
+
});
|
|
70
|
+
setCookie(c, names.refresh, rotated.refreshToken, {
|
|
71
|
+
httpOnly: true,
|
|
72
|
+
sameSite: "Lax",
|
|
73
|
+
secure,
|
|
74
|
+
path: "/",
|
|
75
|
+
maxAge: sessions.refreshTokenTtlSec
|
|
76
|
+
});
|
|
77
|
+
return c.json({ ok: true });
|
|
78
|
+
});
|
|
79
|
+
app.post(paths.logout, async (c) => {
|
|
80
|
+
const claims = c.get("auth");
|
|
81
|
+
if (claims?.sid) await sessions.revokeSession(claims.sid);
|
|
82
|
+
setCookie(c, names.access, "", {
|
|
83
|
+
httpOnly: true,
|
|
84
|
+
secure,
|
|
85
|
+
path: "/",
|
|
86
|
+
maxAge: 0
|
|
87
|
+
});
|
|
88
|
+
setCookie(c, names.refresh, "", {
|
|
89
|
+
httpOnly: true,
|
|
90
|
+
secure,
|
|
91
|
+
path: "/",
|
|
92
|
+
maxAge: 0
|
|
93
|
+
});
|
|
94
|
+
return c.json({ ok: true });
|
|
95
|
+
});
|
|
96
|
+
return app;
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/hono/middleware.ts
|
|
100
|
+
/**
|
|
101
|
+
* cookie 优先、Authorization: Bearer 兼容;
|
|
102
|
+
* access 过期且 refresh cookie 有效 → 透明 rotate 续期;c.set('auth', claims)。
|
|
103
|
+
* 无身份不报错(交给 requireAuth 决定 401/302)。
|
|
104
|
+
*/
|
|
105
|
+
function sessionMiddleware(sessions, opts) {
|
|
106
|
+
const bearer = opts?.bearer ?? true;
|
|
107
|
+
const secure = opts?.secure ?? false;
|
|
108
|
+
const names = sessions.cookies;
|
|
109
|
+
return async (c, next) => {
|
|
110
|
+
let token = getCookie(c, names.access);
|
|
111
|
+
if (!token && bearer) {
|
|
112
|
+
const auth = c.req.header("authorization");
|
|
113
|
+
if (auth?.startsWith("Bearer ")) token = auth.slice(7);
|
|
114
|
+
}
|
|
115
|
+
let claims = token ? await sessions.verifyAccessToken(token) : null;
|
|
116
|
+
if (!claims) {
|
|
117
|
+
const refreshToken = getCookie(c, names.refresh);
|
|
118
|
+
if (refreshToken) {
|
|
119
|
+
const rotated = await sessions.refresh(refreshToken);
|
|
120
|
+
if (rotated) {
|
|
121
|
+
claims = await sessions.verifyAccessToken(rotated.accessToken);
|
|
122
|
+
setCookie(c, names.access, rotated.accessToken, {
|
|
123
|
+
httpOnly: true,
|
|
124
|
+
sameSite: "Lax",
|
|
125
|
+
secure,
|
|
126
|
+
path: "/",
|
|
127
|
+
maxAge: sessions.accessTokenTtlSec
|
|
128
|
+
});
|
|
129
|
+
setCookie(c, names.refresh, rotated.refreshToken, {
|
|
130
|
+
httpOnly: true,
|
|
131
|
+
sameSite: "Lax",
|
|
132
|
+
secure,
|
|
133
|
+
path: "/",
|
|
134
|
+
maxAge: sessions.refreshTokenTtlSec
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (claims) c.set("auth", claims);
|
|
140
|
+
await next();
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** 无身份:/api 或 Accept:json → 401 JSON;页面请求 → 302 login */
|
|
144
|
+
function requireAuth(opts) {
|
|
145
|
+
const loginPath = opts?.loginPath ?? "/auth/login";
|
|
146
|
+
return async (c, next) => {
|
|
147
|
+
if (c.get("auth")) {
|
|
148
|
+
await next();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (c.req.path.startsWith("/api") || c.req.header("accept")?.includes("application/json")) return c.json({ error: "unauthorized" }, 401);
|
|
152
|
+
const returnTo = c.req.url;
|
|
153
|
+
return c.redirect(`${loginPath}?returnTo=${encodeURIComponent(returnTo)}`);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
export { createAuthRoutes, requireAuth, sessionMiddleware };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as RefreshTokenStore, c as OAuth2ClientConfig, d as TokenResponse, i as SessionTokens, l as AuthUser, n as SessionManager, o as createDrizzleSessionStore, r as SessionManagerConfig, s as OAuth2Client, t as SessionClaims, u as OAuth2Endpoints } from "./manager-DUAs-ASy.js";
|
|
2
|
+
export { type AuthUser, OAuth2Client, type OAuth2ClientConfig, type OAuth2Endpoints, type RefreshTokenStore, type SessionClaims, SessionManager, type SessionManagerConfig, type SessionTokens, type TokenResponse, createDrizzleSessionStore };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { randomToken } from "@homurajs/core";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
4
|
+
import { sql } from "drizzle-orm";
|
|
5
|
+
import { isSqlite } from "@homurajs/db";
|
|
6
|
+
//#region src/oauth2/client.ts
|
|
7
|
+
const DEFAULT_STATE_TTL = 3e5;
|
|
8
|
+
/**
|
|
9
|
+
* 通用标准 OAuth2 (Authorization Code Flow) 客户端。
|
|
10
|
+
* 服务端(如 ../oauth2-dingtalk)由 3p 预设注入,本类不感知具体 IdP。
|
|
11
|
+
*/
|
|
12
|
+
var OAuth2Client = class {
|
|
13
|
+
config;
|
|
14
|
+
fetchImpl;
|
|
15
|
+
stateTtl;
|
|
16
|
+
constructor(config) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
19
|
+
this.stateTtl = config.stateTtlMs ?? DEFAULT_STATE_TTL;
|
|
20
|
+
}
|
|
21
|
+
/** 生成 authorize URL(state 入 kv),returnTo 编码进 state 关联存储 */
|
|
22
|
+
async authorizeUrl(returnTo) {
|
|
23
|
+
const state = randomToken(16);
|
|
24
|
+
await this.config.stateKv.set(state, returnTo ?? "/", this.stateTtl);
|
|
25
|
+
const url = new URL(this.config.endpoints.authorizeUrl);
|
|
26
|
+
url.searchParams.append("client_id", this.config.clientId);
|
|
27
|
+
url.searchParams.append("redirect_uri", this.config.redirectUri);
|
|
28
|
+
url.searchParams.append("response_type", "code");
|
|
29
|
+
if (this.config.scopes?.length) url.searchParams.append("scope", this.config.scopes.join(" "));
|
|
30
|
+
url.searchParams.append("state", state);
|
|
31
|
+
for (const [k, v] of Object.entries(this.config.authorizeExtraParams ?? {})) url.searchParams.append(k, v);
|
|
32
|
+
return url.toString();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 回调:state 一次性消费校验 → code 换 token → 拉 userinfo → mapUser
|
|
36
|
+
* 返回 { user, returnTo },供 hono 路由建 session 后跳转
|
|
37
|
+
*/
|
|
38
|
+
async callback(params) {
|
|
39
|
+
if (!params.code) throw new Error("缺少 code");
|
|
40
|
+
let returnTo = "/";
|
|
41
|
+
if (params.state) {
|
|
42
|
+
const stored = await this.config.stateKv.take(params.state);
|
|
43
|
+
if (stored === null) throw new Error("state 无效或已过期");
|
|
44
|
+
returnTo = stored;
|
|
45
|
+
} else if (!this.config.allowNoState) throw new Error("缺少 state(如需放行请显式 allowNoState: true,dev only)");
|
|
46
|
+
const token = await this.exchangeToken(params.code);
|
|
47
|
+
const raw = await this.fetchUserinfo(token.access_token);
|
|
48
|
+
return {
|
|
49
|
+
user: this.config.mapUser(raw),
|
|
50
|
+
returnTo
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** 内部:code 换 token(POST tokenUrl,form 编码) */
|
|
54
|
+
async exchangeToken(code) {
|
|
55
|
+
const body = new URLSearchParams({
|
|
56
|
+
grant_type: "authorization_code",
|
|
57
|
+
code,
|
|
58
|
+
client_id: this.config.clientId,
|
|
59
|
+
client_secret: this.config.clientSecret,
|
|
60
|
+
redirect_uri: this.config.redirectUri
|
|
61
|
+
});
|
|
62
|
+
const res = await this.fetchImpl(this.config.endpoints.tokenUrl, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
65
|
+
body: body.toString()
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) throw new Error(`token 端点失败: ${res.status} ${await res.text()}`);
|
|
68
|
+
return await res.json();
|
|
69
|
+
}
|
|
70
|
+
/** 内部:Bearer 拉 userinfo */
|
|
71
|
+
async fetchUserinfo(accessToken) {
|
|
72
|
+
const res = await this.fetchImpl(this.config.endpoints.userinfoUrl, {
|
|
73
|
+
method: "GET",
|
|
74
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) throw new Error(`userinfo 端点失败: ${res.status} ${await res.text()}`);
|
|
77
|
+
return await res.json();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/session/manager.ts
|
|
82
|
+
const DEFAULT_ACCESS_TTL = 7200;
|
|
83
|
+
const DEFAULT_REFRESH_TTL = 2592e3;
|
|
84
|
+
const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
85
|
+
/**
|
|
86
|
+
* 自管 session:短 JWT(HS256)+ opaque refresh(rotate + familyId 复用检测整族吊销)。
|
|
87
|
+
* 上游(oauth2-dingtalk)不支持 refresh token,生命周期完全自管。
|
|
88
|
+
*/
|
|
89
|
+
var SessionManager = class {
|
|
90
|
+
config;
|
|
91
|
+
store;
|
|
92
|
+
key;
|
|
93
|
+
accessTtl;
|
|
94
|
+
refreshTtl;
|
|
95
|
+
cookieNames;
|
|
96
|
+
nowFn;
|
|
97
|
+
constructor(config, store) {
|
|
98
|
+
this.config = config;
|
|
99
|
+
this.store = store;
|
|
100
|
+
if (config.secret.length < 32) throw new Error("session secret 必须 >= 32 字节(HS256 强度要求)");
|
|
101
|
+
this.key = new TextEncoder().encode(config.secret);
|
|
102
|
+
this.accessTtl = config.accessTokenTtlSec ?? DEFAULT_ACCESS_TTL;
|
|
103
|
+
this.refreshTtl = config.refreshTokenTtlSec ?? DEFAULT_REFRESH_TTL;
|
|
104
|
+
this.cookieNames = {
|
|
105
|
+
access: config.cookieNames?.access ?? "homura_at",
|
|
106
|
+
refresh: config.cookieNames?.refresh ?? "homura_rt"
|
|
107
|
+
};
|
|
108
|
+
this.nowFn = config.now ?? (() => /* @__PURE__ */ new Date());
|
|
109
|
+
}
|
|
110
|
+
get cookies() {
|
|
111
|
+
return this.cookieNames;
|
|
112
|
+
}
|
|
113
|
+
get accessTokenTtlSec() {
|
|
114
|
+
return this.accessTtl;
|
|
115
|
+
}
|
|
116
|
+
get refreshTokenTtlSec() {
|
|
117
|
+
return this.refreshTtl;
|
|
118
|
+
}
|
|
119
|
+
async createSession(user) {
|
|
120
|
+
const sid = randomToken(16);
|
|
121
|
+
return {
|
|
122
|
+
accessToken: await this.signAccess(user, sid),
|
|
123
|
+
refreshToken: await this.issueRefresh(sid, user, sid)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async verifyAccessToken(token) {
|
|
127
|
+
try {
|
|
128
|
+
const { payload } = await jwtVerify(token, this.key, {
|
|
129
|
+
algorithms: ["HS256"],
|
|
130
|
+
currentDate: this.nowFn()
|
|
131
|
+
});
|
|
132
|
+
return payload;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* rotate:旧 token 一次性消费;复用检出(同 family 二次使用)→ 吊销整族。
|
|
139
|
+
* 返回 null 表示 refresh 链终态(过期/已消费/复用检出)。
|
|
140
|
+
*/
|
|
141
|
+
async refresh(refreshToken) {
|
|
142
|
+
const consumed = await this.store.consume(sha256(refreshToken));
|
|
143
|
+
if (!consumed) return null;
|
|
144
|
+
if (consumed.revoked) {
|
|
145
|
+
await this.store.revokeFamily(consumed.familyId);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const profile = consumed.profile ? JSON.parse(consumed.profile) : {};
|
|
149
|
+
const user = {
|
|
150
|
+
sub: consumed.sub,
|
|
151
|
+
...profile
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
accessToken: await this.signAccess(user, consumed.sid),
|
|
155
|
+
refreshToken: await this.issueRefresh(consumed.sid, user, consumed.familyId)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
async revokeSession(sid) {
|
|
159
|
+
await this.store.revokeFamily(sid);
|
|
160
|
+
}
|
|
161
|
+
async revokeUser(sub) {
|
|
162
|
+
await this.store.revokeUser(sub);
|
|
163
|
+
}
|
|
164
|
+
async signAccess(user, sid) {
|
|
165
|
+
const now = this.nowFn();
|
|
166
|
+
const exp = new Date(now.getTime() + this.accessTtl * 1e3);
|
|
167
|
+
return new SignJWT({
|
|
168
|
+
sub: user.sub,
|
|
169
|
+
nickname: user.nickname,
|
|
170
|
+
avatarUrl: user.avatarUrl,
|
|
171
|
+
sid
|
|
172
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt(now).setJti(randomToken(8)).setExpirationTime(exp).sign(this.key);
|
|
173
|
+
}
|
|
174
|
+
async issueRefresh(sid, user, familyId) {
|
|
175
|
+
const refreshToken = randomBytes(32).toString("base64url");
|
|
176
|
+
const expiresAt = this.nowFn().getTime() + this.refreshTtl * 1e3;
|
|
177
|
+
const profile = user.nickname || user.avatarUrl ? JSON.stringify({
|
|
178
|
+
nickname: user.nickname,
|
|
179
|
+
avatarUrl: user.avatarUrl
|
|
180
|
+
}) : null;
|
|
181
|
+
await this.store.create({
|
|
182
|
+
tokenHash: sha256(refreshToken),
|
|
183
|
+
familyId,
|
|
184
|
+
sid,
|
|
185
|
+
sub: user.sub,
|
|
186
|
+
expiresAt,
|
|
187
|
+
profile
|
|
188
|
+
});
|
|
189
|
+
return refreshToken;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/session/store.ts
|
|
194
|
+
async function exec(db, q) {
|
|
195
|
+
if (isSqlite(db)) await db.run(q);
|
|
196
|
+
else await db.execute(q);
|
|
197
|
+
}
|
|
198
|
+
async function query(db, q) {
|
|
199
|
+
if (isSqlite(db)) return await db.all(q);
|
|
200
|
+
return await db.execute(q) ?? [];
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* drizzle 双方言实现(表定义见 @homurajs/auth/tables,App spread 进 schema 迁移)。
|
|
204
|
+
* consume 用 UPDATE ... RETURNING 原子标记 used_at,已 used 的行返回 revoked:true。
|
|
205
|
+
*/
|
|
206
|
+
function createDrizzleSessionStore(db, opts) {
|
|
207
|
+
const now = opts?.now ?? (() => Date.now());
|
|
208
|
+
return {
|
|
209
|
+
async create(r) {
|
|
210
|
+
await exec(db, sql`INSERT INTO refresh_tokens (token_hash, family_id, sid, sub, expires_at, profile)
|
|
211
|
+
VALUES (${r.tokenHash}, ${r.familyId}, ${r.sid}, ${r.sub}, ${r.expiresAt}, ${r.profile ?? null})`);
|
|
212
|
+
},
|
|
213
|
+
async consume(tokenHash) {
|
|
214
|
+
const ts = now();
|
|
215
|
+
const claimed = (await query(db, sql`UPDATE refresh_tokens
|
|
216
|
+
SET used_at = ${ts}
|
|
217
|
+
WHERE token_hash = ${tokenHash} AND expires_at > ${ts} AND used_at IS NULL
|
|
218
|
+
RETURNING family_id, sid, sub, profile`))[0];
|
|
219
|
+
if (claimed) return {
|
|
220
|
+
familyId: claimed.family_id,
|
|
221
|
+
sid: claimed.sid,
|
|
222
|
+
sub: claimed.sub,
|
|
223
|
+
profile: claimed.profile,
|
|
224
|
+
revoked: false
|
|
225
|
+
};
|
|
226
|
+
const row = (await query(db, sql`SELECT family_id, used_at FROM refresh_tokens WHERE token_hash = ${tokenHash}`))[0];
|
|
227
|
+
if (!row) return null;
|
|
228
|
+
if (row.used_at !== null) return {
|
|
229
|
+
familyId: row.family_id,
|
|
230
|
+
sid: "",
|
|
231
|
+
sub: "",
|
|
232
|
+
profile: null,
|
|
233
|
+
revoked: true
|
|
234
|
+
};
|
|
235
|
+
return null;
|
|
236
|
+
},
|
|
237
|
+
async revokeFamily(familyId) {
|
|
238
|
+
await exec(db, sql`UPDATE refresh_tokens SET used_at = ${now()} WHERE family_id = ${familyId} AND used_at IS NULL`);
|
|
239
|
+
},
|
|
240
|
+
async revokeUser(sub) {
|
|
241
|
+
await exec(db, sql`UPDATE refresh_tokens SET used_at = ${now()} WHERE sub = ${sub} AND used_at IS NULL`);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
246
|
+
export { OAuth2Client, SessionManager, createDrizzleSessionStore };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { TtlKv } from "@homurajs/core";
|
|
2
|
+
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
|
3
|
+
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
|
+
//#region src/types.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* 上游契约唯一事实源。
|
|
7
|
+
* ⚠ 字段以实读 ../oauth2-dingtalk 源码为准(token/userinfo 端点见 specs/module-oauth2)。
|
|
8
|
+
* 钉钉特有的 userinfo 形状(Oauth2DingtalkUserInfo)在 @homurajs/3p/dingtalk/oauth.ts,
|
|
9
|
+
* auth 包只认标准 OAuth2/OIDC 契约。
|
|
10
|
+
*/
|
|
11
|
+
/** 规范化用户身份(各 IdP 的 mapUser 归一结果) */
|
|
12
|
+
interface AuthUser {
|
|
13
|
+
sub: string;
|
|
14
|
+
nickname?: string;
|
|
15
|
+
avatarUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
interface OAuth2Endpoints {
|
|
18
|
+
authorizeUrl: string;
|
|
19
|
+
tokenUrl: string;
|
|
20
|
+
userinfoUrl: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 标准 OAuth2 token 响应。
|
|
24
|
+
* 实读核对:oauth2-dingtalk POST /:orgId/token 返回
|
|
25
|
+
* { access_token, token_type: 'bearer', expires_in }(src/index.ts:91-95)。
|
|
26
|
+
*/
|
|
27
|
+
interface TokenResponse {
|
|
28
|
+
access_token: string;
|
|
29
|
+
token_type?: string;
|
|
30
|
+
expires_in?: number;
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/oauth2/client.d.ts
|
|
34
|
+
interface OAuth2ClientConfig {
|
|
35
|
+
clientId: string;
|
|
36
|
+
clientSecret: string;
|
|
37
|
+
redirectUri: string;
|
|
38
|
+
scopes?: string[];
|
|
39
|
+
endpoints: OAuth2Endpoints;
|
|
40
|
+
/** 上游原始 userinfo → 规范身份(预设来自 @homurajs/3p/*) */
|
|
41
|
+
mapUser: (raw: unknown) => AuthUser;
|
|
42
|
+
/** state 存储(core 的内存或 db 实现),TTL 5min 一次性消费 */
|
|
43
|
+
stateKv: TtlKv;
|
|
44
|
+
/** 测试注入口;全程不 mock 全局 fetch */
|
|
45
|
+
fetchImpl?: typeof fetch;
|
|
46
|
+
authorizeExtraParams?: Record<string, string>;
|
|
47
|
+
/** dev 显式 opt-in 才允许无 state(修复 Suzuran 的 Redis 缺失放行后门) */
|
|
48
|
+
allowNoState?: boolean;
|
|
49
|
+
/** state TTL(默认 5min) */
|
|
50
|
+
stateTtlMs?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 通用标准 OAuth2 (Authorization Code Flow) 客户端。
|
|
54
|
+
* 服务端(如 ../oauth2-dingtalk)由 3p 预设注入,本类不感知具体 IdP。
|
|
55
|
+
*/
|
|
56
|
+
declare class OAuth2Client {
|
|
57
|
+
private config;
|
|
58
|
+
private fetchImpl;
|
|
59
|
+
private stateTtl;
|
|
60
|
+
constructor(config: OAuth2ClientConfig);
|
|
61
|
+
/** 生成 authorize URL(state 入 kv),returnTo 编码进 state 关联存储 */
|
|
62
|
+
authorizeUrl(returnTo?: string): Promise<string>;
|
|
63
|
+
/**
|
|
64
|
+
* 回调:state 一次性消费校验 → code 换 token → 拉 userinfo → mapUser
|
|
65
|
+
* 返回 { user, returnTo },供 hono 路由建 session 后跳转
|
|
66
|
+
*/
|
|
67
|
+
callback(params: {
|
|
68
|
+
code: string;
|
|
69
|
+
state?: string;
|
|
70
|
+
}): Promise<{
|
|
71
|
+
user: AuthUser;
|
|
72
|
+
returnTo: string;
|
|
73
|
+
}>;
|
|
74
|
+
/** 内部:code 换 token(POST tokenUrl,form 编码) */
|
|
75
|
+
protected exchangeToken(code: string): Promise<TokenResponse>;
|
|
76
|
+
/** 内部:Bearer 拉 userinfo */
|
|
77
|
+
protected fetchUserinfo(accessToken: string): Promise<unknown>;
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/session/record.d.ts
|
|
81
|
+
/** refresh_tokens 表记录(store 实现与迁移共用);时间用 epoch 毫秒(原生 SQL 双方言一致) */
|
|
82
|
+
interface RefreshTokenRecord {
|
|
83
|
+
/** SHA-256 hex */
|
|
84
|
+
tokenHash: string;
|
|
85
|
+
familyId: string;
|
|
86
|
+
sid: string;
|
|
87
|
+
sub: string;
|
|
88
|
+
/** epoch ms */
|
|
89
|
+
expiresAt: number;
|
|
90
|
+
/** JSON 字符串 { nickname?, avatarUrl? },rotate 时回填 access token */
|
|
91
|
+
profile?: string | null;
|
|
92
|
+
}
|
|
93
|
+
/** consume 结果:revoked=true 表示该 token 此前已被消费(复用信号 → 触发族吊销) */
|
|
94
|
+
interface ConsumedToken {
|
|
95
|
+
familyId: string;
|
|
96
|
+
sid: string;
|
|
97
|
+
sub: string;
|
|
98
|
+
profile?: string | null;
|
|
99
|
+
revoked: boolean;
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/session/store.d.ts
|
|
103
|
+
/** 双方言数据库实例(与 @homurajs/db 的 Database 结构一致,但保持本包 peer 边界独立) */
|
|
104
|
+
type DrizzleDb = PostgresJsDatabase | NodeSQLiteDatabase;
|
|
105
|
+
interface RefreshTokenStore {
|
|
106
|
+
create(r: RefreshTokenRecord): Promise<void>;
|
|
107
|
+
/** 一次性消费:原子标记已用并返回记录;已消费过的返回 revoked:true(复用信号) */
|
|
108
|
+
consume(tokenHash: string): Promise<ConsumedToken | null>;
|
|
109
|
+
revokeFamily(familyId: string): Promise<void>;
|
|
110
|
+
revokeUser(sub: string): Promise<void>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* drizzle 双方言实现(表定义见 @homurajs/auth/tables,App spread 进 schema 迁移)。
|
|
114
|
+
* consume 用 UPDATE ... RETURNING 原子标记 used_at,已 used 的行返回 revoked:true。
|
|
115
|
+
*/
|
|
116
|
+
declare function createDrizzleSessionStore(db: DrizzleDb, opts?: {
|
|
117
|
+
now?: () => number;
|
|
118
|
+
}): RefreshTokenStore;
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/session/manager.d.ts
|
|
121
|
+
interface SessionManagerConfig {
|
|
122
|
+
/** HS256 密钥(>=32 bytes,启动校验) */
|
|
123
|
+
secret: string;
|
|
124
|
+
accessTokenTtlSec?: number;
|
|
125
|
+
refreshTokenTtlSec?: number;
|
|
126
|
+
cookieNames?: {
|
|
127
|
+
access?: string;
|
|
128
|
+
refresh?: string;
|
|
129
|
+
};
|
|
130
|
+
/** 时间注入口(测试 fake timers) */
|
|
131
|
+
now?: () => Date;
|
|
132
|
+
}
|
|
133
|
+
interface SessionClaims {
|
|
134
|
+
sub: string;
|
|
135
|
+
nickname?: string;
|
|
136
|
+
avatarUrl?: string;
|
|
137
|
+
sid: string;
|
|
138
|
+
exp: number;
|
|
139
|
+
}
|
|
140
|
+
interface SessionTokens {
|
|
141
|
+
accessToken: string;
|
|
142
|
+
refreshToken: string;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 自管 session:短 JWT(HS256)+ opaque refresh(rotate + familyId 复用检测整族吊销)。
|
|
146
|
+
* 上游(oauth2-dingtalk)不支持 refresh token,生命周期完全自管。
|
|
147
|
+
*/
|
|
148
|
+
declare class SessionManager {
|
|
149
|
+
private readonly config;
|
|
150
|
+
private readonly store;
|
|
151
|
+
private readonly key;
|
|
152
|
+
private readonly accessTtl;
|
|
153
|
+
private readonly refreshTtl;
|
|
154
|
+
private readonly cookieNames;
|
|
155
|
+
private readonly nowFn;
|
|
156
|
+
constructor(config: SessionManagerConfig, store: RefreshTokenStore);
|
|
157
|
+
get cookies(): {
|
|
158
|
+
access: string;
|
|
159
|
+
refresh: string;
|
|
160
|
+
};
|
|
161
|
+
get accessTokenTtlSec(): number;
|
|
162
|
+
get refreshTokenTtlSec(): number;
|
|
163
|
+
createSession(user: AuthUser): Promise<SessionTokens>;
|
|
164
|
+
verifyAccessToken(token: string): Promise<SessionClaims | null>;
|
|
165
|
+
/**
|
|
166
|
+
* rotate:旧 token 一次性消费;复用检出(同 family 二次使用)→ 吊销整族。
|
|
167
|
+
* 返回 null 表示 refresh 链终态(过期/已消费/复用检出)。
|
|
168
|
+
*/
|
|
169
|
+
refresh(refreshToken: string): Promise<SessionTokens | null>;
|
|
170
|
+
revokeSession(sid: string): Promise<void>;
|
|
171
|
+
revokeUser(sub: string): Promise<void>;
|
|
172
|
+
private signAccess;
|
|
173
|
+
private issueRefresh;
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
export { RefreshTokenStore as a, OAuth2ClientConfig as c, TokenResponse as d, SessionTokens as i, AuthUser as l, SessionManager as n, createDrizzleSessionStore as o, SessionManagerConfig as r, OAuth2Client as s, SessionClaims as t, OAuth2Endpoints as u };
|
package/dist/tables.d.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
//#region src/tables.d.ts
|
|
2
|
+
export declare const pgAuthTables: {
|
|
3
|
+
refreshTokens: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
4
|
+
name: "refresh_tokens";
|
|
5
|
+
schema: undefined;
|
|
6
|
+
columns: {
|
|
7
|
+
tokenHash: import("drizzle-orm/pg-core").PgBuildColumn<"refresh_tokens", import("drizzle-orm/pg-core").SetIsPrimaryKey<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
|
|
8
|
+
name: string;
|
|
9
|
+
tableName: "refresh_tokens";
|
|
10
|
+
dataType: "string";
|
|
11
|
+
data: string;
|
|
12
|
+
driverParam: string;
|
|
13
|
+
notNull: true;
|
|
14
|
+
hasDefault: false;
|
|
15
|
+
isPrimaryKey: false;
|
|
16
|
+
isAutoincrement: false;
|
|
17
|
+
hasRuntimeDefault: false;
|
|
18
|
+
enumValues: undefined;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}>;
|
|
22
|
+
familyId: import("drizzle-orm/pg-core").PgBuildColumn<"refresh_tokens", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
|
|
23
|
+
name: string;
|
|
24
|
+
tableName: "refresh_tokens";
|
|
25
|
+
dataType: "string";
|
|
26
|
+
data: string;
|
|
27
|
+
driverParam: string;
|
|
28
|
+
notNull: true;
|
|
29
|
+
hasDefault: false;
|
|
30
|
+
isPrimaryKey: false;
|
|
31
|
+
isAutoincrement: false;
|
|
32
|
+
hasRuntimeDefault: false;
|
|
33
|
+
enumValues: undefined;
|
|
34
|
+
identity: undefined;
|
|
35
|
+
generated: undefined;
|
|
36
|
+
}>;
|
|
37
|
+
sid: import("drizzle-orm/pg-core").PgBuildColumn<"refresh_tokens", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
|
|
38
|
+
name: string;
|
|
39
|
+
tableName: "refresh_tokens";
|
|
40
|
+
dataType: "string";
|
|
41
|
+
data: string;
|
|
42
|
+
driverParam: string;
|
|
43
|
+
notNull: true;
|
|
44
|
+
hasDefault: false;
|
|
45
|
+
isPrimaryKey: false;
|
|
46
|
+
isAutoincrement: false;
|
|
47
|
+
hasRuntimeDefault: false;
|
|
48
|
+
enumValues: undefined;
|
|
49
|
+
identity: undefined;
|
|
50
|
+
generated: undefined;
|
|
51
|
+
}>;
|
|
52
|
+
sub: import("drizzle-orm/pg-core").PgBuildColumn<"refresh_tokens", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
|
|
53
|
+
name: string;
|
|
54
|
+
tableName: "refresh_tokens";
|
|
55
|
+
dataType: "string";
|
|
56
|
+
data: string;
|
|
57
|
+
driverParam: string;
|
|
58
|
+
notNull: true;
|
|
59
|
+
hasDefault: false;
|
|
60
|
+
isPrimaryKey: false;
|
|
61
|
+
isAutoincrement: false;
|
|
62
|
+
hasRuntimeDefault: false;
|
|
63
|
+
enumValues: undefined;
|
|
64
|
+
identity: undefined;
|
|
65
|
+
generated: undefined;
|
|
66
|
+
}>;
|
|
67
|
+
expiresAt: import("drizzle-orm/pg-core").PgBuildColumn<"refresh_tokens", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgBigInt53Builder>, {
|
|
68
|
+
name: string;
|
|
69
|
+
tableName: "refresh_tokens";
|
|
70
|
+
dataType: "number int53";
|
|
71
|
+
data: number;
|
|
72
|
+
driverParam: string | number;
|
|
73
|
+
notNull: true;
|
|
74
|
+
hasDefault: false;
|
|
75
|
+
isPrimaryKey: false;
|
|
76
|
+
isAutoincrement: false;
|
|
77
|
+
hasRuntimeDefault: false;
|
|
78
|
+
enumValues: undefined;
|
|
79
|
+
identity: undefined;
|
|
80
|
+
generated: undefined;
|
|
81
|
+
}>;
|
|
82
|
+
usedAt: import("drizzle-orm/pg-core").PgBuildColumn<"refresh_tokens", import("drizzle-orm/pg-core").PgBigInt53Builder, {
|
|
83
|
+
name: string;
|
|
84
|
+
tableName: "refresh_tokens";
|
|
85
|
+
dataType: "number int53";
|
|
86
|
+
data: number;
|
|
87
|
+
driverParam: string | number;
|
|
88
|
+
notNull: false;
|
|
89
|
+
hasDefault: false;
|
|
90
|
+
isPrimaryKey: false;
|
|
91
|
+
isAutoincrement: false;
|
|
92
|
+
hasRuntimeDefault: false;
|
|
93
|
+
enumValues: undefined;
|
|
94
|
+
identity: undefined;
|
|
95
|
+
generated: undefined;
|
|
96
|
+
}>;
|
|
97
|
+
profile: import("drizzle-orm/pg-core").PgBuildColumn<"refresh_tokens", import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>, {
|
|
98
|
+
name: string;
|
|
99
|
+
tableName: "refresh_tokens";
|
|
100
|
+
dataType: "string";
|
|
101
|
+
data: string;
|
|
102
|
+
driverParam: string;
|
|
103
|
+
notNull: false;
|
|
104
|
+
hasDefault: false;
|
|
105
|
+
isPrimaryKey: false;
|
|
106
|
+
isAutoincrement: false;
|
|
107
|
+
hasRuntimeDefault: false;
|
|
108
|
+
enumValues: undefined;
|
|
109
|
+
identity: undefined;
|
|
110
|
+
generated: undefined;
|
|
111
|
+
}>;
|
|
112
|
+
};
|
|
113
|
+
dialect: "pg";
|
|
114
|
+
}>;
|
|
115
|
+
};
|
|
116
|
+
export declare const sqliteAuthTables: {
|
|
117
|
+
refreshTokens: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
118
|
+
name: "refresh_tokens";
|
|
119
|
+
schema: undefined;
|
|
120
|
+
columns: {
|
|
121
|
+
tokenHash: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
122
|
+
name: string;
|
|
123
|
+
tableName: "refresh_tokens";
|
|
124
|
+
dataType: "string";
|
|
125
|
+
data: string;
|
|
126
|
+
driverParam: string;
|
|
127
|
+
notNull: true;
|
|
128
|
+
hasDefault: false;
|
|
129
|
+
isPrimaryKey: true;
|
|
130
|
+
isAutoincrement: false;
|
|
131
|
+
hasRuntimeDefault: false;
|
|
132
|
+
enumValues: [string, ...string[]];
|
|
133
|
+
baseColumn: never;
|
|
134
|
+
identity: undefined;
|
|
135
|
+
generated: undefined;
|
|
136
|
+
}, {}>;
|
|
137
|
+
familyId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
138
|
+
name: string;
|
|
139
|
+
tableName: "refresh_tokens";
|
|
140
|
+
dataType: "string";
|
|
141
|
+
data: string;
|
|
142
|
+
driverParam: string;
|
|
143
|
+
notNull: true;
|
|
144
|
+
hasDefault: false;
|
|
145
|
+
isPrimaryKey: false;
|
|
146
|
+
isAutoincrement: false;
|
|
147
|
+
hasRuntimeDefault: false;
|
|
148
|
+
enumValues: [string, ...string[]];
|
|
149
|
+
baseColumn: never;
|
|
150
|
+
identity: undefined;
|
|
151
|
+
generated: undefined;
|
|
152
|
+
}, {}>;
|
|
153
|
+
sid: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
154
|
+
name: string;
|
|
155
|
+
tableName: "refresh_tokens";
|
|
156
|
+
dataType: "string";
|
|
157
|
+
data: string;
|
|
158
|
+
driverParam: string;
|
|
159
|
+
notNull: true;
|
|
160
|
+
hasDefault: false;
|
|
161
|
+
isPrimaryKey: false;
|
|
162
|
+
isAutoincrement: false;
|
|
163
|
+
hasRuntimeDefault: false;
|
|
164
|
+
enumValues: [string, ...string[]];
|
|
165
|
+
baseColumn: never;
|
|
166
|
+
identity: undefined;
|
|
167
|
+
generated: undefined;
|
|
168
|
+
}, {}>;
|
|
169
|
+
sub: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
170
|
+
name: string;
|
|
171
|
+
tableName: "refresh_tokens";
|
|
172
|
+
dataType: "string";
|
|
173
|
+
data: string;
|
|
174
|
+
driverParam: string;
|
|
175
|
+
notNull: true;
|
|
176
|
+
hasDefault: false;
|
|
177
|
+
isPrimaryKey: false;
|
|
178
|
+
isAutoincrement: false;
|
|
179
|
+
hasRuntimeDefault: false;
|
|
180
|
+
enumValues: [string, ...string[]];
|
|
181
|
+
baseColumn: never;
|
|
182
|
+
identity: undefined;
|
|
183
|
+
generated: undefined;
|
|
184
|
+
}, {}>;
|
|
185
|
+
expiresAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
186
|
+
name: string;
|
|
187
|
+
tableName: "refresh_tokens";
|
|
188
|
+
dataType: "number int53";
|
|
189
|
+
data: number;
|
|
190
|
+
driverParam: number;
|
|
191
|
+
notNull: true;
|
|
192
|
+
hasDefault: false;
|
|
193
|
+
isPrimaryKey: false;
|
|
194
|
+
isAutoincrement: false;
|
|
195
|
+
hasRuntimeDefault: false;
|
|
196
|
+
enumValues: undefined;
|
|
197
|
+
baseColumn: never;
|
|
198
|
+
identity: undefined;
|
|
199
|
+
generated: undefined;
|
|
200
|
+
}, {}>;
|
|
201
|
+
usedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
202
|
+
name: string;
|
|
203
|
+
tableName: "refresh_tokens";
|
|
204
|
+
dataType: "number int53";
|
|
205
|
+
data: number;
|
|
206
|
+
driverParam: number;
|
|
207
|
+
notNull: false;
|
|
208
|
+
hasDefault: false;
|
|
209
|
+
isPrimaryKey: false;
|
|
210
|
+
isAutoincrement: false;
|
|
211
|
+
hasRuntimeDefault: false;
|
|
212
|
+
enumValues: undefined;
|
|
213
|
+
baseColumn: never;
|
|
214
|
+
identity: undefined;
|
|
215
|
+
generated: undefined;
|
|
216
|
+
}, {}>;
|
|
217
|
+
profile: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
218
|
+
name: string;
|
|
219
|
+
tableName: "refresh_tokens";
|
|
220
|
+
dataType: "string";
|
|
221
|
+
data: string;
|
|
222
|
+
driverParam: string;
|
|
223
|
+
notNull: false;
|
|
224
|
+
hasDefault: false;
|
|
225
|
+
isPrimaryKey: false;
|
|
226
|
+
isAutoincrement: false;
|
|
227
|
+
hasRuntimeDefault: false;
|
|
228
|
+
enumValues: [string, ...string[]];
|
|
229
|
+
baseColumn: never;
|
|
230
|
+
identity: undefined;
|
|
231
|
+
generated: undefined;
|
|
232
|
+
}, {}>;
|
|
233
|
+
};
|
|
234
|
+
dialect: "sqlite";
|
|
235
|
+
}>;
|
|
236
|
+
};
|
|
237
|
+
//#endregion
|
package/dist/tables.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { pgCol, pgTable, sqliteCol, sqliteTable } from "@homurajs/db/schema-helpers";
|
|
2
|
+
import { index } from "drizzle-orm/pg-core";
|
|
3
|
+
import { index as index$1 } from "drizzle-orm/sqlite-core";
|
|
4
|
+
//#region src/tables.ts
|
|
5
|
+
/**
|
|
6
|
+
* 双方言 auth 表定义:App 把 pgAuthTables/sqliteAuthTables spread 进
|
|
7
|
+
* 自己的 schema 参与迁移(refresh_tokens 表)。
|
|
8
|
+
*
|
|
9
|
+
* ⚠ 列名显式 snake_case,必须与 session/store.ts 的原生 SQL 严格对齐
|
|
10
|
+
* (token_hash/family_id/sid/sub/expires_at/used_at/profile)。
|
|
11
|
+
* parity 测试只比对 pg↔sqlite 两侧一致,测不出与原生 SQL 的偏差——
|
|
12
|
+
* 改列名务必同步 store.ts,并以 drizzle-kit generate 的产物为准复核。
|
|
13
|
+
*/
|
|
14
|
+
const pgAuthTables = { refreshTokens: pgTable("refresh_tokens", {
|
|
15
|
+
tokenHash: pgCol.text("token_hash").primaryKey(),
|
|
16
|
+
familyId: pgCol.text("family_id").notNull(),
|
|
17
|
+
sid: pgCol.text("sid").notNull(),
|
|
18
|
+
sub: pgCol.text("sub").notNull(),
|
|
19
|
+
expiresAt: pgCol.epochMs("expires_at").notNull(),
|
|
20
|
+
usedAt: pgCol.epochMs("used_at"),
|
|
21
|
+
profile: pgCol.text("profile")
|
|
22
|
+
}, (t) => [index("refresh_tokens_family_idx").on(t.familyId), index("refresh_tokens_sub_idx").on(t.sub)]) };
|
|
23
|
+
const sqliteAuthTables = { refreshTokens: sqliteTable("refresh_tokens", {
|
|
24
|
+
tokenHash: sqliteCol.text("token_hash").primaryKey(),
|
|
25
|
+
familyId: sqliteCol.text("family_id").notNull(),
|
|
26
|
+
sid: sqliteCol.text("sid").notNull(),
|
|
27
|
+
sub: sqliteCol.text("sub").notNull(),
|
|
28
|
+
expiresAt: sqliteCol.epochMs("expires_at").notNull(),
|
|
29
|
+
usedAt: sqliteCol.epochMs("used_at"),
|
|
30
|
+
profile: sqliteCol.text("profile")
|
|
31
|
+
}, (t) => [index$1("refresh_tokens_family_idx").on(t.familyId), index$1("refresh_tokens_sub_idx").on(t.sub)]) };
|
|
32
|
+
//#endregion
|
|
33
|
+
export { pgAuthTables, sqliteAuthTables };
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homurajs/auth",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "26.10.13",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "homura 认证:通用标准 OAuth2 (Authorization Code Flow) 客户端 + 自管 session(HS256 短 JWT + opaque refresh rotate)",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.5"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./hono": {
|
|
18
|
+
"types": "./dist/hono.d.ts",
|
|
19
|
+
"import": "./dist/hono.js"
|
|
20
|
+
},
|
|
21
|
+
"./tables": {
|
|
22
|
+
"types": "./dist/tables.d.ts",
|
|
23
|
+
"import": "./dist/tables.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public",
|
|
28
|
+
"registry": "https://registry.npmjs.org/"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@homurajs/core": "26.10.13",
|
|
32
|
+
"@homurajs/db": "26.10.13",
|
|
33
|
+
"jose": "^6.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"drizzle-orm": "1.0.0-rc.4",
|
|
37
|
+
"hono": "^4.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"drizzle-orm": {
|
|
41
|
+
"optional": true
|
|
42
|
+
},
|
|
43
|
+
"hono": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"tsdown": "^0.23.1"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run"
|
|
54
|
+
}
|
|
6
55
|
}
|