@_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,672 @@
1
+ /**
2
+ * Configures a provider that supports username and password authentication. This is usually
3
+ * paired with the `PasswordUI`.
4
+ *
5
+ * ```ts
6
+ * import { PasswordUI } from "@openauthjs/openauth/ui/password"
7
+ * import { PasswordProvider } from "@openauthjs/openauth/provider/password"
8
+ *
9
+ * export default issuer({
10
+ * providers: {
11
+ * password: PasswordProvider(
12
+ * PasswordUI({
13
+ * copy: {
14
+ * error_email_taken: "This email is already taken."
15
+ * },
16
+ * sendCode: (email, code) => console.log(email, code)
17
+ * })
18
+ * )
19
+ * },
20
+ * // ...
21
+ * })
22
+ * ```
23
+ *
24
+ * Behind the scenes, the `PasswordProvider` expects callbacks that implements request handlers
25
+ * that generate the UI for the following.
26
+ *
27
+ * ```ts
28
+ * PasswordProvider({
29
+ * // ...
30
+ * login: (req, form, error) => Promise<Response>
31
+ * register: (req, state, form, error) => Promise<Response>
32
+ * change: (req, state, form, error) => Promise<Response>
33
+ * })
34
+ * ```
35
+ *
36
+ * This allows you to create your own UI for each of these screens.
37
+ *
38
+ * @packageDocumentation
39
+ */
40
+ import { UnknownStateError } from "../error.js"
41
+ import { Storage } from "../storage/storage.js"
42
+ import { Provider } from "./provider.js"
43
+ import { generateUnbiasedDigits, timingSafeCompare } from "../random.js"
44
+ import { v1 } from "@standard-schema/spec"
45
+
46
+ /**
47
+ * @internal
48
+ */
49
+ export interface PasswordHasher<T> {
50
+ hash(password: string): Promise<T>
51
+ verify(password: string, compare: T): Promise<boolean>
52
+ }
53
+
54
+ export interface PasswordConfig {
55
+ /**
56
+ * @internal
57
+ */
58
+ length?: number
59
+ /**
60
+ * @internal
61
+ */
62
+ hasher?: PasswordHasher<any>
63
+ /**
64
+ * The request handler to generate the UI for the login screen.
65
+ *
66
+ * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
67
+ * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
68
+ * ojects.
69
+ *
70
+ * In case of an error, this is called again with the `error`.
71
+ *
72
+ * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
73
+ * in return.
74
+ */
75
+ login: (
76
+ req: Request,
77
+ form?: FormData,
78
+ error?: PasswordLoginError,
79
+ ) => Promise<Response>
80
+ /**
81
+ * The request handler to generate the UI for the register screen.
82
+ *
83
+ * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
84
+ * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
85
+ * ojects.
86
+ *
87
+ * Also passes in the current `state` of the flow and any `error` that occurred.
88
+ *
89
+ * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
90
+ * in return.
91
+ */
92
+ register: (
93
+ req: Request,
94
+ state: PasswordRegisterState,
95
+ form?: FormData,
96
+ error?: PasswordRegisterError,
97
+ ) => Promise<Response>
98
+ /**
99
+ * The request handler to generate the UI for the change password screen.
100
+ *
101
+ * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
102
+ * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)
103
+ * ojects.
104
+ *
105
+ * Also passes in the current `state` of the flow and any `error` that occurred.
106
+ *
107
+ * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object
108
+ * in return.
109
+ */
110
+ change: (
111
+ req: Request,
112
+ state: PasswordChangeState,
113
+ form?: FormData,
114
+ error?: PasswordChangeError,
115
+ ) => Promise<Response>
116
+ /**
117
+ * Callback to send the confirmation pin code to the user.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * {
122
+ * sendCode: async (email, code) => {
123
+ * // Send an email with the code
124
+ * }
125
+ * }
126
+ * ```
127
+ */
128
+ sendCode: (email: string, code: string) => Promise<void>
129
+ /**
130
+ * Callback to validate the password on sign up and password reset.
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * {
135
+ * validatePassword: (password) => {
136
+ * return password.length < 8 ? "Password must be at least 8 characters" : undefined
137
+ * }
138
+ * }
139
+ * ```
140
+ */
141
+ validatePassword?:
142
+ | v1.StandardSchema
143
+ | ((password: string) => Promise<string | undefined> | string | undefined)
144
+ }
145
+
146
+ /**
147
+ * The states that can happen on the register screen.
148
+ *
149
+ * | State | Description |
150
+ * | ----- | ----------- |
151
+ * | `start` | The user is asked to enter their email address and password to start the flow. |
152
+ * | `code` | The user needs to enter the pin code to verify their email. |
153
+ */
154
+ export type PasswordRegisterState =
155
+ | {
156
+ type: "start"
157
+ }
158
+ | {
159
+ type: "code"
160
+ code: string
161
+ email: string
162
+ password: string
163
+ }
164
+
165
+ /**
166
+ * The errors that can happen on the register screen.
167
+ *
168
+ * | Error | Description |
169
+ * | ----- | ----------- |
170
+ * | `email_taken` | The email is already taken. |
171
+ * | `invalid_email` | The email is invalid. |
172
+ * | `invalid_code` | The code is invalid. |
173
+ * | `invalid_password` | The password is invalid. |
174
+ * | `password_mismatch` | The passwords do not match. |
175
+ */
176
+ export type PasswordRegisterError =
177
+ | {
178
+ type: "invalid_code"
179
+ }
180
+ | {
181
+ type: "email_taken"
182
+ }
183
+ | {
184
+ type: "invalid_email"
185
+ }
186
+ | {
187
+ type: "invalid_password"
188
+ }
189
+ | {
190
+ type: "password_mismatch"
191
+ }
192
+ | {
193
+ type: "validation_error"
194
+ message?: string
195
+ }
196
+
197
+ /**
198
+ * The state of the password change flow.
199
+ *
200
+ * | State | Description |
201
+ * | ----- | ----------- |
202
+ * | `start` | The user is asked to enter their email address to start the flow. |
203
+ * | `code` | The user needs to enter the pin code to verify their email. |
204
+ * | `update` | The user is asked to enter their new password and confirm it. |
205
+ */
206
+ export type PasswordChangeState =
207
+ | {
208
+ type: "start"
209
+ redirect: string
210
+ }
211
+ | {
212
+ type: "code"
213
+ code: string
214
+ email: string
215
+ redirect: string
216
+ }
217
+ | {
218
+ type: "update"
219
+ redirect: string
220
+ email: string
221
+ }
222
+
223
+ /**
224
+ * The errors that can happen on the change password screen.
225
+ *
226
+ * | Error | Description |
227
+ * | ----- | ----------- |
228
+ * | `invalid_email` | The email is invalid. |
229
+ * | `invalid_code` | The code is invalid. |
230
+ * | `invalid_password` | The password is invalid. |
231
+ * | `password_mismatch` | The passwords do not match. |
232
+ */
233
+ export type PasswordChangeError =
234
+ | {
235
+ type: "invalid_email"
236
+ }
237
+ | {
238
+ type: "invalid_code"
239
+ }
240
+ | {
241
+ type: "invalid_password"
242
+ }
243
+ | {
244
+ type: "password_mismatch"
245
+ }
246
+ | {
247
+ type: "validation_error"
248
+ message: string
249
+ }
250
+
251
+ /**
252
+ * The errors that can happen on the login screen.
253
+ *
254
+ * | Error | Description |
255
+ * | ----- | ----------- |
256
+ * | `invalid_email` | The email is invalid. |
257
+ * | `invalid_password` | The password is invalid. |
258
+ */
259
+ export type PasswordLoginError =
260
+ | {
261
+ type: "invalid_password"
262
+ }
263
+ | {
264
+ type: "invalid_email"
265
+ }
266
+
267
+ export function PasswordProvider(
268
+ config: PasswordConfig,
269
+ ): Provider<{ email: string }> {
270
+ const hasher = config.hasher ?? ScryptHasher()
271
+ function generate() {
272
+ return generateUnbiasedDigits(6)
273
+ }
274
+ return {
275
+ type: "password",
276
+ init(routes, ctx) {
277
+ routes.get("/authorize", async (c) =>
278
+ ctx.forward(c, await config.login(c.req.raw)),
279
+ )
280
+
281
+ routes.post("/authorize", async (c) => {
282
+ const fd = await c.req.formData()
283
+ async function error(err: PasswordLoginError) {
284
+ return ctx.forward(c, await config.login(c.req.raw, fd, err))
285
+ }
286
+ const email = fd.get("email")?.toString()?.toLowerCase()
287
+ if (!email) return error({ type: "invalid_email" })
288
+ const hash = await Storage.get<HashedPassword>(ctx.storage, [
289
+ "email",
290
+ email,
291
+ "password",
292
+ ])
293
+ const password = fd.get("password")?.toString()
294
+ if (!password || !hash || !(await hasher.verify(password, hash)))
295
+ return error({ type: "invalid_password" })
296
+ return ctx.success(
297
+ c,
298
+ {
299
+ email: email,
300
+ },
301
+ {
302
+ invalidate: async (subject) => {
303
+ await Storage.set(
304
+ ctx.storage,
305
+ ["email", email, "subject"],
306
+ subject,
307
+ )
308
+ },
309
+ },
310
+ )
311
+ })
312
+
313
+ routes.get("/register", async (c) => {
314
+ const state: PasswordRegisterState = {
315
+ type: "start",
316
+ }
317
+ await ctx.set(c, "provider", 60 * 60 * 24, state)
318
+ return ctx.forward(c, await config.register(c.req.raw, state))
319
+ })
320
+
321
+ routes.post("/register", async (c) => {
322
+ const fd = await c.req.formData()
323
+ const email = fd.get("email")?.toString()?.toLowerCase()
324
+ const action = fd.get("action")?.toString()
325
+ const provider = await ctx.get<PasswordRegisterState>(c, "provider")
326
+
327
+ async function transition(
328
+ next: PasswordRegisterState,
329
+ err?: PasswordRegisterError,
330
+ ) {
331
+ await ctx.set<PasswordRegisterState>(
332
+ c,
333
+ "provider",
334
+ 60 * 60 * 24,
335
+ next,
336
+ )
337
+ return ctx.forward(c, await config.register(c.req.raw, next, fd, err))
338
+ }
339
+
340
+ if (action === "register" && provider.type === "start") {
341
+ const password = fd.get("password")?.toString()
342
+ const repeat = fd.get("repeat")?.toString()
343
+ if (!email) return transition(provider, { type: "invalid_email" })
344
+ if (!password)
345
+ return transition(provider, { type: "invalid_password" })
346
+ if (password !== repeat)
347
+ return transition(provider, { type: "password_mismatch" })
348
+ if (config.validatePassword) {
349
+ let validationError: string | undefined
350
+ try {
351
+ if (typeof config.validatePassword === "function") {
352
+ validationError = await config.validatePassword(password)
353
+ } else {
354
+ const res =
355
+ await config.validatePassword["~standard"].validate(password)
356
+
357
+ if (res.issues?.length) {
358
+ throw new Error(
359
+ res.issues.map((issue) => issue.message).join(", "),
360
+ )
361
+ }
362
+ }
363
+ } catch (error) {
364
+ validationError =
365
+ error instanceof Error ? error.message : undefined
366
+ }
367
+ if (validationError)
368
+ return transition(provider, {
369
+ type: "validation_error",
370
+ message: validationError,
371
+ })
372
+ }
373
+ const existing = await Storage.get(ctx.storage, [
374
+ "email",
375
+ email,
376
+ "password",
377
+ ])
378
+ if (existing) return transition(provider, { type: "email_taken" })
379
+ const code = generate()
380
+ await config.sendCode(email, code)
381
+ return transition({
382
+ type: "code",
383
+ code,
384
+ password: await hasher.hash(password),
385
+ email,
386
+ })
387
+ }
388
+
389
+ if (action === "register" && provider.type === "code") {
390
+ const code = generate()
391
+ await config.sendCode(provider.email, code)
392
+ return transition({
393
+ type: "code",
394
+ code,
395
+ password: provider.password,
396
+ email: provider.email,
397
+ })
398
+ }
399
+
400
+ if (action === "verify" && provider.type === "code") {
401
+ const code = fd.get("code")?.toString()
402
+ if (!code || !timingSafeCompare(code, provider.code))
403
+ return transition(provider, { type: "invalid_code" })
404
+ const existing = await Storage.get(ctx.storage, [
405
+ "email",
406
+ provider.email,
407
+ "password",
408
+ ])
409
+ if (existing)
410
+ return transition({ type: "start" }, { type: "email_taken" })
411
+ await Storage.set(
412
+ ctx.storage,
413
+ ["email", provider.email, "password"],
414
+ provider.password,
415
+ )
416
+ return ctx.success(c, {
417
+ email: provider.email,
418
+ })
419
+ }
420
+
421
+ return transition({ type: "start" })
422
+ })
423
+
424
+ routes.get("/change", async (c) => {
425
+ let redirect =
426
+ c.req.query("redirect_uri") || getRelativeUrl(c, "./authorize")
427
+ const state: PasswordChangeState = {
428
+ type: "start",
429
+ redirect,
430
+ }
431
+ await ctx.set(c, "provider", 60 * 60 * 24, state)
432
+ return ctx.forward(c, await config.change(c.req.raw, state))
433
+ })
434
+
435
+ routes.post("/change", async (c) => {
436
+ const fd = await c.req.formData()
437
+ const action = fd.get("action")?.toString()
438
+ const provider = await ctx.get<PasswordChangeState>(c, "provider")
439
+ if (!provider) throw new UnknownStateError()
440
+
441
+ async function transition(
442
+ next: PasswordChangeState,
443
+ err?: PasswordChangeError,
444
+ ) {
445
+ await ctx.set<PasswordChangeState>(c, "provider", 60 * 60 * 24, next)
446
+ return ctx.forward(c, await config.change(c.req.raw, next, fd, err))
447
+ }
448
+
449
+ if (action === "code") {
450
+ const email = fd.get("email")?.toString()?.toLowerCase()
451
+ if (!email)
452
+ return transition(
453
+ { type: "start", redirect: provider.redirect },
454
+ { type: "invalid_email" },
455
+ )
456
+ const code = generate()
457
+ await config.sendCode(email, code)
458
+
459
+ return transition({
460
+ type: "code",
461
+ code,
462
+ email,
463
+ redirect: provider.redirect,
464
+ })
465
+ }
466
+
467
+ if (action === "verify" && provider.type === "code") {
468
+ const code = fd.get("code")?.toString()
469
+ if (!code || !timingSafeCompare(code, provider.code))
470
+ return transition(provider, { type: "invalid_code" })
471
+ return transition({
472
+ type: "update",
473
+ email: provider.email,
474
+ redirect: provider.redirect,
475
+ })
476
+ }
477
+
478
+ if (action === "update" && provider.type === "update") {
479
+ const existing = await Storage.get(ctx.storage, [
480
+ "email",
481
+ provider.email,
482
+ "password",
483
+ ])
484
+ if (!existing) return c.redirect(provider.redirect, 302)
485
+
486
+ const password = fd.get("password")?.toString()
487
+ const repeat = fd.get("repeat")?.toString()
488
+ if (!password)
489
+ return transition(provider, { type: "invalid_password" })
490
+ if (password !== repeat)
491
+ return transition(provider, { type: "password_mismatch" })
492
+
493
+ if (config.validatePassword) {
494
+ let validationError: string | undefined
495
+ try {
496
+ if (typeof config.validatePassword === "function") {
497
+ validationError = await config.validatePassword(password)
498
+ } else {
499
+ const res =
500
+ await config.validatePassword["~standard"].validate(password)
501
+
502
+ if (res.issues?.length) {
503
+ throw new Error(
504
+ res.issues.map((issue) => issue.message).join(", "),
505
+ )
506
+ }
507
+ }
508
+ } catch (error) {
509
+ validationError =
510
+ error instanceof Error ? error.message : undefined
511
+ }
512
+ if (validationError)
513
+ return transition(provider, {
514
+ type: "validation_error",
515
+ message: validationError,
516
+ })
517
+ }
518
+
519
+ await Storage.set(
520
+ ctx.storage,
521
+ ["email", provider.email, "password"],
522
+ await hasher.hash(password),
523
+ )
524
+ const subject = await Storage.get<string>(ctx.storage, [
525
+ "email",
526
+ provider.email,
527
+ "subject",
528
+ ])
529
+ if (subject) await ctx.invalidate(subject)
530
+
531
+ return c.redirect(provider.redirect, 302)
532
+ }
533
+
534
+ return transition({ type: "start", redirect: provider.redirect })
535
+ })
536
+ },
537
+ }
538
+ }
539
+
540
+ import * as jose from "jose"
541
+ import { TextEncoder } from "node:util"
542
+
543
+ interface HashedPassword {}
544
+
545
+ /**
546
+ * @internal
547
+ */
548
+ export function PBKDF2Hasher(opts?: { iterations?: number }): PasswordHasher<{
549
+ hash: string
550
+ salt: string
551
+ iterations: number
552
+ }> {
553
+ const iterations = opts?.iterations ?? 600000
554
+ return {
555
+ async hash(password) {
556
+ const encoder = new TextEncoder()
557
+ const bytes = encoder.encode(password)
558
+ const salt = crypto.getRandomValues(new Uint8Array(16))
559
+ const keyMaterial = await crypto.subtle.importKey(
560
+ "raw",
561
+ bytes,
562
+ "PBKDF2",
563
+ false,
564
+ ["deriveBits"],
565
+ )
566
+ const hash = await crypto.subtle.deriveBits(
567
+ {
568
+ name: "PBKDF2",
569
+ hash: "SHA-256",
570
+ salt: salt,
571
+ iterations,
572
+ },
573
+ keyMaterial,
574
+ 256,
575
+ )
576
+ const hashBase64 = jose.base64url.encode(new Uint8Array(hash))
577
+ const saltBase64 = jose.base64url.encode(salt)
578
+ return {
579
+ hash: hashBase64,
580
+ salt: saltBase64,
581
+ iterations,
582
+ }
583
+ },
584
+ async verify(password, compare) {
585
+ const encoder = new TextEncoder()
586
+ const passwordBytes = encoder.encode(password)
587
+ const salt = jose.base64url.decode(compare.salt)
588
+ const params = {
589
+ name: "PBKDF2",
590
+ hash: "SHA-256",
591
+ salt,
592
+ iterations: compare.iterations,
593
+ }
594
+ const keyMaterial = await crypto.subtle.importKey(
595
+ "raw",
596
+ passwordBytes,
597
+ "PBKDF2",
598
+ false,
599
+ ["deriveBits"],
600
+ )
601
+ const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256)
602
+ const hashBase64 = jose.base64url.encode(new Uint8Array(hash))
603
+ return hashBase64 === compare.hash
604
+ },
605
+ }
606
+ }
607
+ import { timingSafeEqual, randomBytes, scrypt } from "node:crypto"
608
+ import { getRelativeUrl } from "../util.js"
609
+
610
+ /**
611
+ * @internal
612
+ */
613
+ export function ScryptHasher(opts?: {
614
+ N?: number
615
+ r?: number
616
+ p?: number
617
+ }): PasswordHasher<{
618
+ hash: string
619
+ salt: string
620
+ N: number
621
+ r: number
622
+ p: number
623
+ }> {
624
+ const N = opts?.N ?? 16384
625
+ const r = opts?.r ?? 8
626
+ const p = opts?.p ?? 1
627
+
628
+ return {
629
+ async hash(password) {
630
+ const salt = randomBytes(16)
631
+ const keyLength = 32 // 256 bits
632
+
633
+ const derivedKey = await new Promise<Buffer>((resolve, reject) => {
634
+ scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => {
635
+ if (err) reject(err)
636
+ else resolve(derivedKey)
637
+ })
638
+ })
639
+
640
+ const hashBase64 = derivedKey.toString("base64")
641
+ const saltBase64 = salt.toString("base64")
642
+
643
+ return {
644
+ hash: hashBase64,
645
+ salt: saltBase64,
646
+ N,
647
+ r,
648
+ p,
649
+ }
650
+ },
651
+
652
+ async verify(password, compare) {
653
+ const salt = Buffer.from(compare.salt, "base64")
654
+ const keyLength = 32 // 256 bits
655
+
656
+ const derivedKey = await new Promise<Buffer>((resolve, reject) => {
657
+ scrypt(
658
+ password,
659
+ salt,
660
+ keyLength,
661
+ { N: compare.N, r: compare.r, p: compare.p },
662
+ (err, derivedKey) => {
663
+ if (err) reject(err)
664
+ else resolve(derivedKey)
665
+ },
666
+ )
667
+ })
668
+
669
+ return timingSafeEqual(derivedKey, Buffer.from(compare.hash, "base64"))
670
+ },
671
+ }
672
+ }
@@ -0,0 +1,33 @@
1
+ import type { Context, Hono } from "hono"
2
+ import { StorageAdapter } from "../storage/storage.js"
3
+
4
+ export type ProviderRoute = Hono
5
+
6
+ export interface Provider<Properties = any> {
7
+ type: string
8
+ init: (route: ProviderRoute, options: ProviderOptions<Properties>) => void
9
+ client?: (input: {
10
+ clientID: string
11
+ clientSecret: string
12
+ params: Record<string, string>
13
+ }) => Promise<Properties>
14
+ }
15
+
16
+ export interface ProviderOptions<Properties> {
17
+ name: string
18
+ success: (
19
+ ctx: Context,
20
+ properties: Properties,
21
+ opts?: {
22
+ invalidate?: (subject: string) => Promise<void>
23
+ },
24
+ ) => Promise<Response>
25
+ forward: (ctx: Context, response: Response) => Response
26
+ set: <T>(ctx: Context, key: string, maxAge: number, value: T) => Promise<void>
27
+ get: <T>(ctx: Context, key: string) => Promise<T>
28
+ unset: (ctx: Context, key: string) => Promise<void>
29
+ invalidate: (subject: string) => Promise<void>
30
+ storage: StorageAdapter
31
+ }
32
+ export class ProviderError extends Error {}
33
+ export class ProviderUnknownError extends ProviderError {}