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:
parent
763f64080a
commit
4d56a32ed2
7 changed files with 71 additions and 95 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue