Skip to content

Commit 3e3c9e6

Browse files
lukeshaythdxr
andauthored
Add password validation callback (#174)
* add password validation callback * support standard schema * Create real-coats-clap.md --------- Co-authored-by: Dax <mail@thdxr.com>
1 parent 70233c7 commit 3e3c9e6

File tree

5 files changed

+115
-21
lines changed

5 files changed

+115
-21
lines changed

.changeset/real-coats-clap.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@openauthjs/openauth": patch
3+
"@openauthjs/example-issuer-bun": patch
4+
---
5+
6+
Add password validation callback

examples/issuer/bun/issuer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export default issuer({
2121
sendCode: async (email, code) => {
2222
console.log(email, code)
2323
},
24+
validatePassword: (password) => {
25+
if (password.length < 8) {
26+
return "Password must be at least 8 characters"
27+
}
28+
},
2429
}),
2530
),
2631
},

packages/openauth/src/provider/password.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { UnknownStateError } from "../error.js"
4141
import { Storage } from "../storage/storage.js"
4242
import { Provider } from "./provider.js"
4343
import { generateUnbiasedDigits, timingSafeCompare } from "../random.js"
44+
import { v1 } from "@standard-schema/spec"
4445

4546
/**
4647
* @internal
@@ -125,6 +126,21 @@ export interface PasswordConfig {
125126
* ```
126127
*/
127128
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)
128144
}
129145

130146
/**
@@ -173,6 +189,10 @@ export type PasswordRegisterError =
173189
| {
174190
type: "password_mismatch"
175191
}
192+
| {
193+
type: "validation_error"
194+
message?: string
195+
}
176196

177197
/**
178198
* The state of the password change flow.
@@ -223,6 +243,10 @@ export type PasswordChangeError =
223243
| {
224244
type: "password_mismatch"
225245
}
246+
| {
247+
type: "validation_error"
248+
message: string
249+
}
226250

227251
/**
228252
* The errors that can happen on the login screen.
@@ -321,6 +345,31 @@ export function PasswordProvider(
321345
return transition(provider, { type: "invalid_password" })
322346
if (password !== repeat)
323347
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+
}
324373
const existing = await Storage.get(ctx.storage, [
325374
"email",
326375
email,

packages/openauth/src/ui/password.tsx

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ const DEFAULT_COPY = {
5555
* Error message when the passwords do not match.
5656
*/
5757
error_password_mismatch: "Passwords do not match.",
58+
/**
59+
* Error message when the user enters a password that fails validation.
60+
*/
61+
error_validation_error: "Password does not meet requirements.",
5862
/**
5963
* Title of the register page.
6064
*/
@@ -136,18 +140,8 @@ type PasswordUICopy = typeof DEFAULT_COPY
136140
/**
137141
* Configure the password UI.
138142
*/
139-
export interface PasswordUIOptions {
140-
/**
141-
* Callback to send the confirmation code to the user.
142-
*
143-
* @example
144-
* ```ts
145-
* async (email, code) => {
146-
* // Send an email with the code
147-
* }
148-
* ```
149-
*/
150-
sendCode: PasswordConfig["sendCode"]
143+
export interface PasswordUIOptions
144+
extends Pick<PasswordConfig, "sendCode" | "validatePassword"> {
151145
/**
152146
* Custom copy for the UI.
153147
*/
@@ -164,6 +158,7 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig {
164158
...input.copy,
165159
}
166160
return {
161+
validatePassword: input.validatePassword,
167162
sendCode: input.sendCode,
168163
login: async (_req, form, error): Promise<Response> => {
169164
const jsx = (
@@ -214,13 +209,23 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig {
214209
const emailError = ["invalid_email", "email_taken"].includes(
215210
error?.type || "",
216211
)
217-
const passwordError = ["invalid_password", "password_mismatch"].includes(
218-
error?.type || "",
219-
)
212+
const passwordError = [
213+
"invalid_password",
214+
"password_mismatch",
215+
"validation_error",
216+
].includes(error?.type || "")
220217
const jsx = (
221218
<Layout>
222219
<form data-component="form" method="post">
223-
<FormAlert message={error?.type && copy?.[`error_${error.type}`]} />
220+
<FormAlert
221+
message={
222+
error?.type
223+
? error.type === "validation_error"
224+
? (error.message ?? copy?.[`error_${error.type}`])
225+
: copy?.[`error_${error.type}`]
226+
: undefined
227+
}
228+
/>
224229
{state.type === "start" && (
225230
<>
226231
<input type="hidden" name="action" value="register" />
@@ -292,13 +297,23 @@ export function PasswordUI(input: PasswordUIOptions): PasswordConfig {
292297
})
293298
},
294299
change: async (_req, state, form, error): Promise<Response> => {
295-
const passwordError = ["invalid_password", "password_mismatch"].includes(
296-
error?.type || "",
297-
)
300+
const passwordError = [
301+
"invalid_password",
302+
"password_mismatch",
303+
"validation_error",
304+
].includes(error?.type || "")
298305
const jsx = (
299306
<Layout>
300307
<form data-component="form" method="post" replace>
301-
<FormAlert message={error?.type && copy?.[`error_${error.type}`]} />
308+
<FormAlert
309+
message={
310+
error?.type
311+
? error.type === "validation_error"
312+
? (error.message ?? copy?.[`error_${error.type}`])
313+
: copy?.[`error_${error.type}`]
314+
: undefined
315+
}
316+
/>
302317
{state.type === "start" && (
303318
<>
304319
<input type="hidden" name="action" value="code" />

www/src/content/docs/docs/provider/password.mdx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ The errors that can happen on the change password screen.
8080
| `invalid_code` | The code is invalid. |
8181
| `invalid_password` | The password is invalid. |
8282
| `password_mismatch` | The passwords do not match. |
83+
| `validation_error` | The password does not meet requirements. |
8384
</Segment>
8485
## PasswordChangeState
8586
<Segment>
@@ -103,6 +104,7 @@ The state of the password change flow.
103104
- <p>[<code class="key">login</code>](#passwordconfig.login) <code class="primitive">(req: <code class="type">Request</code>, form?: <code class="type">FormData</code>, error?: [<code class="type">PasswordLoginError</code>](/docs/provider/password#passwordloginerror)) => <code class="primitive">Promise</code><code class="symbol">&lt;</code><code class="type">Response</code><code class="symbol">&gt;</code></code></p>
104105
- <p>[<code class="key">register</code>](#passwordconfig.register) <code class="primitive">(req: <code class="type">Request</code>, state: [<code class="type">PasswordRegisterState</code>](/docs/provider/password#passwordregisterstate), form?: <code class="type">FormData</code>, error?: [<code class="type">PasswordRegisterError</code>](/docs/provider/password#passwordregistererror)) => <code class="primitive">Promise</code><code class="symbol">&lt;</code><code class="type">Response</code><code class="symbol">&gt;</code></code></p>
105106
- <p>[<code class="key">sendCode</code>](#passwordconfig.sendcode) <code class="primitive">(email: <code class="primitive">string</code>, code: <code class="primitive">string</code>) => <code class="primitive">Promise</code><code class="symbol">&lt;</code><code class="primitive">void</code><code class="symbol">&gt;</code></code></p>
107+
- <p>[<code class="key">validatePassword</code>](#passwordconfig.sendcode) <code class="primitive">(password: <code class="primitive">string</code>) => <code class="primitive">Promise</code><code class="symbol">&lt;</code><code class="primitive">string</code><code class="symbol">|</code><code class="primitive">void</code><code class="symbol">&gt;</code><code class="symbol">|</code><code class="primitive">string</code><code class="symbol">|</code><code class="primitive">void</code></code></p>
106108
</Section>
107109
</Segment>
108110
<NestedTitle id="passwordconfig.change" Tag="h4" parent="PasswordConfig.">change</NestedTitle>
@@ -175,6 +177,22 @@ Callback to send the confirmation pin code to the user.
175177
}
176178
```
177179
</Segment>
180+
<NestedTitle id="passwordconfig.sendcode" Tag="h4" parent="PasswordConfig.">validatePassword</NestedTitle>
181+
<Segment>
182+
<Section type="parameters">
183+
<InlineSection>
184+
**Type** <code class="primitive">(password: <code class="primitive">string</code>) => <code class="primitive">Promise</code><code class="symbol">&lt;</code><code class="primitive">string</code><code class="symbol">|</code><code class="primitive">void</code><code class="symbol">&gt;</code><code class="symbol">|</code><code class="primitive">string</code><code class="symbol">|</code><code class="primitive">void</code></code>
185+
</InlineSection>
186+
</Section>
187+
Callback to validate the password on sign up and password reset.
188+
```ts
189+
{
190+
validatePassword: (password) => {
191+
return password.length < 8 ? "Password must be at least 8 characters" : undefined
192+
}
193+
}
194+
```
195+
</Segment>
178196
## PasswordLoginError
179197
<Segment>
180198
<Section type="parameters">
@@ -205,6 +223,7 @@ The errors that can happen on the register screen.
205223
| `invalid_code` | The code is invalid. |
206224
| `invalid_password` | The password is invalid. |
207225
| `password_mismatch` | The passwords do not match. |
226+
| `validation_error` | The password does not meet requirements. |
208227
</Segment>
209228
## PasswordRegisterState
210229
<Segment>
@@ -220,4 +239,4 @@ The states that can happen on the register screen.
220239
| `start` | The user is asked to enter their email address and password to start the flow. |
221240
| `code` | The user needs to enter the pin code to verify their email. |
222241
</Segment>
223-
</div>
242+
</div>

0 commit comments

Comments
 (0)