auth0 / auth0-spa-js

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

Timeouts when trying to create auth client after initial authentication? #583

Closed jrista closed 4 years ago

jrista commented 4 years ago

Describe the problem

I have an Ionic 5/Angular 9 app that I have been having a heck of a time getting working with Auth0. I've tried every solution I can, including the Ionic 4 QuickStart approach (which does not seem to work properly with the latest versions of Cordova and Ionic), as well as most of the other OAuth options.

I've finally just settled, for the time being, on using the Auth0-spa-js lib, which works in the browser, to open the Auth0 login page in Chrome or Safari on the device. I have then set up universal/app links to deep link back into the app.

This works on the initial login, the deep link gets to the app and I am able to complete login. However upon closing the app, and re-opening it, when I try to create the auth client again, the act of creating the client seems to get me stuck in a timeout/failure scenario. I've checked the logs in Auth0, and I HAVE had some silent auth failures. Initially this was due to my universal/app links domain not being included in the origins.

HOWEVER, after adding it to origins, which seemed odd in the first place, I am now getting an error about the origin of a postMessage request not matching the origin of the window. Which is exactly right...the origin of my window, being an Ionic app, should be either http://localhost (Android/) or ionic://localhost (iOS).

As far as I can tell, the timeout occurs simply when creating the Auth0 client. I don't even have to actually use any of the other Observables I've created on my Auth0WebService...simply the act of creating the Auth0 client with a call to createAuth0Client appears to be enough to cause the timeouts...but only after I've actually logged in once and am able to use client.getTokenSilently().

Seems the Auth0 client is tracking something somewhere, and when the client is created it immediately tries to interact with Auth0. Somehow, those interactions are going awry and timing out. I've read other issues that indicate the timeout is inevitable due to the fact that postMessage is being used, and you cannot change that behavior? In any case...it's a serious issue. Even more serious as the incorrect origin is being used with the postMessage call (and I am not even sure how that happens...the app actually RUNS at http://localhost or ionic://localhost...how postMessage is using the app links domain is beyond me...

What was the expected behavior?

It is correct that my app's origin is http://localhost|ionic://localhost. The redirect_url is simply that, a redirect url, but it is NOT an origin of my app, and should NOT be used as an origin of my app. Auth0 should allow me to configure, or pass in as an argument, the proper origin of my app and use that origin when it does whatever it is doing when creating the Auth0 client, rather than using the universal|app links domain.

Reproduction

postMessage error:

Line 1 - Msg: Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('https://yourapp.com') does not match the recipient window's origin ('http://localhost').

Code Samples

SAMPLE 1 (app.component):

@Component({
  selector: 'app-root',
  template: `
    <ion-app>
      <ion-router-outlet></ion-router-outlet>
    </ion-app>
  `
})
export class AppComponent {
  constructor(
    private platform: Platform,
    private store: Store<AppStore>,
    private zone: NgZone
  ) {
    App.addListener('appUrlOpen', data => {
      this.zone.run(() => {
        const pathAndQuery = data?.url?.split('yourapp.com').pop();
        if (pathAndQuery) {
          // Dispatch NgRx action to queue auth request (redirect back from Auth0) and log user in
          store.dispatch(queueAuthRequest({url: pathAndQuery}));
        }
      })
    });

    from(platform.ready())
      .pipe(tap(() => log.info(TAGS, 'Platform is ready!')))
      .subscribe(() => 
        // Dispatch NgRx action to indicate app has initialized, to later be handled to silently restore previously logged in user with Auth0
        store.dispatch(appInitialized());
      );
  }
}

SAMPLE 2 (auth-web.service):

import createAuth0Client from '@auth0/auth0-spa-js';
import { Capacitor } from '@capacitor/core';
// ... other imports ...

@Injectable({ providedIn: 'any' })
export class Auth0WebService {
  constructor() {  }

  client$ = of(Capacitor.isNative).pipe(
    map(isNative => (isNative ? environment.auth0mobile : environment.auth0web)),
    tap(config => console.log('Found auth0 client config?', !!config)),
    filter(config => !!config),
    tap(config => console.log('Creating auth0 client', config)),
    switchMap(config => createAuth0Client({
      ...config,
      cacheLocation: 'localstorage',
      authorizeTimeoutInSeconds: 10
    })),
    shareReplay(1),
    tap(
      () => console.log('Created auth0 client.'),
      err => console.error('Error initializing Auth0 Web Client:', err)
    )
  );

  callback$ = this.client$.pipe(
    tap(() => console.log('Handling auth0 redirect callback.')),
    concatMap(client => client.handleRedirectCallback()),
    tap(
      () => console.log('Handled auth0 redirect callback.'),
      err => console.error('Error handling Auth0 callback:', err)
    )
  );

  user$ = this.client$.pipe(
    tap(() => console.log('Getting auth0 claims.')),
    concatMap(client => client.getUser()),
    tap(
      () => console.log('Auth0 claims retrieved.'),
      err => console.error('Error getting Auth0 claims:', err)
    )
  );

  login() {
    return this.client$.pipe(
      tap(() => console.log('Logging in with auth0...')),
      concatMap(client => client.loginWithRedirect()),
      tap(() => console.log('Auth0 login initiated.'))
    );
  }

  logout() {
    return this.client$.pipe(
      tap(() => console.log('Logging out from auth0...')),
      tap(client =>
        client.logout({
          returnTo: Capacitor.isNative ? environment.auth0mobile.redirect_uri : environment.auth0web.redirect_uri,
        })
      ),
      tap(() => console.log('Auth0 logout initiated.'))
    );
  }
}

SAMPLE 3 (auth-web.effects):

import { queueAuthRequest, restoreAuthorization } from './auth-web.actions';
import { Auth0WebService } from './auth-web.service';
// ... other imports ...

export const getTokenSilently = (auth: Auth0WebService) =>
  pipe(
    switchMap(claims =>
      auth.client$.pipe(
        switchMap(client => client.getTokenSilently({
          redirect_uri: Capacitor.isNative 
            ? environment.auth0mobile.origin 
            : environment.auth0web.origin
          })
        ),
        map(token => ({ claims, token }))
      )
    )
  );

@Injectable()
export class AuthWebEffects {
  constructor(
    private actions$: Actions,
    private auth: Auth0WebService,
    private store: Store<AppState>,
    private location: Location,
    private nav: NavController
  ) {}

  // NOTE: When the app starts, if the auth0 parameters are not in the url, try to restore the prior authenticated user...
  restoreAuthentication$ = createEffect(() =>
    this.actions$.pipe(
      ofType(appInitialize),
      map(() => this.location.path()),
      filter(url => !url.includes('code=') && !url.includes('state=')),
      tap(() => console.log('Restoring previous authentication...')),
      exhaustMap(() => this.auth.user$),
      tap(claims => console.log('Previous claims:', claims)),
      filter(claims => !!claims),
      getTokenSilently(this.auth),
      map(claimsAndToken => restoreAuthorization(claimsAndToken))
    )
  );

  launchApp$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(restoreAuthorization),
        tap(() => console.log('Previous authentication restored!')),
        tap(() => this.nav.navigateRoot('/app'))
      ),
    { dispatch: false }
  );

  // NOTE: This effect will ultimately result in a redirection to auth0's /authenticate page,
  // AWAY from the app...
  logIn$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(login),
        tap(() => console.log('Logging in...')),
        concatMap(() => this.auth.login())
      ),
    { dispatch: false }
  );

  logOut$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(logout),
        tap(() => console.log('Logging out...')),
        concatMap(() => this.auth.logout())
      ),
    { dispatch: false }
  );

  // NOTE: This effect will handle the redirection back to the app from auth0 when running in browser...
  completeAuthentication$ = createEffect(() =>
    this.actions$.pipe(
      ofType(appInitialize),
      map(() => this.location.path()),
      filter(url => url.includes('code=') && url.includes('state=')),
      tap(() => console.log('Completing authentication process...')),
      exhaustMap(url =>
        this.auth.client$.pipe(
          switchMap(client => client.handleRedirectCallback(url)),
          switchMap(() => this.auth.user$),
          getTokenSilently(this.auth)
        )
      ),
      filter(({ claims }) => !!claims),
      tap(() => console.log('Authentication succeeded!')),
      map(claimsAndToken => authenticated(claimsAndToken)),
      tap(() => this.nav.navigateRoot('/app'))
    )
  );

  // NOTE: This effect will handle the redirection back to the app from auth0 when running on mobile device...
  completeQueuedAuthentication$ = createEffect(() =>
    this.actions$.pipe(
      ofType(queueAuthRequest),
      tap(() => console.log('Completing authentication process...')),
      exhaustMap(({ url }) =>
        this.auth.client$.pipe(
          switchMap(client => client.handleRedirectCallback(url)),
          switchMap(() => this.auth.user$),
          getTokenSilently(this.auth)
        )
      ),
      tap(({ claims }) => console.log('Authentication claims:', claims)),
      filter(({ claims }) => !!claims),
      tap(() => console.log('Authentication succeeded!')),
      map(claimsAndToken => authenticated(claimsAndToken)),
      tap(() => this.nav.navigateRoot('/app'))
    )
  );
}

Environment

jrista commented 4 years ago

On deeper investigation, it appears as though getTokenSilently() IS properly configurable. If you pass in a redirect_url on the options to this method, it uses that to check origins (according to the docs in the source code for Auth0 SPA):

  /**
   * There's no actual redirect when getting a token silently,
   * but, according to the spec, a `redirect_uri` param is required.
   * Auth0 uses this parameter to validate that the current `origin`
   * matches the `redirect_uri` `origin` when sending the response.
   * It must be whitelisted in the "Allowed Web Origins" in your
   * Auth0 Application's settings.
   */
  redirect_uri?: string;

I added this to my own getTokenSilently() calls...however it appears that when creating the Auth0 client, it does its own getTokenSilently() call somewhere along the line, and uses the wrong origin. As far as I can tell, there is no way to override the origin used in this case (even though a full set of options are passed to the createAuth0Client call...they simply do not appear to be used properly to do this internal silent token retrieval.

jrista commented 4 years ago

Looks like the Auth0Client can be imported and created directly, assuming it is imported properly. Appears the createAuth0Client is simply creating the client, then calling checkSession() which does the internal getTokenSilently().

Creating my own instance of the client allowed me to get around the issue with the createAuth0Clients behavior. That said...it does appear there is a bug here, where using createAuth0Client is unable to use the proper configuration. Even if the right config was passed in, it would use the redirect_url as the origin, which is still wrong. In my own case, I ended up adding an origin property to my config (otherwise ignored by Auth0Client), set to http://localhost, which is used when I call getTokenSilently and it works.

It appears the proper, valid origin needs to be configurable independently of the redirect_url in order for this library to work with an Ionic app (which, even though hosted within a native app container, is STILL an SPA!!)

stevehobbsdev commented 4 years ago

Thanks for the report @jrista, and I'm glad you came to the solution that I was going to mention, which is to use the constructor directly instead of createAuth0Client.

Unfortunately we don't really support it being used in an Ionic app yet due to the issues you're seeing, as we don't yet have a good sample to hand that demonstrates a good solid implementation. However, this is something we're looking at putting together and will take this feedback on board.

Closing this for now but feel free to continue the conversation; I've taken the feedback internally for when we iterate and enable this scenario in the future.

jrista commented 4 years ago

Hi Steve,

Ionic does seem to be a bit of a dead zone right now with regards to support from Auth0. I've tried every solution you guys offer, where an SDK of some kind from Auth0 exists. So far, I have not been able to get any to work, except the SPA lib, and in this particular....configuration.

The redirects are the key problem. Apps just don't handle redirects well. The only time they do handle redirects, is when it is the OS browser (Safari on iOS, Chrome on Android) were the SYSTEM is able to handle app schemas or universal/app links to redirect to the app.

The only mechanism I found that seemed to generally support an APP, was the Device Code flow. I had never used that before, and at first I wasn't sure what it would do. I thought maybe it would work, since it allowed me to make a backend request to initiate an authentication flow with a token from Auth0, then display a "verification page" to the client (I wasn't sure what that would be at first), then poll for the user's token. I was rather sad that when i finally saw the verification page, it ONLY accepted the code...and did not actually allow a user to "log in" with their username/password or SSO account.

I am curious...is it possible to create some kind of flow like that, where the app initiates the process and gets some kind of short term, single-use token, displays a LOGIN page to the user, and polls for the token? That would allow apps to avoid having to deal with a redirection flow, which are not necessarily secure anyway with apps (I think there is potential for app schemas to be spoofed and a login token to be captured by a malicious app). And it wouldn't be very hard to implement with apps, either.

It seems that there has long been this nasty gap from the standpoint of handling auth with an identity server in apps. And sadly, none of the OAuth flows REALLY seem to support apps properly. They all, excluding Device Code flow, seem to rely on HTTP redirects (Even the Authorization Code flows do...unless I've missed something critical.)

Anyway. I've run into these challenges many times in the past with apps...and it would be wonderful if we could finally solve the problem in a good, truly secure way.

td-edge commented 3 years ago

@jrista I've been pouring over your statements to try and figure out how you got auth0 to redirect back to the correct deep link for your application on mobile with ionic/angular. I have the browser working pretty seamlessly on http://localhost:8100. I can log in a redirect to my homepage at '/home', navigate around, etc. But mobile is killing me.

Right now, my allowed callback URL for mobile takes the form: YOUR_PACKAGE_ID://YOUR_DOMAIN/cordova/YOUR_PACKAGE_ID/callback. With this, I can pull up a browser in the app and create a successful login event. The auth0 logs show a login from mobile for my user. However, I end up back at the same login button page for my app and I never get redirected to the homepage. Based on what you've written, it sounds like I need to actually redirect back to the iOS origin ionic://localhost or, more ideally, ionic://localhost/home upon returning from safari. I have been using this code block for my auth service:

export class AuthService {
  // Create an observable of Auth0 instance of client
  auth0Client$ = (from(
    createAuth0Client({
      domain: AUTH.AUTH0_DOMAIN,
      client_id: AUTH.AUTH0_CLIENT_ID,
      redirect_uri: AUTH.AUTH0_REDIRECT_URI,
      audience: AUTH.AUTH0_AUDIENCE_ID,
      responseType: 'token id_token',
      scope: 'openid profile email',
      cacheLocation: 'localstorage',
      useRefreshTokens: true
    })
  ) as Observable<Auth0Client>).pipe(
    shareReplay(1), // Every subscription receives the same shared value
    catchError(err => throwError(err))
  );
  // Define observables for SDK methods that return promises by default
  // For each Auth0 SDK method, first ensure the client instance is ready
  // concatMap: Using the client instance, call SDK method; SDK returns a promise
  // from: Convert that resulting promise into an observable
  isAuthenticated$ = this.auth0Client$.pipe(
    concatMap((client: Auth0Client) => from(client.isAuthenticated())),
    tap(res => this.loggedIn = res)
  );
  handleRedirectCallback$ = this.auth0Client$.pipe(
    concatMap((client: Auth0Client) => from(client.handleRedirectCallback()))
  );
  // Create subject and public observable of user profile data
  private userProfileSubject$ = new BehaviorSubject<any>(null);
  userProfile$ = this.userProfileSubject$.asObservable();
  // Create a local property for login status
  loggedIn: boolean = null;

  constructor(private router: Router) {
    // On initial load, check authentication state with authorization server
    // Set up local auth streams if user is already authenticated
    this.localAuthSetup();
    // Handle redirect from Auth0 login
    this.handleAuthCallback();
  }

  // When calling, options can be passed if desired
  // https://auth0.github.io/auth0-spa-js/classes/auth0client.html#getuser
  getUser$(options?): Observable<any> {
    return this.auth0Client$.pipe(
      concatMap((client: Auth0Client) => from(client.getUser(options))),
      tap(user => this.userProfileSubject$.next(user))
    );
  }

  private localAuthSetup() {
    // This should only be called on app initialization
    // Set up local authentication streams
    const checkAuth$ = this.isAuthenticated$.pipe(
      concatMap((loggedIn: boolean) => {
        if (loggedIn) {
          // If authenticated, get user and set in app
          // NOTE: you could pass options here if needed
          return this.getUser$();
        }
        // If not authenticated, return stream that emits 'false'
        return of(loggedIn);
      })
    );
    checkAuth$.subscribe();
  }

  login(redirectPath: string = '/home') {
    // A desired redirect path can be passed to login method
    // (e.g., from a route guard)
    // Ensure Auth0 client instance exists
    this.auth0Client$.subscribe((client: Auth0Client) => {
      // Call method to log in
      client.loginWithRedirect({
        redirect_uri: AUTH.AUTH0_REDIRECT_URI,
        appState: { target: redirectPath }
      });
    });
  }

  private handleAuthCallback() {
    // Call when app reloads after user logs in with Auth0
    const params = window.location.search;
    if (params.includes('code=') && params.includes('state=')) {
      let targetRoute: string; // Path to redirect to after login processsed
      const authComplete$ = this.handleRedirectCallback$.pipe(
        // Have client, now call method to handle auth callback redirect
        tap(cbRes => {
          // Get and set target redirect route from callback results
          targetRoute = cbRes.appState && cbRes.appState.target ? cbRes.appState.target : '/';
        }),
        concatMap(() => {
          // Redirect callback complete; get user and login status
          return combineLatest([
            this.getUser$(),
            this.isAuthenticated$
          ]);
        })
      );
      // Subscribe to authentication completion observable
      // Response will be an array of user and login status
      authComplete$.subscribe(([user, loggedIn]) => {
        // Redirect to target route after callback processing
        this.router.navigate([targetRoute]);
      });
    }
  }

  logout() {
    // Ensure Auth0 client instance exists
    this.auth0Client$.subscribe((client: Auth0Client) => {
      // Call method to log out
      client.logout({
        client_id: AUTH.AUTH0_CLIENT_ID,
        returnTo: AUTH.AUTH0_LOGOUT_URL
      });
    });
  }

}

I suspect I need to do something different in the handler function for mobile, but nothing I try seems to work. (I noticed you have two different handlers.) I'm sure there are additional issues regarding the timeout that I'm going to run into, but I need to at least get into the app first. I am hitting a complete brick wall with this redirect situation. It doesn't help that I'm not really a mobile developer.

Is there something easy I'm overlooking?

jrista commented 3 years ago

@td-edge I have not actually figured it out properly. In the end, I resorted to using a bit of a hack, which was to redirect to the SYSTEM (OS) browser app for the login, then have it redirect back to an app link with deep linking to get it back into my app. I had to handle the deep link in such a manner that it routed to the appropriate start page, rather than the login page. This was far from an ideal solution...popping out of the app to authenticate is just...terrible...

I was not able to figure out how to get the auth0 login loop working 100% completely within the app, though. All of the solutions that have been documented, such as using the safari cordova plugin (which seemed to be abandoned a year and a half ago), did not quite seem to work (safari only works on ios, and that plugin seemed to cause a slough of build issues when I had it installed when building android). This is the gap I mentioned before...there does not seem to be a working solution for logging in with OAuth on mobile with auth0 right now. It's a nasty problem that has existed for years, and we have been hacking around it with mobile apps without even any apparent attempt to solve the problem.

The only potential solution that I've read about so far, is the Ionic Enterprise auth library. You need Ionic Enterprise to use it (extremely expensive, btw), but it was written more recently than the cordova safari plugin based approach recommended by Auth0, and as far as I understand it works today. I have not been able to try it yet, on account that none of my clients have been able to afford Ionic Enterprise. So, the gaping hole in auth support for phones seems to remain...

td-edge commented 3 years ago

@jrista Thanks for the response. I was able to dig into this further, and I think my code isn't actually redirecting back to the app at all, but rather trying to open a new instance of the app altogether. This seems to jive with the callback structure that is recommended in the docs. I'm also never reaching the handler, and this would explain why. I think I'm going to abandon auth0-spa-js and attempt to use the auth0-cordova library. I'm afraid anything I try to hack would open a security vulnerability. This means I may have to nullify the benefit of ionic and have a branch for mobile and a branch for the browser. Hopefully it doesn't devolve into a project for mobile (read: swift or flutter) and a project for browser.

Given the authentication flows that exist (PKCE, implicit), it doesn't surprise me too much that there isn't single solution for this yet. If I had more time, I would dig into the bowels of this library and and attempt a merge request. I did see the Ionic Enterprise option but was also turned off by the price. I guess they just spent the time to write the appropriate wrappers for everything. Tough sell for small businesses and startups that just need something that functions until they can get enough working capital.

jrista commented 3 years ago

@td-edge Hmm, that's interesting. Are you using an app schema, or are you using an actual app link? There are the custom schemas, then there are the http links you can use that will redirect into your app. I think what I did with the app I was working on, was an app link, so our client actually added the necessary .wellknown files to their web server at the properly configured domain. I wonder if that may be one difference...and if you are using a schema, maybe switching to an app link might help.

td-edge commented 3 years ago

@jrista I used the redirect URL form that followed the auth0 docs regarding Ionic and native: YOUR_PACKAGE_ID://YOUR_DOMAIN/cordova/YOUR_PACKAGE_ID/callback Since I'm using the loginWithRedirect method, I assumed it had to follow that form in order to reach the correct app again. Just using the origin ionic://localhost didn't seem to provide enough context for the auth to work. I'm pretty raw with mobile development, so I'm learning a lot of this as I go.

jrista commented 3 years ago

I'm not sure that the auth0 docs are up to date. For YOUR_PACKAGE_ID to work, I am pretty sure you have to set up an app schema first, for your package id. But I've never actually used that specific pathing either. I am not sure what auth0 docs you are looking at, but if it is the same docs I was looking at, a) I don't think they are up to date and b) the cordova safari plugin has been abandoned for over a year and a half and I'm not sure it still works.

td-edge commented 3 years ago

https://auth0.com/docs/quickstart/native/ionic4/01-login

td-edge commented 3 years ago

It seems the biggest issue is crypto working with the webpack. There is crypto-browserify which seems to load in, but I don't know if it can actually be used as a replacement. This is horrible. Okta's just as bad. How are more people not having these problems??

jrista commented 3 years ago

I dunno. I couldn't even get Android to build with the SafariViewController plugin installed. The android build would spit out huge volumes of errors. I didn't think Safari even worked on Android, but once I removed that plugin the build issues stopped.

It's quite the travesty, for sure. I don't know how mobile app auth has been ignored for so long, either. There surely must be a better way. Some people seem to loath polling, but the Device Code polling mechanism, actually worked really great. If only I could poll and check that a user had logged in properly with their full credentials...the problem would be solved. :P

Abildtoft commented 2 years ago

Has there been any progress on this? I'm also seeing the timeout error...

Aarbel commented 2 years ago

@Abildtoft @jrista

Have you found a workaround / solution for this ?

Oussie00 commented 1 year ago

@Abildtoft @jrista @Aarbel have you found any workaround/solution in the meantime?

Jeff-hive9 commented 1 year ago

Hi All, I am seeing the same issue with the auth0-react package ( https://www.npmjs.com/package/@auth0/auth0-react/v/2.2.1 ) Based on @jrista 's work above I was able to identify that it is the same issue. During initialization the component, in auth0-provider.tsx. it calls client.checkSession() with no arguments.

image

This does not work correctly for an ionic capacitor application. The arguments need to be slightly different to work in the iframe. Namely the rediectCallbackUrl is used as the validation, and in a capacitor app this is not the same as the window location, since the app is effectively running on localhost. the _authorizationParams.redirecturi should be set to _window.location.origin in order to allow it to validate. Addtionally there are other settings like the timeout which are useful here.

Only solution I can see currently is modify the package to allow passing arguments to the checkSession.

frederikprijck commented 1 year ago

@Jeff-hive9 did you check https://auth0.com/docs/quickstart/native/ionic-react ? More specifically settings useRefreshTokensFallback to false ?

That should avoid that time-out and is a setting we explicitly call out for ionic work around the issue you accuratly describe, however passing the mentioned redirect callback to check session will not work, the only solution is to disable the iframe through setting useRefreshTokensFallback to false.