summaryrefslogtreecommitdiffhomepage
path: root/packages/server/src/authentication/verifyAuthenticationResponse.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/server/src/authentication/verifyAuthenticationResponse.ts')
-rw-r--r--packages/server/src/authentication/verifyAuthenticationResponse.ts212
1 files changed, 212 insertions, 0 deletions
diff --git a/packages/server/src/authentication/verifyAuthenticationResponse.ts b/packages/server/src/authentication/verifyAuthenticationResponse.ts
new file mode 100644
index 0000000..6e5f3e0
--- /dev/null
+++ b/packages/server/src/authentication/verifyAuthenticationResponse.ts
@@ -0,0 +1,212 @@
+import base64url from 'base64url';
+import {
+ AuthenticationCredentialJSON,
+ AuthenticatorDevice,
+ UserVerificationRequirement,
+} from '@simplewebauthn/typescript-types';
+
+import decodeClientDataJSON from '../helpers/decodeClientDataJSON';
+import toHash from '../helpers/toHash';
+import convertPublicKeyToPEM from '../helpers/convertPublicKeyToPEM';
+import verifySignature from '../helpers/verifySignature';
+import parseAuthenticatorData from '../helpers/parseAuthenticatorData';
+import isBase64URLString from '../helpers/isBase64URLString';
+
+export type VerifyAuthenticationResponseOpts = {
+ credential: AuthenticationCredentialJSON;
+ expectedChallenge: string;
+ expectedOrigin: string | string[];
+ expectedRPID: string | string[];
+ authenticator: AuthenticatorDevice;
+ fidoUserVerification?: UserVerificationRequirement;
+};
+
+/**
+ * Verify that the user has legitimately completed the login process
+ *
+ * **Options:**
+ *
+ * @param credential Authenticator credential returned by browser's `startAssertion()`
+ * @param expectedChallenge The base64url-encoded `options.challenge` returned by
+ * `generateAssertionOptions()`
+ * @param expectedOrigin Website URL (or array of URLs) that the registration should have occurred on
+ * @param expectedRPID RP ID (or array of IDs) that was specified in the registration options
+ * @param authenticator An internal {@link AuthenticatorDevice} matching the credential's ID
+ * @param fidoUserVerification (Optional) The value specified for `userVerification` when calling
+ * `generateAssertionOptions()`. Activates FIDO-specific user presence and verification checks.
+ * Omitting this value defaults verification to a WebAuthn-specific user presence requirement.
+ */
+export default function verifyAuthenticationResponse(
+ options: VerifyAuthenticationResponseOpts,
+): VerifiedAuthenticationResponse {
+ const {
+ credential,
+ expectedChallenge,
+ expectedOrigin,
+ expectedRPID,
+ authenticator,
+ fidoUserVerification,
+ } = options;
+ const { id, rawId, type: credentialType, response } = credential;
+
+ // Ensure credential specified an ID
+ if (!id) {
+ throw new Error('Missing credential ID');
+ }
+
+ // Ensure ID is base64url-encoded
+ if (id !== rawId) {
+ throw new Error('Credential ID was not base64url-encoded');
+ }
+
+ // Make sure credential type is public-key
+ if (credentialType !== 'public-key') {
+ throw new Error(`Unexpected credential type ${credentialType}, expected "public-key"`);
+ }
+
+ if (!response) {
+ throw new Error('Credential missing response');
+ }
+
+ if (typeof response?.clientDataJSON !== 'string') {
+ throw new Error('Credential response clientDataJSON was not a string');
+ }
+
+ const clientDataJSON = decodeClientDataJSON(response.clientDataJSON);
+
+ const { type, origin, challenge, tokenBinding } = clientDataJSON;
+
+ // Make sure we're handling an assertion
+ if (type !== 'webauthn.get') {
+ throw new Error(`Unexpected authentication response type: ${type}`);
+ }
+
+ // Ensure the device provided the challenge we gave it
+ if (challenge !== expectedChallenge) {
+ throw new Error(
+ `Unexpected authentication response challenge "${challenge}", expected "${expectedChallenge}"`,
+ );
+ }
+
+ // Check that the origin is our site
+ if (Array.isArray(expectedOrigin)) {
+ if (!expectedOrigin.includes(origin)) {
+ const joinedExpectedOrigin = expectedOrigin.join(', ');
+ throw new Error(
+ `Unexpected authentication response origin "${origin}", expected one of: ${joinedExpectedOrigin}`,
+ );
+ }
+ } else {
+ if (origin !== expectedOrigin) {
+ throw new Error(
+ `Unexpected authentication response origin "${origin}", expected "${expectedOrigin}"`,
+ );
+ }
+ }
+
+ if (!isBase64URLString(response.authenticatorData)) {
+ throw new Error('Credential response authenticatorData was not a base64url string');
+ }
+
+ if (!isBase64URLString(response.signature)) {
+ throw new Error('Credential response signature was not a base64url string');
+ }
+
+ if (response.userHandle && typeof response.userHandle !== 'string') {
+ throw new Error('Credential response userHandle was not a string');
+ }
+
+ if (tokenBinding) {
+ if (typeof tokenBinding !== 'object') {
+ throw new Error('ClientDataJSON tokenBinding was not an object');
+ }
+
+ if (['present', 'supported', 'notSupported'].indexOf(tokenBinding.status) < 0) {
+ throw new Error(`Unexpected tokenBinding status ${tokenBinding.status}`);
+ }
+ }
+
+ const authDataBuffer = base64url.toBuffer(response.authenticatorData);
+ const parsedAuthData = parseAuthenticatorData(authDataBuffer);
+ const { rpIdHash, flags, counter } = parsedAuthData;
+
+ // Make sure the response's RP ID is ours
+ if (typeof expectedRPID === 'string') {
+ const expectedRPIDHash = toHash(Buffer.from(expectedRPID, 'ascii'));
+ if (!rpIdHash.equals(expectedRPIDHash)) {
+ throw new Error(`Unexpected RP ID hash`);
+ }
+ } else {
+ // Go through each expected RP ID and try to find one that matches
+ const foundMatch = expectedRPID.some(expected => {
+ const expectedRPIDHash = toHash(Buffer.from(expected, 'ascii'));
+ return rpIdHash.equals(expectedRPIDHash);
+ });
+
+ if (!foundMatch) {
+ throw new Error(`Unexpected RP ID hash`);
+ }
+ }
+
+ // Enforce user verification if required
+ if (fidoUserVerification) {
+ if (fidoUserVerification === 'required') {
+ // Require `flags.uv` be true (implies `flags.up` is true)
+ if (!flags.uv) {
+ throw new Error('User verification required, but user could not be verified');
+ }
+ } else if (fidoUserVerification === 'preferred' || fidoUserVerification === 'discouraged') {
+ // Ignore `flags.uv`
+ }
+ } else {
+ // WebAuthn only requires the user presence flag be true
+ if (!flags.up) {
+ throw new Error('User not present during authentication');
+ }
+ }
+
+ const clientDataHash = toHash(base64url.toBuffer(response.clientDataJSON));
+ const signatureBase = Buffer.concat([authDataBuffer, clientDataHash]);
+
+ const publicKey = convertPublicKeyToPEM(authenticator.credentialPublicKey);
+ const signature = base64url.toBuffer(response.signature);
+
+ if ((counter > 0 || authenticator.counter > 0) && counter <= authenticator.counter) {
+ // Error out when the counter in the DB is greater than or equal to the counter in the
+ // dataStruct. It's related to how the authenticator maintains the number of times its been
+ // used for this client. If this happens, then someone's somehow increased the counter
+ // on the device without going through this site
+ throw new Error(
+ `Response counter value ${counter} was lower than expected ${authenticator.counter}`,
+ );
+ }
+
+ const toReturn = {
+ verified: verifySignature(signature, signatureBase, publicKey),
+ authenticationInfo: {
+ newCounter: counter,
+ credentialID: authenticator.credentialID,
+ },
+ };
+
+ return toReturn;
+}
+
+/**
+ * Result of authentication verification
+ *
+ * @param verified If the authentication response could be verified
+ * @param authenticationInfo.credentialID The ID of the authenticator used during authentication.
+ * Should be used to identify which DB authenticator entry needs its `counter` updated to the value
+ * below
+ * @param authenticationInfo.newCounter The number of times the authenticator identified above
+ * reported it has been used. **Should be kept in a DB for later reference to help prevent replay
+ * attacks!**
+ */
+export type VerifiedAuthenticationResponse = {
+ verified: boolean;
+ authenticationInfo: {
+ credentialID: Buffer;
+ newCounter: number;
+ };
+};