Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

auth0 always shows login dialog on browser refresh

I'm using the new auth0-spa-js library with universal login. I followed the guide on https://auth0.com/docs/quickstart/spa/angular2/01-login to the letter, but still - on browser reload client.isAuthenticated() will always return false and it will redirect to the login page.

This is very frustrating.

EDIT: Removed links to github, and added my code directly in post as requested

EDIT2: Solution posted at the bottom of this post

auth0 configuration

Application

Allowed Callback URLs:  http://localhost:3000/callback
Allowed Web Origins:    http://localhost:3000
Allowed Logout URLs:    http://localhost:3000
Allowed Origins (CORS): http://localhost:3000
JWT Expiration          36000

API

Token expiration:       86400
Token Expiration For Browser Flows: 7200

Don't exactly know what the difference is between these two sections (Application/Api config), nor which of these I am actually using when going through the normal universal login flow, but I'll post them anyway.

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { AppRoutes } from './app.routing';
import { AppComponent } from './app.component';
import { DashboardComponent } from './views/dashboard/dashboard.component';
import { CallbackComponent } from './shared/auth/callback/callback.component';

@NgModule({
  declarations: [
    AppComponent,
    DashboardComponent,
    CallbackComponent
  ],
  imports: [
    BrowserModule,
    AppRoutes,
    HttpClientModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

app.routing.ts

import { Routes, RouterModule } from '@angular/router';
import { CallbackComponent } from './shared/auth/callback/callback.component';
import { DashboardComponent } from './views/dashboard/dashboard.component';
import { AuthGuard } from './shared/auth/auth.guard';

const routes: Routes = [
  { path: '', pathMatch: 'full', component: DashboardComponent, canActivate: [AuthGuard] },
  { path: 'callback', component: CallbackComponent },
  { path: '**', redirectTo: '' }
];

export const AppRoutes = RouterModule.forRoot(routes);

app.component.ts

import { Component, OnInit } from '@angular/core';
import { AuthService } from './shared/auth/auth.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'logic-energy';

  constructor(private auth: AuthService) { }

  ngOnInit() {
    // On initial load, check authentication state with authorization server
    // Set up local auth streams if user is already authenticated
    this.auth.localAuthSetup();
  }
}

auth.service.ts

import { Injectable } from '@angular/core';
import createAuth0Client from '@auth0/auth0-spa-js';
import Auth0Client from '@auth0/auth0-spa-js/dist/typings/Auth0Client';
import { environment } from 'src/environments/environment';
import { from, of, Observable, BehaviorSubject, combineLatest, throwError } from 'rxjs';
import { tap, catchError, concatMap, shareReplay, take } from 'rxjs/operators';
import { Router } from '@angular/router';

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  // Create an observable of Auth0 instance of client
  auth0Client$ = (from(
    createAuth0Client({
      domain: environment.auth.domain,
      client_id: environment.auth.clientId,
      redirect_uri: `${window.location.origin}/callback`
    })
  ) 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) { }

  // 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))
    );
  }

  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((response: { [key: string]: any } | boolean) => {
      // If authenticated, response will be user object
      // If not authenticated, response will be 'false'
      this.loggedIn = !!response;
    });
  }

  login(redirectPath: string = '/') {
    // 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: `${window.location.origin}/callback`,
        appState: { target: redirectPath }
      });
    });
  }

  handleAuthCallback() {
    // Only the callback component should call this method
    // Call when app reloads after user logs in with Auth0
    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]) => {
    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: environment.auth.clientId,
        returnTo: `${window.location.origin}`
      });
    });
  }

  getTokenSilently$(options?): Observable<string> {
    return this.auth0Client$.pipe(
      concatMap((client: Auth0Client) => from(client.getTokenSilently(options)))
    );
  }
}

auth.guard.ts

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, CanActivate } from '@angular/router';
import { Observable } from 'rxjs';
import { AuthService } from './auth.service';
import { tap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {

  constructor(private auth: AuthService) {}

  canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | Promise<boolean|UrlTree> | boolean {
    return this.auth.isAuthenticated$.pipe(
      tap(loggedIn => {
        if (!loggedIn) {
          this.auth.login(state.url);
        }
      })
    );
  }
}

callback.component.ts

import { Component, OnInit } from '@angular/core';
import { AuthService } from '../auth.service';

@Component({
  selector: 'app-callback',
  templateUrl: './callback.component.html',
  styleUrls: ['./callback.component.scss']
})
export class CallbackComponent implements OnInit {

  constructor(private auth: AuthService) { }

  ngOnInit() {
    this.auth.handleAuthCallback();
  }
}

By inspecting the network tab in devtools, I can see the following calls are made:

Before login:

  • authorize using several query params => returning HTTP 200 with an empty body.
  • login => Returning the login page

After login:

  • authorize => returning HTTP 302 empty body
  • authorize again (with a different set of params) => returning HTTP 302 empty body
  • authorize again (with a another different set of params) => returning HTTP 302 empty body
  • callback => returning HTTP 302 empty body
  • callback => returning HTTP 200 with my callback html

NOTE: Every other time it stops here and will not redirect to root, which is kind of strange.

After callback redirects:

  • authorize => returning HTTP 200 empty body
  • token => which receives a valid token. I can Base64 decode it, and it seems ok (except for some garbage in the nonce property)

Hitting refresh in the browser, will repeat this process

I've double checked the auth0 configuration. This works as expected on a react app using the older auth0-js, I'm using the same client_id with the same url's configured.

What am I doing wrong? Is there a manual step I have to do which is not described in the documentation? Do I have to migrate to the older auth0-js library for this to work?

Update

I've set some breakpoints in auth0-spa-js, and I see that when the application starts, it tries to run the getTokenSilently(), but it allways rejects the promise with a "login_required".

Even right after login, it calls the url first and rejects (even if the http request returns HTTP 200, because response has an empty body?), then it tries the internal cache after and it goes through.

As long as I do not refresh, auth0 will use the token from the cache, but it throws immediately if it tries to validate from http.

One thing I see, the following code is run each time getTokenSilently() is not fetching from cache:

stateIn = encodeState(createRandomString());
nonceIn = createRandomString();
code_verifier = createRandomString();
return [4 /*yield*/, sha256(code_verifier)];

In other words, it asks auth0 backend if I'm authenticated based on completely random strings, always. Shouldn't it be storing some of these in the browser if this is what allows it to identify me and my session?

Update 2 / Solution

Uhm... It would seem that the Chrome Plugin "Privacy Badger", which can prevent cookies from being stored, actually also has an effect on the site if you browse it through other browsers (when chrome is open). It actually cleared out the session the moment it was processed. The code above works, I just had to tweak the plugin. Urk...

In case I'm not the only one who forgets what extensions are installed, I will leave this issue here, so others might not waste an entire day debugging something which needn't be debugged.

like image 929
Øystein Amundsen Avatar asked Oct 28 '19 14:10

Øystein Amundsen


1 Answers

I just noticed that you do not have a route registered for your call back:

const routes: Routes = [
  { path: '', pathMatch: 'full', component: DashboardComponent, canActivate: [AuthGuard] },
  { path: 'callback', component: CallbackComponent },
  { path: '**', redirectTo: '' }
];

https://auth0.com/docs/quickstart/spa/angular2/01-login#handle-login-redirects

like image 66
Damian C Avatar answered Oct 17 '22 03:10

Damian C