auth0 / auth0-spa-js

Auth0 authentication for Single Page Applications (SPA) with PKCE
MIT License
901 stars 356 forks source link

timeout error when using getTokenSilently and user is not authenticated #810

Closed troyblank closed 2 years ago

troyblank commented 2 years ago

Describe the problem

When using getTokenSilently the promise will take whatever time is set with timeoutInSeconds and then give the error "timeout" when the user does not have a valid token. This is not ideal because I do not want the user to wait a long time before I can determine they do not have a valid token and send them to the login page.

What was the expected behavior?

For getTokenSilently to error as soon as call to /authorize finishes and not timeout.

Reproduction

First make sure the user has no previous authentication. I do this by usually using a new incognito window in chrome. Then call attemptToGetAuthToken anywhere in the application using the code below.

import createAuth0Client, { Auth0Client } from '@auth0/auth0-spa-js'

export const configureAuth0Client = async(): Promise<Auth0Client> => {
    return await createAuth0Client( {
        domain: 'REDACTED',
        client_id: 'REDACTED',
        audience: 'REDACTED',
        leeway: 300,
    } )
}

export const attemptToGetAuthToken = async(): Promise<AuthType> => new Promise( ( resolve, reject ) => {
    const urls = getUrls()

    configureAuth0Client().then( ( auth0Client ) => {
        auth0Client.getTokenSilently( {
            audience: 'REDACTED',
            scope: 'openid profile email',
            redirect_uri: 'REDACTED',
            leeway: 300,
        } ).then( ( jwt ) => {
            // This gets called when there is a valid token to fetch in a timely fashion.
            resolve( parseJWT( jwt ) )
        } ).catch( ( error ) => {
            // This always gets called after 60 seconds (default timeoutInSeconds) if there is not a valid token to fetch.
            console.log(error)
            reject( new Error( error.error ) )
        } )
    } )
} )

The error that is logged out in the console is

Error: Timeout
    at n [as constructor] (auth0-spa-js.production.esm.js:15)
    at new n (auth0-spa-js.production.esm.js:15)
    at auth0-spa-js.production.esm.js:15

Please note the auth0 logs in this scenario are all green.

{
  "date": "2021-10-01T21:17:34.587Z",
  "type": "ssa",
  "client_id": "[REDACTED]",
  "client_name": "[REDACTED]",
  "ip": "[REDACTED]",
  "user_agent": "Chrome 94.0.4606 / Mac OS X 10.15.7",
  "details": {
    "prompts": [],
    "completedAt": 1633123054585,
    "elapsedTime": null,
    "session_id": "[REDACTED]"
  },
  "hostname": "[REDACTED]",
  "session_connection": "[REDACTED]",
  "user_id": "[REDACTED]",
  "user_name": "[REDACTED]",
  "auth0_client": {
    "name": "auth0-spa-js",
    "version": "1.18.0"
  },
  "log_id": "[REDACTED]",
  "_id": "[REDACTED]",
  "isMobile": false,
  "description": "Successful silent authentication"
}

Also the call locally to /authorize when the timeout occurs returns with a 200 status in usually less than 2 seconds. I would figure this return would tell getTokenSilently to respond in some fashion but it does not appear to be the case. The return is an html page with the title "Authorization Response".

Can the behavior be reproduced using the SPA SDK Playground?

It can not, although I did notice the checkSession never allows getTokenSilently to be called in this playground because it checks for a cookie first.

if (!this.cookieStorage.get(this.isAuthenticatedCookieName))

I am attempting to use getTokenSilently without checking any perviously stored cookies. Maybe this is bad form?

Environment

stevehobbsdev commented 2 years ago

Hi @troyblank,

This is usually down to some misconfiguration on the Auth0 app side. Does this FAQ item help you any?

troyblank commented 2 years ago

@stevehobbsdev ,

Thanks for the information but unfortunately I don't think my issue is with the Auth0Client. I did double check everything in the FAQ and I appear to be in the green in that regard.

So to demonstrate where everything is breaking down here is a modification to my original example with a few "timestamped" logs that show Auth0Client is always fetched quickly but the getTokenSilently is what takes a very long time to report error.

export const attemptToGetAuthToken = async(): Promise<AuthType> => new Promise( ( resolve, reject ) => {
    console.log( 'Started to look for tokens', new Date() )
    const urls = getUrls()

    configureAuth0Client().then( ( auth0Client ) => {
        console.log( 'Received Auth0 client', new Date() )

        auth0Client.getTokenSilently( {
            audience: urls.api,
            scope: 'openid profile email',
            redirect_uri: urls.properties.login,
            leeway: 300,
        } ).then( ( jwt ) => {
            // This gets called when there is a valid token to fetch in a timely fashion.
            resolve( parseJWT( jwt ) )
        } ).catch( ( { error } ) => {
            // This always gets called after 60 seconds (default timeoutInSeconds) if there is valid token to fetch.
            console.log( 'getTokenSilently finally errors with timeout', new Date() )
            reject( new Error( error ) )
        } )
    } )
} )

and the output

Started to look for tokens Mon Oct 04 2021 08:51:29 GMT-0500 (Central Daylight Time)
Received Auth0 client Mon Oct 04 2021 08:51:29 GMT-0500 (Central Daylight Time)
getTokenSilently finally errors with timeout Mon Oct 04 2021 08:52:46 GMT-0500 (Central Daylight Time)

As you can see it takes 60 seconds to error out, which is the default timeoutInSeconds. When a user first hits the app and has yet to login, I want to be able to redirect them to a login page if they have no authentication. By using getTokenSilently only the user has to wait 60 seconds before I can determine they are not authenticated.

troyblank commented 2 years ago

Also if it helps here is the response I am getting back from /authorize as soon as getTokenSilently is called.

<!DOCTYPE html><html><head><title>Authorization Response</title></head><body><script type="text/javascript">(function(window, document) {var targetOrigin = "http://localhost:8020";var webMessageRequest = {};var authorizationResponse = {type: "authorization_response",response: {"code":"REDACTED","state":"REDACTED"}};var mainWin = (window.opener) ? window.opener : window.parent;if (webMessageRequest["web_message_uri"] && webMessageRequest["web_message_target"]) {window.addEventListener("message", function(evt) {if (evt.origin != targetOrigin)return;switch (evt.data.type) {case "relay_response":var messageTargetWindow = evt.source.frames[webMessageRequest["web_message_target"]];if (messageTargetWindow) {messageTargetWindow.postMessage(authorizationResponse, webMessageRequest["web_message_uri"]);window.close();}break;}});mainWin.postMessage({type: "relay_request"}, targetOrigin);} else {mainWin.postMessage(authorizationResponse, targetOrigin);}})(this, this.document);</script></body></html>

It usually takes less than a second for me. What is strange is this response does nothing for the getTokenSilently promise. I figured as soon as /authorize gets a response so should getTokenSIlently.

stevehobbsdev commented 2 years ago

Thanks @troyblank. This is definitely not normal and I'm unable to reproduce it with my tenant and the playground. You mention it's unreprodible there, but you can configure the switches at the bottom - if you turn off "Use Auth0Client Constructor", then createAuth0Client will be called and then you can press the "Get Token" button to call getTokenSilently. What should happen is that the call should return very quickly with a login_required error.

Things to try, to rule out a configuration problem:

https://user-images.githubusercontent.com/766403/135995959-b181efee-c285-4d1f-aa87-7fb15b95120e.mov

If you're not getting anywhere with the above, there are other things we could try with the code you've mentioned above. I've put some comments inline to make it easier to see:

import createAuth0Client, { Auth0Client } from '@auth0/auth0-spa-js'

export const configureAuth0Client = async(): Promise<Auth0Client> => {
    return await createAuth0Client( {
        domain: 'REDACTED',
        client_id: 'REDACTED',
        audience: 'REDACTED',
        leeway: 300,
    } )
}

export const attemptToGetAuthToken = async(): Promise<AuthType> => new Promise( ( resolve, reject ) => {
    const urls = getUrls()

    configureAuth0Client().then( ( auth0Client ) => {
        auth0Client.getTokenSilently( {
            audience: 'REDACTED',           // If this is the same as in `createAuth0Client` above, this can be omitted
            scope: 'openid profile email', // This is the same value as the default and can be omitted
            redirect_uri: 'REDACTED',      // This should be specified in createAuth0Client
            leeway: 300,
        } ).then( ( jwt ) => {
            resolve( parseJWT( jwt ) )
        } ).catch( ( error ) => {
            console.log(error)
            reject( new Error( error.error ) )
        } )
    } )
} )

Although some of these things might seem innocuous, they could be affecting the experience and would be good to understand that in case we need to do something to fix it. I'd also like to understand what led you to configuring the SDK in this way and whether we could do a better job of explaining the best set up.

Give those steps a try and let me know how you get on.

troyblank commented 2 years ago

@stevehobbsdev Thanks for these insights. I tried the commented suggestions but still get the timeout.

I think I am closer at figuring out why this timesout. My understanding of how getTokenSilently is probably way off. I assumed when /authorize returned a status thegetTokenSilently promise would also respond. But looking at this line inside @auth0/auth0-spa-js it appears the iframe is never cleaned up and thus no message is ever sent. I find this frustrating because I am not sure why at this point you would not want to communicate back to the invoker.

This is further confirmed when converting to React and using @auth0/auth0-react. These timeouts still occur (by not getting an error till 1.5 mins in) as it uses @auth0/auth0-spa-js under the hood. The errors that take 1.5 seconds do go away until when wrapping the component that is using useAuth0 with a withAuthenticationRequired. This forces the user to redirect to whatever the redirect uri setting is. It is at this point I realized this is probably the intended use case.

My failing of understand is this library really does not want you to handle any places where the user does not have a token or is not authenticated. I feel it really does expect you to rely on a setup that allows auth0 to push the user to the redirect_uri before the inevitable timeout occurs. I feel the friction I was seeing in this issue was my app could not rely on getTokenSilently alone, I need other tools such as withAuthenticationRequired so that auth0 forces the user to a login page before the timeout error occurs.

If there was any suggestion I would make to improve this library would be to not just return in iframeEventHandler on this line and instead at least provide a a response back so the invoker knows something is wrong. Obviously this is spoken from a fairly narrow perspective so I would love to hear feedback on if that is a good idea or not.

stevehobbsdev commented 2 years ago

Unfortunately, the web_message flow (which is what it's doing by default when you call getTokenSilently) relies on Auth0 server returning an HTML page that contains some code that calls postMessage, which the SDK responds to. The timeout is there in the event that Auth0 returns something (like an error page) that doesn't invoke the right thing. This happens to be the case when your Auth0 tenant is misconfigured, usually if you haven't used the correct "Allowed Web Origins" value. You mentioned that this was all configured correctly, so I'm not sure what the case may be at this point. And the HTML snippet you posted earlier looks healthy.

You should be able to do what you're trying to do: call getTokenSilently to figure out whether the user is logged in or not and get a login_required error as my video shows, without a timeout.

I tried the commented suggestions but still get the timeout.

Do you still get the timeout even when using the brucke.auth0.com tenant as defaulted in the Playground?

If there was any suggestion I would make to improve this library would be to not just return in iframeEventHandler on this line

I think that's a good suggestion and will investigate that further 👍🏻

troyblank commented 2 years ago

As stated in my first post the playground will never hit this error because before it calls getTokenSilently it does this check and thus getTokenSilently is never called without already having authentication:

if (!this.cookieStorage.get(this.isAuthenticatedCookieName))

My issue is getTokenSilently times out when there IS NO token or authentication. It is possible (and I would bet) that removing this check and a few other modifications will show the timeout issue.

stevehobbsdev commented 2 years ago

I feel like we're going around in circles - I mentioned that with the playground, you're able to actually hit a button that explicitly calls getTokenSilently and you can do this without being logged in, as shown in the video. getTokenSilently itself does not check for this cookie.

I do apologise if I'm missing something but I'm not sure how to explain it any clearer.

It is possible (and I would bet) that removing this check and a few other modifications will show the timeout issue.

I've removed the check in the playground environment and it does not show a timeout issue, but I can't reproduce your issue as it is, which is why I'm leaning so hard on a config issue to begin with; I don't feel like we've totally ruled it out yet.

I think to progress any further I'm going to need a small, isolated reproducible app from yourself that demonstrates the timeout. What would be helpful is if you can also supply the domain and client ID you're using, if you're happy to do so. If you'd prefer that not to be public, feel free to DM me in the Auth0 Community Forums with the information there. Also please test your repro with the domain and client ID used in the playground (it's set up to work on an app at http://localhost:3000) to verify that you're seeing the timeout there as well.

troyblank commented 2 years ago

My apologies I do see getTokenSilently running per your video. The playground might be a bit to dense for me to figure out what Is going wrong on my end, and I am struggling to see a 1 to 1 ratio of getTokenSIlently in the playground to the one I am hitting while developing.

Here is a repo with an example https://github.com/troyblank/getTokenSilently

If you run this project you will see it takes 1.5 mins to get a response from getTokenSilently. Please take a look and tell me what you think.

stevehobbsdev commented 2 years ago

No worries, we'll get to the bottom of it 💪🏻

I am struggling to see a 1 to 1 ratio of getTokenSIlently in the playground

Really the scenario we're testing is being able to call getTokenSilently when the user is not logged in, I think that's what the problem reduces to here. And you should 100% be able to do that.

I've checked out the sample and recorded a debug session. I did hit a timeout error initially when I loaded it, and it was because the client that it's configured with on brucke.auth0.com does not have http://localhost:9000 listed in its Allowed Callback URLs setting. That client uses http://localhost:3000 as its callback. So I reconfigured the sample you made to run on port 3000 instead and without any other code changes, it works fine.

I've attached the video here. You'll see me load it initially, then stop and reconfigure the port after checking out the network trace. Then I can reload the app and I get "Auth is done checking" on the screen after a few ms.

https://user-images.githubusercontent.com/766403/136089684-49c12565-1c3d-4b6e-be88-39259fe25cf8.mov

This still feels to me like a config problem so far; the next thing I would do is double - and triple - check the fields for the Auth0 client in your Auth0 dashboard, and ensure that Allowed Redirect URLs and Allowed Web Origins have the correct values.

troyblank commented 2 years ago

AH! cool thanks. So yes this has to be on the tenant side then 🤔. I am not off to figure out what the issue is there. In all transparency when I started this I did not have access to edit the client to test things out. It wasn't till I was complaining about this error a ton that I did get access 😂.

I looked at the client's config and all looked good to me. I had my localhost and port in Allowed Callback URLs as well as Allowed Web Origins. I did notice it was not in Allowed Origins (CORS) though. So I added it there and saved. The callback would not timeout anymore, hooray!.

I still think this experience could be improved upon though, the error is still vague to me even though we have pin-pointed it to be a tenant issue. Particularly with poor people like me without admin keys :)

Thanks again!!

stevehobbsdev commented 2 years ago

Great! Glad you've managed to resolve it. I definitely agree it can be improved upon, with this particular thing we're kind of at the whim of what the server is returning to us but I'll take that feedback on board and see what we can do 👍🏻

frederikprijck commented 2 years ago

I believe we could probably add a little section to the readme that mentions the URL configuration just as is mentioned on https://auth0.com/docs/quickstart/spa/vanillajs.

stevehobbsdev commented 2 years ago

I'll borrow this one from nextjs-auth0 and tweak it a bit 👍🏻