Initial Commit
This commit is contained in:
118
packages/identity/auth/webauthn/src/parser/client-data.ts
Normal file
118
packages/identity/auth/webauthn/src/parser/client-data.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { constantTimeEqual, fromBase64Url } from "@suaveplan/crypto-utils";
|
||||
import { WEBAUTHN_CODES, WebAuthnError } from "../errors/error-codes.js";
|
||||
import {
|
||||
type ClientDataJSON,
|
||||
ClientDataJSONSchema,
|
||||
} from "../schemas/schemas.js";
|
||||
|
||||
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
/** Discriminated `type` field accepted by the WebAuthn registration ceremony. */
|
||||
export const CLIENT_DATA_TYPE_CREATE = "webauthn.create";
|
||||
/** Discriminated `type` field accepted by the WebAuthn assertion ceremony. */
|
||||
export const CLIENT_DATA_TYPE_GET = "webauthn.get";
|
||||
|
||||
/**
|
||||
* Parse a raw `clientDataJSON` byte string into a typed
|
||||
* {@link ClientDataJSON} object. Throws `WEBAUTHN.MALFORMED_RESPONSE`
|
||||
* when the bytes are not valid UTF-8 JSON or do not conform to the
|
||||
* `ClientDataJSONSchema`.
|
||||
*/
|
||||
export function parseClientDataJSON(bytes: Uint8Array): ClientDataJSON {
|
||||
let text: string;
|
||||
try {
|
||||
text = TEXT_DECODER.decode(bytes);
|
||||
} catch (cause) {
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.MALFORMED_RESPONSE,
|
||||
"error.webauthn.client-data.invalid-utf8",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch (cause) {
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.MALFORMED_RESPONSE,
|
||||
"error.webauthn.client-data.invalid-json",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
const parsed = ClientDataJSONSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.MALFORMED_RESPONSE,
|
||||
"error.webauthn.client-data.invalid-shape",
|
||||
{ context: { issues: parsed.error.issues }, cause: parsed.error }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** Validation context shared between register and authenticate. */
|
||||
export interface ValidateClientDataInput {
|
||||
readonly clientDataJSON: Uint8Array;
|
||||
readonly expectedType:
|
||||
| typeof CLIENT_DATA_TYPE_CREATE
|
||||
| typeof CLIENT_DATA_TYPE_GET;
|
||||
readonly expectedOrigin: string;
|
||||
readonly expectedChallenge: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a parsed `clientDataJSON` against the caller's expected
|
||||
* type, origin, and challenge. Mismatches throw a typed
|
||||
* {@link WebAuthnError} with the appropriate W3C-aligned code.
|
||||
*/
|
||||
export function validateClientDataJSON(
|
||||
input: ValidateClientDataInput
|
||||
): ClientDataJSON {
|
||||
const data = parseClientDataJSON(input.clientDataJSON);
|
||||
if (data.type !== input.expectedType) {
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.MALFORMED_RESPONSE,
|
||||
"error.webauthn.client-data.type-mismatch",
|
||||
{
|
||||
context: {
|
||||
expected: input.expectedType,
|
||||
got: data.type,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (!constantTimeEqual(utf8(data.origin), utf8(input.expectedOrigin))) {
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.ORIGIN_MISMATCH,
|
||||
"error.webauthn.client-data.origin-mismatch",
|
||||
{
|
||||
context: {
|
||||
expected: input.expectedOrigin,
|
||||
got: data.origin,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
let decodedChallenge: Uint8Array;
|
||||
try {
|
||||
decodedChallenge = fromBase64Url(data.challenge);
|
||||
} catch (cause) {
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.MALFORMED_RESPONSE,
|
||||
"error.webauthn.client-data.challenge-not-base64url",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
if (!constantTimeEqual(decodedChallenge, input.expectedChallenge)) {
|
||||
throw new WebAuthnError(
|
||||
WEBAUTHN_CODES.CHALLENGE_MISMATCH,
|
||||
"error.webauthn.client-data.challenge-mismatch"
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const ENCODER = new TextEncoder();
|
||||
function utf8(s: string): Uint8Array {
|
||||
return ENCODER.encode(s);
|
||||
}
|
||||
Reference in New Issue
Block a user