0.2.5 • Published 5 months ago

@postinumero/react-router-oidc-client v0.2.5

Weekly downloads
-
License
ISC
Repository
github
Last release
5 months ago

@postinumero/react-router-oidc-client

A React Router integration for OpenID Connect (OIDC) authentication, built on top of oidc-client-ts.

Provides hooks and utilities for managing authentication in a React Router application, including protected routes, user authentication state management, and automatic login redirects.

  • 🔗 Keycloak support with role-based access controls
  • 📌 See the example for a complete implementation

Setup

1. Configure Authentication in root.tsx

import {
  loadOIDCRoot,
  useRemoveLogoutIntentSearchParam,
  useRevalidateUser,
} from "@postinumero/react-router-oidc-client";

// Load authentication state for the client
export const clientLoader = async (args: Route.ClientLoaderArgs) => {
  return {
    ...(await loadOIDCRoot(args)),
  };
};

// App Layout with authentication revalidation
export function Layout({ children }: PropsWithChildren) {
  useRevalidateUser(); // Ensure user state is kept up to date

  return <>{children}</>;
}

// Root component
export default function App() {
  useRemoveLogoutIntentSearchParam(); // Clean up logout parameters from URL

  return <>{/* App content here */}</>;
}

2. Define Authentication Routes in routes.ts

Add authentication-related routes from the package.

import auth from "@postinumero/react-router-oidc-client/routes";

export default [
  // Other application routes
  ...auth, // Includes login & logout handlers
];

3. Handle Unauthorized Access with an Error Boundary

To handle 401 Unauthorized route errors, wrap root.tsx's ErrorBoundary with withHandleAuthErrorBoundary.

import {
  LoginForm,
  LoginRedirect,
  withHandleAuthErrorBoundary,
} from "@postinumero/react-router-oidc-client";
import { isRouteErrorResponse } from "react-router";

export const ErrorBoundary = withHandleAuthErrorBoundary(
  function UnauthorizedErrorBoundary(props) {
    // Redirect directly to external login flow with optional IDP hint:
    return (
      <LoginRedirect
        intent="redirect"
        {...{ "extraQueryParams.kc_idp_hint": "suomi-fi" }}
      />
    );

    // OR render a login form:
    return (
      <LoginForm>
        <input type="text" name="username" placeholder="Username" required />
        <input
          type="password"
          name="password"
          placeholder="Password"
          required
        />
        {isRouteErrorResponse(props.error) && props.error.data}
        <button>Login</button>
      </LoginForm>
    );

    // OR render a login link with optional IDP hint for external authentication:
    return (
      <LoginLink
        data={{
          intent: "redirect",
          "extraQueryParams.kc_idp_hint": "suomi-fi",
        }}
      >
        Login with Suomi.fi
      </LoginLink>
    );
  },
  function ErrorBoundary() {
    // Handle other errors
  },
);

Keycloak Integration

In root.tsx:

import {
  initKeycloak,
  loadOIDCRoot,
} from "@postinumero/react-router-oidc-client/keycloak";

initKeycloak({
  url: "https://example.com",
  realm: "example",
  client_id: "example-client",
});

export const clientLoader = async (args: Route.ClientLoaderArgs) => {
  return {
    ...(await loadOIDCRoot(args)),
  };
};

Server Side Rendering

1. Add server and client middlewares, add loader, add clientLoader.hydrate

In root.tsx:

import {
  loadOIDCRoot,
  oidc_ssr_clientMiddleware,
  oidc_ssr_middleware,
} from "@postinumero/react-router-oidc-client";

export const unstable_middleware = [oidc_ssr_middleware];
export const unstable_clientMiddleware = [oidc_ssr_clientMiddleware];

export const loader = async (args: Route.LoaderArgs) => {
  return {
    ...(await loadOIDCRoot(args)),
  };
};

export const clientLoader = async (args: Route.ClientLoaderArgs) => {
  return {
    ...(await loadOIDCRoot(args)),
  };
};

// For redirect login flow
clientLoader.hydrate = true;

2. Protect routes in loaders and actions

import { authenticated } from "@postinumero/react-router-oidc-client";

export const loader = async (args: Route.LoaderArgs) => {
  await authenticated(args);

  return null;
};

API Reference

Authentication Hooks & Utilities

getUser

Retrieve the current authenticated user.

import { getUser } from "@postinumero/react-router-oidc-client";

export const clientLoader = async () => {
  const user = await getUser();
  console.log(user?.access_token);
};

useUser

Retrieve the current authenticated user.

import { useUser } from "@postinumero/react-router-oidc-client";

function Component() {
  const user = useUser();
  // ...
}

<LoginForm>

import { LoginForm } from "@postinumero/react-router-oidc-client";

function Component() {
  return (
    <LoginForm>
      <input type="text" name="username" placeholder="Username" required />
      <input type="password" name="password" placeholder="Password" required />
      <button>Login</button>
    </LoginForm>
  );
}

Create a login form to use redirect flow with an optional IDP hint:

<LoginForm>
  <input type="hidden" name="extraQueryParams.kc_idp_hint" value="suomi-fi" />
  <button name="intent" value="redirect">
    Login with Suomi.fi
  </button>
</LoginForm>

<LoginLink>

Create a login link with optional IDP hint.

import { LoginLink } from "@postinumero/react-router-oidc-client";

function Component() {
  return (
    <LoginLink
      data={{ intent: "redirect", "extraQueryParams.kc_idp_hint": "suomi-fi" }}
    >
      Login with Suomi.fi
    </LoginLink>
  );
}

<LoginRedirect>

Redirect directly to the external login flow with an optional IDP hint.

import { LoginRedirect } from "@postinumero/react-router-oidc-client";

function Component() {
  return (
    <LoginRedirect
      intent="redirect"
      {...{ "extraQueryParams.kc_idp_hint": "suomi-fi" }}
    />
  );
}

<LogoutForm>

Handle user logout.

import { LogoutForm } from "@postinumero/react-router-oidc-client";

function Component() {
  return (
    <LogoutForm>
      <button>Logout</button>
    </LogoutForm>
  );
}

Perform silent logout, stay on current page even if current route is protected:

<LogoutForm redirect={location.href}>
  <button>Logout</button>
</LogoutForm>

Perform redirect logout, redirect to "/" if current route is protected:

<LogoutForm>
  <button name="intent" value="redirect">
    Logout
  </button>
</LogoutForm>

Perform redirect logout, stay on current page even if current route is protected:

<LogoutForm redirect={location.href}>
  <button name="intent" value="redirect">
    Logout
  </button>
</LogoutForm>

useUserEvent

Subscribe to UserManager events.

import { useUserEvent } from "@postinumero/react-router-oidc-client";

useUserEvent("unloaded", () => {
  console.log("You have been signed out.");
});

useLoginError

Access the raw login error inside an error boundary (e.g., after a failed login redirect).

import { useLoginError } from "@postinumero/react-router-oidc-client";

const loginError = useLoginError();

console.log(loginError?.error_description); // => "Invalid user credentials"

useLoginErrorMessage

Access a user-friendly, translated error message. Requires react-intl.

import { useLoginErrorMessage } from "@postinumero/react-router-oidc-client";

const loginErrorMessage = useLoginErrorMessage();

console.log(loginErrorMessage); // => "Invalid username or password"

<IsAuthenticated>

Conditionally render content based on authentication state.

import { IsAuthenticated } from "@postinumero/react-router-oidc-client";

function Component() {
  return (
    <IsAuthenticated fallback="Please log in">Welcome back!</IsAuthenticated>
  );
}

useIsAuthenticated

Check if a user is authenticated.

import { useIsAuthenticated } from "@postinumero/react-router-oidc-client";

function Component() {
  const isAuthenticated = useIsAuthenticated();
  // ...
}

Protected Route

Ensure a route is only accessible when authenticated.

import { authenticated } from "@postinumero/react-router-oidc-client";

export const clientLoader = async (args: Route.ClientLoaderArgs) => {
  const user = await authenticated(args);
  // ...
};

Keycloak-Specific API

getKeycloakUser

Retrieve the current authenticated Keycloak user.

import { getKeycloakUser } from "@postinumero/react-router-oidc-client/keycloak";

export const clientLoader = async () => {
  const user = await getKeycloakUser();
  // ...
};

useKeycloakUser

Retrieve the current authenticated Keycloak user.

import { useKeycloakUser } from "@postinumero/react-router-oidc-client/keycloak";

function Component() {
  const user = useKeycloakUser();
  // ...
}

<HasRole>, <HasRealmRole>, <HasResourceRole>

Render UI conditionally based on user roles.

import {
  HasRole,
  HasRealmRole,
  HasResourceRole,
} from "@postinumero/react-router-oidc-client/keycloak";

function Component() {
  return (
    <>
      <HasRole foo fallback={'User does not have role "foo"'} />
      <HasRole user viewer editor>
        User has roles "user", "editor", & "viewer"
      </HasRole>
      <HasRealmRole user viewer>
        User has realm roles "user" & "viewer"
      </HasRealmRole>
      <HasResourceRole example-client={["user", "editor"]}>
        User has "example-client" resource roles "user" & "editor"
      </HasResourceRole>
    </>
  );
}

useHasRole, useHasRealmRole, useHasResourceRole

import {
  useHasRole,
  useHasRealmRole,
  useHasResourceRole,
} from "@postinumero/react-router-oidc-client/keycloak";

export default function Component() {
  const hasFoo = useHasRole({ foo: true });
  const canView = useHasRealmRole({ user: true, viewer: true });
  const canEdit = useHasResourceRole({
    "example-client": ["user", "editor"],
  });
}

Keycloak Protected Route

Require specific roles for a protected route.

import { authenticated } from "@postinumero/react-router-oidc-client/keycloak";

export const clientLoader = async (args: Route.ClientLoaderArgs) => {
  const user = await authenticated(args, {
    realmRoles: ["viewer"],
    resourceRoles: { "example-client": ["user", "editor"] },
  });
  // ...
};
0.2.5

5 months ago

0.2.4

5 months ago

0.2.3

5 months ago

0.2.2

6 months ago

0.2.1

7 months ago

0.2.0

8 months ago

0.1.0

10 months ago