MasterKale / SimpleWebAuthn

WebAuthn, Simplified. A collection of TypeScript-first libraries for simpler WebAuthn integration. Supports modern browsers, Node, Deno, and more.
https://simplewebauthn.dev
MIT License
1.62k stars 137 forks source link

Calling startAuthentication with a pending conditional UI auth fails in iOS 16.1 simulator #281

Closed sameh-amwal closed 2 years ago

sameh-amwal commented 2 years ago

Hello again, I have tested the conditional UI support in the browser library and it is working great both on Safari 16.1 on Mac and Chrome Canary. however there seems to be a problem when testing it on mobile safari on iOS 16.1 simulator (doesn't seem to work on existing iOS 16 simulator). It does seem that when there is an outstanding startAuthentication call with useBrowserAutofill true, if the traditional webauth is used for signing in, it aborts the outstanding conditional UI but it starts the new request before the aborted request being fully aborted resulting in that call failing. Calling the startAuthentication method again in exactly the same way but without outstanding request works fine then.

Here is a successful conditional UI sign in

https://user-images.githubusercontent.com/100665288/193427560-d15eb783-c3b8-49ab-a769-b7da948be572.mov

Here is the traditional webauth sign in. Notice the first attempt fails with error at 00:05

NotAllowedError: No available authenticator recognized any of the allowed credentials
WebAuthnError — index.js:48
identifyAuthenticationError — index.js:185
(anonymous function) — index.js:237
asyncFunctionResume
(anonymous function)
promiseReactionJobWithoutPromise
promiseReactionJob

https://user-images.githubusercontent.com/100665288/193427498-6fd07c52-e5cc-4bf4-b781-a29f0e825f69.mov

In the example code here it seems the calling for normal request happens at the error handling of aborting the conditional one. this seems convoluted and not sure if this is really needed to handle the abort correctly or not

let startConditionalRequest = async () => {
  if (window.PublicKeyCredential.isConditionalMediationAvailable) {
    console.log("Conditional UI is understood by the browser");
    if (!await window.PublicKeyCredential.isConditionalMediationAvailable()) {
      showError("Conditional UI is understood by your browser but not available");
      return;
    }
  } else {
    // Normally, this would mean Conditional Mediation is not available. However, the "current"
    // development implementation on chrome exposes availability via
    // navigator.credentials.conditionalMediationSupported. You won't have to add this code
    // by the time the feature is released.
    if (!navigator.credentials.conditionalMediationSupported) {
      showError("Your browser does not implement Conditional UI (are you running the right chrome/safari version with the right flags?)");
      return;
    } else {
      console.log("This browser understand the old version of Conditional UI feature detection");
    }
  }
  abortController = new AbortController();
  abortSignal = abortController.signal;

  try {
    let credential = await navigator.credentials.get({
      signal: abortSignal,
      publicKey: {
        // Don't do this in production!
        challenge: new Uint8Array([1, 2, 3, 4])
      },
      mediation: "conditional"
    });
    if (credential) {
      let username = String.fromCodePoint(...new Uint8Array(credential.response.userHandle));
      window.location = "site.html?username=" + username;
    } else {
      showError("Credential returned null");
    }
  } catch (error) {
    if (error.name == "AbortError") {
      console.log("request aborted, starting vanilla request");
      startNormalRequest();
      return;
    }
    showError(error.toString());
  }
}
MasterKale commented 2 years ago

I'm going to suggest that this is an iOS 16 issue. I tested the following workflow on both https://webauthn.io and https://example.simplewebauthn.dev, two sites I've authored that both support conditional UI via SimpleWebAuthn:

  1. Register a credential as usual
  2. Immediately reload the page and click Authenticate
  3. Choose a credential and complete Face ID
  4. Observe results

In Safari 16.1 on macOS Ventura, both sites operated as expected; no error was thrown by the aborting of the conditional UI request and subsequent attempt to call navigator.credentials.get().

In Safari on iOS 16.0.2, both sites errored out as you described after Step 2. I could still successfully complete the OS's WebAuthn request for Face ID, but the request to .get() never resolved and so the credential never made it to the server.

If you have the Ventura beta running on something then I'd suggest submitting an issue via the Feedback Assistant application. It'll let you submit issues for iOS as well. I may do this as well in the next day or two.

MasterKale commented 2 years ago

Oh, fascinating, it's the second authentication attempt, via modal UI, that throws the NotAllowedError on iOS 16:

Screen Shot 2022-10-02 at 9 37 47 PM

But iOS goes ahead and still prompts you for the WebAuthn interaction 🤔

MasterKale commented 2 years ago

I submitted feedback and raised it with someone at Apple (as best I can via Twitter lol)

Here's a basic HTML reproduction page I created and included in my feedback, to prove it's not a SimpleWebAuthn issue :)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Conditional UI iOS 16 Bug</title>
</head>
<body>
  <button id="btnAuth">Authenticate</button>
  <script>
    const abortController = new AbortController();

    async function startModalUIAuthentication() {
      abortController.abort('Starting modal auth attempt');

      try {
        const credential = await navigator.credentials.get({
          publicKey: {
            challenge: new Uint8Array([1,2,3,4]),
          },
        });

        console.log(credential);
      } catch (err) {
        // iOS 16 will immediately throw a NotAllowedError here
        // but still prompt for WebAuthn interaction
        console.error('Error with modal UI auth:', err);
      }
    }

    async function startConditionalUIAuthentication() {
      console.log('Starting conditional UI auth attempt');

      try {
        const credential = await navigator.credentials.get({
          publicKey: {
            challenge: new Uint8Array([1,2,3,4]),
          },
          mediation: 'conditional',
          signal: abortController.signal,
        });
      } catch (err) {
        console.error('Error with conditional UI auth:', err);
      }
    }

    document.querySelector('#btnAuth').addEventListener('click', startModalUIAuthentication);
    startConditionalUIAuthentication();
  </script>
</body>
</html>
sameh-amwal commented 2 years ago

I am not sure that it is a bug in iOS 16 or it is an implementation issue due to the complexity of abort signal. Maybe startModalUIAuthentication should only be called after the conditional one has been completely aborted as in this example code? That way there is no overlap at all between the 2 calls to navigator.credentials.get. The second call happens after abort is completed.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Conditional UI iOS 16 Bug</title>
</head>
<body>
  <button id="btnAuth">Authenticate</button>
  <script>
    const abortController = new AbortController();

    async function startModalUIAuthentication() {
      try {
        const credential = await navigator.credentials.get({
          publicKey: {
            challenge: new Uint8Array([1,2,3,4]),
          },
        });

        console.log(credential);
      } catch (err) {
        // iOS 16 will immediately throw a NotAllowedError here
        // but still prompt for WebAuthn interaction
        console.error('Error with modal UI auth:', err);
      }
    }

    async function startConditionalUIAuthentication() {
      console.log('Starting conditional UI auth attempt');

      try {
        const credential = await navigator.credentials.get({
          publicKey: {
            challenge: new Uint8Array([1,2,3,4]),
          },
          mediation: 'conditional',
          signal: abortController.signal,
        });
      } catch (err) {
        if (error.name == "AbortError") {
          console.log("request aborted, starting vanilla request");
          startModalUIAuthentication();
          return;
        }
        console.error('Error with conditional UI auth:', err);
      }
    }

    document.querySelector('#btnAuth').addEventListener('click', () => abortController.abort('Starting modal auth attempt') );
    startConditionalUIAuthentication();
  </script>
</body>
</html>
MasterKale commented 2 years ago

I'm converting this into a Discussion because the issue seems to exist pretty firmly at the browser/OS level.

And if we start discussing implementation questions like, "should the modal UI get triggered from an AbortError detected when Conditional UI errors out", that's going to still be something handled by a project using SimpleWebAuthn as opposed to anything I could address within this library 🤔