@_mustachio/openauth 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/dist/esm/client.js +186 -0
  2. package/dist/esm/css.d.js +0 -0
  3. package/dist/esm/error.js +73 -0
  4. package/dist/esm/index.js +14 -0
  5. package/dist/esm/issuer.js +558 -0
  6. package/dist/esm/jwt.js +16 -0
  7. package/dist/esm/keys.js +113 -0
  8. package/dist/esm/pkce.js +35 -0
  9. package/dist/esm/provider/apple.js +28 -0
  10. package/dist/esm/provider/arctic.js +43 -0
  11. package/dist/esm/provider/code.js +58 -0
  12. package/dist/esm/provider/cognito.js +16 -0
  13. package/dist/esm/provider/discord.js +15 -0
  14. package/dist/esm/provider/facebook.js +24 -0
  15. package/dist/esm/provider/github.js +15 -0
  16. package/dist/esm/provider/google.js +25 -0
  17. package/dist/esm/provider/index.js +3 -0
  18. package/dist/esm/provider/jumpcloud.js +15 -0
  19. package/dist/esm/provider/keycloak.js +15 -0
  20. package/dist/esm/provider/linkedin.js +15 -0
  21. package/dist/esm/provider/m2m.js +17 -0
  22. package/dist/esm/provider/microsoft.js +24 -0
  23. package/dist/esm/provider/oauth2.js +119 -0
  24. package/dist/esm/provider/oidc.js +69 -0
  25. package/dist/esm/provider/passkey.js +315 -0
  26. package/dist/esm/provider/password.js +306 -0
  27. package/dist/esm/provider/provider.js +10 -0
  28. package/dist/esm/provider/slack.js +15 -0
  29. package/dist/esm/provider/spotify.js +15 -0
  30. package/dist/esm/provider/twitch.js +15 -0
  31. package/dist/esm/provider/x.js +16 -0
  32. package/dist/esm/provider/yahoo.js +15 -0
  33. package/dist/esm/random.js +27 -0
  34. package/dist/esm/storage/aws.js +39 -0
  35. package/dist/esm/storage/cloudflare.js +42 -0
  36. package/dist/esm/storage/dynamo.js +116 -0
  37. package/dist/esm/storage/memory.js +88 -0
  38. package/dist/esm/storage/storage.js +36 -0
  39. package/dist/esm/subject.js +7 -0
  40. package/dist/esm/ui/base.js +407 -0
  41. package/dist/esm/ui/code.js +151 -0
  42. package/dist/esm/ui/form.js +43 -0
  43. package/dist/esm/ui/icon.js +92 -0
  44. package/dist/esm/ui/passkey.js +329 -0
  45. package/dist/esm/ui/password.js +338 -0
  46. package/dist/esm/ui/select.js +187 -0
  47. package/dist/esm/ui/theme.js +115 -0
  48. package/dist/esm/util.js +54 -0
  49. package/dist/types/client.d.ts +466 -0
  50. package/dist/types/client.d.ts.map +1 -0
  51. package/dist/types/error.d.ts +77 -0
  52. package/dist/types/error.d.ts.map +1 -0
  53. package/dist/types/index.d.ts +20 -0
  54. package/dist/types/index.d.ts.map +1 -0
  55. package/dist/types/issuer.d.ts +465 -0
  56. package/dist/types/issuer.d.ts.map +1 -0
  57. package/dist/types/jwt.d.ts +6 -0
  58. package/dist/types/jwt.d.ts.map +1 -0
  59. package/dist/types/keys.d.ts +18 -0
  60. package/dist/types/keys.d.ts.map +1 -0
  61. package/dist/types/pkce.d.ts +7 -0
  62. package/dist/types/pkce.d.ts.map +1 -0
  63. package/dist/types/provider/apple.d.ts +108 -0
  64. package/dist/types/provider/apple.d.ts.map +1 -0
  65. package/dist/types/provider/arctic.d.ts +16 -0
  66. package/dist/types/provider/arctic.d.ts.map +1 -0
  67. package/dist/types/provider/code.d.ts +74 -0
  68. package/dist/types/provider/code.d.ts.map +1 -0
  69. package/dist/types/provider/cognito.d.ts +64 -0
  70. package/dist/types/provider/cognito.d.ts.map +1 -0
  71. package/dist/types/provider/discord.d.ts +38 -0
  72. package/dist/types/provider/discord.d.ts.map +1 -0
  73. package/dist/types/provider/facebook.d.ts +74 -0
  74. package/dist/types/provider/facebook.d.ts.map +1 -0
  75. package/dist/types/provider/github.d.ts +38 -0
  76. package/dist/types/provider/github.d.ts.map +1 -0
  77. package/dist/types/provider/google.d.ts +74 -0
  78. package/dist/types/provider/google.d.ts.map +1 -0
  79. package/dist/types/provider/index.d.ts +4 -0
  80. package/dist/types/provider/index.d.ts.map +1 -0
  81. package/dist/types/provider/jumpcloud.d.ts +38 -0
  82. package/dist/types/provider/jumpcloud.d.ts.map +1 -0
  83. package/dist/types/provider/keycloak.d.ts +67 -0
  84. package/dist/types/provider/keycloak.d.ts.map +1 -0
  85. package/dist/types/provider/linkedin.d.ts +6 -0
  86. package/dist/types/provider/linkedin.d.ts.map +1 -0
  87. package/dist/types/provider/m2m.d.ts +34 -0
  88. package/dist/types/provider/m2m.d.ts.map +1 -0
  89. package/dist/types/provider/microsoft.d.ts +89 -0
  90. package/dist/types/provider/microsoft.d.ts.map +1 -0
  91. package/dist/types/provider/oauth2.d.ts +133 -0
  92. package/dist/types/provider/oauth2.d.ts.map +1 -0
  93. package/dist/types/provider/oidc.d.ts +91 -0
  94. package/dist/types/provider/oidc.d.ts.map +1 -0
  95. package/dist/types/provider/passkey.d.ts +143 -0
  96. package/dist/types/provider/passkey.d.ts.map +1 -0
  97. package/dist/types/provider/password.d.ts +210 -0
  98. package/dist/types/provider/password.d.ts.map +1 -0
  99. package/dist/types/provider/provider.d.ts +29 -0
  100. package/dist/types/provider/provider.d.ts.map +1 -0
  101. package/dist/types/provider/slack.d.ts +59 -0
  102. package/dist/types/provider/slack.d.ts.map +1 -0
  103. package/dist/types/provider/spotify.d.ts +38 -0
  104. package/dist/types/provider/spotify.d.ts.map +1 -0
  105. package/dist/types/provider/twitch.d.ts +38 -0
  106. package/dist/types/provider/twitch.d.ts.map +1 -0
  107. package/dist/types/provider/x.d.ts +38 -0
  108. package/dist/types/provider/x.d.ts.map +1 -0
  109. package/dist/types/provider/yahoo.d.ts +38 -0
  110. package/dist/types/provider/yahoo.d.ts.map +1 -0
  111. package/dist/types/random.d.ts +3 -0
  112. package/dist/types/random.d.ts.map +1 -0
  113. package/dist/types/storage/aws.d.ts +4 -0
  114. package/dist/types/storage/aws.d.ts.map +1 -0
  115. package/dist/types/storage/cloudflare.d.ts +34 -0
  116. package/dist/types/storage/cloudflare.d.ts.map +1 -0
  117. package/dist/types/storage/dynamo.d.ts +65 -0
  118. package/dist/types/storage/dynamo.d.ts.map +1 -0
  119. package/dist/types/storage/memory.d.ts +49 -0
  120. package/dist/types/storage/memory.d.ts.map +1 -0
  121. package/dist/types/storage/storage.d.ts +15 -0
  122. package/dist/types/storage/storage.d.ts.map +1 -0
  123. package/dist/types/subject.d.ts +122 -0
  124. package/dist/types/subject.d.ts.map +1 -0
  125. package/dist/types/ui/base.d.ts +5 -0
  126. package/dist/types/ui/base.d.ts.map +1 -0
  127. package/dist/types/ui/code.d.ts +104 -0
  128. package/dist/types/ui/code.d.ts.map +1 -0
  129. package/dist/types/ui/form.d.ts +6 -0
  130. package/dist/types/ui/form.d.ts.map +1 -0
  131. package/dist/types/ui/icon.d.ts +6 -0
  132. package/dist/types/ui/icon.d.ts.map +1 -0
  133. package/dist/types/ui/passkey.d.ts +5 -0
  134. package/dist/types/ui/passkey.d.ts.map +1 -0
  135. package/dist/types/ui/password.d.ts +139 -0
  136. package/dist/types/ui/password.d.ts.map +1 -0
  137. package/dist/types/ui/select.d.ts +55 -0
  138. package/dist/types/ui/select.d.ts.map +1 -0
  139. package/dist/types/ui/theme.d.ts +207 -0
  140. package/dist/types/ui/theme.d.ts.map +1 -0
  141. package/dist/types/util.d.ts +8 -0
  142. package/dist/types/util.d.ts.map +1 -0
  143. package/package.json +51 -0
  144. package/src/client.ts +749 -0
  145. package/src/css.d.ts +4 -0
  146. package/src/error.ts +120 -0
  147. package/src/index.ts +26 -0
  148. package/src/issuer.ts +1302 -0
  149. package/src/jwt.ts +17 -0
  150. package/src/keys.ts +139 -0
  151. package/src/pkce.ts +40 -0
  152. package/src/provider/apple.ts +127 -0
  153. package/src/provider/arctic.ts +66 -0
  154. package/src/provider/code.ts +227 -0
  155. package/src/provider/cognito.ts +74 -0
  156. package/src/provider/discord.ts +45 -0
  157. package/src/provider/facebook.ts +84 -0
  158. package/src/provider/github.ts +45 -0
  159. package/src/provider/google.ts +85 -0
  160. package/src/provider/index.ts +3 -0
  161. package/src/provider/jumpcloud.ts +45 -0
  162. package/src/provider/keycloak.ts +75 -0
  163. package/src/provider/linkedin.ts +12 -0
  164. package/src/provider/m2m.ts +56 -0
  165. package/src/provider/microsoft.ts +100 -0
  166. package/src/provider/oauth2.ts +297 -0
  167. package/src/provider/oidc.ts +179 -0
  168. package/src/provider/passkey.ts +655 -0
  169. package/src/provider/password.ts +672 -0
  170. package/src/provider/provider.ts +33 -0
  171. package/src/provider/slack.ts +67 -0
  172. package/src/provider/spotify.ts +45 -0
  173. package/src/provider/twitch.ts +45 -0
  174. package/src/provider/x.ts +46 -0
  175. package/src/provider/yahoo.ts +45 -0
  176. package/src/random.ts +24 -0
  177. package/src/storage/aws.ts +59 -0
  178. package/src/storage/cloudflare.ts +77 -0
  179. package/src/storage/dynamo.ts +193 -0
  180. package/src/storage/memory.ts +135 -0
  181. package/src/storage/storage.ts +46 -0
  182. package/src/subject.ts +130 -0
  183. package/src/ui/base.tsx +118 -0
  184. package/src/ui/code.tsx +215 -0
  185. package/src/ui/form.tsx +40 -0
  186. package/src/ui/icon.tsx +95 -0
  187. package/src/ui/passkey.tsx +321 -0
  188. package/src/ui/password.tsx +405 -0
  189. package/src/ui/select.tsx +221 -0
  190. package/src/ui/theme.ts +319 -0
  191. package/src/ui/ui.css +252 -0
  192. package/src/util.ts +58 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * Configures a provider that supports passkey (WebAuthn) authentication.
3
+ *
4
+ * ```ts
5
+ * import { PasskeyProvider } from "@openauthjs/openauth/provider/passkey"
6
+ *
7
+ * export default issuer({
8
+ * providers: {
9
+ * passkey: PasskeyProvider({
10
+ * rpName: "My Application",
11
+ * rpID: "example.com", // optional - can also be passed in as a query parameter (see the UI)
12
+ * origin: "https://example.com", // optional - can also be passed in as a query parameter (see the UI)
13
+ * userCanRegisterPasskey: async (userId, req) => { // optional
14
+ * // Check if the user is allowed to register a passkey
15
+ * return true
16
+ * }
17
+ * })
18
+ * },
19
+ * // ...
20
+ * })
21
+ * ```
22
+ *
23
+ * PasskeyProvider implements WebAuthn (Web Authentication) to enable passwordless
24
+ * authentication using biometrics, mobile devices, or security keys. It handles
25
+ * the complete flow for registering new passkeys and authenticating with them.
26
+ *
27
+ * The provider requires configuration of:
28
+ * - Relying Party information (rpName, rpID)
29
+ * - Origin validation
30
+ * - UI handlers for authorization and registration
31
+ *
32
+ * It automatically manages:
33
+ * - Challenge generation
34
+ * - Credential storage
35
+ * - Registration verification
36
+ * - Authentication verification
37
+ *
38
+ * This implementation is powered by [@simplewebauthn/server](https://simplewebauthn.dev),
39
+ * which provides the core WebAuthn functionality for passkey authentication.
40
+ *
41
+ * @packageDocumentation
42
+ */
43
+
44
+ import type {
45
+ AuthenticatorTransportFuture,
46
+ CredentialDeviceType,
47
+ Base64URLString,
48
+ AuthenticatorSelectionCriteria,
49
+ PublicKeyCredentialCreationOptionsJSON,
50
+ PublicKeyCredentialRequestOptionsJSON,
51
+ RegistrationResponseJSON,
52
+ AuthenticationResponseJSON,
53
+ VerifiedRegistrationResponse,
54
+ } from "@simplewebauthn/server"
55
+ import {
56
+ generateRegistrationOptions,
57
+ verifyRegistrationResponse,
58
+ generateAuthenticationOptions,
59
+ verifyAuthenticationResponse,
60
+ } from "@simplewebauthn/server"
61
+
62
+ import type { Provider, ProviderOptions, ProviderRoute } from "./provider.js"
63
+ import { Storage } from "../storage/storage.js"
64
+ import type { Context } from "hono"
65
+
66
+ /**
67
+ * Converts a Uint8Array to a Base64URL encoded string.
68
+ * This is used to convert binary data for storage in databases or JSON.
69
+ *
70
+ * @param bytes - The Uint8Array to convert
71
+ * @returns Base64URL encoded string
72
+ */
73
+ function uint8ArrayToBase64Url(bytes: Uint8Array): string {
74
+ let str = ""
75
+
76
+ for (const charCode of bytes) {
77
+ str += String.fromCharCode(charCode)
78
+ }
79
+
80
+ const base64String = btoa(str)
81
+
82
+ return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
83
+ }
84
+
85
+ /**
86
+ * Converts a Base64URL encoded string back to a Uint8Array.
87
+ * This is used to convert stored data back to binary format for WebAuthn operations.
88
+ *
89
+ * @param base64urlString - The Base64URL encoded string to convert
90
+ * @returns Uint8Array containing the decoded data
91
+ */
92
+ function base64UrlToUint8Array(base64urlString: string): Uint8Array {
93
+ // Convert from Base64URL to Base64
94
+ const base64 = base64urlString.replace(/-/g, "+").replace(/_/g, "/")
95
+ /**
96
+ * Pad with '=' until it's a multiple of four
97
+ * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding
98
+ * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding
99
+ * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding
100
+ * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding
101
+ */
102
+ const padLength = (4 - (base64.length % 4)) % 4
103
+ const padded = base64.padEnd(base64.length + padLength, "=")
104
+
105
+ // Convert to a binary string
106
+ const binary = atob(padded)
107
+
108
+ // Convert binary string to buffer
109
+ const buffer = new ArrayBuffer(binary.length)
110
+ const bytes = new Uint8Array(buffer)
111
+
112
+ for (let i = 0; i < binary.length; i++) {
113
+ bytes[i] = binary.charCodeAt(i)
114
+ }
115
+
116
+ return bytes
117
+ }
118
+
119
+ /**
120
+ * User model for passkey authentication.
121
+ * Contains the core user data needed for WebAuthn operations.
122
+ */
123
+ export type UserModel = {
124
+ id: string // User's unique ID (must be stable and unique)
125
+ username: string
126
+ // other user fields...
127
+ }
128
+
129
+ /**
130
+ * Original PasskeyModel structure for in-memory use.
131
+ * Represents a registered credential with public key as Uint8Array.
132
+ */
133
+ export type PasskeyModel = {
134
+ id: string
135
+ publicKey: Uint8Array
136
+ userId: string // Foreign key to UserModel
137
+ webauthnUserID: string
138
+ counter: number
139
+ deviceType: CredentialDeviceType
140
+ backedUp: boolean
141
+ transports?: AuthenticatorTransportFuture[]
142
+ }
143
+
144
+ /**
145
+ * PasskeyModel version for KV storage with publicKey as string.
146
+ * Used for storing credentials in a key-value store.
147
+ */
148
+ export type PasskeyModelStored = Omit<PasskeyModel, "publicKey"> & {
149
+ publicKey: string // Stored as Base64URL string
150
+ }
151
+
152
+ // --- Storage Key Definitions ---
153
+ const userKey = (userId: string) => ["passkey", "user", userId]
154
+ const passkeyKey = (userId: string, credentialId: Base64URLString) => [
155
+ "passkey",
156
+ "user",
157
+ userId,
158
+ "credential",
159
+ credentialId,
160
+ "passkey",
161
+ ]
162
+ const optionsKey = (userId: string) => ["passkey", "user", userId, "options"]
163
+ const userPasskeysIndexKey = (userId: string) => [
164
+ "passkey",
165
+ "user",
166
+ userId,
167
+ "passkeys",
168
+ ] // Stores list of credentialIDs
169
+
170
+ // Configuration
171
+ const DEFAULT_COPY = {
172
+ error_user_not_allowed:
173
+ "There is already an account with this email. Login to add a passkey.",
174
+ }
175
+
176
+ /**
177
+ * Configuration for the PasskeyProvider.
178
+ * Defines how the passkey authentication flow should behave.
179
+ */
180
+ export interface PasskeyProviderConfig {
181
+ /**
182
+ * Custom authorization handler that generates the UI for authorization.
183
+ */
184
+ authorize: (req: Request) => Promise<Response>
185
+
186
+ /**
187
+ * Custom registration handler that generates the UI for registration.
188
+ */
189
+ register: (req: Request) => Promise<Response>
190
+
191
+ /**
192
+ * The human-readable name of the relying party (your application).
193
+ */
194
+ rpName: string
195
+
196
+ /**
197
+ * The ID of the relying party, typically the domain name without protocol.
198
+ */
199
+ rpID?: string
200
+
201
+ /**
202
+ * The origin URL(s) that are allowed to initiate WebAuthn ceremonies.
203
+ */
204
+ origin?: string | string[]
205
+
206
+ /**
207
+ * Optional function to check if a user is allowed to register a passkey.
208
+ */
209
+ userCanRegisterPasskey?: (userId: string, req: Request) => Promise<boolean>
210
+
211
+ /**
212
+ * Optional WebAuthn authenticator selection criteria.
213
+ */
214
+ authenticatorSelection?: AuthenticatorSelectionCriteria
215
+
216
+ /**
217
+ * Optional attestation type.
218
+ */
219
+ attestationType?: "none" | "direct" | "enterprise"
220
+
221
+ /**
222
+ * Optional timeout for challenges in milliseconds.
223
+ */
224
+ timeout?: number
225
+
226
+ /**
227
+ * Custom copy texts for error messages and UI elements.
228
+ */
229
+ copy?: Partial<typeof DEFAULT_COPY>
230
+ }
231
+
232
+ /**
233
+ * Creates a passkey (WebAuthn) authentication provider.
234
+ *
235
+ * This provider enables passwordless authentication using biometrics, hardware security
236
+ * keys, or platform authenticators. It implements the Web Authentication (WebAuthn) standard.
237
+ *
238
+ * It handles:
239
+ * - Passkey registration (creating new credentials)
240
+ * - Authentication with existing passkeys
241
+ * - Secure storage of credentials
242
+ * - Challenge verification
243
+ *
244
+ * @param config Configuration options for the passkey provider
245
+ * @returns A Provider instance configured for passkey authentication
246
+ */
247
+ export function PasskeyProvider(
248
+ config: PasskeyProviderConfig,
249
+ ): Provider<{ userId: string; credentialId?: Base64URLString }> {
250
+ const copy = {
251
+ ...DEFAULT_COPY,
252
+ ...config.copy,
253
+ }
254
+ return {
255
+ type: "passkey",
256
+ init(
257
+ routes: ProviderRoute,
258
+ ctx: ProviderOptions<{
259
+ userId: string
260
+ credentialId?: Base64URLString
261
+ verified: boolean
262
+ }>,
263
+ ) {
264
+ const {
265
+ rpName,
266
+ authenticatorSelection,
267
+ attestationType = "none",
268
+ timeout = 5 * 60 * 1000, // 5 minutes in ms for challenge
269
+ } = config
270
+
271
+ // --- Internal Data Access Functions using options.storage ---
272
+
273
+ async function getStoredUserById(
274
+ userId: string,
275
+ ): Promise<UserModel | null> {
276
+ return await Storage.get<UserModel>(ctx.storage, userKey(userId))
277
+ }
278
+
279
+ async function saveUser(user: UserModel): Promise<void> {
280
+ await Storage.set(ctx.storage, userKey(user.id), user)
281
+ }
282
+
283
+ async function getStoredPasskeyById(
284
+ userId: string,
285
+ credentialID: Base64URLString,
286
+ ): Promise<PasskeyModel | null> {
287
+ const storedPasskey = await Storage.get<PasskeyModelStored>(
288
+ ctx.storage,
289
+ passkeyKey(userId, credentialID),
290
+ )
291
+ if (!storedPasskey) return null
292
+ return {
293
+ ...storedPasskey,
294
+ publicKey: base64UrlToUint8Array(storedPasskey.publicKey),
295
+ }
296
+ }
297
+
298
+ async function getStoredUserPasskeys(
299
+ userId: string,
300
+ ): Promise<PasskeyModel[]> {
301
+ const passkeyIds =
302
+ (await Storage.get<Base64URLString[]>(
303
+ ctx.storage,
304
+ userPasskeysIndexKey(userId),
305
+ )) || []
306
+ const passkeys: PasskeyModel[] = []
307
+ for (const id of passkeyIds) {
308
+ const pk = await getStoredPasskeyById(userId, id)
309
+ if (pk) passkeys.push(pk)
310
+ }
311
+ return passkeys
312
+ }
313
+
314
+ async function saveNewStoredPasskey(
315
+ passkeyData: PasskeyModel,
316
+ ): Promise<void> {
317
+ const storablePasskey: PasskeyModelStored = {
318
+ ...passkeyData,
319
+ publicKey: uint8ArrayToBase64Url(passkeyData.publicKey),
320
+ }
321
+ await Storage.set(
322
+ ctx.storage,
323
+ passkeyKey(passkeyData.userId, passkeyData.id),
324
+ storablePasskey,
325
+ )
326
+
327
+ // Update user's passkey index
328
+ const passkeyIds =
329
+ (await Storage.get<Base64URLString[]>(
330
+ ctx.storage,
331
+ userPasskeysIndexKey(passkeyData.userId),
332
+ )) || []
333
+ if (!passkeyIds.includes(passkeyData.id)) {
334
+ passkeyIds.push(passkeyData.id)
335
+ await Storage.set(
336
+ ctx.storage,
337
+ userPasskeysIndexKey(passkeyData.userId),
338
+ passkeyIds,
339
+ )
340
+ }
341
+ }
342
+
343
+ async function updateStoredPasskeyCounter(
344
+ userId: string,
345
+ credentialID: Base64URLString,
346
+ newCounter: number,
347
+ ): Promise<void> {
348
+ const passkey = await getStoredPasskeyById(userId, credentialID)
349
+ if (passkey) {
350
+ passkey.counter = newCounter
351
+ const storablePasskey: PasskeyModelStored = {
352
+ ...passkey,
353
+ publicKey: uint8ArrayToBase64Url(passkey.publicKey),
354
+ }
355
+ await Storage.set(
356
+ ctx.storage,
357
+ passkeyKey(userId, credentialID),
358
+ storablePasskey,
359
+ )
360
+ }
361
+ }
362
+
363
+ routes.get("/authorize", async (c) => {
364
+ return ctx.forward(c, await config.authorize(c.req.raw))
365
+ })
366
+
367
+ routes.get("/register", async (c) => {
368
+ return ctx.forward(c, await config.register(c.req.raw))
369
+ })
370
+
371
+ // --- REGISTRATION FLOW ---
372
+ routes.get("/register-request", async (c: Context) => {
373
+ const userId = c.req.query("userId")
374
+ const rpID = config.rpID || c.req.query("rpID")
375
+ const otherDevice = c.req.query("otherDevice") === "true"
376
+
377
+ if (!userId) {
378
+ return c.json({ error: "User ID for registration is required." }, 400)
379
+ }
380
+ if (!rpID) {
381
+ return c.json({ error: "RP ID for registration is required." }, 400)
382
+ }
383
+ const username = c.req.query("username") || userId
384
+
385
+ let user = await getStoredUserById(userId)
386
+
387
+ if (config.userCanRegisterPasskey) {
388
+ const isAllowed = await config.userCanRegisterPasskey(
389
+ userId,
390
+ c.req.raw,
391
+ )
392
+ if (!isAllowed) {
393
+ return c.json(
394
+ {
395
+ error: copy.error_user_not_allowed,
396
+ },
397
+ 403,
398
+ )
399
+ }
400
+ }
401
+ // If user does not exist, you might create them here or expect them to be pre-registered
402
+ if (!user) {
403
+ user = { id: userId, username }
404
+ await saveUser(user)
405
+ }
406
+
407
+ const userPasskeys = await getStoredUserPasskeys(user.id)
408
+
409
+ const regOptions: PublicKeyCredentialCreationOptionsJSON =
410
+ await generateRegistrationOptions({
411
+ rpName,
412
+ rpID,
413
+ userName: user.username,
414
+ attestationType,
415
+ excludeCredentials: userPasskeys.map((pk) => ({
416
+ id: pk.id,
417
+ transports: pk.transports,
418
+ })),
419
+ authenticatorSelection: authenticatorSelection ?? {
420
+ residentKey: "preferred",
421
+ userVerification: "preferred",
422
+ authenticatorAttachment: otherDevice
423
+ ? "cross-platform"
424
+ : "platform",
425
+ },
426
+ timeout,
427
+ })
428
+ await Storage.set(ctx.storage, optionsKey(user.id), regOptions)
429
+ return c.json(regOptions)
430
+ })
431
+
432
+ routes.post("/register-verify", async (c: Context) => {
433
+ const body: RegistrationResponseJSON = await c.req.json()
434
+
435
+ const { userId } = c.req.query() as { userId: string }
436
+ const rpID = config.rpID || c.req.query("rpID")
437
+ const origin = config.origin || c.req.query("origin")
438
+ if (!userId) {
439
+ return c.json(
440
+ {
441
+ verified: false,
442
+ error: "User ID for verification is required.",
443
+ },
444
+ 400,
445
+ )
446
+ }
447
+ if (!rpID) {
448
+ return c.json({ error: "RP ID for verification is required." }, 400)
449
+ }
450
+ if (!origin) {
451
+ return c.json({ error: "Origin for verification is required." }, 400)
452
+ }
453
+
454
+ const user = await getStoredUserById(userId)
455
+ if (!user) {
456
+ return c.json(
457
+ { verified: false, error: "User not found during verification." },
458
+ 404,
459
+ )
460
+ }
461
+ const regOptions =
462
+ await Storage.get<PublicKeyCredentialCreationOptionsJSON>(
463
+ ctx.storage,
464
+ optionsKey(user.id),
465
+ )
466
+ if (!regOptions) {
467
+ return c.json(
468
+ { verified: false, error: "Registration options not found." },
469
+ 400,
470
+ )
471
+ }
472
+ const challenge = regOptions.challenge
473
+
474
+ let verification: VerifiedRegistrationResponse
475
+ try {
476
+ verification = await verifyRegistrationResponse({
477
+ response: body,
478
+ expectedChallenge: challenge,
479
+ expectedOrigin: origin,
480
+ expectedRPID: rpID,
481
+ requireUserVerification:
482
+ authenticatorSelection?.userVerification !== "discouraged",
483
+ })
484
+ } catch (error: any) {
485
+ console.error("Passkey Registration Verification Error:", error)
486
+ return c.json({ verified: false, error: error.message }, 400)
487
+ }
488
+
489
+ const { verified, registrationInfo } = verification
490
+
491
+ if (verified && registrationInfo) {
492
+ const { credential, credentialDeviceType, credentialBackedUp } =
493
+ registrationInfo
494
+
495
+ if (credential) {
496
+ const newPasskey: PasskeyModel = {
497
+ id: credential.id,
498
+ userId: user.id,
499
+ webauthnUserID: regOptions.user.id,
500
+ publicKey: credential.publicKey,
501
+ counter: credential.counter,
502
+ transports: credential.transports,
503
+ deviceType: credentialDeviceType,
504
+ backedUp: credentialBackedUp,
505
+ }
506
+
507
+ await saveNewStoredPasskey(newPasskey)
508
+
509
+ return ctx.success(c, {
510
+ userId: user.id,
511
+ credentialId: newPasskey.id,
512
+ verified: true,
513
+ })
514
+ }
515
+ }
516
+ return c.json(
517
+ { verified: false, error: "Registration verification failed." },
518
+ 400,
519
+ )
520
+ })
521
+
522
+ // --- AUTHENTICATION FLOW ---
523
+ routes.get("/authenticate-options", async (c: Context) => {
524
+ const { userId } = c.req.query() as { userId?: string }
525
+ if (!userId) {
526
+ return c.json(
527
+ { error: "User ID for authentication is required." },
528
+ 400,
529
+ )
530
+ }
531
+ const rpID = config.rpID || c.req.query("rpID")
532
+ if (!rpID) {
533
+ return c.json({ error: "RP ID for authentication is required." }, 400)
534
+ }
535
+
536
+ const userForAuth = await getStoredUserById(userId)
537
+ if (!userForAuth) {
538
+ return c.json({ error: "User not found for authentication." }, 404)
539
+ }
540
+
541
+ const userPasskeys = await getStoredUserPasskeys(userForAuth.id)
542
+ const allowCredentialsList = userPasskeys.map((pk) => ({
543
+ id: pk.id,
544
+ transports: pk.transports,
545
+ }))
546
+
547
+ const authOptions: PublicKeyCredentialRequestOptionsJSON =
548
+ await generateAuthenticationOptions({
549
+ rpID,
550
+ allowCredentials: allowCredentialsList,
551
+ userVerification:
552
+ authenticatorSelection?.userVerification ?? "preferred",
553
+ timeout,
554
+ })
555
+
556
+ await Storage.set(ctx.storage, optionsKey(userForAuth.id), authOptions)
557
+ return c.json(authOptions)
558
+ })
559
+
560
+ routes.post("/authenticate-verify", async (c: Context) => {
561
+ const body: AuthenticationResponseJSON = await c.req.json()
562
+ const { userId } = c.req.query() as { userId?: string }
563
+ if (!userId) {
564
+ return c.json(
565
+ { error: "User ID for authentication is required." },
566
+ 400,
567
+ )
568
+ }
569
+ const rpID = config.rpID || c.req.query("rpID")
570
+ if (!rpID) {
571
+ return c.json({ error: "RP ID for authentication is required." }, 400)
572
+ }
573
+ const origin = config.origin || c.req.query("origin")
574
+ if (!origin) {
575
+ return c.json(
576
+ { error: "Origin for authentication is required." },
577
+ 400,
578
+ )
579
+ }
580
+
581
+ const user = await getStoredUserById(userId)
582
+ if (!user) {
583
+ return c.json(
584
+ { verified: false, error: `User ${userId} not found.` },
585
+ 404,
586
+ )
587
+ }
588
+
589
+ const authOptions =
590
+ await Storage.get<PublicKeyCredentialRequestOptionsJSON>(
591
+ ctx.storage,
592
+ optionsKey(user.id),
593
+ )
594
+
595
+ if (!authOptions) {
596
+ return c.json({ error: "Authentication options not found." }, 400)
597
+ }
598
+ const passkey = await getStoredPasskeyById(userId, body.id)
599
+
600
+ if (!passkey) {
601
+ return c.json(
602
+ {
603
+ verified: false,
604
+ error: `Passkey ${body.id} not found for user ${user.username}.`,
605
+ },
606
+ 400,
607
+ )
608
+ }
609
+
610
+ const { publicKey, counter, transports } = passkey
611
+
612
+ if (!publicKey || typeof counter !== "number" || !transports) {
613
+ return c.json({ error: "Passkey not found for authentication." }, 400)
614
+ }
615
+
616
+ const challenge = authOptions.challenge
617
+ if (!challenge) {
618
+ return c.json({ error: "Authentication challenge not found." }, 400)
619
+ }
620
+
621
+ const verification = await verifyAuthenticationResponse({
622
+ response: body,
623
+ expectedChallenge: challenge,
624
+ expectedOrigin: origin || "",
625
+ expectedRPID: rpID,
626
+ credential: {
627
+ id: passkey.id,
628
+ publicKey: publicKey,
629
+ counter: counter,
630
+ transports: transports,
631
+ },
632
+ })
633
+
634
+ const { verified, authenticationInfo } = verification
635
+
636
+ if (verified) {
637
+ await updateStoredPasskeyCounter(
638
+ user.id,
639
+ passkey.id,
640
+ authenticationInfo.newCounter,
641
+ )
642
+ return ctx.success(c, {
643
+ userId: user.id,
644
+ credentialId: passkey.id,
645
+ verified: true,
646
+ })
647
+ }
648
+ return c.json(
649
+ { verified: false, error: "Authentication verification failed." },
650
+ 400,
651
+ )
652
+ })
653
+ },
654
+ }
655
+ }