DEV Community

Super
Super

Posted on • Updated on

Single Page Application: Authentication and Authorization in Angular

Introduction

In a Single Page Application (SPA), each element has its own existence and lifecycle, rather than being part of a global page state. Authentication and authorization can affect some or all elements on the screen.

Authentication Process in an SPA

  1. User login: Obtain an access and refresh token.
  2. Client-side storage: Store tokens and minimal details in local storage.
  3. Login resolver: Redirect away from the login page if the user is authenticated.
  4. Router auth guard: Redirect to the login page if the user is unauthenticated.
  5. Logout: Remove stored data.

Additional Considerations

  • HTTP interceptor: Use the token for API calls.
  • 401 handling: Request a new token when necessary.
  • User display: Retrieve and display user details on the screen.
  • Redirect URL: Track additional information.

Other concerns include third-party adaptation and server-side rendering for obtaining access tokens.

Basic Login Example

Begin with a simple authentication form that requires a username and password. The API accepts the credentials and returns an access token and refresh token.

// Auth service
@Injectable({ providedIn: 'root' })
export class AuthService {
  private _loginUrl = '/auth/login';
  constructor(private _http: HttpClient) {}
  // login method
  Login(username: string, password: string): Observable<any> {
    return this._http.post(this._loginUrl, { username, password }).pipe(
      map((response) => {
        // prepare the response to be handled, then return
        return response;
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

When the login is successful, save the information in localStorage:

Login(username: string, password: string): Observable<any> {
  return this._http.post(this._loginUrl, { username, password }).pipe(
    map((response) => {
      const retUser: IAuthInfo = <IAuthInfo>(<any>response).data;
      // save in localStorage
      localStorage.setItem('user', JSON.stringify(retUser));
      return retUser;
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

Upon revisiting the site, user information should be populated from localStorage.

Auth State Management

To manage the authentication state, use RxJS BehaviorSubject and Observable:

// Auth service
export class AuthService {
  // create an internal subject and an observable to keep track
  private stateItem: BehaviorSubject<IAuthInfo | null> = new BehaviorSubject(null);
  stateItem$: Observable<IAuthInfo | null> = this.stateItem.asObservable();
}
Enter fullscreen mode Exit fullscreen mode

Handling User Status

If the localStorage status indicates the user is logged in and the page is refreshed, the user should be redirected to the appropriate location.

Logout Process

To log out, remove the state and localStorage data:

// services/auth.service
Logout() {
  this.RemoveState();
  localStorage.removeItem(ConfigService.Config.Auth.userAccessKey);
}
Enter fullscreen mode Exit fullscreen mode

Auth Guard

To protect private routes and redirect unauthorized users, use an AuthGuard:

// services/auth.guard
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate, CanActivateChild {
    constructor(private authState: AuthService, private _router: Router) {}

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
      return this.secure(route);
    }

    canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
      return this.secure(route);
    }

    private secure(route: ActivatedRouteSnapshot | Route): Observable<boolean> {
      return this.authState.stateItem$.pipe(
         map(user => {
            if (!user) {
               this._router.navigateByUrl('/login');
               return false;
            }
            // user exists
            return true;
         })
      );
    }
}
Enter fullscreen mode Exit fullscreen mode

Additional Use Cases

In future iterations, the access token can be used for API calls, and handling a 401 error can be implemented.

HTTP Interceptor

To automatically add the access token to API calls, use an HTTP interceptor. This will help manage authentication headers for all requests.

// services/auth.interceptor.ts
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const authToken = this.authService.getAccessToken();

    if (authToken) {
      const authReq = req.clone({
        headers: req.headers.set('Authorization', 'Bearer ' + authToken),
      });
      return next.handle(authReq);
    } else {
      return next.handle(req);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Register the interceptor in the app.module.ts:

// app.module.ts
@NgModule({
  // ...
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true,
    },
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Handling 401 Errors

To handle 401 errors, create another interceptor that detects the error and refreshes the access token if necessary.

// services/error.interceptor.ts
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService, private router: Router) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401) {
          // Refresh the access token and retry the request.
          return this.authService.refreshAccessToken().pipe(
            switchMap((newToken) => {
              const authReq = req.clone({
                headers: req.headers.set('Authorization', 'Bearer ' + newToken),
              });
              return next.handle(authReq);
            })
          );
        } else {
          return throwError(error);
        }
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Register the error interceptor in the app.module.ts:

// app.module.ts
@NgModule({
  // ...
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: ErrorInterceptor,
      multi: true,
    },
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

User Display

To display user information, create a component that subscribes to the authService.stateItem$ Observable and updates the UI accordingly.

// components/user-display/user-display.component.ts
@Component({
  selector: 'app-user-display',
  templateUrl: './user-display.component.html',
  styleUrls: ['./user-display.component.scss'],
})
export class UserDisplayComponent implements OnInit {
  user$: Observable<IUser>;

  constructor(private authService: AuthService) {}

  ngOnInit(): void {
    this.user$ = this.authService.stateItem$.pipe(map((state) => state?.payload));
  }
}
Enter fullscreen mode Exit fullscreen mode

Include this component wherever it is needed to display user information.

Redirect URL

To redirect users to the original URL they were attempting to access before being redirected to the login page, store the URL during the authentication process.

Update the AuthGuard to save the attempted URL:

// services/auth.guard.ts
private secure(route: ActivatedRouteSnapshot | Route): Observable<boolean> {
  return this.authState.stateItem$.pipe(
    map((user) => {
      if (!user) {
        // Save the attempted URL
        this.authService.setRedirectUrl(route.url);
        this._router.navigateByUrl('/login');
        return false;
      }
      return true;
    })
  );
}
Enter fullscreen mode Exit fullscreen mode

Add methods to the AuthService to store and retrieve the redirect URL:

// services/auth.service.ts
private redirectUrl: string;

setRedirectUrl(url: string) {
  this.redirectUrl = url;
}

getRedirectUrl(): string {
  return this.redirectUrl;
}
Enter fullscreen mode Exit fullscreen mode

Modify the login component to redirect the user to the stored URL upon successful login:

// login component
login() {
  this.authService.Login('username', 'password').subscribe({
    next: (result) => {
      const redirectUrl = this.authService.getRedirectUrl();

      if (redirectUrl) {
        this.router.navigateByUrl(redirectUrl);
      } else {
        this.router.navigateByUrl('/private/dashboard');
      }
    },
    // ...
  });
}
Enter fullscreen mode Exit fullscreen mode

This will ensure that users are taken back to the original URL they attempted to access after logging in.

Third-Party Authentication

To integrate third-party authentication providers, such as Google or Facebook, follow these general steps:

  1. Register your application with the third-party provider and obtain the required credentials (client ID and secret).
  2. Implement a "Login with [Provider]" button in your login component that redirects users to the provider's authentication page.
  3. Create an endpoint in your backend to handle the authentication response and exchange the authorization code for an access token.
  4. Save the third-party access token in your database and return a custom access token (JWT) to your SPA.
  5. Adapt your AuthService to handle third-party authentication and store the received JWT.

Server-Side Rendering (SSR)

If your application uses server-side rendering, you'll need to handle the initial authentication state differently. In this case, the access token can be fetched during the SSR process:

  1. In your server-side rendering logic, check for the access token in cookies or local storage.
  2. If an access token is found, include it in the initial state of your application.
  3. In your AuthService, use the APP_INITIALIZER to read the access token from the initial state instead of local storage.

Token Refresh

For better security, your application should implement token refresh logic. When the access token expires, the application should use the refresh token to request a new access token without requiring the user to log in again.

  1. Add a method in the AuthService to request a new access token using the refresh token.
  2. Update the ErrorInterceptor to call the refresh token method when a 401 status is encountered.
  3. Store the new access token and update the authentication state.
  4. Retry the original API call with the new access token.

Roles and Permissions

To further enhance the authorization process, you can implement role-based access control using roles and permissions.

  1. Assign roles and permissions to users during the registration or login process.
  2. Include the user's roles and permissions in the JWT payload.
  3. Create a custom AuthRoleGuard that checks for the required roles and permissions in the JWT before granting access.
  4. Protect routes using the AuthRoleGuard based on the roles and permissions required.

By implementing these additional features, you can create a robust and secure authentication and authorization system for your Single Page Application.

Conclusion

In conclusion, adding a login system to a website makes it safer and easier to use. We talked about many steps like logging in, saving information, and making sure only the right people can see certain things. We also discussed some cool extra features, like using other websites to log in or giving different people different permissions. Following these steps will help create a cool and safe website that everyone enjoys using. Keep learning and improving your website over time!

Top comments (2)

Collapse
 
avwerosuoghene profile image
Avwerosuoghene Darhare-Igben

Exceptional guide on SPA authentication!

Collapse
 
thomasbnt profile image
Thomas Bnt ☕

Agular? :O