Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add challengeUrl #2152

Open
nsatragno opened this issue Sep 24, 2024 · 16 comments
Open

Add challengeUrl #2152

nsatragno opened this issue Sep 24, 2024 · 16 comments

Comments

@nsatragno
Copy link
Member

WebAuthn challenges usually need to be fetched from the server. This introduces extra latency, especially in cases where the page is loaded from offline storage and apps. This extra latency delays when WebAuthn credentials can be shown to the user in an empty allow-list request.

Proposed Change

Add a challengeUrl parameter that lets authenticators (or user agents) asynchronously fetch the challenge. This would let browsers render the list of credentials before the challenge comes back, improving the user experience. Add feature detection for it.

This obsoletes issue #1856.

@nsatragno nsatragno added this to the Futures (catch-all) milestone Sep 24, 2024
@nsatragno nsatragno self-assigned this Sep 24, 2024
@arianvp
Copy link

arianvp commented Sep 25, 2024

And I assume the challengeURL would return a application/octet-stream right?

Given I have this boilerplate code in my repo I like this change.

        async function newChallenge() {
            const challengeResponse = await fetch("/challenge", {
                method: "POST",
                headers: { "Accept": "application/octet-stream" }
            });

            if (!challengeResponse.ok) {
                throw new Error("Failed to fetch challenge");
            }

            const challenge = await challengeResponse.arrayBuffer();
            return challenge;
        }

        async function getPasskey() {
            return await navigator.credentials.get({
                publicKey: {
                    challenge: (await newChallenge()),
                    allowCredentials: [],
                    userVerification: "required",
                }
            });
        }

@arianvp
Copy link

arianvp commented Sep 25, 2024

Another option could be that we change the type of challenge parameter to BufferSource | Promise<BufferSource>.

The promise then gets executed asynchronously with the UI showing. This solves some issues with having to pull in HTTP semantics into the API.

@MasterKale
Copy link
Contributor

Promise<BufferSource>, what I'm going to say is an alternative way of pronouncing "challenge callback", which was discussed at pretty extensive length over here:

#1856

The issue has all but been closed since then.

In the WAWG discussion at TPAC yesterday the challenge URL was understood to be a better pattern for mobile use cases as well, so that whether you're in a browser or a native app an RP could allow for parallelizable credential discovery and challenge requesting to significantly improve some at-scale passkey auth scenarios (the idea is rpId could be specified earlier in page/app initialization, then while the platform/browser is discovering available credentials from available providers using the RP ID, a separate request for the challenge could occur in parallel to then pass in as client data to whatever authenticator/provider the user ultimately chooses to sign in with.)

@zacknewman
Copy link
Contributor

zacknewman commented Sep 25, 2024

Why do challenges need to be fetched from the server? Couldn't the challenge also be generated client-side? This would reduce latency even further. Is the idea that mobile devices are not powerful enough to generate 16 bytes of entropy for the challenge?

@kenrb
Copy link

kenrb commented Sep 25, 2024

The server wouldn't be able to trust a challenge that was generated on the client. If the assertion was being replayed, the client would just replay the same challenge.

@nsatragno
Copy link
Member Author

You can get away with generating client-side challenges if they're based on a timestamp. Then, they could only be replayed within the period that they're valid. If you can accept that, then it's a great option for UX.

@agl
Copy link
Contributor

agl commented Sep 25, 2024

You can get away with generating client-side challenges if they're based on a timestamp.

Each step away from "randomly generated at the server" costs some bit of security:

Method Characteristics
Randomly generated at the server Best
Server-encrypted timestamp Assertions can be replayed within the accepted time window
Client-generated timestamp Same reply is possible but also attackers with transient access can generate assertions that will be valid in the future. (This also depends on client–server clock sync.)
Fixed challenge Degrades to being a bearer token, like a password

@emlun
Copy link
Member

emlun commented Sep 27, 2024

In my implementation experience, challenges typically need to be associated with a particular session so that the server can verify that the assertion is signed over the expected challenge for that session. How would this association be expressed in a challengeUrl? I'm guessing you'd have to use either query parameters or a session cookie?

@zacknewman
Copy link
Contributor

zacknewman commented Sep 30, 2024

In my implementation experience, challenges typically need to be associated with a particular session so that the server can verify that the assertion is signed over the expected challenge for that session. How would this association be expressed in a challengeUrl? I'm guessing you'd have to use either query parameters or a session cookie?

If the challenge is at least 16 bytes of random data as you recommend, then shouldn't that be enough to associate with a particular session since it's functionally globally unique? As long as the challenge is removed from memory of course.

For example in my implementation, I have a hash table keyed by the 16-byte random challenges. This hash table contains the expiration of the challenge/ceremony. When a client sends a response, either the challenge is part of the hash table or not. If it is, it is removed from the hash table and the rest of the ceremony is completed. There is no "session id" involved.

@kenrb
Copy link

kenrb commented Oct 8, 2024

I have uploaded an explainer to the wiki with a set of proposed details on how this would work.

@arianvp
Copy link

arianvp commented Oct 8, 2024

I don't think a GET request is a good idea. As the request is not idempotent. It should be a POST. We might also want to think of adding Cache-Control: no-store

@Firehed
Copy link

Firehed commented Oct 9, 2024

Agreed @arianvp. HTTP semantics aside, there are countless situations where the proposal might not work for a given RP - including political/bureaucratic, non-technical reasons - and I'd be disappointed if only a subset could benefit from the improvements this change could yield. Any application today that a) DOES1 need any kind of authn to get their challenge and b) uses anything other than cookies to perform that authn (e.g. Authorization header) couldn't use this as proposed.

In the WAWG discussion at TPAC yesterday the challenge URL was understood to be a better pattern for mobile use cases as well, so that whether you're in a browser or a native app an RP could allow for parallelizable credential discovery and challenge requesting to significantly improve some at-scale passkey auth scenarios

@MasterKale are you or others able to provide a bit more background here, especially for those of us that were not at TPAC? From the rest of your comment, I'm interpreting "better pattern" to mean "better pattern than challengeCallback: () => Promise<BufferSource>" rather than "better pattern than the status quo of inlined BufferSource", so please correct me if I'm misinterpreting!

I'd especially like help understanding how this impacts native apps at all, since they have their own platform-native APIs (e.g. the ASAuthorization family in Apple's ecosystem) and are, at most, only really impacted by the definition of the signing process.

Implementation aside, I think it would also be worth adding another value into getClientCapabilities() to indicate the availability of wherever this lands.

If possible, I think it's also worth adjusting the "fetch behavior" section to replace the create/get split with conditional/non-conditional mediation. This would allow conditional create to benefit in the same ways (reduced data loading when not needed, being able to have short challenge TTLs, etc) as conditional get.

Footnotes

  1. this isn't commentary on whether that should be the case, as has already been discussed at length in the thread.

@kenrb
Copy link

kenrb commented Oct 9, 2024

@arianvp's suggestion of using a POST makes sense, and I can update the explainer with that change.

HTTP semantics aside, there are countless situations where the proposal might not work for a given RP - including political/bureaucratic, non-technical reasons - and I'd be disappointed if only a subset could benefit from the improvements this change could yield.

That's true about Authorization headers but, generalizing a bit, I don't see a version of this where the request is as flexible as using the Fetch API directly, and sites still have the option of using that as they might be doing today. If there is a specific problem that RPs are going to often run into then we should probably try to accommodate that. Setting up an HTTP endpoint to serve random bytes and cache them in a session-keyed map doesn't seem like a terribly complicated thing to do, although perhaps there are constraints I'm not aware of.

@Firehed
Copy link

Firehed commented Oct 10, 2024

If there is a specific problem that RPs are going to often run into then we should probably try to accommodate that.

The challenge I see here is that any given backend stack tends to have its own unique requirements (this has certainly been the case at every company I've worked at), such that I expect relatively few would be able to benefit from this enhancement. Beyond what's already been discussed with sessions and authn, automatic CSRF mitigation rules applied by frameworks come to mind.

As an alternative, I wonder if instead of accepting only a string, accepting any fetch resource parameter (i.e. string or Request) could work instead. It would provide most of the flexibility of the challengeCallback approach while still offering a simple/recommended default path with very little additional complexity in the browser-side implementation.

If a string is passed, use that as you've already described with some default semantics; if you provide a Request then it would be used exactly as-is with the general caveats already applied (the response must be ok, have a content-type of application/octet-stream, and a content-length of >=16 bytes)

Internally something like this:

let request
if (typeof challengeUrl === "string") {
  request = new Request(challengeUrl, {
    headers: { Expect: 'application/octet-stream' },
    method: 'POST', 
    // ... (others as specified)
  })
} else {
  request = challengeUrl
}
const response = await fetch(request)
if (!response.ok) {
 // fail the webauthn process
}
const challenge = await response.arrayBuffer()
// continue as if a challenge had been provided directly

Thoughts?

@kenrb
Copy link

kenrb commented Oct 11, 2024

That sounds like a reasonable thing that a browser could do, but someone in an offline discussion pointed out to me that this all has to be possible for the underlying platforms to implement as well. In cases where browsers pass requests through to platform WebAuthn APIs, it will be they who are fetching the challenge, not the browsers. This causes some problems, including for the explainer as currently written.

For one, it means the request should not be credentialed. It is undesirable for browsers to be passing user session cookies, for example, to passkey providers.

Also, while it might be possible to specify a set of arguments that RPs can add for certain special handling of the request (such as additional HTTP headers), passkey providers in general shouldn't be expected to have up-to-date implementations of the Fetch API, which would be implied if we allowed a resource Request as a parameter.

@kenrb
Copy link

kenrb commented Oct 30, 2024

I've somewhat expanded and modified the explainer, fleshing out some of the concerns I mentioned above. Unfortunately for this to be viable I think we have to make it considerably more restrictive for RPs, rather than less, for reasons that are now in the security section.

I understand this might make it more difficult for RPs to deploy.

The proposed constraints are:

  • The user agent must reject any URL that does not use the https: scheme.
  • The user agent must reject any URL that is not same-site with the RP (i.e. under the same registrable domain).
  • The user agent must ensure that the request conforms to page's Content Security Policy, such as the default-src directive.
  • The fetching application must send the challengeURL request uncredentialed.
  • The fetching application must not follow redirects.
  • The fetching application must reject a response if there is any error in TLS certificate validation.
  • The fetching application must reject a response that does not have the specified (non-standard) Content-type header.

RPs can use a query string in the URL to convey information to the challengeURL endpoint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants