@sigma-auth/better-auth-plugin 0.0.1
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 +15 -0
- package/dist/client/index.d.ts +59 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +246 -0
- package/dist/client/index.js.map +1 -0
- package/dist/next/index.d.ts +56 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +108 -0
- package/dist/next/index.js.map +1 -0
- package/dist/provider/index.d.ts +64 -0
- package/dist/provider/index.d.ts.map +1 -0
- package/dist/provider/index.js +717 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/server/index.d.ts +43 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +83 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +74 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +82 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import { PublicKey } from "@bsv/sdk";
|
|
2
|
+
import { APIError, createAuthEndpoint, sessionMiddleware, } from "better-auth/api";
|
|
3
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
4
|
+
import { createAuthMiddleware } from "better-auth/plugins";
|
|
5
|
+
import { parseAuthToken, verifyAuthToken } from "bitcoin-auth";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
/**
|
|
8
|
+
* Sigma Auth provider plugin for Better Auth
|
|
9
|
+
* This is the OAuth provider that runs on auth.sigmaidentity.com
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { betterAuth } from "better-auth";
|
|
14
|
+
* import { sigmaProvider } from "@sigma-auth/better-auth-plugin/provider";
|
|
15
|
+
*
|
|
16
|
+
* export const auth = betterAuth({
|
|
17
|
+
* plugins: [
|
|
18
|
+
* sigmaProvider({
|
|
19
|
+
* getPool: () => dbPool,
|
|
20
|
+
* cache: redisCache,
|
|
21
|
+
* resolveBAPId: async (pool, userId, pubkey, register) => {
|
|
22
|
+
* // Custom BAP ID resolution logic
|
|
23
|
+
* },
|
|
24
|
+
* }),
|
|
25
|
+
* ],
|
|
26
|
+
* });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export const sigmaProvider = (options) => ({
|
|
30
|
+
id: "sigma",
|
|
31
|
+
schema: {
|
|
32
|
+
user: {
|
|
33
|
+
fields: {
|
|
34
|
+
pubkey: {
|
|
35
|
+
type: "string",
|
|
36
|
+
required: true,
|
|
37
|
+
unique: true,
|
|
38
|
+
},
|
|
39
|
+
...(options?.enableSubscription
|
|
40
|
+
? {
|
|
41
|
+
subscriptionTier: {
|
|
42
|
+
type: "string",
|
|
43
|
+
required: false,
|
|
44
|
+
defaultValue: "free",
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
: {}),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
session: {
|
|
51
|
+
fields: {
|
|
52
|
+
...(options?.enableSubscription
|
|
53
|
+
? {
|
|
54
|
+
subscriptionTier: {
|
|
55
|
+
type: "string",
|
|
56
|
+
required: false,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
: {}),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
oauthAccessToken: {
|
|
63
|
+
fields: {
|
|
64
|
+
selectedBapId: {
|
|
65
|
+
type: "string",
|
|
66
|
+
required: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
oauthApplication: {
|
|
71
|
+
fields: {
|
|
72
|
+
owner_bap_id: {
|
|
73
|
+
type: "string",
|
|
74
|
+
required: true,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
oauthConsent: {
|
|
79
|
+
fields: {
|
|
80
|
+
selectedBapId: {
|
|
81
|
+
type: "string",
|
|
82
|
+
required: false,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
hooks: {
|
|
88
|
+
after: [
|
|
89
|
+
{
|
|
90
|
+
matcher: (ctx) => ctx.path === "/oauth2/token",
|
|
91
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
92
|
+
console.log("🔵 [Sigma Plugin] AFTER hook triggered for /oauth2/token");
|
|
93
|
+
const body = ctx.body;
|
|
94
|
+
const grantType = body.grant_type;
|
|
95
|
+
// Only handle authorization_code grant (not refresh_token)
|
|
96
|
+
if (grantType !== "authorization_code") {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Check if token exchange was successful
|
|
100
|
+
const responseBody = ctx.context.returned;
|
|
101
|
+
if (!responseBody ||
|
|
102
|
+
typeof responseBody !== "object" ||
|
|
103
|
+
!("access_token" in responseBody)) {
|
|
104
|
+
return; // Token exchange failed, skip BAP ID storage
|
|
105
|
+
}
|
|
106
|
+
// Only proceed if we have cache option
|
|
107
|
+
if (!options?.cache) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
// Get the access token from response to find the related consent
|
|
112
|
+
const accessToken = responseBody
|
|
113
|
+
.access_token;
|
|
114
|
+
// Query the access token record to get userId and clientId using adapter
|
|
115
|
+
const tokenRecords = await ctx.context.adapter.findMany({
|
|
116
|
+
model: "oauthAccessToken",
|
|
117
|
+
where: [{ field: "accessToken", value: accessToken }],
|
|
118
|
+
limit: 1,
|
|
119
|
+
});
|
|
120
|
+
if (tokenRecords.length === 0 || !tokenRecords[0]) {
|
|
121
|
+
console.warn("⚠️ [OAuth Token Hook] No access token found in database");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const { userId, clientId } = tokenRecords[0];
|
|
125
|
+
// Query the most recent consent record for this user/client to get selectedBapId
|
|
126
|
+
const consentRecords = await ctx.context.adapter.findMany({
|
|
127
|
+
model: "oauthConsent",
|
|
128
|
+
where: [
|
|
129
|
+
{ field: "userId", value: userId },
|
|
130
|
+
{ field: "clientId", value: clientId },
|
|
131
|
+
],
|
|
132
|
+
limit: 1,
|
|
133
|
+
sortBy: { field: "createdAt", direction: "desc" },
|
|
134
|
+
});
|
|
135
|
+
const consentRecord = consentRecords[0];
|
|
136
|
+
if (!consentRecord || !consentRecord.selectedBapId) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const selectedBapId = consentRecord.selectedBapId;
|
|
140
|
+
// Update the oauthAccessToken record with the selected BAP ID
|
|
141
|
+
await ctx.context.adapter.update({
|
|
142
|
+
model: "oauthAccessToken",
|
|
143
|
+
where: [{ field: "accessToken", value: accessToken }],
|
|
144
|
+
update: {
|
|
145
|
+
selectedBapId,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
console.log(`✅ [OAuth Token Hook] Stored BAP ID in access token: user=${userId.substring(0, 15)}... bap=${selectedBapId.substring(0, 15)}...`);
|
|
149
|
+
// Update user record with selected identity's profile data
|
|
150
|
+
if (options?.getPool) {
|
|
151
|
+
const pool = options.getPool();
|
|
152
|
+
const client = await pool.connect();
|
|
153
|
+
try {
|
|
154
|
+
// Query profile table for selected identity
|
|
155
|
+
const profileResult = await client.query("SELECT bap_id, name, image, member_pubkey FROM profile WHERE bap_id = $1 AND user_id = $2 LIMIT 1", [selectedBapId, userId]);
|
|
156
|
+
const profile = profileResult.rows[0];
|
|
157
|
+
if (profile) {
|
|
158
|
+
// Update user record with profile data
|
|
159
|
+
await ctx.context.adapter.update({
|
|
160
|
+
model: "user",
|
|
161
|
+
where: [{ field: "id", value: userId }],
|
|
162
|
+
update: {
|
|
163
|
+
name: profile.name,
|
|
164
|
+
image: profile.image,
|
|
165
|
+
...(profile.member_pubkey && {
|
|
166
|
+
pubkey: profile.member_pubkey,
|
|
167
|
+
}),
|
|
168
|
+
updatedAt: new Date(),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
client.release();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.error("❌ [OAuth Token] Error storing identity selection:", error);
|
|
180
|
+
}
|
|
181
|
+
}),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
matcher: (ctx) => ctx.path === "/oauth2/userinfo",
|
|
185
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
186
|
+
// Get the access token from Authorization header
|
|
187
|
+
const authHeader = ctx.headers?.get?.("authorization");
|
|
188
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const accessToken = authHeader.substring(7);
|
|
192
|
+
try {
|
|
193
|
+
// Look up the access token record to get selectedBapId using adapter
|
|
194
|
+
const tokenRecords = await ctx.context.adapter.findMany({
|
|
195
|
+
model: "oauthAccessToken",
|
|
196
|
+
where: [{ field: "accessToken", value: accessToken }],
|
|
197
|
+
limit: 1,
|
|
198
|
+
});
|
|
199
|
+
const tokenRecord = tokenRecords[0];
|
|
200
|
+
if (!tokenRecord || !tokenRecord.selectedBapId) {
|
|
201
|
+
return; // No selected BAP ID, use primary (default behavior)
|
|
202
|
+
}
|
|
203
|
+
const selectedBapId = tokenRecord.selectedBapId;
|
|
204
|
+
// Get BAP ID details from profile table using getPool if available
|
|
205
|
+
if (!options?.getPool) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const pool = options.getPool();
|
|
209
|
+
const client = await pool.connect();
|
|
210
|
+
try {
|
|
211
|
+
const bapResult = await client.query("SELECT bap_id, name, image, profile FROM profile WHERE bap_id = $1 LIMIT 1", [selectedBapId]);
|
|
212
|
+
if (bapResult.rows.length === 0) {
|
|
213
|
+
throw new Error(`Selected identity not found: ${selectedBapId}`);
|
|
214
|
+
}
|
|
215
|
+
const selectedName = bapResult.rows[0].name;
|
|
216
|
+
let selectedImage = bapResult.rows[0].image;
|
|
217
|
+
let profileData = bapResult.rows[0].profile;
|
|
218
|
+
// If profile JSONB is NULL, fetch from blockchain and populate it
|
|
219
|
+
if (!profileData) {
|
|
220
|
+
try {
|
|
221
|
+
const profileResponse = await fetch("https://api.sigmaidentity.com/api/v1/identity/get", {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: { "Content-Type": "application/json" },
|
|
224
|
+
body: JSON.stringify({ idKey: selectedBapId }),
|
|
225
|
+
});
|
|
226
|
+
if (profileResponse.ok) {
|
|
227
|
+
const apiData = (await profileResponse.json());
|
|
228
|
+
if (apiData.result) {
|
|
229
|
+
profileData = apiData.result;
|
|
230
|
+
// Update database with complete profile JSONB
|
|
231
|
+
await client.query(`UPDATE profile SET
|
|
232
|
+
profile = $1,
|
|
233
|
+
image = COALESCE($2, image),
|
|
234
|
+
updated_at = NOW()
|
|
235
|
+
WHERE bap_id = $3`, [
|
|
236
|
+
JSON.stringify(profileData),
|
|
237
|
+
profileData.identity?.image || null,
|
|
238
|
+
selectedBapId,
|
|
239
|
+
]);
|
|
240
|
+
if (profileData.identity?.image) {
|
|
241
|
+
selectedImage = profileData.identity.image;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (fetchError) {
|
|
247
|
+
console.error(`❌ [OAuth Userinfo] Failed to fetch profile:`, fetchError);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Modify the response to use selected BAP ID instead of primary
|
|
251
|
+
const responseBody = ctx.context.returned;
|
|
252
|
+
if (responseBody && typeof responseBody === "object") {
|
|
253
|
+
// Fetch the member pubkey for this BAP ID from KV reverse index
|
|
254
|
+
let memberPubkey = null;
|
|
255
|
+
if (options.cache) {
|
|
256
|
+
try {
|
|
257
|
+
const reverseKey = `bap:member_pubkey:${selectedBapId}`;
|
|
258
|
+
memberPubkey = await options.cache.get(reverseKey);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
console.error(`❌ [OAuth Userinfo] Error fetching member pubkey:`, error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Return the modified userinfo response
|
|
265
|
+
return {
|
|
266
|
+
...responseBody,
|
|
267
|
+
// Standard OIDC claims
|
|
268
|
+
name: selectedName,
|
|
269
|
+
given_name: profileData?.identity?.givenName || selectedName,
|
|
270
|
+
family_name: profileData?.identity?.familyName || null,
|
|
271
|
+
picture: selectedImage || responseBody.picture || null,
|
|
272
|
+
// Custom claims
|
|
273
|
+
pubkey: memberPubkey || responseBody.pubkey,
|
|
274
|
+
bap: profileData || null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
client.release();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
console.error("❌ [OAuth Userinfo] Error retrieving selected BAP ID:", error);
|
|
284
|
+
}
|
|
285
|
+
}),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
matcher: (ctx) => ctx.path === "/oauth2/consent",
|
|
289
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
290
|
+
// Only proceed if we have cache option
|
|
291
|
+
if (!options?.cache) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const body = ctx.body;
|
|
295
|
+
const consentCode = body.consent_code;
|
|
296
|
+
const accept = body.accept;
|
|
297
|
+
// Only store selectedBapId if consent was accepted
|
|
298
|
+
if (!accept || !consentCode) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
// Get session for userId
|
|
303
|
+
const session = ctx.context.session;
|
|
304
|
+
if (!session?.user?.id) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Wait a bit for Better Auth to create the consent record
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
309
|
+
// Query the database to get the clientId from the consent record
|
|
310
|
+
const consentRecords = await ctx.context.adapter.findMany({
|
|
311
|
+
model: "oauthConsent",
|
|
312
|
+
where: [{ field: "userId", value: session.user.id }],
|
|
313
|
+
limit: 1,
|
|
314
|
+
sortBy: { field: "createdAt", direction: "desc" },
|
|
315
|
+
});
|
|
316
|
+
const consentRecord = consentRecords[0];
|
|
317
|
+
if (!consentRecord) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const { id: consentId } = consentRecord;
|
|
321
|
+
// Retrieve selected BAP ID from cache/KV
|
|
322
|
+
const selectedBapId = await options.cache.get(`consent:${consentCode}:bap_id`);
|
|
323
|
+
if (!selectedBapId) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Update the consent record with selectedBapId using adapter
|
|
327
|
+
await ctx.context.adapter.update({
|
|
328
|
+
model: "oauthConsent",
|
|
329
|
+
where: [{ field: "id", value: consentId }],
|
|
330
|
+
update: {
|
|
331
|
+
selectedBapId,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
console.log(`✅ [OAuth Consent Hook] Stored BAP ID in consent: user=${session.user.id.substring(0, 15)}... bap=${selectedBapId.substring(0, 15)}...`);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
console.error("❌ [OAuth Consent Hook] Error storing identity selection:", error);
|
|
338
|
+
}
|
|
339
|
+
}),
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
before: [
|
|
343
|
+
{
|
|
344
|
+
matcher: (ctx) => ctx.path === "/oauth2/token",
|
|
345
|
+
handler: createAuthMiddleware(async (ctx) => {
|
|
346
|
+
const body = ctx.body;
|
|
347
|
+
const grantType = body.grant_type;
|
|
348
|
+
// Handle authorization_code grant type (exchange code for token)
|
|
349
|
+
if (grantType === "authorization_code") {
|
|
350
|
+
// Get client_id from request body
|
|
351
|
+
const clientId = body.client_id;
|
|
352
|
+
if (!clientId) {
|
|
353
|
+
throw new APIError("BAD_REQUEST", {
|
|
354
|
+
message: "Missing client_id in request body",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Lookup OAuth client by client_id
|
|
358
|
+
const clients = await ctx.context.adapter.findMany({
|
|
359
|
+
model: "oauthApplication",
|
|
360
|
+
where: [{ field: "clientId", value: clientId }],
|
|
361
|
+
});
|
|
362
|
+
if (clients.length === 0) {
|
|
363
|
+
throw new APIError("UNAUTHORIZED", {
|
|
364
|
+
message: `OAuth client not registered: ${clientId}`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
const client = clients[0];
|
|
368
|
+
// Validate client authentication via Bitcoin signature
|
|
369
|
+
const headers = new Headers(ctx.headers || {});
|
|
370
|
+
const authToken = headers.get("x-auth-token");
|
|
371
|
+
if (!authToken) {
|
|
372
|
+
throw new APIError("UNAUTHORIZED", {
|
|
373
|
+
message: "Missing X-Auth-Token header for client authentication",
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// Parse the auth token to extract pubkey
|
|
377
|
+
const parsed = parseAuthToken(authToken);
|
|
378
|
+
if (!parsed?.pubkey) {
|
|
379
|
+
throw new APIError("UNAUTHORIZED", {
|
|
380
|
+
message: "Invalid Bitcoin auth token format",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
// Verify the pubkey from signature matches the client's memberPubkey
|
|
384
|
+
if (!client.metadata) {
|
|
385
|
+
throw new APIError("UNAUTHORIZED", {
|
|
386
|
+
message: `Client ${clientId} has no metadata`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const metadata = JSON.parse(client.metadata);
|
|
390
|
+
const expectedPubkey = metadata.memberPubkey;
|
|
391
|
+
if (!expectedPubkey) {
|
|
392
|
+
throw new APIError("UNAUTHORIZED", {
|
|
393
|
+
message: `Client ${clientId} has no memberPubkey in metadata`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
if (parsed.pubkey !== expectedPubkey) {
|
|
397
|
+
throw new APIError("UNAUTHORIZED", {
|
|
398
|
+
message: "Bitcoin signature pubkey does not match client",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
// Get request body for signature verification
|
|
402
|
+
const bodyString = new URLSearchParams(Object.entries(body).map(([k, v]) => [k, String(v)])).toString();
|
|
403
|
+
// Verify Bitcoin signature with body
|
|
404
|
+
const verifyData = {
|
|
405
|
+
requestPath: "/oauth2/token",
|
|
406
|
+
timestamp: parsed.timestamp,
|
|
407
|
+
body: bodyString,
|
|
408
|
+
};
|
|
409
|
+
const isValid = verifyAuthToken(authToken, verifyData, 5);
|
|
410
|
+
if (!isValid) {
|
|
411
|
+
throw new APIError("UNAUTHORIZED", {
|
|
412
|
+
message: "Invalid Bitcoin signature",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
console.log(`✅ [OAuth Token] Client authenticated via Bitcoin signature (clientId: ${clientId})`);
|
|
416
|
+
// Inject client_id into request body for Better Auth to process
|
|
417
|
+
const modifiedBody = {
|
|
418
|
+
...ctx.body,
|
|
419
|
+
client_id: clientId,
|
|
420
|
+
};
|
|
421
|
+
return {
|
|
422
|
+
context: {
|
|
423
|
+
...ctx,
|
|
424
|
+
body: modifiedBody,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// Handle refresh_token grant type
|
|
429
|
+
if (grantType === "refresh_token") {
|
|
430
|
+
const refreshToken = body.refresh_token;
|
|
431
|
+
if (!refreshToken) {
|
|
432
|
+
throw new APIError("BAD_REQUEST", {
|
|
433
|
+
message: "Missing refresh_token",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
// Get client_id from request body
|
|
437
|
+
const clientId = body.client_id;
|
|
438
|
+
if (!clientId) {
|
|
439
|
+
throw new APIError("BAD_REQUEST", {
|
|
440
|
+
message: "Missing client_id in request body",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
// Lookup OAuth client by client_id
|
|
444
|
+
const clients = await ctx.context.adapter.findMany({
|
|
445
|
+
model: "oauthApplication",
|
|
446
|
+
where: [{ field: "clientId", value: clientId }],
|
|
447
|
+
});
|
|
448
|
+
if (clients.length === 0) {
|
|
449
|
+
throw new APIError("UNAUTHORIZED", {
|
|
450
|
+
message: `OAuth client not registered: ${clientId}`,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
const client = clients[0];
|
|
454
|
+
// Validate client signature first
|
|
455
|
+
const headers = new Headers(ctx.headers || {});
|
|
456
|
+
const authToken = headers.get("x-auth-token");
|
|
457
|
+
if (!authToken) {
|
|
458
|
+
throw new APIError("UNAUTHORIZED", {
|
|
459
|
+
message: "Missing X-Auth-Token header for client authentication",
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const parsed = parseAuthToken(authToken);
|
|
463
|
+
if (!parsed?.pubkey) {
|
|
464
|
+
throw new APIError("UNAUTHORIZED", {
|
|
465
|
+
message: "Invalid Bitcoin auth token format",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (!client.metadata) {
|
|
469
|
+
throw new APIError("UNAUTHORIZED", {
|
|
470
|
+
message: `Client ${clientId} has no metadata`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
const metadata = JSON.parse(client.metadata);
|
|
474
|
+
const expectedPubkey = metadata.memberPubkey;
|
|
475
|
+
if (!expectedPubkey) {
|
|
476
|
+
throw new APIError("UNAUTHORIZED", {
|
|
477
|
+
message: `Client ${clientId} has no memberPubkey in metadata`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (parsed.pubkey !== expectedPubkey) {
|
|
481
|
+
throw new APIError("UNAUTHORIZED", {
|
|
482
|
+
message: "Bitcoin signature pubkey does not match client",
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
const bodyString = new URLSearchParams(Object.entries(body).map(([k, v]) => [k, String(v)])).toString();
|
|
486
|
+
const verifyData = {
|
|
487
|
+
requestPath: "/oauth2/token",
|
|
488
|
+
timestamp: parsed.timestamp,
|
|
489
|
+
body: bodyString,
|
|
490
|
+
};
|
|
491
|
+
const isValid = verifyAuthToken(authToken, verifyData, 5);
|
|
492
|
+
if (!isValid) {
|
|
493
|
+
throw new APIError("UNAUTHORIZED", {
|
|
494
|
+
message: "Invalid Bitcoin signature",
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
console.log(`✅ [OAuth Token Refresh] Client authenticated via Bitcoin signature (clientId: ${clientId})`);
|
|
498
|
+
// Inject client_id into request body for Better Auth to process
|
|
499
|
+
const modifiedBody = {
|
|
500
|
+
...ctx.body,
|
|
501
|
+
client_id: clientId,
|
|
502
|
+
};
|
|
503
|
+
return {
|
|
504
|
+
context: {
|
|
505
|
+
...ctx,
|
|
506
|
+
body: modifiedBody,
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
// Unknown grant type
|
|
511
|
+
throw new APIError("BAD_REQUEST", {
|
|
512
|
+
message: `Unsupported grant_type: ${grantType}`,
|
|
513
|
+
});
|
|
514
|
+
}),
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
},
|
|
518
|
+
endpoints: {
|
|
519
|
+
/**
|
|
520
|
+
* Store selected BAP ID for OAuth consent
|
|
521
|
+
*/
|
|
522
|
+
storeConsentBapId: createAuthEndpoint("/sigma/store-consent-bap-id", {
|
|
523
|
+
method: "POST",
|
|
524
|
+
body: z.object({
|
|
525
|
+
consentCode: z.string(),
|
|
526
|
+
bapId: z.string(),
|
|
527
|
+
}),
|
|
528
|
+
use: [sessionMiddleware],
|
|
529
|
+
}, async (ctx) => {
|
|
530
|
+
// Session is guaranteed to exist due to sessionMiddleware
|
|
531
|
+
const _session = ctx.context.session;
|
|
532
|
+
// Validate options
|
|
533
|
+
if (!options?.cache) {
|
|
534
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
535
|
+
message: "Plugin configuration error: cache not available",
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
const { consentCode, bapId } = ctx.body;
|
|
539
|
+
try {
|
|
540
|
+
// Store in KV with 5 minute TTL
|
|
541
|
+
const kvKey = `consent:${consentCode}:bap_id`;
|
|
542
|
+
await options.cache.set(kvKey, bapId, { ex: 300 });
|
|
543
|
+
return ctx.json({ success: true });
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
console.error("❌ [Store Consent BAP ID] Error storing selection:", error);
|
|
547
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
548
|
+
message: "Failed to store identity selection",
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}),
|
|
552
|
+
signInSigma: createAuthEndpoint("/sign-in/sigma", {
|
|
553
|
+
method: "POST",
|
|
554
|
+
body: z.optional(z.object({
|
|
555
|
+
bapId: z.string().optional(),
|
|
556
|
+
})),
|
|
557
|
+
}, async (ctx) => {
|
|
558
|
+
// Get auth token from header
|
|
559
|
+
const authToken = ctx.headers?.get("x-auth-token");
|
|
560
|
+
if (!authToken) {
|
|
561
|
+
throw new APIError("UNAUTHORIZED", {
|
|
562
|
+
message: "No auth token provided",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
// Parse the auth token
|
|
566
|
+
const parsed = parseAuthToken(authToken);
|
|
567
|
+
if (!parsed?.pubkey) {
|
|
568
|
+
throw new APIError("BAD_REQUEST", {
|
|
569
|
+
message: "Invalid auth token format",
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
// Verify the auth token
|
|
573
|
+
const verifyData = {
|
|
574
|
+
requestPath: "/api/auth/sign-in/sigma",
|
|
575
|
+
timestamp: parsed.timestamp,
|
|
576
|
+
};
|
|
577
|
+
const isValid = verifyAuthToken(authToken, verifyData, 5);
|
|
578
|
+
if (!isValid) {
|
|
579
|
+
throw new APIError("UNAUTHORIZED", {
|
|
580
|
+
message: "Invalid auth token signature",
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
// Extract pubkey from the parsed token
|
|
584
|
+
const pubkey = parsed.pubkey;
|
|
585
|
+
// Try to find user by pubkey first
|
|
586
|
+
const users = await ctx.context.adapter.findMany({
|
|
587
|
+
model: "user",
|
|
588
|
+
where: [{ field: "pubkey", value: pubkey }],
|
|
589
|
+
});
|
|
590
|
+
let user = users[0];
|
|
591
|
+
// If not found by user.pubkey, check profile table for member_pubkey
|
|
592
|
+
if (!user && options?.getPool) {
|
|
593
|
+
const pool = options.getPool();
|
|
594
|
+
const client = await pool.connect();
|
|
595
|
+
try {
|
|
596
|
+
const profileResult = await client.query("SELECT user_id FROM profile WHERE member_pubkey = $1 LIMIT 1", [pubkey]);
|
|
597
|
+
const profileRow = profileResult.rows[0];
|
|
598
|
+
if (profileRow) {
|
|
599
|
+
const userId = profileRow.user_id;
|
|
600
|
+
// Fetch the user record
|
|
601
|
+
const foundUsers = await ctx.context.adapter.findMany({
|
|
602
|
+
model: "user",
|
|
603
|
+
where: [{ field: "id", value: userId }],
|
|
604
|
+
});
|
|
605
|
+
user = foundUsers[0];
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
client.release();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (!user) {
|
|
613
|
+
// Create new user with pubkey (no email)
|
|
614
|
+
try {
|
|
615
|
+
user = (await ctx.context.adapter.create({
|
|
616
|
+
model: "user",
|
|
617
|
+
data: {
|
|
618
|
+
name: PublicKey.fromString(pubkey).toAddress(),
|
|
619
|
+
pubkey,
|
|
620
|
+
emailVerified: false,
|
|
621
|
+
createdAt: new Date(),
|
|
622
|
+
updatedAt: new Date(),
|
|
623
|
+
},
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
// If duplicate key error, try to find the user again by pubkey
|
|
628
|
+
if (error &&
|
|
629
|
+
typeof error === "object" &&
|
|
630
|
+
"code" in error &&
|
|
631
|
+
error.code === "23505") {
|
|
632
|
+
const existingUsers = await ctx.context.adapter.findMany({
|
|
633
|
+
model: "user",
|
|
634
|
+
where: [{ field: "pubkey", value: pubkey }],
|
|
635
|
+
});
|
|
636
|
+
user = existingUsers[0];
|
|
637
|
+
if (!user) {
|
|
638
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
639
|
+
message: "User exists but cannot be found",
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
throw error;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// Resolve BAP ID if resolver is provided
|
|
649
|
+
if (options?.resolveBAPId && options?.getPool) {
|
|
650
|
+
const pool = options.getPool();
|
|
651
|
+
const bapId = await options.resolveBAPId(pool, user.id, pubkey, true);
|
|
652
|
+
if (bapId) {
|
|
653
|
+
console.log(`✅ BAP ID resolved and registered: ${bapId.substring(0, 20)}...`);
|
|
654
|
+
// Update user record with profile data from profile table
|
|
655
|
+
const selectedBapId = ctx.body?.bapId;
|
|
656
|
+
const client = await pool.connect();
|
|
657
|
+
try {
|
|
658
|
+
let profileResult;
|
|
659
|
+
if (selectedBapId) {
|
|
660
|
+
// Query profile for selected identity
|
|
661
|
+
profileResult = await client.query("SELECT bap_id, name, image, member_pubkey FROM profile WHERE bap_id = $1 AND user_id = $2 LIMIT 1", [selectedBapId, user.id]);
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
// Query for primary profile
|
|
665
|
+
profileResult = await client.query("SELECT bap_id, name, image, member_pubkey FROM profile WHERE user_id = $1 AND is_primary = true LIMIT 1", [user.id]);
|
|
666
|
+
}
|
|
667
|
+
const selectedProfile = profileResult.rows[0];
|
|
668
|
+
if (selectedProfile) {
|
|
669
|
+
// Update user record with profile data
|
|
670
|
+
await ctx.context.adapter.update({
|
|
671
|
+
model: "user",
|
|
672
|
+
where: [{ field: "id", value: user.id }],
|
|
673
|
+
update: {
|
|
674
|
+
name: selectedProfile.name,
|
|
675
|
+
image: selectedProfile.image,
|
|
676
|
+
...(selectedProfile.member_pubkey && {
|
|
677
|
+
pubkey: selectedProfile.member_pubkey,
|
|
678
|
+
}),
|
|
679
|
+
updatedAt: new Date(),
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
client.release();
|
|
686
|
+
}
|
|
687
|
+
// Re-fetch user to get updated profile data
|
|
688
|
+
const updatedUsers = await ctx.context.adapter.findMany({
|
|
689
|
+
model: "user",
|
|
690
|
+
where: [{ field: "id", value: user.id }],
|
|
691
|
+
});
|
|
692
|
+
if (updatedUsers[0]) {
|
|
693
|
+
user = updatedUsers[0];
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Create session
|
|
698
|
+
const session = await ctx.context.internalAdapter.createSession(user.id, ctx);
|
|
699
|
+
if (!session) {
|
|
700
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
701
|
+
message: "Internal Server Error",
|
|
702
|
+
status: 500,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
await setSessionCookie(ctx, { session, user });
|
|
706
|
+
return ctx.json({
|
|
707
|
+
token: session.token,
|
|
708
|
+
user: {
|
|
709
|
+
id: user.id,
|
|
710
|
+
pubkey: user.pubkey,
|
|
711
|
+
name: user.name,
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
}),
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
//# sourceMappingURL=index.js.map
|