@draftlab/auth 0.1.3 → 0.1.5
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/dist/provider/passkey.d.ts +104 -0
- package/dist/provider/passkey.js +324 -0
- package/dist/ui/base.d.ts +2 -2
- package/dist/ui/passkey.d.ts +30 -0
- package/dist/ui/passkey.js +341 -0
- package/dist/ui/select.js +1 -0
- package/package.json +4 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Provider } from "./provider.js";
|
|
2
|
+
import { AuthenticatorSelectionCriteria, AuthenticatorTransportFuture, Base64URLString, CredentialDeviceType } from "@simplewebauthn/server";
|
|
3
|
+
|
|
4
|
+
//#region src/provider/passkey.d.ts
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* User model for passkey authentication.
|
|
8
|
+
* Contains the core user data needed for WebAuthn operations.
|
|
9
|
+
*/
|
|
10
|
+
interface UserModel {
|
|
11
|
+
id: string;
|
|
12
|
+
username: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Original PasskeyModel structure for in-memory use.
|
|
16
|
+
* Represents a registered credential with public key as Uint8Array.
|
|
17
|
+
*/
|
|
18
|
+
interface PasskeyModel {
|
|
19
|
+
id: string;
|
|
20
|
+
publicKey: Uint8Array;
|
|
21
|
+
userId: string;
|
|
22
|
+
webauthnUserID: string;
|
|
23
|
+
counter: number;
|
|
24
|
+
deviceType: CredentialDeviceType;
|
|
25
|
+
backedUp: boolean;
|
|
26
|
+
transports?: AuthenticatorTransportFuture[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* PasskeyModel version for KV storage with publicKey as string.
|
|
30
|
+
* Used for storing credentials in a key-value store.
|
|
31
|
+
*/
|
|
32
|
+
interface PasskeyModelStored extends Omit<PasskeyModel, "publicKey"> {
|
|
33
|
+
publicKey: string;
|
|
34
|
+
}
|
|
35
|
+
declare const DEFAULT_COPY: {
|
|
36
|
+
error_user_not_allowed: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Configuration for the PasskeyProvider.
|
|
40
|
+
* Defines how the passkey authentication flow should behave.
|
|
41
|
+
*/
|
|
42
|
+
interface PasskeyProviderConfig {
|
|
43
|
+
/**
|
|
44
|
+
* Custom authorization handler that generates the UI for authorization.
|
|
45
|
+
*/
|
|
46
|
+
authorize: (req: Request) => Promise<Response>;
|
|
47
|
+
/**
|
|
48
|
+
* Custom registration handler that generates the UI for registration.
|
|
49
|
+
*/
|
|
50
|
+
register: (req: Request) => Promise<Response>;
|
|
51
|
+
/**
|
|
52
|
+
* The human-readable name of the relying party (your application).
|
|
53
|
+
*/
|
|
54
|
+
rpName: string;
|
|
55
|
+
/**
|
|
56
|
+
* The ID of the relying party, typically the domain name without protocol.
|
|
57
|
+
*/
|
|
58
|
+
rpID?: string;
|
|
59
|
+
/**
|
|
60
|
+
* The origin URL(s) that are allowed to initiate WebAuthn ceremonies.
|
|
61
|
+
*/
|
|
62
|
+
origin?: string | string[];
|
|
63
|
+
/**
|
|
64
|
+
* Optional function to check if a user is allowed to register a passkey.
|
|
65
|
+
*/
|
|
66
|
+
userCanRegisterPasskey?: (userId: string, req: Request) => Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* Optional WebAuthn authenticator selection criteria.
|
|
69
|
+
*/
|
|
70
|
+
authenticatorSelection?: AuthenticatorSelectionCriteria;
|
|
71
|
+
/**
|
|
72
|
+
* Optional attestation type.
|
|
73
|
+
*/
|
|
74
|
+
attestationType?: "none" | "direct" | "enterprise";
|
|
75
|
+
/**
|
|
76
|
+
* Optional timeout for challenges in milliseconds.
|
|
77
|
+
*/
|
|
78
|
+
timeout?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Custom copy texts for error messages and UI elements.
|
|
81
|
+
*/
|
|
82
|
+
copy?: Partial<typeof DEFAULT_COPY>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Creates a passkey (WebAuthn) authentication provider.
|
|
86
|
+
*
|
|
87
|
+
* This provider enables passwordless authentication using biometrics, hardware security
|
|
88
|
+
* keys, or platform authenticators. It implements the Web Authentication (WebAuthn) standard.
|
|
89
|
+
*
|
|
90
|
+
* It handles:
|
|
91
|
+
* - Passkey registration (creating new credentials)
|
|
92
|
+
* - Authentication with existing passkeys
|
|
93
|
+
* - Secure storage of credentials
|
|
94
|
+
* - Challenge verification
|
|
95
|
+
*
|
|
96
|
+
* @param config Configuration options for the passkey provider
|
|
97
|
+
* @returns A Provider instance configured for passkey authentication
|
|
98
|
+
*/
|
|
99
|
+
declare const PasskeyProvider: (config: PasskeyProviderConfig) => Provider<{
|
|
100
|
+
userId: string;
|
|
101
|
+
credentialId?: Base64URLString;
|
|
102
|
+
}>;
|
|
103
|
+
//#endregion
|
|
104
|
+
export { PasskeyModel, PasskeyModelStored, PasskeyProvider, PasskeyProviderConfig, UserModel };
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { Storage } from "../storage/storage.js";
|
|
2
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
|
3
|
+
|
|
4
|
+
//#region src/provider/passkey.ts
|
|
5
|
+
/**
|
|
6
|
+
* Converts a Uint8Array to a Base64URL encoded string.
|
|
7
|
+
* This is used to convert binary data for storage in databases or JSON.
|
|
8
|
+
*
|
|
9
|
+
* @param bytes - The Uint8Array to convert
|
|
10
|
+
* @returns Base64URL encoded string
|
|
11
|
+
*/
|
|
12
|
+
const uint8ArrayToBase64Url = (bytes) => {
|
|
13
|
+
let str = "";
|
|
14
|
+
for (const charCode of bytes) str += String.fromCharCode(charCode);
|
|
15
|
+
const base64String = btoa(str);
|
|
16
|
+
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Converts a Base64URL encoded string back to a Uint8Array.
|
|
20
|
+
* This is used to convert stored data back to binary format for WebAuthn operations.
|
|
21
|
+
*
|
|
22
|
+
* @param base64urlString - The Base64URL encoded string to convert
|
|
23
|
+
* @returns Uint8Array containing the decoded data
|
|
24
|
+
*/
|
|
25
|
+
const base64UrlToUint8Array = (base64urlString) => {
|
|
26
|
+
const base64 = base64urlString.replace(/-/g, "+").replace(/_/g, "/");
|
|
27
|
+
/**
|
|
28
|
+
* Pad with '=' until it's a multiple of four
|
|
29
|
+
* (4 - (85 % 4 = 1) = 3) % 4 = 3 padding
|
|
30
|
+
* (4 - (86 % 4 = 2) = 2) % 4 = 2 padding
|
|
31
|
+
* (4 - (87 % 4 = 3) = 1) % 4 = 1 padding
|
|
32
|
+
* (4 - (88 % 4 = 0) = 4) % 4 = 0 padding
|
|
33
|
+
*/
|
|
34
|
+
const padLength = (4 - base64.length % 4) % 4;
|
|
35
|
+
const padded = base64.padEnd(base64.length + padLength, "=");
|
|
36
|
+
const binary = atob(padded);
|
|
37
|
+
const buffer = new ArrayBuffer(binary.length);
|
|
38
|
+
const bytes = new Uint8Array(buffer);
|
|
39
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
40
|
+
return bytes;
|
|
41
|
+
};
|
|
42
|
+
const userKey = (userId) => [
|
|
43
|
+
"passkey",
|
|
44
|
+
"user",
|
|
45
|
+
userId
|
|
46
|
+
];
|
|
47
|
+
const passkeyKey = (userId, credentialId) => [
|
|
48
|
+
"passkey",
|
|
49
|
+
"user",
|
|
50
|
+
userId,
|
|
51
|
+
"credential",
|
|
52
|
+
credentialId,
|
|
53
|
+
"passkey"
|
|
54
|
+
];
|
|
55
|
+
const optionsKey = (userId) => [
|
|
56
|
+
"passkey",
|
|
57
|
+
"user",
|
|
58
|
+
userId,
|
|
59
|
+
"options"
|
|
60
|
+
];
|
|
61
|
+
const userPasskeysIndexKey = (userId) => [
|
|
62
|
+
"passkey",
|
|
63
|
+
"user",
|
|
64
|
+
userId,
|
|
65
|
+
"passkeys"
|
|
66
|
+
];
|
|
67
|
+
const DEFAULT_COPY = { error_user_not_allowed: "There is already an account with this email. Login to add a passkey." };
|
|
68
|
+
/**
|
|
69
|
+
* Creates a passkey (WebAuthn) authentication provider.
|
|
70
|
+
*
|
|
71
|
+
* This provider enables passwordless authentication using biometrics, hardware security
|
|
72
|
+
* keys, or platform authenticators. It implements the Web Authentication (WebAuthn) standard.
|
|
73
|
+
*
|
|
74
|
+
* It handles:
|
|
75
|
+
* - Passkey registration (creating new credentials)
|
|
76
|
+
* - Authentication with existing passkeys
|
|
77
|
+
* - Secure storage of credentials
|
|
78
|
+
* - Challenge verification
|
|
79
|
+
*
|
|
80
|
+
* @param config Configuration options for the passkey provider
|
|
81
|
+
* @returns A Provider instance configured for passkey authentication
|
|
82
|
+
*/
|
|
83
|
+
const PasskeyProvider = (config) => {
|
|
84
|
+
const copy = {
|
|
85
|
+
...DEFAULT_COPY,
|
|
86
|
+
...config.copy
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
type: "passkey",
|
|
90
|
+
init(routes, ctx) {
|
|
91
|
+
const { rpName, authenticatorSelection, attestationType = "none", timeout = 5 * 60 * 1e3 } = config;
|
|
92
|
+
const getStoredUserById = async (userId) => {
|
|
93
|
+
return await Storage.get(ctx.storage, userKey(userId));
|
|
94
|
+
};
|
|
95
|
+
const saveUser = async (user) => {
|
|
96
|
+
await Storage.set(ctx.storage, userKey(user.id), user);
|
|
97
|
+
};
|
|
98
|
+
const getStoredPasskeyById = async (userId, credentialID) => {
|
|
99
|
+
const storedPasskey = await Storage.get(ctx.storage, passkeyKey(userId, credentialID));
|
|
100
|
+
if (!storedPasskey) return null;
|
|
101
|
+
return {
|
|
102
|
+
...storedPasskey,
|
|
103
|
+
publicKey: base64UrlToUint8Array(storedPasskey.publicKey)
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const getStoredUserPasskeys = async (userId) => {
|
|
107
|
+
const passkeyIds = await Storage.get(ctx.storage, userPasskeysIndexKey(userId)) || [];
|
|
108
|
+
const passkeys = [];
|
|
109
|
+
for (const id of passkeyIds) {
|
|
110
|
+
const pk = await getStoredPasskeyById(userId, id);
|
|
111
|
+
if (pk) passkeys.push(pk);
|
|
112
|
+
}
|
|
113
|
+
return passkeys;
|
|
114
|
+
};
|
|
115
|
+
const saveNewStoredPasskey = async (passkeyData) => {
|
|
116
|
+
const storablePasskey = {
|
|
117
|
+
...passkeyData,
|
|
118
|
+
publicKey: uint8ArrayToBase64Url(passkeyData.publicKey)
|
|
119
|
+
};
|
|
120
|
+
await Storage.set(ctx.storage, passkeyKey(passkeyData.userId, passkeyData.id), storablePasskey);
|
|
121
|
+
const passkeyIds = await Storage.get(ctx.storage, userPasskeysIndexKey(passkeyData.userId)) || [];
|
|
122
|
+
if (!passkeyIds.includes(passkeyData.id)) {
|
|
123
|
+
passkeyIds.push(passkeyData.id);
|
|
124
|
+
await Storage.set(ctx.storage, userPasskeysIndexKey(passkeyData.userId), passkeyIds);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const updateStoredPasskeyCounter = async (userId, credentialID, newCounter) => {
|
|
128
|
+
const passkey = await getStoredPasskeyById(userId, credentialID);
|
|
129
|
+
if (passkey) {
|
|
130
|
+
passkey.counter = newCounter;
|
|
131
|
+
const storablePasskey = {
|
|
132
|
+
...passkey,
|
|
133
|
+
publicKey: uint8ArrayToBase64Url(passkey.publicKey)
|
|
134
|
+
};
|
|
135
|
+
await Storage.set(ctx.storage, passkeyKey(userId, credentialID), storablePasskey);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
routes.get("/authorize", async (c) => {
|
|
139
|
+
return ctx.forward(c, await config.authorize(c.request));
|
|
140
|
+
});
|
|
141
|
+
routes.get("/register", async (c) => {
|
|
142
|
+
return ctx.forward(c, await config.register(c.request));
|
|
143
|
+
});
|
|
144
|
+
routes.get("/register-request", async (c) => {
|
|
145
|
+
const userId = c.query("userId");
|
|
146
|
+
const rpID = config.rpID || c.query("rpID");
|
|
147
|
+
const otherDevice = c.query("otherDevice") === "true";
|
|
148
|
+
if (!userId) return c.json({ error: "User ID for registration is required." }, { status: 400 });
|
|
149
|
+
if (!rpID) return c.json({ error: "RP ID for registration is required." }, { status: 400 });
|
|
150
|
+
const username = c.query("username") || userId;
|
|
151
|
+
let user = await getStoredUserById(userId);
|
|
152
|
+
if (config.userCanRegisterPasskey) {
|
|
153
|
+
const isAllowed = await config.userCanRegisterPasskey(userId, c.request);
|
|
154
|
+
if (!isAllowed) return c.json({ error: copy.error_user_not_allowed }, { status: 403 });
|
|
155
|
+
}
|
|
156
|
+
if (!user) {
|
|
157
|
+
user = {
|
|
158
|
+
id: userId,
|
|
159
|
+
username
|
|
160
|
+
};
|
|
161
|
+
await saveUser(user);
|
|
162
|
+
}
|
|
163
|
+
const userPasskeys = await getStoredUserPasskeys(user.id);
|
|
164
|
+
const regOptions = await generateRegistrationOptions({
|
|
165
|
+
rpName,
|
|
166
|
+
rpID,
|
|
167
|
+
userName: user.username,
|
|
168
|
+
attestationType,
|
|
169
|
+
excludeCredentials: userPasskeys.map((pk) => ({
|
|
170
|
+
id: pk.id,
|
|
171
|
+
transports: pk.transports
|
|
172
|
+
})),
|
|
173
|
+
authenticatorSelection: authenticatorSelection ?? {
|
|
174
|
+
residentKey: "preferred",
|
|
175
|
+
userVerification: "preferred",
|
|
176
|
+
authenticatorAttachment: otherDevice ? "cross-platform" : "platform"
|
|
177
|
+
},
|
|
178
|
+
timeout
|
|
179
|
+
});
|
|
180
|
+
await Storage.set(ctx.storage, optionsKey(user.id), regOptions);
|
|
181
|
+
return c.json(regOptions);
|
|
182
|
+
});
|
|
183
|
+
routes.post("/register-verify", async (c) => {
|
|
184
|
+
const body = await c.parseJson();
|
|
185
|
+
const userId = c.query("userId");
|
|
186
|
+
const rpID = config.rpID || c.query("rpID");
|
|
187
|
+
const origin = config.origin || c.query("origin");
|
|
188
|
+
if (!userId) return c.json({
|
|
189
|
+
verified: false,
|
|
190
|
+
error: "User ID for verification is required."
|
|
191
|
+
}, { status: 400 });
|
|
192
|
+
if (!rpID) return c.json({ error: "RP ID for verification is required." }, { status: 400 });
|
|
193
|
+
if (!origin) return c.json({ error: "Origin for verification is required." }, { status: 400 });
|
|
194
|
+
const user = await getStoredUserById(userId);
|
|
195
|
+
if (!user) return c.json({
|
|
196
|
+
verified: false,
|
|
197
|
+
error: "User not found during verification."
|
|
198
|
+
}, { status: 404 });
|
|
199
|
+
const regOptions = await Storage.get(ctx.storage, optionsKey(user.id));
|
|
200
|
+
if (!regOptions) return c.json({
|
|
201
|
+
verified: false,
|
|
202
|
+
error: "Registration options not found."
|
|
203
|
+
}, { status: 400 });
|
|
204
|
+
const challenge = regOptions.challenge;
|
|
205
|
+
let verification;
|
|
206
|
+
try {
|
|
207
|
+
verification = await verifyRegistrationResponse({
|
|
208
|
+
response: body,
|
|
209
|
+
expectedChallenge: challenge,
|
|
210
|
+
expectedOrigin: origin,
|
|
211
|
+
expectedRPID: rpID,
|
|
212
|
+
requireUserVerification: authenticatorSelection?.userVerification !== "discouraged"
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
console.error("Passkey Registration Verification Error:", error);
|
|
216
|
+
return c.json({
|
|
217
|
+
verified: false,
|
|
218
|
+
error: error.message
|
|
219
|
+
}, { status: 400 });
|
|
220
|
+
}
|
|
221
|
+
const { verified, registrationInfo } = verification;
|
|
222
|
+
if (verified && registrationInfo) {
|
|
223
|
+
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
|
|
224
|
+
if (credential) {
|
|
225
|
+
const newPasskey = {
|
|
226
|
+
id: credential.id,
|
|
227
|
+
userId: user.id,
|
|
228
|
+
webauthnUserID: regOptions.user.id,
|
|
229
|
+
publicKey: credential.publicKey,
|
|
230
|
+
counter: credential.counter,
|
|
231
|
+
transports: credential.transports,
|
|
232
|
+
deviceType: credentialDeviceType,
|
|
233
|
+
backedUp: credentialBackedUp
|
|
234
|
+
};
|
|
235
|
+
await saveNewStoredPasskey(newPasskey);
|
|
236
|
+
return ctx.success(c, {
|
|
237
|
+
userId: user.id,
|
|
238
|
+
credentialId: newPasskey.id,
|
|
239
|
+
verified: true
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return c.json({
|
|
244
|
+
verified: false,
|
|
245
|
+
error: "Registration verification failed."
|
|
246
|
+
}, { status: 400 });
|
|
247
|
+
});
|
|
248
|
+
routes.get("/authenticate-options", async (c) => {
|
|
249
|
+
const userId = c.query("userId");
|
|
250
|
+
if (!userId) return c.json({ error: "User ID for authentication is required." }, { status: 400 });
|
|
251
|
+
const rpID = config.rpID || c.query("rpID");
|
|
252
|
+
if (!rpID) return c.json({ error: "RP ID for authentication is required." }, { status: 400 });
|
|
253
|
+
const userForAuth = await getStoredUserById(userId);
|
|
254
|
+
if (!userForAuth) return c.json({ error: "User not found for authentication." }, { status: 404 });
|
|
255
|
+
const userPasskeys = await getStoredUserPasskeys(userForAuth.id);
|
|
256
|
+
const allowCredentialsList = userPasskeys.map((pk) => ({
|
|
257
|
+
id: pk.id,
|
|
258
|
+
transports: pk.transports
|
|
259
|
+
}));
|
|
260
|
+
const authOptions = await generateAuthenticationOptions({
|
|
261
|
+
rpID,
|
|
262
|
+
allowCredentials: allowCredentialsList,
|
|
263
|
+
userVerification: authenticatorSelection?.userVerification ?? "preferred",
|
|
264
|
+
timeout
|
|
265
|
+
});
|
|
266
|
+
await Storage.set(ctx.storage, optionsKey(userForAuth.id), authOptions);
|
|
267
|
+
return c.json(authOptions);
|
|
268
|
+
});
|
|
269
|
+
routes.post("/authenticate-verify", async (c) => {
|
|
270
|
+
const body = await c.parseJson();
|
|
271
|
+
const userId = c.query("userId");
|
|
272
|
+
if (!userId) return c.json({ error: "User ID for authentication is required." }, { status: 400 });
|
|
273
|
+
const rpID = config.rpID || c.query("rpID");
|
|
274
|
+
if (!rpID) return c.json({ error: "RP ID for authentication is required." }, { status: 400 });
|
|
275
|
+
const origin = config.origin || c.query("origin");
|
|
276
|
+
if (!origin) return c.json({ error: "Origin for authentication is required." }, { status: 400 });
|
|
277
|
+
const user = await getStoredUserById(userId);
|
|
278
|
+
if (!user) return c.json({
|
|
279
|
+
verified: false,
|
|
280
|
+
error: `User ${userId} not found.`
|
|
281
|
+
}, { status: 404 });
|
|
282
|
+
const authOptions = await Storage.get(ctx.storage, optionsKey(user.id));
|
|
283
|
+
if (!authOptions) return c.json({ error: "Authentication options not found." }, { status: 400 });
|
|
284
|
+
const passkey = await getStoredPasskeyById(userId, body.id);
|
|
285
|
+
if (!passkey) return c.json({
|
|
286
|
+
verified: false,
|
|
287
|
+
error: `Passkey ${body.id} not found for user ${user.username}.`
|
|
288
|
+
}, { status: 400 });
|
|
289
|
+
const { publicKey, counter, transports } = passkey;
|
|
290
|
+
if (!publicKey || typeof counter !== "number" || !transports) return c.json({ error: "Passkey not found for authentication." }, { status: 400 });
|
|
291
|
+
const challenge = authOptions.challenge;
|
|
292
|
+
if (!challenge) return c.json({ error: "Authentication challenge not found." }, { status: 400 });
|
|
293
|
+
const verification = await verifyAuthenticationResponse({
|
|
294
|
+
response: body,
|
|
295
|
+
expectedChallenge: challenge,
|
|
296
|
+
expectedOrigin: origin || "",
|
|
297
|
+
expectedRPID: rpID,
|
|
298
|
+
credential: {
|
|
299
|
+
id: passkey.id,
|
|
300
|
+
publicKey,
|
|
301
|
+
counter,
|
|
302
|
+
transports
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
const { verified, authenticationInfo } = verification;
|
|
306
|
+
if (verified) {
|
|
307
|
+
await updateStoredPasskeyCounter(user.id, passkey.id, authenticationInfo.newCounter);
|
|
308
|
+
return ctx.success(c, {
|
|
309
|
+
userId: user.id,
|
|
310
|
+
credentialId: passkey.id,
|
|
311
|
+
verified: true
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return c.json({
|
|
315
|
+
verified: false,
|
|
316
|
+
error: "Authentication verification failed."
|
|
317
|
+
}, { status: 400 });
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
//#endregion
|
|
324
|
+
export { PasskeyProvider };
|
package/dist/ui/base.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Theme } from "../themes/theme.js";
|
|
2
|
-
import * as
|
|
2
|
+
import * as preact0 from "preact";
|
|
3
3
|
import { ComponentChildren } from "preact";
|
|
4
4
|
|
|
5
5
|
//#region src/ui/base.d.ts
|
|
@@ -21,7 +21,7 @@ declare const Layout: ({
|
|
|
21
21
|
theme,
|
|
22
22
|
title,
|
|
23
23
|
size
|
|
24
|
-
}: LayoutProps) =>
|
|
24
|
+
}: LayoutProps) => preact0.JSX.Element;
|
|
25
25
|
/**
|
|
26
26
|
* Helper function to render a Preact component to HTML string
|
|
27
27
|
*/
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PasskeyProviderConfig } from "../provider/passkey.js";
|
|
2
|
+
|
|
3
|
+
//#region src/ui/passkey.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Strongly typed copy text configuration for passkey UI
|
|
7
|
+
*/
|
|
8
|
+
interface PasskeyUICopy {
|
|
9
|
+
readonly authorize_title: string;
|
|
10
|
+
readonly authorize_description: string;
|
|
11
|
+
readonly register_title: string;
|
|
12
|
+
readonly register_description: string;
|
|
13
|
+
readonly register: string;
|
|
14
|
+
readonly register_with_passkey: string;
|
|
15
|
+
readonly register_other_device: string;
|
|
16
|
+
readonly register_prompt: string;
|
|
17
|
+
readonly login_prompt: string;
|
|
18
|
+
readonly login: string;
|
|
19
|
+
readonly login_with_passkey: string;
|
|
20
|
+
readonly change_prompt: string;
|
|
21
|
+
readonly code_resend: string;
|
|
22
|
+
readonly code_return: string;
|
|
23
|
+
readonly input_email: string;
|
|
24
|
+
}
|
|
25
|
+
interface PasskeyUIOptions extends Omit<PasskeyProviderConfig, "authorize" | "register" | "copy"> {
|
|
26
|
+
readonly copy?: Partial<PasskeyUICopy>;
|
|
27
|
+
}
|
|
28
|
+
declare const PasskeyUI: (options: PasskeyUIOptions) => PasskeyProviderConfig;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { PasskeyUI };
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { Layout, renderToHTML } from "./base.js";
|
|
2
|
+
import { FormAlert } from "./form.js";
|
|
3
|
+
import { jsx, jsxs } from "preact/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
//#region src/ui/passkey.tsx
|
|
6
|
+
const DEFAULT_COPY = {
|
|
7
|
+
authorize_title: "Sign in with Passkey",
|
|
8
|
+
authorize_description: "Passkeys are a simple and more secure alternative to passwords. With passkeys, you can log in with your PIN, biometric sensor, or hardware security key.",
|
|
9
|
+
register_title: "Create a Passkey",
|
|
10
|
+
register_description: "Create a passkey to enable secure, passwordless authentication for your account.",
|
|
11
|
+
register: "Register",
|
|
12
|
+
register_with_passkey: "Register With Passkey",
|
|
13
|
+
register_other_device: "Use another device",
|
|
14
|
+
register_prompt: "Don't have an account?",
|
|
15
|
+
login_prompt: "Already have an account?",
|
|
16
|
+
login: "Login",
|
|
17
|
+
login_with_passkey: "Login With Passkey",
|
|
18
|
+
change_prompt: "Forgot password?",
|
|
19
|
+
code_resend: "Resend code",
|
|
20
|
+
code_return: "Back to",
|
|
21
|
+
input_email: "Email"
|
|
22
|
+
};
|
|
23
|
+
const PasskeyUI = (options) => {
|
|
24
|
+
const { rpName, rpID, origin, userCanRegisterPasskey, authenticatorSelection, attestationType, timeout } = options;
|
|
25
|
+
const copy = {
|
|
26
|
+
...DEFAULT_COPY,
|
|
27
|
+
...options.copy
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
authorize: async () => {
|
|
31
|
+
const jsx$1 = /* @__PURE__ */ jsxs(Layout, { children: [
|
|
32
|
+
/* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: `
|
|
33
|
+
window.addEventListener("load", async () => {
|
|
34
|
+
const { startAuthentication } = SimpleWebAuthnBrowser;
|
|
35
|
+
const authorizeForm = document.getElementById("authorizeForm");
|
|
36
|
+
const origin = window.location.origin;
|
|
37
|
+
const rpID = window.location.hostname;
|
|
38
|
+
|
|
39
|
+
const showMessage = (msg) => {
|
|
40
|
+
const messageEl = document.querySelector("[data-slot='message']");
|
|
41
|
+
if (messageEl) {
|
|
42
|
+
messageEl.innerHTML = msg;
|
|
43
|
+
} else {
|
|
44
|
+
// Create alert if it doesn't exist
|
|
45
|
+
const alertDiv = document.createElement("div");
|
|
46
|
+
alertDiv.setAttribute("data-component", "form-alert");
|
|
47
|
+
alertDiv.setAttribute("role", "alert");
|
|
48
|
+
alertDiv.setAttribute("aria-live", "polite");
|
|
49
|
+
alertDiv.setAttribute("data-color", "error");
|
|
50
|
+
alertDiv.innerHTML = '<span data-slot="message">' + msg + '</span>';
|
|
51
|
+
authorizeForm.insertBefore(alertDiv, authorizeForm.firstChild);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const clearMessage = () => {
|
|
56
|
+
const alertDiv = document.querySelector("[data-component='form-alert']");
|
|
57
|
+
if (alertDiv) {
|
|
58
|
+
alertDiv.remove();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
authorizeForm.addEventListener("submit", async (e) => {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
const formData = new FormData(authorizeForm);
|
|
65
|
+
const email = formData.get("email");
|
|
66
|
+
clearMessage();
|
|
67
|
+
|
|
68
|
+
// GET authentication options from the endpoint that calls
|
|
69
|
+
// @simplewebauthn/server -> generateAuthenticationOptions()
|
|
70
|
+
const resp = await fetch(
|
|
71
|
+
"./passkey/authenticate-options?userId=" + email + "&rpID=" + rpID
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const optionsJSON = await resp.json();
|
|
75
|
+
|
|
76
|
+
if (optionsJSON.error) {
|
|
77
|
+
showMessage(optionsJSON.error);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let attResp;
|
|
82
|
+
try {
|
|
83
|
+
// Pass the options to the authenticator and wait for a response
|
|
84
|
+
attResp = await startAuthentication({ optionsJSON });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
showMessage(error);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const verificationResp = await fetch(
|
|
91
|
+
"./passkey/authenticate-verify?userId=" +
|
|
92
|
+
email +
|
|
93
|
+
"&rpID=" +
|
|
94
|
+
rpID +
|
|
95
|
+
"&origin=" +
|
|
96
|
+
origin,
|
|
97
|
+
{
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: {
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify(attResp),
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Check if the request was redirected and the final response is OK
|
|
107
|
+
if (verificationResp.redirected && verificationResp.ok) {
|
|
108
|
+
// Navigate the browser to the final URL
|
|
109
|
+
window.location.href = verificationResp.url;
|
|
110
|
+
} else {
|
|
111
|
+
// Handle errors (e.g., 4xx, 5xx status codes from the final URL)
|
|
112
|
+
console.error(
|
|
113
|
+
"Request failed:",
|
|
114
|
+
verificationResp.status,
|
|
115
|
+
verificationResp.statusText
|
|
116
|
+
);
|
|
117
|
+
try {
|
|
118
|
+
const errorData = await verificationResp.json();
|
|
119
|
+
showMessage(errorData.error);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
showMessage("Something went wrong");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
` } }),
|
|
127
|
+
/* @__PURE__ */ jsx("h1", { children: copy.authorize_title }),
|
|
128
|
+
/* @__PURE__ */ jsx("p", { children: copy.authorize_description }),
|
|
129
|
+
/* @__PURE__ */ jsxs("form", {
|
|
130
|
+
id: "authorizeForm",
|
|
131
|
+
"data-component": "form",
|
|
132
|
+
children: [
|
|
133
|
+
/* @__PURE__ */ jsx(FormAlert, {}),
|
|
134
|
+
/* @__PURE__ */ jsx("input", {
|
|
135
|
+
"data-component": "input",
|
|
136
|
+
type: "email",
|
|
137
|
+
name: "email",
|
|
138
|
+
required: true,
|
|
139
|
+
placeholder: copy.input_email
|
|
140
|
+
}),
|
|
141
|
+
/* @__PURE__ */ jsx("button", {
|
|
142
|
+
type: "submit",
|
|
143
|
+
id: "btnLogin",
|
|
144
|
+
"data-component": "button",
|
|
145
|
+
children: copy.login_with_passkey
|
|
146
|
+
}),
|
|
147
|
+
/* @__PURE__ */ jsx("div", {
|
|
148
|
+
"data-component": "form-footer",
|
|
149
|
+
children: /* @__PURE__ */ jsxs("span", { children: [
|
|
150
|
+
copy.register_prompt,
|
|
151
|
+
" ",
|
|
152
|
+
/* @__PURE__ */ jsx("a", {
|
|
153
|
+
"data-component": "link",
|
|
154
|
+
href: "./register",
|
|
155
|
+
children: copy.register
|
|
156
|
+
})
|
|
157
|
+
] })
|
|
158
|
+
})
|
|
159
|
+
]
|
|
160
|
+
}),
|
|
161
|
+
/* @__PURE__ */ jsx("script", { src: "https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js" })
|
|
162
|
+
] });
|
|
163
|
+
return new Response(renderToHTML(jsx$1), {
|
|
164
|
+
status: 200,
|
|
165
|
+
headers: { "Content-Type": "text/html" }
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
register: async () => {
|
|
169
|
+
const jsx$1 = /* @__PURE__ */ jsxs(Layout, { children: [
|
|
170
|
+
/* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: `
|
|
171
|
+
window.addEventListener("load", async () => {
|
|
172
|
+
const { startRegistration } = SimpleWebAuthnBrowser;
|
|
173
|
+
const registerForm = document.getElementById("registerForm");
|
|
174
|
+
const origin = window.location.origin;
|
|
175
|
+
const rpID = window.location.hostname;
|
|
176
|
+
|
|
177
|
+
const showMessage = (msg) => {
|
|
178
|
+
const messageEl = document.querySelector("[data-slot='message']");
|
|
179
|
+
if (messageEl) {
|
|
180
|
+
messageEl.innerHTML = msg;
|
|
181
|
+
} else {
|
|
182
|
+
// Create alert if it doesn't exist
|
|
183
|
+
const alertDiv = document.createElement("div");
|
|
184
|
+
alertDiv.setAttribute("data-component", "form-alert");
|
|
185
|
+
alertDiv.setAttribute("role", "alert");
|
|
186
|
+
alertDiv.setAttribute("aria-live", "polite");
|
|
187
|
+
alertDiv.setAttribute("data-color", "error");
|
|
188
|
+
alertDiv.innerHTML = '<span data-slot="message">' + msg + '</span>';
|
|
189
|
+
registerForm.insertBefore(alertDiv, registerForm.firstChild);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const clearMessage = () => {
|
|
194
|
+
const alertDiv = document.querySelector("[data-component='form-alert']");
|
|
195
|
+
if (alertDiv) {
|
|
196
|
+
alertDiv.remove();
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Start registration when the user clicks a button
|
|
201
|
+
const register = async (otherDevice = false) => {
|
|
202
|
+
const formData = new FormData(registerForm);
|
|
203
|
+
const email = formData.get("email");
|
|
204
|
+
clearMessage();
|
|
205
|
+
|
|
206
|
+
// GET registration options from the endpoint that calls
|
|
207
|
+
// @simplewebauthn/server -> generateRegistrationOptions()
|
|
208
|
+
const resp = await fetch(
|
|
209
|
+
"./passkey/register-request?userId=" +
|
|
210
|
+
email +
|
|
211
|
+
"&origin=" +
|
|
212
|
+
origin +
|
|
213
|
+
"&rpID=" +
|
|
214
|
+
rpID +
|
|
215
|
+
"&otherDevice=" +
|
|
216
|
+
otherDevice,
|
|
217
|
+
);
|
|
218
|
+
const optionsJSON = await resp.json();
|
|
219
|
+
|
|
220
|
+
if (optionsJSON.error) {
|
|
221
|
+
showMessage(optionsJSON.error);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let attResp;
|
|
226
|
+
try {
|
|
227
|
+
// Pass the options to the authenticator and wait for a response
|
|
228
|
+
attResp = await startRegistration({ optionsJSON });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
showMessage(error);
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// POST the response to the endpoint that calls
|
|
235
|
+
// @simplewebauthn/server -> verifyRegistrationResponse()
|
|
236
|
+
try {
|
|
237
|
+
const verificationResp = await fetch(
|
|
238
|
+
"./passkey/register-verify?userId=" +
|
|
239
|
+
email +
|
|
240
|
+
"&origin=" +
|
|
241
|
+
origin +
|
|
242
|
+
"&rpID=" +
|
|
243
|
+
rpID,
|
|
244
|
+
{
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: {
|
|
247
|
+
"Content-Type": "application/json",
|
|
248
|
+
},
|
|
249
|
+
body: JSON.stringify(attResp),
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Check if the request was redirected and the final response is OK
|
|
254
|
+
if (verificationResp.redirected && verificationResp.ok) {
|
|
255
|
+
// Navigate the browser to the final URL
|
|
256
|
+
window.location.href = verificationResp.url;
|
|
257
|
+
} else {
|
|
258
|
+
// Handle errors (e.g., 4xx, 5xx status codes from the final URL)
|
|
259
|
+
console.error(
|
|
260
|
+
"Request failed:",
|
|
261
|
+
verificationResp.status,
|
|
262
|
+
verificationResp.statusText
|
|
263
|
+
);
|
|
264
|
+
try {
|
|
265
|
+
const errorData = await verificationResp.json();
|
|
266
|
+
showMessage(errorData.error);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
showMessage("Something went wrong");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error(error);
|
|
273
|
+
showMessage("Something went wrong");
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
registerForm.addEventListener("submit", (e) => {
|
|
278
|
+
e.preventDefault();
|
|
279
|
+
register();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
` } }),
|
|
283
|
+
/* @__PURE__ */ jsx("h1", { children: copy.register_title }),
|
|
284
|
+
/* @__PURE__ */ jsx("p", { children: copy.register_description }),
|
|
285
|
+
/* @__PURE__ */ jsxs("form", {
|
|
286
|
+
id: "registerForm",
|
|
287
|
+
"data-component": "form",
|
|
288
|
+
children: [
|
|
289
|
+
/* @__PURE__ */ jsx(FormAlert, {}),
|
|
290
|
+
/* @__PURE__ */ jsx("input", {
|
|
291
|
+
"data-component": "input",
|
|
292
|
+
type: "email",
|
|
293
|
+
name: "email",
|
|
294
|
+
required: true,
|
|
295
|
+
placeholder: copy.input_email
|
|
296
|
+
}),
|
|
297
|
+
/* @__PURE__ */ jsx("button", {
|
|
298
|
+
"data-component": "button",
|
|
299
|
+
type: "submit",
|
|
300
|
+
id: "btnRegister",
|
|
301
|
+
children: copy.register_with_passkey
|
|
302
|
+
}),
|
|
303
|
+
/* @__PURE__ */ jsx("button", {
|
|
304
|
+
"data-component": "button",
|
|
305
|
+
type: "submit",
|
|
306
|
+
id: "btnOtherDevice",
|
|
307
|
+
children: copy.register_other_device
|
|
308
|
+
}),
|
|
309
|
+
/* @__PURE__ */ jsx("div", {
|
|
310
|
+
"data-component": "form-footer",
|
|
311
|
+
children: /* @__PURE__ */ jsxs("span", { children: [
|
|
312
|
+
copy.login_prompt,
|
|
313
|
+
" ",
|
|
314
|
+
/* @__PURE__ */ jsx("a", {
|
|
315
|
+
"data-component": "link",
|
|
316
|
+
href: "./authorize",
|
|
317
|
+
children: copy.login
|
|
318
|
+
})
|
|
319
|
+
] })
|
|
320
|
+
})
|
|
321
|
+
]
|
|
322
|
+
}),
|
|
323
|
+
/* @__PURE__ */ jsx("script", { src: "https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js" })
|
|
324
|
+
] });
|
|
325
|
+
return new Response(renderToHTML(jsx$1), {
|
|
326
|
+
status: 200,
|
|
327
|
+
headers: { "Content-Type": "text/html" }
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
rpName,
|
|
331
|
+
rpID,
|
|
332
|
+
origin,
|
|
333
|
+
userCanRegisterPasskey,
|
|
334
|
+
authenticatorSelection,
|
|
335
|
+
attestationType,
|
|
336
|
+
timeout
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
//#endregion
|
|
341
|
+
export { PasskeyUI };
|
package/dist/ui/select.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@draftlab/auth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Core implementation for @draftlab/auth",
|
|
6
6
|
"author": "Matheus Pergoli",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "^24.0.
|
|
40
|
+
"@types/node": "^24.0.14",
|
|
41
41
|
"tsdown": "^0.12.9",
|
|
42
42
|
"typescript": "^5.8.3",
|
|
43
43
|
"@draftlab/tsconfig": "0.1.0"
|
|
@@ -55,8 +55,9 @@
|
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
+
"@simplewebauthn/server": "^13.1.2",
|
|
58
59
|
"@standard-schema/spec": "^1.0.0",
|
|
59
|
-
"jose": "^6.0.
|
|
60
|
+
"jose": "^6.0.12",
|
|
60
61
|
"preact": "^10.26.9",
|
|
61
62
|
"preact-render-to-string": "^6.5.13",
|
|
62
63
|
"@draftlab/auth-router": "0.0.4"
|