@homurajs/auth 26.10.14 → 26.10.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -31
- package/dist/hono.d.ts +61 -15
- package/dist/hono.js +294 -19
- package/dist/index.d.ts +46 -2
- package/dist/index.js +323 -9
- package/dist/provider-Bf7d5mkl.d.ts +164 -0
- package/dist/provider-BkUYQ703.d.ts +157 -0
- package/dist/provider-DMtwLgGf.js +215 -0
- package/dist/provider-zTWN9myS.js +276 -0
- package/dist/tables.d.ts +850 -15
- package/dist/tables.js +101 -11
- package/dist/{manager-_5q5sluE.d.ts → types-C-SlEMFJ.d.ts} +86 -49
- package/dist/webauthn.d.ts +21 -0
- package/dist/webauthn.js +164 -0
- package/package.json +11 -3
package/dist/tables.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
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";
|
|
2
|
+
import { index, uniqueIndex } from "drizzle-orm/pg-core";
|
|
3
|
+
import { index as index$1, uniqueIndex as uniqueIndex$1 } from "drizzle-orm/sqlite-core";
|
|
4
4
|
//#region src/tables.ts
|
|
5
5
|
/**
|
|
6
6
|
* 双方言 auth 表定义:App 把 pgAuthTables/sqliteAuthTables spread 进
|
|
7
|
-
* 自己的 schema
|
|
7
|
+
* 自己的 schema 参与迁移。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* 包含五张表:
|
|
10
|
+
* - `refresh_tokens`:session refresh token(rotate + family 吊销)
|
|
11
|
+
* - `users`:本地身份锚点(密码/WebAuthn/OAuth2 共享)
|
|
12
|
+
* - `passwords`:密码凭证(1:1 users)
|
|
13
|
+
* - `oauth2_identity_links`:OAuth2 身份关联(1:N users)
|
|
14
|
+
* - `webauthn_credentials`:WebAuthn 凭证(1:N users)
|
|
15
|
+
*
|
|
16
|
+
* ⚠ 列名显式 snake_case,必须与各 store 的原生 SQL / PostgREST 列名严格对齐。
|
|
11
17
|
* parity 测试只比对 pg↔sqlite 两侧一致,测不出与原生 SQL 的偏差——
|
|
12
|
-
* 改列名务必同步 store
|
|
18
|
+
* 改列名务必同步 store 实现,并以 drizzle-kit generate 的产物为准复核。
|
|
13
19
|
*/
|
|
14
|
-
const
|
|
20
|
+
const USERS_TABLE = "users";
|
|
21
|
+
const PASSWORDS_TABLE = "passwords";
|
|
22
|
+
const OAUTH2_IDENTITY_LINKS_TABLE = "oauth2_identity_links";
|
|
23
|
+
const WEBAUTHN_CREDENTIALS_TABLE = "webauthn_credentials";
|
|
24
|
+
const pgRefreshTokens = pgTable("refresh_tokens", {
|
|
15
25
|
tokenHash: pgCol.text("token_hash").primaryKey(),
|
|
16
26
|
familyId: pgCol.text("family_id").notNull(),
|
|
17
27
|
sid: pgCol.text("sid").notNull(),
|
|
@@ -19,8 +29,8 @@ const pgAuthTables = { refreshTokens: pgTable("refresh_tokens", {
|
|
|
19
29
|
expiresAt: pgCol.epochMs("expires_at").notNull(),
|
|
20
30
|
usedAt: pgCol.epochMs("used_at"),
|
|
21
31
|
profile: pgCol.text("profile")
|
|
22
|
-
}, (t) => [index("refresh_tokens_family_idx").on(t.familyId), index("refresh_tokens_sub_idx").on(t.sub)])
|
|
23
|
-
const
|
|
32
|
+
}, (t) => [index("refresh_tokens_family_idx").on(t.familyId), index("refresh_tokens_sub_idx").on(t.sub)]);
|
|
33
|
+
const sqliteRefreshTokens = sqliteTable("refresh_tokens", {
|
|
24
34
|
tokenHash: sqliteCol.text("token_hash").primaryKey(),
|
|
25
35
|
familyId: sqliteCol.text("family_id").notNull(),
|
|
26
36
|
sid: sqliteCol.text("sid").notNull(),
|
|
@@ -28,6 +38,86 @@ const sqliteAuthTables = { refreshTokens: sqliteTable("refresh_tokens", {
|
|
|
28
38
|
expiresAt: sqliteCol.epochMs("expires_at").notNull(),
|
|
29
39
|
usedAt: sqliteCol.epochMs("used_at"),
|
|
30
40
|
profile: sqliteCol.text("profile")
|
|
31
|
-
}, (t) => [index$1("refresh_tokens_family_idx").on(t.familyId), index$1("refresh_tokens_sub_idx").on(t.sub)])
|
|
41
|
+
}, (t) => [index$1("refresh_tokens_family_idx").on(t.familyId), index$1("refresh_tokens_sub_idx").on(t.sub)]);
|
|
42
|
+
const pgUsers = pgTable(USERS_TABLE, {
|
|
43
|
+
id: pgCol.id("id"),
|
|
44
|
+
username: pgCol.text("username"),
|
|
45
|
+
nickname: pgCol.text("nickname"),
|
|
46
|
+
avatarUrl: pgCol.text("avatar_url"),
|
|
47
|
+
createdAt: pgCol.epochMs("created_at").notNull(),
|
|
48
|
+
updatedAt: pgCol.epochMs("updated_at").notNull()
|
|
49
|
+
}, (t) => [uniqueIndex("users_username_uidx").on(t.username)]);
|
|
50
|
+
const sqliteUsers = sqliteTable(USERS_TABLE, {
|
|
51
|
+
id: sqliteCol.id("id"),
|
|
52
|
+
username: sqliteCol.text("username"),
|
|
53
|
+
nickname: sqliteCol.text("nickname"),
|
|
54
|
+
avatarUrl: sqliteCol.text("avatar_url"),
|
|
55
|
+
createdAt: sqliteCol.epochMs("created_at").notNull(),
|
|
56
|
+
updatedAt: sqliteCol.epochMs("updated_at").notNull()
|
|
57
|
+
}, (t) => [uniqueIndex$1("users_username_uidx").on(t.username)]);
|
|
58
|
+
const pgPasswords = pgTable(PASSWORDS_TABLE, {
|
|
59
|
+
userId: pgCol.text("user_id").primaryKey(),
|
|
60
|
+
passwordHash: pgCol.text("password_hash").notNull(),
|
|
61
|
+
createdAt: pgCol.epochMs("created_at").notNull(),
|
|
62
|
+
updatedAt: pgCol.epochMs("updated_at").notNull()
|
|
63
|
+
});
|
|
64
|
+
const sqlitePasswords = sqliteTable(PASSWORDS_TABLE, {
|
|
65
|
+
userId: sqliteCol.text("user_id").primaryKey(),
|
|
66
|
+
passwordHash: sqliteCol.text("password_hash").notNull(),
|
|
67
|
+
createdAt: sqliteCol.epochMs("created_at").notNull(),
|
|
68
|
+
updatedAt: sqliteCol.epochMs("updated_at").notNull()
|
|
69
|
+
});
|
|
70
|
+
const pgOAuth2IdentityLinks = pgTable(OAUTH2_IDENTITY_LINKS_TABLE, {
|
|
71
|
+
id: pgCol.id("id"),
|
|
72
|
+
userId: pgCol.text("user_id").notNull(),
|
|
73
|
+
provider: pgCol.text("provider").notNull(),
|
|
74
|
+
providerSub: pgCol.text("provider_sub").notNull(),
|
|
75
|
+
rawProfile: pgCol.text("raw_profile"),
|
|
76
|
+
createdAt: pgCol.epochMs("created_at").notNull()
|
|
77
|
+
}, (t) => [index("oauth2_links_user_idx").on(t.userId), uniqueIndex("oauth2_links_provider_sub_uidx").on(t.provider, t.providerSub)]);
|
|
78
|
+
const sqliteOAuth2IdentityLinks = sqliteTable(OAUTH2_IDENTITY_LINKS_TABLE, {
|
|
79
|
+
id: sqliteCol.id("id"),
|
|
80
|
+
userId: sqliteCol.text("user_id").notNull(),
|
|
81
|
+
provider: sqliteCol.text("provider").notNull(),
|
|
82
|
+
providerSub: sqliteCol.text("provider_sub").notNull(),
|
|
83
|
+
rawProfile: sqliteCol.text("raw_profile"),
|
|
84
|
+
createdAt: sqliteCol.epochMs("created_at").notNull()
|
|
85
|
+
}, (t) => [index$1("oauth2_links_user_idx").on(t.userId), uniqueIndex$1("oauth2_links_provider_sub_uidx").on(t.provider, t.providerSub)]);
|
|
86
|
+
const pgWebauthnCredentials = pgTable(WEBAUTHN_CREDENTIALS_TABLE, {
|
|
87
|
+
id: pgCol.text("id").primaryKey(),
|
|
88
|
+
userId: pgCol.text("user_id").notNull(),
|
|
89
|
+
publicKey: pgCol.text("public_key").notNull(),
|
|
90
|
+
counter: pgCol.int("counter").notNull(),
|
|
91
|
+
deviceType: pgCol.text("device_type").notNull(),
|
|
92
|
+
backedUp: pgCol.int("backed_up").notNull(),
|
|
93
|
+
transports: pgCol.text("transports"),
|
|
94
|
+
displayName: pgCol.text("display_name"),
|
|
95
|
+
createdAt: pgCol.epochMs("created_at").notNull()
|
|
96
|
+
}, (t) => [index("webauthn_creds_user_idx").on(t.userId)]);
|
|
97
|
+
const sqliteWebauthnCredentials = sqliteTable(WEBAUTHN_CREDENTIALS_TABLE, {
|
|
98
|
+
id: sqliteCol.text("id").primaryKey(),
|
|
99
|
+
userId: sqliteCol.text("user_id").notNull(),
|
|
100
|
+
publicKey: sqliteCol.text("public_key").notNull(),
|
|
101
|
+
counter: sqliteCol.int("counter").notNull(),
|
|
102
|
+
deviceType: sqliteCol.text("device_type").notNull(),
|
|
103
|
+
backedUp: sqliteCol.int("backed_up").notNull(),
|
|
104
|
+
transports: sqliteCol.text("transports"),
|
|
105
|
+
displayName: sqliteCol.text("display_name"),
|
|
106
|
+
createdAt: sqliteCol.epochMs("created_at").notNull()
|
|
107
|
+
}, (t) => [index$1("webauthn_creds_user_idx").on(t.userId)]);
|
|
108
|
+
const pgAuthTables = {
|
|
109
|
+
refreshTokens: pgRefreshTokens,
|
|
110
|
+
users: pgUsers,
|
|
111
|
+
passwords: pgPasswords,
|
|
112
|
+
oauth2IdentityLinks: pgOAuth2IdentityLinks,
|
|
113
|
+
webauthnCredentials: pgWebauthnCredentials
|
|
114
|
+
};
|
|
115
|
+
const sqliteAuthTables = {
|
|
116
|
+
refreshTokens: sqliteRefreshTokens,
|
|
117
|
+
users: sqliteUsers,
|
|
118
|
+
passwords: sqlitePasswords,
|
|
119
|
+
oauth2IdentityLinks: sqliteOAuth2IdentityLinks,
|
|
120
|
+
webauthnCredentials: sqliteWebauthnCredentials
|
|
121
|
+
};
|
|
32
122
|
//#endregion
|
|
33
|
-
export { pgAuthTables, sqliteAuthTables };
|
|
123
|
+
export { OAUTH2_IDENTITY_LINKS_TABLE, PASSWORDS_TABLE, USERS_TABLE, WEBAUTHN_CREDENTIALS_TABLE, pgAuthTables, sqliteAuthTables };
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { TtlKv } from "@homurajs/core";
|
|
2
1
|
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
|
|
3
2
|
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
3
|
//#region src/types.d.ts
|
|
@@ -30,53 +29,6 @@ interface TokenResponse {
|
|
|
30
29
|
expires_in?: number;
|
|
31
30
|
}
|
|
32
31
|
//#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
32
|
//#region src/session/record.d.ts
|
|
81
33
|
/** refresh_tokens 表记录(store 实现与迁移共用);时间用 epoch 毫秒(原生 SQL 双方言一致) */
|
|
82
34
|
interface RefreshTokenRecord {
|
|
@@ -173,4 +125,89 @@ declare class SessionManager {
|
|
|
173
125
|
private issueRefresh;
|
|
174
126
|
}
|
|
175
127
|
//#endregion
|
|
176
|
-
|
|
128
|
+
//#region src/identity/types.d.ts
|
|
129
|
+
/**
|
|
130
|
+
* 本地身份模型核心类型。
|
|
131
|
+
*
|
|
132
|
+
* - `users` 表是身份锚点:三类凭证(TextPassword / WebAuthn / OAuth2 identity link)
|
|
133
|
+
* 都指向它,session 的 sub 也是 local user.id(不再是 IdP subject)。
|
|
134
|
+
* - OAuth2-only 用户的 username 可为 null(没有密码登录能力)。
|
|
135
|
+
*/
|
|
136
|
+
/** 本地用户(users 表行) */
|
|
137
|
+
interface LocalUser {
|
|
138
|
+
id: string;
|
|
139
|
+
username: string | null;
|
|
140
|
+
nickname: string | null;
|
|
141
|
+
avatarUrl: string | null;
|
|
142
|
+
createdAt: number;
|
|
143
|
+
updatedAt: number;
|
|
144
|
+
}
|
|
145
|
+
/** OAuth2 身份关联(oauth2_identity_links 表行) */
|
|
146
|
+
interface IdentityLink {
|
|
147
|
+
id: string;
|
|
148
|
+
userId: string;
|
|
149
|
+
provider: string;
|
|
150
|
+
providerSub: string;
|
|
151
|
+
rawProfile: string | null;
|
|
152
|
+
createdAt: number;
|
|
153
|
+
}
|
|
154
|
+
/** WebAuthn 凭证(webauthn_credentials 表行) */
|
|
155
|
+
interface WebauthnCredentialRecord {
|
|
156
|
+
id: string;
|
|
157
|
+
userId: string;
|
|
158
|
+
publicKey: string;
|
|
159
|
+
counter: number;
|
|
160
|
+
deviceType: string;
|
|
161
|
+
backedUp: number;
|
|
162
|
+
transports: string | null;
|
|
163
|
+
displayName: string | null;
|
|
164
|
+
createdAt: number;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 用户仓储:三类凭证 + identity resolver 共用。
|
|
168
|
+
* Drizzle 双方言与 PostgREST 两版实现共享此接口。
|
|
169
|
+
*/
|
|
170
|
+
interface UserStore {
|
|
171
|
+
/** 按 id 查用户;不存在返回 null */
|
|
172
|
+
findById(id: string): Promise<LocalUser | null>;
|
|
173
|
+
/** 按 username 查用户(精确匹配);不存在返回 null */
|
|
174
|
+
findByUsername(username: string): Promise<LocalUser | null>;
|
|
175
|
+
/** 创建用户;username 重复抛唯一约束冲突 */
|
|
176
|
+
create(input: {
|
|
177
|
+
username?: string | null;
|
|
178
|
+
nickname?: string | null;
|
|
179
|
+
avatarUrl?: string | null;
|
|
180
|
+
}): Promise<LocalUser>;
|
|
181
|
+
/** 更新 nickname/avatarUrl(部分更新,不传字段不动) */
|
|
182
|
+
update(id: string, patch: {
|
|
183
|
+
nickname?: string;
|
|
184
|
+
avatarUrl?: string;
|
|
185
|
+
}): Promise<LocalUser>;
|
|
186
|
+
/**
|
|
187
|
+
* 删除用户(按 id)。用于注册失败时清理孤儿 user(见 PasswordProvider.register)。
|
|
188
|
+
* ⚠ 不级联删除凭证记录(passwords/oauth2_identity_links/webauthn_credentials)——
|
|
189
|
+
* 调用方自行保证只在「刚建、尚无凭证」的 user 上调用,或先清凭证再删 user。
|
|
190
|
+
*/
|
|
191
|
+
delete(id: string): Promise<void>;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* OAuth2 身份关联仓储。
|
|
195
|
+
* 用于 identity resolver 在 callback 时查 link → 找 user,或 JIT 建户时创建 link。
|
|
196
|
+
*/
|
|
197
|
+
interface OAuth2LinkStore {
|
|
198
|
+
/** 按 (provider, providerSub) 查关联;不存在返回 null */
|
|
199
|
+
findByProvider(provider: string, providerSub: string): Promise<IdentityLink | null>;
|
|
200
|
+
/** 按 userId 查所有关联 */
|
|
201
|
+
findByUserId(userId: string): Promise<IdentityLink[]>;
|
|
202
|
+
/** 创建关联;(provider, providerSub) 唯一约束冲突抛错 */
|
|
203
|
+
create(input: {
|
|
204
|
+
userId: string;
|
|
205
|
+
provider: string;
|
|
206
|
+
providerSub: string;
|
|
207
|
+
rawProfile?: string | null;
|
|
208
|
+
}): Promise<IdentityLink>;
|
|
209
|
+
/** 删除关联 */
|
|
210
|
+
delete(id: string): Promise<void>;
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
export { TokenResponse as _, WebauthnCredentialRecord as a, SessionManagerConfig as c, RefreshTokenStore as d, createDrizzleSessionStore as f, OAuth2Endpoints as g, AuthUser as h, UserStore as i, SessionTokens as l, RefreshTokenRecord as m, LocalUser as n, SessionClaims as o, ConsumedToken as p, OAuth2LinkStore as r, SessionManager as s, IdentityLink as t, DrizzleDb as u };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { s as SessionManager } from "./types-C-SlEMFJ.js";
|
|
2
|
+
import { a as WebAuthProvider, c as WEBAUTHN_CREDENTIALS_TABLE, d as WebauthnCredentialStore, i as RegistrationResponse, l as createRestWebauthnCredentialStore, n as AuthenticationResponse, o as WebAuthProviderConfig, r as RegistrationOptions, s as RestWebauthnCredentialStoreOptions, t as AuthenticationOptions, u as createDrizzleWebauthnCredentialStore } from "./provider-Bf7d5mkl.js";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
//#region src/webauthn/routes.d.ts
|
|
5
|
+
interface WebAuthnPaths {
|
|
6
|
+
registerOptions?: string;
|
|
7
|
+
registerVerify?: string;
|
|
8
|
+
authOptions?: string;
|
|
9
|
+
authVerify?: string;
|
|
10
|
+
listCredentials?: string;
|
|
11
|
+
deleteCredential?: string;
|
|
12
|
+
}
|
|
13
|
+
interface WebAuthnRoutesOptions {
|
|
14
|
+
webauthn: WebAuthProvider;
|
|
15
|
+
sessions: SessionManager;
|
|
16
|
+
paths?: WebAuthnPaths;
|
|
17
|
+
secure?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function createWebAuthnRoutes(opts: WebAuthnRoutesOptions): Hono;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { type AuthenticationOptions, type AuthenticationResponse, type RegistrationOptions, type RegistrationResponse, type RestWebauthnCredentialStoreOptions, WEBAUTHN_CREDENTIALS_TABLE, WebAuthProvider, type WebAuthProviderConfig, type WebAuthnPaths, type WebAuthnRoutesOptions, type WebauthnCredentialStore, createDrizzleWebauthnCredentialStore, createRestWebauthnCredentialStore };
|
package/dist/webauthn.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { i as createDrizzleWebauthnCredentialStore, n as WEBAUTHN_CREDENTIALS_TABLE, r as createRestWebauthnCredentialStore, t as WebAuthProvider } from "./provider-zTWN9myS.js";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { setCookie } from "hono/cookie";
|
|
4
|
+
//#region src/webauthn/routes.ts
|
|
5
|
+
/**
|
|
6
|
+
* WebAuthn 路由(JSON API,不渲染页面)。
|
|
7
|
+
*
|
|
8
|
+
* POST /auth/webauthn/register/options → 已登录用户 → registration options JSON
|
|
9
|
+
* POST /auth/webauthn/register/verify → 验证 attestation → 存储凭证 → 200 JSON
|
|
10
|
+
* POST /auth/webauthn/auth/options → authentication options JSON
|
|
11
|
+
* POST /auth/webauthn/auth/verify → 验证 assertion → session cookies + 200 JSON
|
|
12
|
+
* GET /auth/webauthn/credentials → 已登录用户 → 凭证列表
|
|
13
|
+
* POST /auth/webauthn/credentials/delete → 已登录用户 → 删除指定凭证
|
|
14
|
+
*
|
|
15
|
+
* 独立子路径,消费者从 @homurajs/auth/webauthn 取 createWebAuthnRoutes 自行挂载。
|
|
16
|
+
*/
|
|
17
|
+
function createWebAuthnRoutes(opts) {
|
|
18
|
+
const { webauthn, sessions } = opts;
|
|
19
|
+
const paths = {
|
|
20
|
+
registerOptions: opts.paths?.registerOptions ?? "/auth/webauthn/register/options",
|
|
21
|
+
registerVerify: opts.paths?.registerVerify ?? "/auth/webauthn/register/verify",
|
|
22
|
+
authOptions: opts.paths?.authOptions ?? "/auth/webauthn/auth/options",
|
|
23
|
+
authVerify: opts.paths?.authVerify ?? "/auth/webauthn/auth/verify",
|
|
24
|
+
listCredentials: opts.paths?.listCredentials ?? "/auth/webauthn/credentials",
|
|
25
|
+
deleteCredential: opts.paths?.deleteCredential ?? "/auth/webauthn/credentials/delete"
|
|
26
|
+
};
|
|
27
|
+
const secure = opts.secure ?? false;
|
|
28
|
+
const names = sessions.cookies;
|
|
29
|
+
const app = new Hono();
|
|
30
|
+
app.post(paths.registerOptions, async (c) => {
|
|
31
|
+
const claims = c.get("auth");
|
|
32
|
+
if (!claims) return c.json({ error: "unauthorized" }, 401);
|
|
33
|
+
try {
|
|
34
|
+
const options = await webauthn.generateRegistrationOptions(claims.sub);
|
|
35
|
+
return c.json(options);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return c.json({
|
|
38
|
+
error: "options_failed",
|
|
39
|
+
message: e.message
|
|
40
|
+
}, 400);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
app.post(paths.registerVerify, async (c) => {
|
|
44
|
+
const claims = c.get("auth");
|
|
45
|
+
if (!claims) return c.json({ error: "unauthorized" }, 401);
|
|
46
|
+
let body;
|
|
47
|
+
try {
|
|
48
|
+
body = await c.req.json();
|
|
49
|
+
} catch {
|
|
50
|
+
return c.json({ error: "invalid_body" }, 400);
|
|
51
|
+
}
|
|
52
|
+
if (!body.response) return c.json({ error: "missing_response" }, 400);
|
|
53
|
+
try {
|
|
54
|
+
const result = await webauthn.verifyRegistration(claims.sub, body.response, body.displayName);
|
|
55
|
+
return c.json({
|
|
56
|
+
ok: true,
|
|
57
|
+
credentialId: result.id
|
|
58
|
+
});
|
|
59
|
+
} catch (e) {
|
|
60
|
+
return c.json({
|
|
61
|
+
error: "verify_failed",
|
|
62
|
+
message: e.message
|
|
63
|
+
}, 400);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
app.post(paths.authOptions, async (c) => {
|
|
67
|
+
let body = {};
|
|
68
|
+
try {
|
|
69
|
+
body = await c.req.json().catch(() => ({}));
|
|
70
|
+
} catch {}
|
|
71
|
+
try {
|
|
72
|
+
const { options, challengeKey } = await webauthn.generateAuthenticationOptions(body.userId);
|
|
73
|
+
return c.json({
|
|
74
|
+
...options,
|
|
75
|
+
challengeKey
|
|
76
|
+
});
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return c.json({
|
|
79
|
+
error: "options_failed",
|
|
80
|
+
message: e.message
|
|
81
|
+
}, 400);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
app.post(paths.authVerify, async (c) => {
|
|
85
|
+
let body;
|
|
86
|
+
try {
|
|
87
|
+
body = await c.req.json();
|
|
88
|
+
} catch {
|
|
89
|
+
return c.json({ error: "invalid_body" }, 400);
|
|
90
|
+
}
|
|
91
|
+
if (!body.response) return c.json({ error: "missing_response" }, 400);
|
|
92
|
+
try {
|
|
93
|
+
const user = await webauthn.verifyAuthentication(body.response, body.challengeKey);
|
|
94
|
+
const authUser = {
|
|
95
|
+
sub: user.id,
|
|
96
|
+
nickname: user.nickname ?? void 0,
|
|
97
|
+
avatarUrl: user.avatarUrl ?? void 0
|
|
98
|
+
};
|
|
99
|
+
const tokens = await sessions.createSession(authUser);
|
|
100
|
+
setCookie(c, names.access, tokens.accessToken, {
|
|
101
|
+
httpOnly: true,
|
|
102
|
+
sameSite: "Lax",
|
|
103
|
+
secure,
|
|
104
|
+
path: "/",
|
|
105
|
+
maxAge: sessions.accessTokenTtlSec
|
|
106
|
+
});
|
|
107
|
+
setCookie(c, names.refresh, tokens.refreshToken, {
|
|
108
|
+
httpOnly: true,
|
|
109
|
+
sameSite: "Lax",
|
|
110
|
+
secure,
|
|
111
|
+
path: "/",
|
|
112
|
+
maxAge: sessions.refreshTokenTtlSec
|
|
113
|
+
});
|
|
114
|
+
return c.json({
|
|
115
|
+
ok: true,
|
|
116
|
+
user: {
|
|
117
|
+
id: user.id,
|
|
118
|
+
username: user.username,
|
|
119
|
+
nickname: user.nickname
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return c.json({
|
|
124
|
+
error: "verify_failed",
|
|
125
|
+
message: e.message
|
|
126
|
+
}, 401);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
app.get(paths.listCredentials, async (c) => {
|
|
130
|
+
const claims = c.get("auth");
|
|
131
|
+
if (!claims) return c.json({ error: "unauthorized" }, 401);
|
|
132
|
+
const creds = await webauthn.listCredentials(claims.sub);
|
|
133
|
+
return c.json({ credentials: creds.map((c) => ({
|
|
134
|
+
id: c.id,
|
|
135
|
+
displayName: c.displayName,
|
|
136
|
+
deviceType: c.deviceType,
|
|
137
|
+
backedUp: c.backedUp === 1,
|
|
138
|
+
createdAt: c.createdAt
|
|
139
|
+
})) });
|
|
140
|
+
});
|
|
141
|
+
app.post(paths.deleteCredential, async (c) => {
|
|
142
|
+
const claims = c.get("auth");
|
|
143
|
+
if (!claims) return c.json({ error: "unauthorized" }, 401);
|
|
144
|
+
let body;
|
|
145
|
+
try {
|
|
146
|
+
body = await c.req.json();
|
|
147
|
+
} catch {
|
|
148
|
+
return c.json({ error: "invalid_body" }, 400);
|
|
149
|
+
}
|
|
150
|
+
if (!body.credentialId) return c.json({ error: "missing_credential_id" }, 400);
|
|
151
|
+
try {
|
|
152
|
+
await webauthn.deleteCredential(claims.sub, body.credentialId);
|
|
153
|
+
return c.json({ ok: true });
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return c.json({
|
|
156
|
+
error: "delete_failed",
|
|
157
|
+
message: e.message
|
|
158
|
+
}, 400);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return app;
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
export { WEBAUTHN_CREDENTIALS_TABLE, WebAuthProvider, createDrizzleWebauthnCredentialStore, createRestWebauthnCredentialStore, createWebAuthnRoutes };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homurajs/auth",
|
|
3
|
-
"version": "26.10.
|
|
3
|
+
"version": "26.10.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "homura 认证:通用标准 OAuth2 (Authorization Code Flow) 客户端 + 自管 session(HS256 短 JWT + opaque refresh rotate)",
|
|
6
6
|
"engines": {
|
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
"./tables": {
|
|
22
22
|
"types": "./dist/tables.d.ts",
|
|
23
23
|
"import": "./dist/tables.js"
|
|
24
|
+
},
|
|
25
|
+
"./webauthn": {
|
|
26
|
+
"types": "./dist/webauthn.d.ts",
|
|
27
|
+
"import": "./dist/webauthn.js"
|
|
24
28
|
}
|
|
25
29
|
},
|
|
26
30
|
"publishConfig": {
|
|
@@ -28,15 +32,19 @@
|
|
|
28
32
|
"registry": "https://registry.npmjs.org/"
|
|
29
33
|
},
|
|
30
34
|
"dependencies": {
|
|
31
|
-
"@homurajs/core": "26.10.
|
|
32
|
-
"@homurajs/db": "26.10.
|
|
35
|
+
"@homurajs/core": "26.10.15",
|
|
36
|
+
"@homurajs/db": "26.10.15",
|
|
33
37
|
"jose": "^6.0.0"
|
|
34
38
|
},
|
|
35
39
|
"peerDependencies": {
|
|
40
|
+
"@simplewebauthn/server": "^11.0.0",
|
|
36
41
|
"drizzle-orm": "1.0.0-rc.4",
|
|
37
42
|
"hono": "^4.0.0"
|
|
38
43
|
},
|
|
39
44
|
"peerDependenciesMeta": {
|
|
45
|
+
"@simplewebauthn/server": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
40
48
|
"drizzle-orm": {
|
|
41
49
|
"optional": true
|
|
42
50
|
},
|