Angular Auth0

L’authentification

C’est un grand classique auquel on ne peut pas échapper : identifier les utilisateurs (le plus souvent avec un login / mot de passe) de manière à leur proposer du contenu spécifique adapté.

Le problème c’est qu’il faut stocker ces identifiants de manière sécurisée, proposer des mécaniques pour récupérer des accès oubliés, empêcher les bots de créer de faux comptes… Si on veut faire les choses bien, ça devient vite très compliqué. Et on n’a pas encore parlé de scope, de token, de délégation…

Du coup, si on laissait cette partie compliqué et qui n’apporte rien d’un point de vue fonctionnel à quelqu’un qui sait le faire de manière correct et nous concentrer sur ce qui nous intéresse un peu plus ?

Il existe plusieurs solutions sur le marché, pour ce tutoriel j’ai choisi de parler de Auth0. J’aurais pu continuer sur ma lancée avec Firebase Authentication mais j’aime varier les plaisirs, dans ce tutoriel on va donc s’intéresser à brancher cette solution sur notre application Angular, on ne va donc pas traiter la partie backend (beaucoup moins compliqué) qui sera expliquée dans un futur tutoriel.

Tout le code source de ce tutoriel est disponible ici : https://gitlab.com/gautier-levert/auth0-angular

Création de l’application

Alors on va partir du principe que :

  • vous possédez un compte sur Auth0
  • vous avez déjà une application Angular (avec du routing)

À partir de là on va voir comment créer une application pour tester.

Sur le dashboard de Auth0 on commence par crée une nouvelle application grâce au gros bouton orange “CREATE APPLICATION” et on choisit une application de type “single page application”.

Auth0 création d’appliation

Il est tout à fait possible de suivre le “quick start guide” pour Angular fournit directement mais je vais présente une version alternative ici.

On va se rendre dans l’onglet Settings pour avoir les informations qui nous intéressent. Je vous conseille de laisser un onglet ouvert sur cette page, on va y revenir régulièrement.

Ajout de Auth0 sur Angular

la première étape va consister à ajouter la dépendance au module JS de Auth0

1
yarn add @auth0/auth0-spa-js 

ou pour npm

1
npm install @auth0/auth0-spa-js 

Création du service

On va commencer doucement à créer un service Angular responsable de l’authentification de nos utilisateurs.

Dans ce service Auth on va initialiser une instance de Auth0Client qui nous servira à exécuter nos requêtes.

La création de ce client se faisant de manière asynchrone par la méthode createAuth0Client on va mettre tout ça sous la forme d’un Observable RxJS et le mettre en cache pour éviter de le recréer à chaque fois.

Ça donne quelque chose dans ce genre-là :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  // Create an observable of Auth0 instance of client
  auth0Client$ = from(
    createAuth0Client({
      domain: 'my-domain',
      client_id: 'my-client-id'
    })
  )
    .pipe(
      shareReplay(1) // Every subscription receives the same shared value
    );

Les valeurs domain et client_id sont à récupérer sur le dashboard de Auth0.

On va enfin rajouter quelques méthodes du client pour pouvoir les utiliser sour la forme d’Observable.

Voilà ce que ça donne :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
  /**
   * Performs a redirect to `/authorize` using the parameters
   * provided as arguments. Random and secure `state` and `nonce`
   * parameters will be auto-generated.
   */
  private loginWithRedirect$(options?: RedirectLoginOptions): Observable<void> {
    return this.auth0Client$.pipe(
      concatMap(client => from(client.loginWithRedirect(options)))
    );
  }

  /**
   * Clears the application session and performs a redirect to `/v2/logout`, using
   * the parameters provided as arguments, to clear the Auth0 session.
   * If the `federated` option is specified it also clears the Identity Provider session.
   * If the `localOnly` option is specified, it only clears the application session.
   * It is invalid to set both the `federated` and `localOnly` options to `true`,
   * and an error will be thrown if you do.
   * [Read more about how Logout works at Auth0](https://auth0.com/docs/logout).
   */
  private internalLogout$(options?: LogoutOptions): Observable<void> {
    return this.auth0Client$.pipe(
      tap(client => {
        client.logout(options);
      }),
      ignoreElements()
    );
  }

  /**
   * Returns the user information if available (decoded
   * from the `id_token`).
   */
  private getUser$(options?: GetUserOptions): Observable<any> {
    return this.auth0Client$.pipe(
      concatMap(client => from(client.getUser(options)))
    );
  }

  /**
   * Returns `true` if there's valid information stored,
   * otherwise returns `false`.
   */
  isAuthenticated$(): Observable<boolean> {
    return this.auth0Client$.pipe(
      concatMap(client => from(client.isAuthenticated()))
    );
  }

  /**
   * If there's a valid token stored, return it. Otherwise, opens an
   * iframe with the `/authorize` URL using the parameters provided
   * as arguments. Random and secure `state` and `nonce` parameters
   * will be auto-generated. If the response is successful, results
   * will be valid according to their expiration times.
   */
  getTokenSilently$(options?: GetTokenSilentlyOptions): Observable<string> {
    return this.auth0Client$.pipe(
      concatMap(client => from(client.getTokenSilently(options)))
    );
  }

  /**
   * After the browser redirects back to the callback page,
   * call `handleRedirectCallback` to handle success and error
   * responses from Auth0. If the response is successful, results
   * will be valid according to their expiration times.
   */
  handleRedirectCallback$(): Observable<RedirectLoginResult> {
    return this.auth0Client$.pipe(
      concatMap(client => from(client.handleRedirectCallback()))
    );
  }

Le bouton de login

Maintenant on va créer une barre de navigation avec un bouton permettant de se connecter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<mat-toolbar fxLayout="row">
  <span fxFlex>My Application</span>
  <div fxFlex="grow">
  </div>
  <button mat-raised-button color="primary"
          fxFlex
          (click)="login()">
    Login
  </button>
</mat-toolbar>

dans le composant on appelle le service

1
2
3
4
5
6
  login(): void {
    this.subscription.add(
      this.auth.login$()
        .subscribe()
    );
  }

et enfin dans le service on écrit une méthode pour effectuer le login

1
2
3
4
5
6
7
8
  login$(redirectPath?: string): Observable<void> {
    // A desired redirect path can be passed to login method (e.g., from a route guard)
    return this.loginWithRedirect$({
      appState: {
        target: (redirectPath ? redirectPath : this.router.url)
      }
    });
  }

En appelant cette méthode loginWithRedirect l’utilisateur va quitter votre application pour se retrouver sur une page d’authentification générée par Auth0 c’est ce qu’ils appellent Universal Login.

Il est possible de changer les couleurs et le logo pour le rendre un peu plus proche de votre application.

Lors de l’appel à la méthode on peut utiliser le paramètre appState pour stocker des informations propre à l’application qui pourront être récupérée une fois que l’utilisateur reviendra sur l’application. Pour faire simplement ici il s’agit simplement de l’URL à laquelle ramenée l’utilisateur.

J’ai mis l’URL actuelle de l’utilisateur par défaut (ce qui correspond au cas d’usage le plus courant) pour ne pas avoir à passer ce paramètre systématiquement.

On teste notre super bouton et là… erreur…

Auth0 callback manquante

Il va falloir qu’on configure une callback : une page sur laquelle l’utilisateur sera redirigé une fois la connexion terminée. Cette page est différente de cette donnée dans le appState, cette callback est une URL unique pour notre application. Une fois cette page de callback atteinte on se chargera de rediriger l’utilisateur vers la page qu’il désire.

1
2
3
4
5
6
7
8
  // Create an observable of Auth0 instance of client
  auth0Client$ = from(
    createAuth0Client({
      domain: 'my-domain',
      client_id: 'my-client-id',
      redirect_uri: `${window.location.origin}/user/callback`
    })
  )

J’ai choisi ce path mais si vou préférez en utiliser un autre c’est tout à fait possible.

Il faut maintenant configurer cette URL de callback comme valide pour notre application

Auth0 configuration callback

ainsi que les web origins autorisés.

Auth0 Allowed Web Origins

Si vous exposez votre application sur internet, il faudra préciser l’URL pour tous les noms de domaines que vous utilisez.

Maintenant on peut enfin s’identifier et créer un compte. Malheureusement on ne gère pas encore correctement la callback et il va falloir créer un composant pour ça.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
export class CallbackComponent implements OnInit, OnDestroy {

  private subscription = new Subscription();

  constructor(
    private auth: AuthService,
    private router: Router
  ) {
  }

  ngOnInit(): void {

    // Call when app reloads after user logs in with Auth0
    const params = window.location.search;

    if (!params.includes('code=') || !params.includes('state=')) {
      this.router.navigateByUrl('/');
      return;
    }

    this.subscription.add(
      this.auth.handleRedirectCallback$()
        .pipe(
          map(cbRes => {
            // Get and set target redirect route from callback results
            return cbRes.appState && cbRes.appState.target ? cbRes.appState.target : '/';
          }),
          concatMap((targetRoute) => {
            // Redirect to target route after callback processing
            return this.router.navigateByUrl(targetRoute);
          })
        )
        .subscribe(
          _ => {
          },
          error => {
            console.error(error);
            this.router.navigateByUrl('/');
          }
        )
    );
  }

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }

}

Ce code sert à utiliser les paramètres d’URL de la callback pour authentifier l’utilisateur et enfin le rediriger vers la page qu’il a demandé.

Récupérer les informations de l’utilisateur

On va rajouter un Subject RxJS pour pouvoir récupérer les informations d’authentification de l’utilisateur dans notre service.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  private userProfileSubject = new BehaviorSubject<any>(null);

  private userSubscription: Subscription;

  constructor(
    private router: Router
  ) {
    this.userSubscription = this.getUser$()
      .subscribe(user => this.userProfileSubject.next(user));
  }

On tente d’initialiser la valeur à la construction du service et on va modifier la méthode handleRedirectCallback pour que la valeur soit actualisée correctement.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  handleRedirectCallback$(): Observable<RedirectLoginResult> {
    return this.auth0Client$.pipe(
      concatMap(client => from(client.handleRedirectCallback())),
      tap(_ => {
        this.userSubscription.unsubscribe();
        this.userSubscription = this.getUser$()
          .subscribe(user => this.userProfileSubject.next(user));
      })
    );
  }

Enfin on va exposer sous la forme d’Observable ces valeurs pour un usage dans nos composants

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  get userProfile$(): Observable<any> {
    return this.userProfileSubject.asObservable();
  }

  get loggedIn$(): Observable<boolean> {
    return this.userProfile$
      .pipe(
        map(user => !!user)
      );
  }

Le logout

Pour cette dernière étape, on va proposer à l’utilisateur de se déconnecter en rajoutant un bouton de Logout à la nav-bar.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<mat-toolbar fxLayout="row">
  <span fxFlex>My Application</span>
  <div fxFlex="grow">
  </div>
  <button *ngIf="(loggedIn$ | async)"
          mat-raised-button color="primary"
          fxFlex="noshrink"
          (click)="logout()">
    Logout
  </button>
  <button *ngIf="!(loggedIn$ | async)"
          mat-raised-button color="primary"
          fxFlex="noshrink"
          (click)="login()">
    Login
  </button>
</mat-toolbar>

Lors du clic sur ce bouton, on appelle une nouvelle méthode de notre service.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
  logout$(): Observable<void> {
    return this.internalLogout$({
      returnTo: `${window.location.origin}/user/logout`
    })
      .pipe(
        tap(_ => {
          this.userSubscription.unsubscribe();
          this.userSubscription = this.getUser$()
            .subscribe(user => this.userProfileSubject.next(user));
        }),
        ignoreElements()
      );
  }

L’URL de retour doit également être défnie sur la page Auth0 de notre application.

Auth0 logout URL

Il re nous reste plus qu’à faire un joli composant répondant ce path pour dire au revoir à notre utilisateur.

Un guard pour valider l’authentification

Petit bonus, voici un guard Angular permettant de valider que la personne est bien authentifié qui qui permettra de bloquer l’accès si ce n’est pas le cas.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Injectable()
export class AuthGuard implements CanActivate {

  constructor(
    private auth: AuthService
  ) {
  }

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

}

Un interceptor HTTP

Si vous voulez authentifié des appels HTTP vers votre API, vous pouvez également utiliser cet intercepteur, qui ajoutera automatiquement le token à chaque requête.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Injectable()
export class AuthInterceptor implements HttpInterceptor {

  constructor(
    private auth: AuthService
  ) {
  }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return this.auth.getTokenSilently$()
      .pipe(
        mergeMap(token => {
          const tokenReq = req.clone({
            setHeaders: {Authorization: `Bearer ${token}`}
          });
          return next.handle(tokenReq);
        })
      );
  }
}

Attention tout de même, si vous appelez d’autres API depuis votre application le token risque de fuiter. Il est important de contrôler que l’URL appelée correspond bien à votre API.

Load Comments?