0.0.3 • Published 1 year ago

@bcgov/citz-imb-kc-react v0.0.3

Weekly downloads
-
License
Apache-2.0
Repository
github
Last release
1 year ago

BCGov SSO Keycloak Integration for React

Lifecycle:Experimental License

NPM NodeJS Typescript React

  1. Install package by following the steps at Installing the Package.
  2. Set up the package by following the steps at Basic Setup Guide.
  3. For use with @bcgov/citz-imb-kc-express.
  4. BY DEFUALT, set to work with a proxy pass to the backend using /api.
  5. To use without a proxy pass, add optional parameter and property of login(), logout(), and KeycloakProvider for backendURL.

Table of Contents

General Information

  • For running on a NodeJS:20 React 18 app.
  • For Keycloak Gold Standard.
  • Works with Vanilla JavaScript or Typescript 5.
  • For use with @bcgov/citz-imb-kc-express
  • BY DEFUALT, set to work with a proxy pass to the backend using /api.
  • To use without a proxy pass, add optional parameter and property of login(), logout(), and KeycloakProvider for backendURL.

Installing the Package

Run npm install @bcgov/citz-imb-kc-react or select a specific version tag from NPM Package.

Return to Top

Basic Setup Guide

Provider

  1. Add import import { KeycloakProvider } from "@bcgov/citz-imb-kc-react"; and surround your application code with KeycloakProvider.

Example:

import { KeycloakProvider } from "@bcgov/citz-imb-kc-react";
import App from "./App";
import React from "react";
import { createRoot } from "react-dom/client";

const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(
  <React.StrictMode>
    <KeycloakProvider idpHint="idir">
        <App />
    </KeycloakProvider>
  </React.StrictMode>
);

!NOTE KeycloakProvider has optional props 'backendURL', 'idpHint', and 'onRefreshExpiry'.

Login & Logout

  1. Adding Login and Logout:

Add import import { useKeycloak } from "@bcgov/citz-imb-kc-react"; then add the following to the top of your functional component:

const {
    isAuthenticated,
    login,
    logout,
  } = useKeycloak();

Conditionally render a Login or Logout button:

{isAuthenticated ? (
  <button onClick={() => logout()}>
    LOGOUT
  </button>
) : (
  <button onClick={() => login({ idpHint: "idir" })}>
    LOGIN WITH IDIR
  </button>
)}

User State & Protected API Calls

  1. Accessing user state and making protected API calls:

To access auth state and functions add import import { useKeycloak } from "@bcgov/citz-imb-kc-react"; then add the following to the top of your functional component:

const {
    user,
    hasRole,
    getAuthorizationHeaderValue,
    isAuthenticated,
  } = useKeycloak();

// Is user logged in:
if (isAuthenticated) console.log(`Hi ${user?.display_name}`);

Check if the user has a role like:

// User must have 'Admin' role.
if (hasRole(['Admin'])) // Do something...

// Users must have BOTH 'Member' and 'Commenter' roles.
// requireAllRoles option is true by default.
if (hasRole(['Member', 'Commenter'])) // Do something...

// Users must have EITHER 'Member' or 'Verified' role.
if (hasRole(['Member', 'Verified'], { requireAllRoles: false })) // Do Something...

Complete an authenticated request like in the example below:

Both functions come from useKeycloak.

// NEW way:
// Using fetchProtectedRoute, which is a wrapper for Node Fetch API.
const callTest = async () => {
  const response = await fetchProtectedRoute("/api/test", { 
    method: "GET" 
  });
  return await response.json();
};

// OLD way:
// Using getAuthorizationHeaderValue function.
const callTest = async () => {
    const response = await fetch("/api/test", {
      method: "GET",
      headers: {
        Authorization: getAuthorizationHeaderValue(),
      },
    });
    return await response.json();
  };

For all user properties reference SSO Keycloak Wiki - Identity Provider Attribute Mapping.
Example IDIR user object (Typescript Type is KeycloakUser & KeycloakIdirUser):

{
  "idir_user_guid": "W7802F34D2390EFA9E7JK15923770279",
  "identity_provider": "idir",
  "idir_username": "JOHNDOE",
  "name": "Doe, John CITZ:EX",
  "preferred_username": "a7254c34i2755fea9e7ed15918356158@idir",
  "given_name": "John",
  "display_name": "Doe, John CITZ:EX",
  "family_name": "Doe",
  "email": "john.doe@gov.bc.ca",
  "client_roles": ["Admin"]
}

!Note* 'client_roles' is the only property in this list that can be undefined. All other properties if empty will be an empty string. When checking if a user has a role, it is advised to use the hasRole() function from useKeycloak().

Proxy Pass

  1. Setting up proxy pass:

For use with vite, the following setup as a property of server inside your vite config will work:

proxy: {
  "/api": {
    target: "http://<backend-service-name>:<backend-port>/",
    changeOrigin: true,
    rewrite: (path) => path.replace(/^\/api/, ""),
  },
},

Be sure to replace <backend-service-name> and <backend-port>. For docker containers, this is the service name from the compose file.

For use in Nginx configs, the following setup as a property of server will work:

location /api/ {
  proxy_pass http://<backend-service-name>:<backend-port>/;
  proxy_set_header Host $host;
}

Again, be sure to replace <backend-service-name> and <backend-port>.

Not Using A Proxy Pass

  1. If you are not using a proxy pass, you will need to set backendUrl and idpHint props on KeycloakProvider component and login function.

Even if you are using a proxy, idpHint is always recommended.

// Example usage:
<KeycloakProvider backendURL="http://localhost:3000" idpHint="idir">
</KeycloakProvider>

// Example usage: 
login({ backendURL: "http://localhost:3000", idpHint: "idir" });

Return to Top

Additional Setup Guide

Custom Refresh Expiry Function

  1. Setting a custom function for when the refresh token expires:

BY DEFUALT, when a refresh token expires, the user will be prompted to re-login by the RefreshExpiryDialog. This can be swapped out for a custom solution by adding a onRefreshExpiry prop to KeycloakProvider.

Example:

import { KeycloakProvider } from "@bcgov/citz-imb-kc-react";
import App from "./App";
import React from "react";
import { createRoot } from "react-dom/client";

const customTokenExpiry = () => {
  // Do something such as prompt user to login again.
};

const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(
  <React.StrictMode>
    <KeycloakProvider onRefreshExpiry={() => customTokenExpiry()}>
        <App />
    </KeycloakProvider>
  </React.StrictMode>
);

Here's how the built in RefreshExpiryDialog works:

Within KeycloakProvider is the following functionality:

// State to track if the dialog should be visible.
const [isExpiryDialogVisible, setIsExpiryDialogVisible] = useState(false);

// Set State to be called onRefreshExpiry to make the dialog visible.
setIsExpiryDialogVisible(true)

// The dialog component.
<RefreshExpiryDialog
  loginProps={{ backendURL, idpHint }}
  isVisible={isExpiryDialogVisible}
/>

Here is the RefreshExpiryDialog component:

const RefreshExpiryDialog = (props: RefreshExpiryDialogProps) => {
  const { isVisible, loginProps } = props;
  const { login } = useKeycloak();

  if (!isVisible) return null;

  return (
    <>
      <div className="kcr_dialog-overlay" />
      <dialog className="kcr_dialog" open={isVisible}>
        <div className="kcr_dialog-content">
          <p className="kcr_dialog-message">Your login session has expired.</p>
          <button className="kcr_button" onClick={() => login(loginProps)}>
            RE-LOGIN
          </button>
        </div>
      </dialog>
    </>
  );
};

Here is the RefreshExpiryDialog css classes:

.kcr_dialog-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.5); /* Translucent grey */
  z-index: 999; /* Ensure it's below the dialog and above other elements */
}

.kcr_dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border: 1px solid #484848;
  border-radius: 5px;
  padding: 40px;
  background-color: #fff;
  box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.kcr_dialog-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.kcr_dialog-message {
  font-size: 1.25em;
  font-weight: 700;
  color: #656565;
  font-family: 'Trebuchet MS', Arial, sans-serif;
  text-align: center;
  margin-bottom: 20px;
}

.kcr_button {
  background-color: #234075;
  color: white;
  border: none;
  padding: 10px 20px;
  border: 1px solid #484848;
  border-radius: 5px;
  font-size: 1em;
  font-family: 'Trebuchet MS', Arial, sans-serif;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.kcr_button:hover {
  background-color: #1b325c;
}

Return to Top

Directory Structure

.
├── .github/
|   ├── config/
|   |   └── dep-report.json5                # Configure options for NPM Dep Report.
|   ├── helpers/
|   |   ├── github-api/                     # Functions to access the GitHub API.
|   |   ├── create-npm-dep-report-issues.js # Creates GitHub Issues for Npm Dep Reports.
|   |   ├── create-npm-dep-report.js        # Creates text bodies for Npm Dep Reports.
|   |   ├── parse-json5-config.js           # Parses json5 files for GitHub actions output.
|   |   └── parse-npm-deps.js               # Parses package.json files for changes to package versions.
|   ├── workflows/
|   |   ├── npm-dep-report.yaml             # Reports on new package versions.
|   |   └── releases.yaml                   # Creates a new GitHub Release.
├── .husky/
|   └── post-commit                         # Script that runs after a git commit.
├── scripts/
|   ├── bump-version.mjs                    # Bumps version in package.json file.
|   ├── post-commit-version-change.mjs      # Bumps version when post-commit is run.
|   ├── remove-css-imports.js               # Removes css imports from TypeScript declaration files from the build.
|   ├── remove-dts-files.js                 # Removes TypeScript declaration files from the build.
|   └── remove-empty-dirs.js                # Removes empty directories from the build.
├── src/                                    # Source code for package.
|   ├── components/
|   |   ├── Provider.tsx                    # Provides auth state to an application.
|   |   ├── RefreshExpiryDialog.tsx         # Default dialog to come up when token expires.
|   |   └── Wrapper.tsx                     # Provides auth services such as refresh token tracking.
|   ├── state/
|   |   ├── reducer.ts                      # Manages auth state from context.
|   |   └── useKeycloak.ts                  # Functions using auth state.
|   ├── context.ts                          # React Context for storing auth data.
|   ├── index.ts                            # Export functions for the package.
|   ├── types.ts                            # TypeScript types.
|   └── utils.ts                            # Utility functions.
├── package.json                            # Package config and dependencies.
├── .npmrc                                  # NPM config.
├── rollup.config.js                        # Builds and compiles TypeScript files into JavaScript.
├── rollupdts.config.js                     # Builds and compiles TypeScript declartion files.

Return to Top

Scripts

# Compile all src code into a bundle in build/ directory.
$ npm run build
# Part of 'build' and it bundles the typescipt declarations into a single bundle.d.ts file.
$ npm run build:dts
# Part of build and it removes directories and files before the build.
$ npm run clean:prebuild
# Part of build and it removes directories and files after the build.
$ npm run clean:postbuild
# Used to package the code before a release.
$ npm run pack

Return to Top

Module Exports

These are the functions and types exported by the @bcgov/citz-imb-kc-react module.

import {
  KeycloakProvider, // Manages the keycloak service in your react app.
  useKeycloak, // Hook used for authentication and authorization state and functions.
} from '@bcgov/citz-imb-kc-react';

// TypeScript Types:
import {
  KeycloakProviderProps, // Props for KeycloakProvider component.
  LoginProps, // Props for login function of useKeycloak().
  IdirIdentityProvider, // Used for more efficient login.
  GithubIdentityProvider, // Used for more efficient login.
  BceidIdentityProvider, // Used for more efficient login.
  IdentityProvider, // Combined type for identity providers.
  HasRoleOptions, // Optional options parameter for hasRole function of useKeycloak().
  AuthService, // Type for useKeycloak().
  AuthState, // Type for state of useKeycloak().
  KeycloakUser, // Base user type.
  KeycloakIdirUser, // User types specific to Idir users.
  KeycloakBCeIDUser, // User types specific to BCeID users.
  KeycloakGithubUser, // User types specific to Github users.
} from '@bcgov/citz-imb-kc-react';

Return to Top

TypeScript Types

These are the TypeScript types of the @bcgov/citz-imb-kc-react module.

const reducer = (state: AuthState, action: AuthAction): AuthState;
const useKeycloak = (): AuthService;
const KeycloakProvider = (props: KeycloakProviderProps): React.JSX.Element;

// Defines the possible types of authentication actions.
export enum AuthActionType {
  LOGOUT = 'LOGOUT',
  ATTEMPT_LOGIN = 'ATTEMPT_LOGIN',
  REFRESH_TOKEN = 'REFRESH_TOKEN',
  UNAUTHORIZED = 'UNAUTHORIZED',
}

// Initial authentication state.
export const initialState: AuthState = {
  isLoggingIn: false,
  isAuthenticated: false,
  accessToken: undefined,
  idToken: undefined,
  userInfo: undefined,
};

// PROPS
export type KeycloakProviderProps = {
  backendURL?: string;
  idpHint?: IdentityProvider;
  children: ReactNode;
  onRefreshExpiry?: Function;
};
export type KeycloakWrapperProps = {
  backendURL?: string;
  children: ReactNode;
  onRefreshExpiry?: Function;
};
export type LoginProps = {
  backendURL?: string;
  idpHint?: IdentityProvider;
};
export type RefreshExpiryDialogProps = {
  isVisible: boolean;
  loginProps?: LoginProps;
};

export type IdirIdentityProvider = "idir";
export type GithubIdentityProvider = "githubbcgov" | "githubpublic";
export type BceidIdentityProvider =
  | "bceidbasic"
  | "bceidbusiness"
  | "bceidboth";
export type IdentityProvider =
  | IdirIdentityProvider
  | BceidIdentityProvider
  | GithubIdentityProvider;

export type HasRoleOptions = {
  requireAllRoles?: boolean;
};

export type AuthService = {
  state: AuthState;
  isAuthenticated: boolean;
  isLoggingIn: boolean;
  user?: KeycloakUser;
  getAuthorizationHeaderValue: () => string;
  fetchProtectedRoute: (url: string, options?: any) => Promise<Response>;
  hasRole: (roles: string[], options?: HasRoleOptions) => boolean;
  refreshToken: (backendURL?: string) => Promise<void>;
  login: (options?: LoginProps) => void;
  logout: (backendURL?: string) => void;
};

export type AuthAction = {
  type: AuthActionType;
  payload?: {
    accessToken?: string;
    idToken?: string;
    userInfo?: KeycloakUser;
  };
};

export type AuthState = {
  isLoggedIn: boolean;
  isAuthenticated: boolean;
  accessToken?: string;
  idToken?: string;
  userInfo?: KeycloakUser;
};

export type AuthStateWithDispatch = {
  state: AuthState;
  dispatch: Dispatch<AuthAction>;
};

export type BaseKeycloakUser = {
  name?: string;
  preferred_username: string;
  email: string;
  display_name: string;
  client_roles?: string[];
  scope?: string;
  identity_provider:
    | IdirIdentityProvider
    | BceidIdentityProvider
    | GithubIdentityProvider;
};

export type KeycloakIdirUser = {
  idir_user_guid?: string;
  idir_username?: string;
  given_name?: string;
  family_name?: string;
};

export type KeycloakBCeIDUser = {
  bceid_user_guid?: string;
  bceid_username?: string;
  bceid_business_name?: string;
};

export type KeycloakGithubUser = {
  github_id?: string;
  github_username?: string;
  orgs?: string;
  given_name?: string;
  family_name?: string;
  first_name?: string;
  last_name?: string;
};

export type KeycloakUser = BaseKeycloakUser &
  KeycloakIdirUser &
  KeycloakBCeIDUser &
  KeycloakGithubUser;

Return to Top

Authentication Flow

Return to Top

Applications using Keycloak Solution

The following applications are currently using this keycloak implementation solution:

SET - Salary Estimation Tool PLAY - CITZ IMB Package Testing App

Return to Top

0.0.3

1 year ago

0.0.2

1 year ago

0.0.1

1 year ago