Replacing aurelia-authentication

Hi.

I don’t know how many of you are familiar with Duende Software’s IdentityServer and its companion product BFF (back-end for front-end). The latter is a .Net component designed to package the necessary components to secure browser-based frontends with ASP.NET Core backends.

We’ve been using IdentityServer for quite a while now and the Aurelia component we use in conjunction with IdentityServer is aurelia-authentication. Now as most of us has come to realise, storing access tokens in the browser is a bad idea and the aformentioned BFF component
is Duende’s solution to that particular issue. One of the features of the BFF is that it does not allow login/logout being made by issuing Ajax-calls, but rather expects the client to assign the correct url(s) to the window.location.href property. I’ve made a small test application based on Aurelia 1
since that’s the Aurelia version we’re using in our production applications. I’ve managed to login like so

export function login() {
  PLATFORM.location.href = `http://localhost:33444/bff/login?returnUrl=${PLATFORM.location.origin}/`;
}

(similarely for logout) and I manually call the checkAuthStatus() function in a file called auth.js. Not being a full-time front-end developer I find it just a wee bit difficult to replace aurelia-authentication. The aforementioned auth.js is based off of this code from the BFF SplitHost example (samples/BFF/v3/SplitHosts/FrontendHost/wwwroot/session.js at main · DuendeSoftware/samples · GitHub) with the sligth modification that I’ve removed the event listener and also modified the urls. In the production application that’s going to use the BFF instead of accessing our IdentityServer instance directly, we currently have two components for authentication, login.js and logout.js who calls the corresponding authenticate() and logout() functions in aurelia-authentication. In addition we utilise the AuthenticateStep from aurelia-authentication in our app.router.config.js, i.e. config.addPipelineStep('authorize', AuthenticateStep);.

After a long introduction I guess my questions are:

  1. How can we incorporate the code in auth.js (see below) in a way most suited to Auralia? (in the test application there’s just one page handling login, logout and getAuthStatus() (the latter is done in the attached event).
  2. If required, how do we replace aurelia-authentications AuthenticateStep in our route configuration?

I know this is (probably) a lot to ask of the community, but you’ve always stepped up to the challenge in the past, so we’re hoping for it to happen once again :slight_smile: .

TIA

auth.js:

import { PLATFORM } from 'aurelia-framework';
export var isAuthenticated : boolean = false;
export var user = null;

export async function chechAuthStatus() {
  try {
    var request = new Request('http://localhost:33444/bff/user', {
      headers: new Headers({
        'X-CSRF': '1'
      }),
      credentials: "include"
    });
    const res = await fetch(request);
    if (res.ok) {
      isAuthenticated = true;
      user = await res.json();
    } else if (res.status === 401) {
      isAuthenticated = false;
      user = null;
    } else {
      console.error('Error checking auth status:', res.status, res.statusText);
      isAuthenticated = false;
      user = null;
    }
  } catch (error) {
    console.error('Network error checking auth status:', error);
    isAuthenticated = false;
    user = null;    
  }
}

export function login() {
  PLATFORM.location.href = `http://localhost:33444/bff/login?returnUrl=${PLATFORM.location.origin}/`;
}

export function logout() {
  if (user) {
    const logoutUrlClaim = user.find(claim => claim.type === 'bff:logout_url');
    if (logoutUrlClaim) {
      console.log(PLATFORM.location.origin);
      PLATFORM.location.href = "http://localhost:33444" + logoutUrlClaim.value + `&returnUrl=${PLATFORM.location.origin}/`;
      return;
    } else {
      console.error("Security Error: bff:logout_url claim not found in user data.");
    }
  }
  // Fallback ONLY if user claims were somehow not loaded (less secure)
  console.warn("User claims not loaded, attempting simple fallback logout.");
  PLATFORM.location.href = `http://localhost:33444/bff/logout?returnUrl=${PLATFORM.location.origin}`;
}

I’ve implemented authentication myself using singletons in my Aurelia 1 apps. Here is what you would do.

Put every auth-related bit in a singleton so the whole app gets one instance and you can data-bind off it src/auth-service.ts

// src/auth-service.ts
import { singleton, observable, PLATFORM } from 'aurelia-framework';

@singleton()
export class AuthService {
  @observable public isAuthenticated = false;
  public user: any = null;

  private bff = 'http://localhost:33444';          // adjust once you deploy

  async check(): Promise<void> {
    try {
      const res = await fetch(`${this.bff}/bff/user`, {
        credentials: 'include',
        headers: { 'X-CSRF': '1' }
      });

      if (res.ok) {
        this.isAuthenticated = true;
        this.user = await res.json();
      } else if (res.status === 401) {
        this.isAuthenticated = false;
        this.user = null;
      } else {
        console.error('Auth check failed', res.status, res.statusText);
        this.isAuthenticated = false;
        this.user = null;
      }
    } catch (err) {
      console.error('Network error during auth check', err);
      this.isAuthenticated = false;
      this.user = null;
    }
  }

  login(returnUrl: string = PLATFORM.location.origin): void {
    PLATFORM.location.href =
      `${this.bff}/bff/login?returnUrl=${encodeURIComponent(returnUrl)}`;
  }

  logout(returnUrl: string = PLATFORM.location.origin): void {
    if (this.user) {
      const claim = this.user.find(c => c.type === 'bff:logout_url');
      if (claim) {
        PLATFORM.location.href =
          `${this.bff}${claim.value}&returnUrl=${encodeURIComponent(returnUrl)}`;
        return;
      }
    }
    // fallback
    PLATFORM.location.href =
      `${this.bff}/bff/logout?returnUrl=${encodeURIComponent(returnUrl)}`;
  }
}

Wire it up when the app starts

// app.ts (or main.ts)
import { inject } from 'aurelia-framework';
import { AuthService } from './auth-service';

@inject(AuthService)
export class App {
  constructor(private auth: AuthService) {}

  async activate() {
    // cookies might already be there, so get current status
    await this.auth.check();
  }
}

Anything that needs live status (navbar, etc.) can now bind to auth.isAuthenticated or auth.user.

Replace AuthenticateStep with your own

A pipeline step in Aurelia 1 is just a class with a run method. We’ll call the service before every navigation and bail out if the user is not logged in.

// src/bff-authorize-step.ts
import { Redirect } from 'aurelia-router';
import { AuthService } from './auth-service';

export class BffAuthorizeStep {
  static inject = [AuthService];
  constructor(private auth: AuthService) {}

  async run(navigationInstruction, next) {
    // Any route can opt-out with settings: { auth: false }
    const instructions = navigationInstruction.getAllInstructions();
    if (instructions.some(i => i.config.settings?.auth === false)) {
      return next();
    }

    if (!this.auth.isAuthenticated) {
      await this.auth.check();
    }

    if (this.auth.isAuthenticated) {
      return next();
    }

    // send the user to a simple /login view that just calls auth.login()
    return next.cancel(new Redirect('/login'));
  }
}

Then register it:

// app.router.config.ts
import { BffAuthorizeStep } from './bff-authorize-step';

export function configureRouter(config, router) {
  // your routes here ...
  config.addPipelineStep('authorize', BffAuthorizeStep);
}

If most of your HTTP calls go through Aurelia’s HttpClient, centralise the CSRF header and cookie handling so you never forget.

import { HttpClient } from 'aurelia-fetch-client';
import { inject } from 'aurelia-framework';

@inject(HttpClient)
export class Api {
  constructor(private http: HttpClient) {
    http.configure(cfg =>
      cfg.withDefaults({
        credentials: 'include',
        headers: { 'X-CSRF': '1' }
      })
      .withInterceptor({
        response(res) {
          // If session expired mid-app, force a refresh
          if (res.status === 401) window.location.href = '/login';
          return res;
        }
      }));
  }

  // example API call
  async getThings() {
    const res = await this.http.fetch('/api/things');
    return res.json();
  }
}

Quick recap

  • All auth state lives in a singleton service.
  • A custom pipeline step guards routes, calling the service when needed.
  • Login and logout routes are one-liners that just trigger the BFF redirect.
  • Fetch client adds the CSRF header and cookies automatically.

Let me know if none or some of that doesn’t make sense. But that’s what I would do.

1 Like