File

src/app/core/session/auth/keycloak/keycloak-auth.service.ts

Description

Handles the remote session with keycloak

Index

Properties
Methods

Methods

addAuthHeader
addAuthHeader(headers: any)

Add the Bearer auth header to a existing header object.

Parameters :
Name Type Optional
headers any No
Returns : void
changePassword
changePassword()

Open password reset page in browser. Only works with internet connection.

Returns : Promise<any>
Async getUserinfo
getUserinfo()
Async logout
logout()

Forward to the keycloak logout endpoint to clear the session.

Returns : unknown
logSuccessfulAuth
logSuccessfulAuth()

Log timestamp of last successful authentication

Returns : void
resetKeycloakInit
resetKeycloakInit()

Clear the memoised Keycloak init so the next call re-runs initKeycloak. Required after logout (Keycloak SPA state is otherwise stuck on the previous session) and during recovery from a stuck init.

Returns : void

Properties

Optional accessToken
Type : string
checkSession
Type : unknown
Default value : reuseFirstAsync(async (): Promise<SessionInfo | null> => { await this.initKeycloak(); try { await firstValueFrom( defer(() => this.keycloak.updateToken()).pipe( timeout({ each: KEYCLOAK_OPERATION_TIMEOUT_MS }), ), ); } catch (err) { if (this.isOfflineOrUnavailableError(err)) { Logging.debug("Keycloak updateToken failed (offline/unavailable)", err); throw new RemoteLoginNotAvailableError(err); } throw err; } const token = await this.keycloak.getToken(); if (!token) { return null; } return this.processToken(token); })

Check for an existing SSO session without redirecting to Keycloak. Returns the session info if already authenticated, or null if not.

Intentionally does NOT retry on transient errors: this runs silently on page load and the user is already waiting on the spinner. Failing fast lets the login form render quickly; a retry happens implicitly when the user clicks "Log in".

Static Readonly LAST_AUTH_KEY
Type : string
Default value : "LAST_REMOTE_LOGIN"
login
Type : unknown
Default value : reuseFirstAsync( async (): Promise<SessionInfo> => firstValueFrom( defer(() => this.loginOnce()).pipe( retry({ count: LOGIN_RETRY_DELAYS_MS.length, delay: (err, retryCount) => { const retryable = isRetryableNetworkError(err) || (err instanceof RemoteLoginNotAvailableError && isRetryableNetworkError(err.cause)); if (!retryable) return throwError(() => err); const delayMs = LOGIN_RETRY_DELAYS_MS[retryCount - 1] ?? LOGIN_RETRY_DELAYS_MS[LOGIN_RETRY_DELAYS_MS.length - 1]; Logging.debug( `Keycloak login attempt ${retryCount} failed; retrying in ${delayMs}ms`, err, ); return timer(delayMs); }, resetOnSuccess: true, }), ), ), )

Check for an existing session or forward to the keycloak login page. Retries on transient network errors (5xx / timeout / abort) since this is invoked by an explicit user action and a single transient failure shouldn't push the user back to the offline fallback.

import { inject, Injectable } from "@angular/core";
import { memoize } from "lodash-es";
import { SessionInfo } from "../session-info";
import { KeycloakEventTypeLegacy, KeycloakService } from "keycloak-angular";
import { Logging } from "../../../logging/logging.service";
import { Entity } from "../../../entity/model/entity";
import { ParsedJWT, parseJwt } from "../../session-utils";
import { RemoteLoginNotAvailableError } from "./remote-login-not-available.error";
import { KeycloakUserDto } from "../../../user/user-admin-service/keycloak-user-dto";
import { ActivatedRoute } from "@angular/router";
import { ThirdPartyAuthenticationService } from "../../../../features/third-party-authentication/third-party-authentication.service";
import { reuseFirstAsync } from "#src/app/utils/reuse-first-async";
import { isConnectivityError } from "#src/app/utils/connectivity-error";
import {
  defer,
  firstValueFrom,
  Subscription,
  throwError,
  timer,
  TimeoutError,
} from "rxjs";
import { retry, timeout } from "rxjs/operators";

/**
 * Hard upper bound on individual Keycloak network operations
 * (init, token refresh). Picked well above expected RTT but short enough
 * that a hung proxy/upstream surfaces a clear failure to the user.
 */
const KEYCLOAK_OPERATION_TIMEOUT_MS = 15_000;

/** Backoff schedule for explicit user-initiated login retries. */
const LOGIN_RETRY_DELAYS_MS = [1_000, 3_000];

/**
 * Backoff schedule for *background* token refreshes (OnTokenExpired).
 * Kept short — if the background refresh keeps failing, the next API
 * call's 401 will trigger an explicit login, so there is no point
 * retrying for many seconds and stalling the user's request.
 */
const TOKEN_REFRESH_RETRY_DELAYS_MS = [2_000, 5_000];

/**
 * Minimum remaining lifetime (in seconds) requested when refreshing the
 * token in the background. Matches keycloak-js's `updateToken(minValidity)`
 * argument.
 */
const TOKEN_MIN_VALIDITY_SECONDS = 30;

/**
 * Check whether an error is a retryable network/connectivity error,
 * including keycloak-specific transient failures.
 */
function isRetryableNetworkError(err: any): boolean {
  if (isConnectivityError(err)) return true;
  if (err instanceof TimeoutError) return true;
  const message = `${err?.message ?? ""} ${err?.toString?.() ?? ""}`;
  return message.includes(
    "Timeout when waiting for 3rd party check iframe message.",
  );
}

/**
 * Handles the remote session with keycloak
 */
@Injectable()
export class KeycloakAuthService {
  static readonly LAST_AUTH_KEY = "LAST_REMOTE_LOGIN";
  accessToken?: string;

  private keycloak = inject(KeycloakService);
  private activatedRoute = inject(ActivatedRoute);
  private thirdPartyAuthService = inject(ThirdPartyAuthenticationService);

  private keycloakEventsSub?: Subscription;

  /**
   * Check for an existing SSO session without redirecting to Keycloak.
   * Returns the session info if already authenticated, or null if not.
   *
   * Intentionally does NOT retry on transient errors: this runs silently on
   * page load and the user is already waiting on the spinner. Failing fast
   * lets the login form render quickly; a retry happens implicitly when the
   * user clicks "Log in".
   */
  checkSession = reuseFirstAsync(async (): Promise<SessionInfo | null> => {
    await this.initKeycloak();

    try {
      await firstValueFrom(
        defer(() => this.keycloak.updateToken()).pipe(
          timeout({ each: KEYCLOAK_OPERATION_TIMEOUT_MS }),
        ),
      );
    } catch (err) {
      if (this.isOfflineOrUnavailableError(err)) {
        Logging.debug("Keycloak updateToken failed (offline/unavailable)", err);
        throw new RemoteLoginNotAvailableError(err);
      }
      throw err;
    }
    const token = await this.keycloak.getToken();
    if (!token) {
      return null;
    }

    return this.processToken(token);
  });

  /**
   * Check for an existing session or forward to the keycloak login page.
   * Retries on transient network errors (5xx / timeout / abort) since this
   * is invoked by an explicit user action and a single transient failure
   * shouldn't push the user back to the offline fallback.
   */
  login = reuseFirstAsync(
    async (): Promise<SessionInfo> =>
      firstValueFrom(
        defer(() => this.loginOnce()).pipe(
          retry({
            count: LOGIN_RETRY_DELAYS_MS.length,
            delay: (err, retryCount) => {
              const retryable =
                isRetryableNetworkError(err) ||
                (err instanceof RemoteLoginNotAvailableError &&
                  isRetryableNetworkError(err.cause));
              if (!retryable) return throwError(() => err);
              const delayMs =
                LOGIN_RETRY_DELAYS_MS[retryCount - 1] ??
                LOGIN_RETRY_DELAYS_MS[LOGIN_RETRY_DELAYS_MS.length - 1];
              Logging.debug(
                `Keycloak login attempt ${retryCount} failed; retrying in ${delayMs}ms`,
                err,
              );
              return timer(delayMs);
            },
            resetOnSuccess: true,
          }),
        ),
      ),
  );

  private async loginOnce(): Promise<SessionInfo> {
    const existing = await this.checkSession();
    if (existing) {
      return existing;
    }

    // Forward to the keycloak login page.
    await this.keycloak.login({
      redirectUri: location.href,
      ...this.thirdPartyAuthService.initSessionParams(this.activatedRoute),
    });
    const token = await this.keycloak.getToken();

    return this.processToken(token);
  }

  private initKeycloak = memoize(async () => {
    try {
      await firstValueFrom(
        defer(() =>
          this.keycloak.init({
            config: window.location.origin + "/assets/keycloak.json",
            initOptions: {
              onLoad: "check-sso",
              silentCheckSsoRedirectUri:
                window.location.origin + "/assets/silent-check-sso.html",
            },
            // GitHub API rejects if non GitHub bearer token is present
            shouldAddToken: ({ url }) => !url.includes("api.github.com"),
          }),
        ).pipe(timeout({ each: KEYCLOAK_OPERATION_TIMEOUT_MS })),
      );
    } catch (err) {
      if (this.isOfflineOrUnavailableError(err)) {
        Logging.debug("Keycloak init failed (offline/unavailable)", err);
        err = new RemoteLoginNotAvailableError(err);
      } else {
        Logging.error("Keycloak init failed", err);
      }

      this.initKeycloak.cache.clear();
      throw err;
    }

    // Silently refresh expiring tokens in the background. We deliberately
    // do NOT fall back to a full keycloak.login() redirect here: that would
    // yank the user out of whatever they were doing the moment a transient
    // 504 from the proxy disrupted a refresh. If the silent refresh keeps
    // failing, the next authenticated request will hit a 401 and the
    // existing 401-retry path will then explicitly call login().
    this.keycloakEventsSub?.unsubscribe();
    this.keycloakEventsSub = this.keycloak.keycloakEvents$.subscribe(
      (event) => {
        if (event.type == KeycloakEventTypeLegacy.OnTokenExpired) {
          this.refreshKeycloakToken()
            .then(() => this.cacheCurrentToken())
            .catch((err) =>
              Logging.debug(
                "automatic token refresh failed; will re-auth on next 401",
                err,
              ),
            );
        }
      },
    );
  });

  /**
   * Silently refresh the Keycloak access token if it expires within
   * {@link TOKEN_MIN_VALIDITY_SECONDS}. Bounded by the same per-operation
   * timeout as init/login and retried on transient network errors so a
   * single 504 from the proxy doesn't disrupt an active session.
   */
  private refreshKeycloakToken(): Promise<boolean> {
    return firstValueFrom(
      defer(() => this.keycloak.updateToken(TOKEN_MIN_VALIDITY_SECONDS)).pipe(
        timeout({ each: KEYCLOAK_OPERATION_TIMEOUT_MS }),
        retry({
          count: TOKEN_REFRESH_RETRY_DELAYS_MS.length,
          delay: (err, retryCount) => {
            if (!isRetryableNetworkError(err)) return throwError(() => err);
            const delayMs =
              TOKEN_REFRESH_RETRY_DELAYS_MS[retryCount - 1] ??
              TOKEN_REFRESH_RETRY_DELAYS_MS[
                TOKEN_REFRESH_RETRY_DELAYS_MS.length - 1
              ];
            Logging.debug(
              `Token refresh attempt ${retryCount} failed; retrying in ${delayMs}ms`,
              err,
            );
            return timer(delayMs);
          },
          resetOnSuccess: true,
        }),
      ),
    );
  }

  private async cacheCurrentToken(): Promise<void> {
    const token = await this.keycloak.getToken();
    if (token) {
      this.accessToken = token;
    }
  }

  private isOfflineOrUnavailableError(err: any): boolean {
    // All retryable network errors (5xx / timeout / abort / fetch failures)
    // also count as "unavailable" so they map to RemoteLoginNotAvailableError
    // instead of spamming Sentry.
    if (isRetryableNetworkError(err)) {
      return true;
    }
    return !navigator.onLine;
  }

  private processToken(token: string): SessionInfo {
    if (!token) {
      throw new Error("No token received from Keycloak");
    }

    this.accessToken = token;
    this.logSuccessfulAuth();
    const parsedToken: ParsedJWT = parseJwt(this.accessToken);

    const sessionInfo: SessionInfo = {
      name: parsedToken.username ?? parsedToken.sub,
      id: parsedToken.sub,

      // TODO: access from resource_access.app.roles and also resource_access.realm-management.roles === manage-users ?
      roles: parsedToken["_couchdb.roles"],
      email: parsedToken.email,
    };

    if (parsedToken.username) {
      sessionInfo.entityId = parsedToken.username.includes(":")
        ? parsedToken.username
        : // fallback for legacy config: manually add "User" entity prefix
          Entity.createPrefixedId("User", parsedToken.username);
    } else {
      Logging.debug(
        `User not linked with an entity (userId: ${sessionInfo.id} | ${sessionInfo.name})`,
      );
    }

    if (parsedToken.email) {
      sessionInfo.email = parsedToken.email;
    }

    return sessionInfo;
  }

  /**
   * Add the Bearer auth header to a existing header object.
   * @param headers
   */
  addAuthHeader(headers: any) {
    if (this.accessToken) {
      if (headers.set && typeof headers.set === "function") {
        // PouchDB headers are set as a map
        headers.set("Authorization", "Bearer " + this.accessToken);
      } else {
        // Interceptor headers are set as a simple object
        headers["Authorization"] = "Bearer " + this.accessToken;
      }
    }
  }

  /**
   * Clear the memoised Keycloak init so the next call re-runs initKeycloak.
   * Required after logout (Keycloak SPA state is otherwise stuck on the
   * previous session) and during recovery from a stuck init.
   */
  resetKeycloakInit(): void {
    this.initKeycloak.cache.clear();
    this.keycloakEventsSub?.unsubscribe();
    this.keycloakEventsSub = undefined;
  }

  /**
   * Forward to the keycloak logout endpoint to clear the session.
   */
  async logout() {
    this.resetKeycloakInit();
    this.accessToken = undefined;
    return await this.keycloak.logout(location.href);
  }

  /**
   * Open password reset page in browser.
   * Only works with internet connection.
   */
  changePassword(): Promise<any> {
    return this.keycloak.login({
      action: "UPDATE_PASSWORD",
      redirectUri: location.href,
    });
  }

  async getUserinfo(): Promise<KeycloakUserDto> {
    const user = await this.keycloak.getKeycloakInstance().loadUserInfo();
    return user as KeycloakUserDto;
  }

  /**
   * Log timestamp of last successful authentication
   */
  logSuccessfulAuth() {
    localStorage.setItem(
      KeycloakAuthService.LAST_AUTH_KEY,
      new Date().toISOString(),
    );
  }
}

results matching ""

    No results matching ""