Adopt B32+'s JSON route shell and fetch helper in the WebAuthn paths.

The rebase brought a convention guard that refuses a route.ts exporting POST
outside campaignJsonMutationRoute, and it named all four WebAuthn routes. They
now come from the shell, so the same-origin check is structural rather than
copied, and postJson delegates to postCampaignJson — keeping only what the
ceremonies add on top: the envelope's discriminant decides instead of the
status, and a non-JSON response reads as one sentence while a network failure
still propagates untouched. The two *-options routes have no body, hence
campaignWebAuthnNoBodySchema.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 11:33:22 -03:00
parent 763f64080a
commit 4d56a32ed2
7 changed files with 71 additions and 95 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,12 +2,12 @@ import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/serv
import { NextResponse } from 'next/server'
import type { CampaignWebAuthnOptionsResponse } from '@/lib/campaignWebAuthn'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import { campaignWebAuthnNoBodySchema } from '@/lib/schemas/campaignWebAuthn'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import {
buildCampaignAuthenticationOptions,
campaignWebAuthnSafeMessages,
} from '@/utilities/campaignWebAuthnCeremony'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
@ -18,19 +18,15 @@ type ResponseBody = CampaignWebAuthnOptionsResponse<PublicKeyCredentialRequestOp
* because the passkey is discoverable the response carries no `allowCredentials`
* — so this endpoint cannot be used to probe whether an account exists.
*/
export async function POST(request: Request): Promise<NextResponse<ResponseBody>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
try {
export const POST = campaignJsonMutationRoute(
{
bodySchema: campaignWebAuthnNoBodySchema,
safeMessages: campaignWebAuthnSafeMessages,
genericMessage: 'Não foi possível iniciar a entrada por biometria. Use sua senha.',
},
async (): Promise<NextResponse<ResponseBody>> => {
const options = await buildCampaignAuthenticationOptions()
return NextResponse.json({ status: 'success', options })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: campaignWebAuthnSafeMessages,
genericMessage: 'Não foi possível iniciar a entrada por biometria. Use sua senha.',
})
}
}
},
)

View file

@ -5,10 +5,7 @@ import { getPayload } from 'payload'
import type { CampaignWebAuthnLoginResponse } from '@/lib/campaignWebAuthn'
import { campaignWebAuthnLoginSchema } from '@/lib/schemas/campaignWebAuthn'
import { setCampaignAuthCookie } from '@/utilities/campaignAuth'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import {
campaignWebAuthnSafeMessages,
completeCampaignAuthentication,
@ -18,7 +15,6 @@ import {
CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE,
} from '@/utilities/campaignWebAuthnSession'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
@ -27,30 +23,22 @@ export const dynamic = 'force-dynamic'
* on the way out, with the same 14-day TTL the "Lembrar de mim" checkbox grants
* — enrolling the passkey on this device *is* the trust opt-in.
*/
export async function POST(request: Request): Promise<NextResponse<CampaignWebAuthnLoginResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { credential } = campaignWebAuthnLoginSchema.parse(parsed.body)
export const POST = campaignJsonMutationRoute(
{
bodySchema: campaignWebAuthnLoginSchema,
safeMessages: [
...campaignWebAuthnSafeMessages,
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE,
],
genericMessage: 'Não foi possível entrar com a biometria. Use sua senha.',
},
async ({ credential }): Promise<NextResponse<CampaignWebAuthnLoginResponse>> => {
const payload = await getPayload({ config })
const { token, tokenExpiration } = await completeCampaignAuthentication(payload, credential)
await setCampaignAuthCookie(token, payload, tokenExpiration)
return NextResponse.json({ status: 'success', redirectTo: '/campanha' })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: [
...campaignWebAuthnSafeMessages,
CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_UNAVAILABLE_MESSAGE,
CAMPAIGN_WEBAUTHN_ACCOUNT_LOCKED_MESSAGE,
],
genericMessage: 'Não foi possível entrar com a biometria. Use sua senha.',
})
}
}
},
)

View file

@ -2,13 +2,13 @@ import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/ser
import { NextResponse } from 'next/server'
import type { CampaignWebAuthnOptionsResponse } from '@/lib/campaignWebAuthn'
import { campaignWebAuthnNoBodySchema } from '@/lib/schemas/campaignWebAuthn'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { campaignJsonMutationErrorResponse } from '@/utilities/campaignJsonMutationRoute'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import {
buildCampaignRegistrationOptions,
campaignWebAuthnSafeMessages,
} from '@/utilities/campaignWebAuthnCeremony'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
@ -19,20 +19,16 @@ type ResponseBody = CampaignWebAuthnOptionsResponse<PublicKeyCredentialCreationO
* lives here rather than in `(app)`: two of the four WebAuthn routes are
* anonymous by nature and the `/campanha` cookie path covers all of them.
*/
export async function POST(request: Request): Promise<NextResponse<ResponseBody>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
try {
export const POST = campaignJsonMutationRoute(
{
bodySchema: campaignWebAuthnNoBodySchema,
safeMessages: campaignWebAuthnSafeMessages,
genericMessage: 'Não foi possível iniciar o cadastro da biometria. Tente novamente.',
},
async (): Promise<NextResponse<ResponseBody>> => {
const { payload, actor } = await getCampaignActionContext()
const options = await buildCampaignRegistrationOptions(payload, actor)
return NextResponse.json({ status: 'success', options })
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: campaignWebAuthnSafeMessages,
genericMessage: 'Não foi possível iniciar o cadastro da biometria. Tente novamente.',
})
}
}
},
)

View file

@ -7,31 +7,22 @@ import {
import type { CampaignWebAuthnRegisterResponse } from '@/lib/campaignWebAuthn'
import { campaignWebAuthnRegisterSchema } from '@/lib/schemas/campaignWebAuthn'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import {
campaignJsonMutationErrorResponse,
parseCampaignJsonRequestBody,
} from '@/utilities/campaignJsonMutationRoute'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import {
campaignWebAuthnSafeMessages,
completeCampaignRegistration,
} from '@/utilities/campaignWebAuthnCeremony'
import { CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE } from '@/utilities/campaignWebAuthnChallenge'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
export async function POST(
request: Request,
): Promise<NextResponse<CampaignWebAuthnRegisterResponse>> {
if (!isSameOriginRequest(request)) {
return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 })
}
const parsed = await parseCampaignJsonRequestBody(request)
if (!parsed.ok) return parsed.response
try {
const { credential, deviceLabel } = campaignWebAuthnRegisterSchema.parse(parsed.body)
export const POST = campaignJsonMutationRoute(
{
bodySchema: campaignWebAuthnRegisterSchema,
safeMessages: [...campaignWebAuthnSafeMessages, CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE],
genericMessage: CAMPAIGN_BIOMETRIC_ENROLL_ERROR_MESSAGE,
},
async ({ credential, deviceLabel }): Promise<NextResponse<CampaignWebAuthnRegisterResponse>> => {
const { payload, actor } = await getCampaignActionContext()
const passkey = await completeCampaignRegistration(payload, actor, { credential, deviceLabel })
@ -40,10 +31,5 @@ export async function POST(
message: CAMPAIGN_BIOMETRIC_ENROLLED_MESSAGE,
passkey,
})
} catch (error) {
return campaignJsonMutationErrorResponse(error, {
safeMessages: [...campaignWebAuthnSafeMessages, CAMPAIGN_WEBAUTHN_CHALLENGE_EXPIRED_MESSAGE],
genericMessage: CAMPAIGN_BIOMETRIC_ENROLL_ERROR_MESSAGE,
})
}
}
},
)

View file

@ -21,6 +21,7 @@ import {
CAMPAIGN_BIOMETRIC_DUPLICATE_DEVICE_MESSAGE,
CAMPAIGN_BIOMETRIC_UNSUPPORTED_MESSAGE,
} from '@/lib/campaignAuthCopy'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
CAMPAIGN_WEBAUTHN_ROUTES,
type CampaignPasskeyView,
@ -30,21 +31,21 @@ import {
} from '@/lib/campaignWebAuthn'
import { CampaignWebAuthnError } from '@/lib/campaignWebAuthnSupport'
/**
* Transport is `postCampaignJson`; this adds the two things the ceremonies need
* on top of it. The status is dropped on purpose — a 403/409/500 from these
* routes still carries the typed envelope, so the discriminant decides — and a
* response that is not JSON at all (proxy error page, offline) surfaces as one
* sentence instead of a `SyntaxError` about position 0. A rejection that is not
* a parse failure is a network failure and is rethrown untouched.
*/
const postJson = async <Response>(path: string, body?: unknown): Promise<Response> => {
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body ?? {}),
})
// A 409/403/500 from these routes still carries the typed envelope, so the
// discriminant — not the status — decides. A non-JSON error page (proxy,
// offline) is the only case that has to be invented here.
try {
return (await response.json()) as Response
} catch {
throw new Error('Resposta inválida do servidor.')
const { payload } = await postCampaignJson<Response>(path, body ?? {})
return payload
} catch (error) {
if (error instanceof SyntaxError) throw new Error('Resposta inválida do servidor.')
throw error
}
}

View file

@ -62,4 +62,13 @@ export const campaignPasskeyRemoveSchema = z.object({
passkeyId: positiveRelationshipId,
})
/**
* The two `*-options` routes have nothing to validate: what ceremony to open is
* decided by the session, or by nothing at all in the anonymous case. They still
* go through `campaignJsonMutationRoute` — the same-origin check is the point of
* that shell — and it always parses a body, so the client posts `{}` and this is
* what "no input" looks like at the boundary.
*/
export const campaignWebAuthnNoBodySchema = z.unknown()
export type CampaignPasskeyRemoveInput = z.infer<typeof campaignPasskeyRemoveSchema>