Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 16 additions & 30 deletions apps/blog-next/app/api/verify-email/resend/route.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,28 @@
import { verification } from '@holo-js/auth'
import { resendEmailVerification } from '@holo-js/auth'
import { validate } from '@holo-js/forms'

interface ResendVerificationRequestBody {
readonly email?: string
}
import { resendEmailVerificationForm } from '@/lib/schemas/auth'

async function readRequestBody(request: Request): Promise<ResendVerificationRequestBody> {
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return {}
}
export async function POST(request: Request) {
const submission = await validate(request, resendEmailVerificationForm)

const payload = await request.json().catch(() => null)
if (!payload || typeof payload !== 'object') {
return {}
if (!submission.valid) {
return Response.json(submission.fail(), {
status: submission.fail().status,
})
}

const email = typeof payload.email === 'string' ? payload.email.trim() : undefined
return email ? { email } : {}
}

export async function POST(request: Request) {
const input = await readRequestBody(request)
const { error } = await verification.resend(input)
const { error } = await resendEmailVerification(submission.data.email)
if (error) {
return Response.json({
ok: false as const,
const failure = submission.fail({
status: error.status,
errors: error.fields,
}, {
status: error.status,
})

return Response.json(failure, { status: failure.status })
}

return Response.json({
ok: true as const,
status: 200,
data: {
message: 'A fresh verification email has been sent.',
},
})
return Response.json(submission.success({
message: 'A fresh verification email has been sent.',
}))
}
4 changes: 2 additions & 2 deletions apps/blog-next/app/api/verify-email/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { check, verification } from '@holo-js/auth'
import { check, verifyEmail } from '@holo-js/auth'
import { validate } from '@holo-js/forms'

import { verifyEmailForm } from '@/lib/schemas/auth'
Expand All @@ -13,7 +13,7 @@ export async function POST(request: Request) {
}

const wasAuthenticated = await check()
const { error } = await verification.consume(submission.data.token)
const { error } = await verifyEmail(submission.data.token)
if (error) {
const failure = submission.fail({
status: error.status,
Expand Down
4 changes: 4 additions & 0 deletions apps/blog-next/lib/schemas/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const forgotPasswordForm = schema({
email: field.string().required('Email is required.').email('Enter a valid email address.'),
})

export const resendEmailVerificationForm = schema({
email: field.string().required('Email is required.').email('Enter a valid email address.'),
})

export const resetPasswordForm = schema({
token: field.string().required('Reset token is required.'),
password: field.password().required('Password is required.').min(8, 'Password must be at least 8 characters.').confirmed(),
Expand Down
4 changes: 2 additions & 2 deletions apps/blog-nuxt/server/api/verify-email.post.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { check, verification } from '@holo-js/auth'
import { check, verifyEmail } from '@holo-js/auth'
import { validate } from '@holo-js/forms'

import { verifyEmailForm } from '#shared/schemas/auth'
Expand All @@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
}

const wasAuthenticated = await check()
const { error } = await verification.consume(submission.data.token)
const { error } = await verifyEmail(submission.data.token)
if (error) {
const failure = submission.fail({
status: error.status,
Expand Down
45 changes: 19 additions & 26 deletions apps/blog-nuxt/server/api/verify-email/resend.post.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,29 @@
import { verification } from '@holo-js/auth'
import { resendEmailVerification } from '@holo-js/auth'
import { validate } from '@holo-js/forms'

interface ResendVerificationRequestBody {
readonly email?: string
}
import { resendEmailVerificationForm } from '#shared/schemas/auth'

export default defineEventHandler(async (event) => {
let payload: ResendVerificationRequestBody | null = {}
try {
payload = await readBody<ResendVerificationRequestBody | null>(event)
} catch {
payload = {}
const submission = await validate(event, resendEmailVerificationForm)

if (!submission.valid) {
const failure = submission.fail()
setResponseStatus(event, failure.status)
return failure
}
const email = typeof payload === 'object'
&& payload !== null
&& typeof payload.email === 'string'
? payload.email.trim()
: ''
const { error } = await verification.resend(email ? { email } : undefined)

const { error } = await resendEmailVerification(submission.data.email)
if (error) {
setResponseStatus(event, error.status)
return {
ok: false as const,
const failure = submission.fail({
status: error.status,
errors: error.fields,
}
}
})

return {
ok: true as const,
status: 200,
data: {
message: 'A fresh verification email has been sent.',
},
setResponseStatus(event, failure.status)
return failure
}

return submission.success({
message: 'A fresh verification email has been sent.',
})
})
4 changes: 4 additions & 0 deletions apps/blog-nuxt/shared/schemas/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const forgotPasswordForm = schema({
email: field.string().required('Email is required.').email('Enter a valid email address.'),
})

export const resendEmailVerificationForm = schema({
email: field.string().required('Email is required.').email('Enter a valid email address.'),
})

export const resetPasswordForm = schema({
token: field.string().required('Reset token is required.'),
password: field.password().required('Password is required.').min(8, 'Password must be at least 8 characters.').confirmed(),
Expand Down
4 changes: 4 additions & 0 deletions apps/blog-sveltekit/src/lib/schemas/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export const forgotPasswordForm = schema({
email: field.string().required('Email is required.').email('Enter a valid email address.'),
})

export const resendEmailVerificationForm = schema({
email: field.string().required('Email is required.').email('Enter a valid email address.'),
})

export const resetPasswordForm = schema({
token: field.string().required('Reset token is required.'),
password: field.password().required('Password is required.').min(8, 'Password must be at least 8 characters.').confirmed(),
Expand Down
4 changes: 2 additions & 2 deletions apps/blog-sveltekit/src/routes/api/verify-email/+server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit'
import { check, verification } from '@holo-js/auth'
import { check, verifyEmail } from '@holo-js/auth'
import { validate } from '@holo-js/forms'

import { verifyEmailForm } from '$lib/schemas/auth'
Expand All @@ -14,7 +14,7 @@ export async function POST({ request }: { request: Request }) {
}

const authenticationCheck = check()
const verificationResult = verification.consume(submission.data.token)
const verificationResult = verifyEmail(submission.data.token)
const [wasAuthenticated, { error }] = await Promise.all([authenticationCheck, verificationResult])
if (error) {
const failure = submission.fail({
Expand Down
46 changes: 16 additions & 30 deletions apps/blog-sveltekit/src/routes/api/verify-email/resend/+server.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,29 @@
import { json } from '@sveltejs/kit'
import { verification } from '@holo-js/auth'
import { resendEmailVerification } from '@holo-js/auth'
import { validate } from '@holo-js/forms'

interface ResendVerificationRequestBody {
readonly email?: string
}
import { resendEmailVerificationForm } from '$lib/schemas/auth'

async function readRequestBody(request: Request): Promise<ResendVerificationRequestBody> {
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return {}
}
export async function POST({ request }: { request: Request }) {
const submission = await validate(request, resendEmailVerificationForm)

const payload = await request.json().catch(() => null)
if (!payload || typeof payload !== 'object') {
return {}
if (!submission.valid) {
return json(submission.fail(), {
status: submission.fail().status,
})
}

const email = typeof payload.email === 'string' ? payload.email.trim() : undefined
return email ? { email } : {}
}

export async function POST({ request }: { request: Request }) {
const input = await readRequestBody(request)
const { error } = await verification.resend(input)
const { error } = await resendEmailVerification(submission.data.email)
if (error) {
return json({
ok: false as const,
const failure = submission.fail({
status: error.status,
errors: error.fields,
}, {
status: error.status,
})

return json(failure, { status: failure.status })
}

return json({
ok: true as const,
status: 200,
data: {
message: 'A fresh verification email has been sent.',
},
})
return json(submission.success({
message: 'A fresh verification email has been sent.',
}))
}
24 changes: 15 additions & 9 deletions apps/docs/docs/auth/email-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ AUTH_EMAIL_VERIFICATION_ROUTE=/verify-email
`APP_URL` is used when the framework builds the email link. Applications should not manually construct the
verification URL in normal usage.

The application still owns the verification page and the route that calls `verification.consume(...)`. The framework
The application still owns the verification page and the route that calls `verifyEmail(token)`. The framework
owns the redirect target and the generated email link.

## Registration Flow
Expand Down Expand Up @@ -110,30 +110,36 @@ That lets the app redirect the signed-in user to the verify page instead of reje

## Consuming Verification Tokens

Verification pages consume the token from the emailed link:
Verification pages verify the token from the emailed link:

```ts
import { verification } from '@holo-js/auth'
import { verifyEmail } from '@holo-js/auth'

const { data: verifiedUser, error } = await verification.consume(token)
const { data: verifiedUser, error } = await verifyEmail(token)
```

The verification flow marks the local user as verified and invalidates the token.

## Resending Verification Emails

Applications can resend another verification email with plain object input:
Applications can resend another verification email with a direct email argument:

```ts
import { verification } from '@holo-js/auth'
import { resendEmailVerification } from '@holo-js/auth'

const { error } = await verification.resend({
email: body.email,
})
const { error } = await resendEmailVerification(body.email)
```

This is the intended verify-page flow when the user lands on `/verify-email?email=...` after login.

When you are sending a verification email outside a resend route, use the same API shape with the send-oriented name:

```ts
import { sendEmailVerification } from '@holo-js/auth'

const { error } = await sendEmailVerification(body.email)
```

Expected resend failures come back in `error`, for example:

- `email_verification_user_missing`
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ When `@holo-js/forms` is installed, forms can opt into security directly through
Validation failures and auth failures stay separate:

- `validate(...)` returns form validation failures such as missing fields, bad formats, CSRF errors, and throttling.
- `login(...)`, `register(...)`, `verification.consume(...)`, `requestPasswordReset(...)`, and `resetPassword(...)` return auth failures in `error`.
- `login(...)`, `register(...)`, `verifyEmail(...)`, `requestPasswordReset(...)`, and `resetPassword(...)` return auth failures in `error`.
- Auth failures are plain data with `status` and `fields`, so routes can forward them directly into the normal form response shape.

### Login
Expand Down
14 changes: 14 additions & 0 deletions packages/auth/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ export interface AuthPasswordResetRequestOptions {
readonly expiresAt?: Date
}

export interface AuthEmailVerificationSendOptions {
readonly guard?: string
readonly expiresAt?: Date
}

export interface AuthSessionLoginOptions {
readonly remember?: boolean
}
Expand Down Expand Up @@ -234,6 +239,15 @@ export interface AuthFacade extends AuthGuardFacade {
resetPassword<TInput extends AuthPasswordResetInput>(
input: TInput,
): Promise<AuthResult<AuthUser, AuthPasswordResetConsumeErrorCode, AuthInputFieldErrors<TInput>>>
verifyEmail(token: string): Promise<AuthResult<AuthUser, AuthEmailVerificationConsumeErrorCode, AuthFieldErrors<'token'>>>
sendEmailVerification(): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
sendEmailVerification(email: string): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
sendEmailVerification(email: string | undefined): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
sendEmailVerification(email: string | undefined, options: AuthEmailVerificationSendOptions): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
resendEmailVerification(): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
resendEmailVerification(email: string): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
resendEmailVerification(email: string | undefined): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
resendEmailVerification(email: string | undefined, options: AuthEmailVerificationSendOptions): Promise<AuthResult<EmailVerificationTokenResult, AuthEmailVerificationResendErrorCode, AuthFieldErrors<'_root'>>>
hashPassword(password: string): Promise<string>
verifyPassword(password: string, digest: string): Promise<boolean>
needsPasswordRehash(digest: string): Promise<boolean>
Expand Down
9 changes: 8 additions & 1 deletion packages/auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { check, currentAccessToken, getAuthRuntime, hashPassword, id, impersonate, impersonateById, impersonation, login, loginUsing, loginUsingId, logout, needsPasswordRehash, refreshUser, register, requestPasswordReset, resetPassword, stopImpersonating, tokens, user, verification, verifyPassword } from './runtime'
import { check, currentAccessToken, getAuthRuntime, hashPassword, id, impersonate, impersonateById, impersonation, login, loginUsing, loginUsingId, logout, needsPasswordRehash, refreshUser, register, requestPasswordReset, resendEmailVerification, resetPassword, sendEmailVerification, stopImpersonating, tokens, user, verification, verifyEmail, verifyPassword } from './runtime'

export { AUTH_ERROR_CODES, AuthError, defineAuthConfig, isAuthError } from './contracts'
export {
Expand All @@ -21,12 +21,15 @@ export {
refreshUser,
register,
requestPasswordReset,
resendEmailVerification,
resetAuthRuntime,
resetPassword,
sendEmailVerification,
stopImpersonating,
tokens,
user,
verification,
verifyEmail,
verifyPassword,
} from './runtime'
export type {
Expand All @@ -40,6 +43,7 @@ export type {
AuthEmailVerificationConsumeErrorCode,
AuthEmailVerificationFacade,
AuthEmailVerificationResendErrorCode,
AuthEmailVerificationSendOptions,
AuthEstablishedSession,
AuthFacade,
AuthFieldErrors,
Expand Down Expand Up @@ -104,10 +108,13 @@ const auth = Object.freeze({
needsPasswordRehash,
register,
requestPasswordReset,
resendEmailVerification,
resetPassword,
sendEmailVerification,
stopImpersonating,
tokens,
verification,
verifyEmail,
verifyPassword,
guard(name: string) {
return getAuthRuntime().guard(name)
Expand Down
Loading