Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ function App() {
}

if (auth.error) {
return <div>Oops... {auth.error.message}</div>;
return <div>Oops... {auth.error.kind} caused {auth.error.message}</div>;
}

if (auth.isAuthenticated) {
Expand Down
36 changes: 35 additions & 1 deletion docs/react-oidc-context.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,46 @@ export interface AuthProviderUserManagerProps extends AuthProviderBaseProps {
// @public
export interface AuthState {
activeNavigator?: "signinRedirect" | "signinResourceOwnerCredentials" | "signinPopup" | "signinSilent" | "signoutRedirect" | "signoutPopup" | "signoutSilent";
error?: Error;
error?: ErrorContext;
isAuthenticated: boolean;
isLoading: boolean;
user?: User | null;
}

// @public
export type ErrorContext = Error & {
innerError?: unknown;
} & ({
source: "signinCallback";
} | {
source: "signoutCallback";
} | {
source: "renewSilent";
} | {
source: "signinPopup";
args: SigninPopupArgs | undefined;
} | {
source: "signinSilent";
args: SigninSilentArgs | undefined;
} | {
source: "signinRedirect";
args: SigninRedirectArgs | undefined;
} | {
source: "signinResourceOwnerCredentials";
args: SigninResourceOwnerCredentialsArgs | undefined;
} | {
source: "signoutPopup";
args: SignoutPopupArgs | undefined;
} | {
source: "signoutRedirect";
args: SignoutRedirectArgs | undefined;
} | {
source: "signoutSilent";
args: SignoutSilentArgs | undefined;
} | {
source: "unknown";
});

// @public (undocumented)
export const hasAuthParams: (location?: Location) => boolean;

Expand Down
4 changes: 2 additions & 2 deletions example/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";

import { createRoot } from "react-dom/client";

import { AuthProvider, useAuth } from "../src/.";
import { AuthProvider, useAuth } from "../src";

const oidcConfig = {
authority: "<your authority>",
Expand All @@ -18,7 +18,7 @@ function App() {
}

if (auth.error) {
return <div>Oops... {auth.error.message}</div>;
return <div>Oops... {auth.error.source} caused {auth.error.message}</div>;
}

if (auth.isAuthenticated) {
Expand Down
67 changes: 40 additions & 27 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type { ProcessResourceOwnerPasswordCredentialsArgs, SignoutResponse } from "oidc-client-ts";
import { User, UserManager, type UserManagerSettings } from "oidc-client-ts";
import React from "react";
import { UserManager, type UserManagerSettings, User } from "oidc-client-ts";
import type {
ProcessResourceOwnerPasswordCredentialsArgs,
SignoutResponse,
} from "oidc-client-ts";

import { AuthContext } from "./AuthContext";
import { initialAuthState } from "./AuthState";
import { type ErrorContext, initialAuthState } from "./AuthState";
import { reducer } from "./reducer";
import { hasAuthParams, signinError, signoutError } from "./utils";
import { hasAuthParams, normalizeError, renewSilentError, signinError, signoutError } from "./utils";

/**
* @public
Expand Down Expand Up @@ -49,21 +46,21 @@ export interface AuthProviderBaseProps {
skipSigninCallback?: boolean;

/**
* Match the redirect uri used for logout (e.g. `post_logout_redirect_uri`)
* This provider will then call automatically the `userManager.signoutCallback`.
*
* HINT:
* Do not call `userManager.signoutRedirect()` within a `React.useEffect`, otherwise the
* logout might be unsuccessful.
*
* ```jsx
* <AuthProvider
* matchSignoutCallback={(args) => {
* window &&
* (window.location.href === args.post_logout_redirect_uri);
* }}
* ```
*/
* Match the redirect uri used for logout (e.g. `post_logout_redirect_uri`)
* This provider will then call automatically the `userManager.signoutCallback`.
*
* HINT:
* Do not call `userManager.signoutRedirect()` within a `React.useEffect`, otherwise the
* logout might be unsuccessful.
*
* ```jsx
* <AuthProvider
* matchSignoutCallback={(args) => {
* window &&
* (window.location.href === args.post_logout_redirect_uri);
* }}
* ```
*/
matchSignoutCallback?: (args: UserManagerSettings) => boolean;

/**
Expand Down Expand Up @@ -187,7 +184,7 @@ export const AuthProvider = (props: AuthProviderProps): React.JSX.Element => {
userManagerContextKeys.map((key) => [
key,
userManager[key]?.bind(userManager) ??
unsupportedEnvironment(key),
unsupportedEnvironment(key),
]),
) as Pick<UserManager, typeof userManagerContextKeys[number]>,
Object.fromEntries(
Expand All @@ -202,7 +199,14 @@ export const AuthProvider = (props: AuthProviderProps): React.JSX.Element => {
try {
return await userManager[key](args);
} catch (error) {
dispatch({ type: "ERROR", error: error as Error });
dispatch({
type: "ERROR",
error: {
Comment thread
pamapa marked this conversation as resolved.
...normalizeError(error, `Unknown error while executing ${key}(...).`),
source: key,
args: args,
} as ErrorContext,
});
return null;
} finally {
dispatch({ type: "NAVIGATOR_CLOSE" });
Expand Down Expand Up @@ -235,7 +239,10 @@ export const AuthProvider = (props: AuthProviderProps): React.JSX.Element => {
user = !user ? await userManager.getUser() : user;
dispatch({ type: "INITIALISED", user });
} catch (error) {
dispatch({ type: "ERROR", error: signinError(error) });
dispatch({
type: "ERROR",
error: signinError(error),
});
}

// sign-out
Expand All @@ -245,7 +252,10 @@ export const AuthProvider = (props: AuthProviderProps): React.JSX.Element => {
if (onSignoutCallback) await onSignoutCallback(resp);
}
} catch (error) {
dispatch({ type: "ERROR", error: signoutError(error) });
dispatch({
type: "ERROR",
error: signoutError(error),
});
}
})();
}, [userManager, skipSigninCallback, onSigninCallback, onSignoutCallback, matchSignoutCallback]);
Expand Down Expand Up @@ -273,7 +283,10 @@ export const AuthProvider = (props: AuthProviderProps): React.JSX.Element => {

// event SilentRenewError (silent renew error)
const handleSilentRenewError = (error: Error) => {
dispatch({ type: "ERROR", error });
dispatch({
type: "ERROR",
error: renewSilentError(error),
});
};
userManager.events.addSilentRenewError(handleSilentRenewError);

Expand Down
35 changes: 33 additions & 2 deletions src/AuthState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import type { User } from "oidc-client-ts";
import type {
SigninPopupArgs,
SigninRedirectArgs,
SigninResourceOwnerCredentialsArgs,
SigninSilentArgs,
SignoutPopupArgs,
SignoutRedirectArgs,
SignoutSilentArgs,
User,
} from "oidc-client-ts";

/**
* The auth state which, when combined with the auth methods, make up the return object of the `useAuth` hook.
Expand Down Expand Up @@ -29,9 +38,31 @@ export interface AuthState {
/**
* Was there a signin or silent renew error?
*/
error?: Error;
error?: ErrorContext;
}

/**
* Represents an error while execution of a signing, renew, ...
*
* @public
*/
export type ErrorContext = Error & {
innerError?: unknown;
} & ({ source: "signinCallback" }
| { source: "signoutCallback" }
| { source: "renewSilent" }

| { source: "signinPopup"; args: SigninPopupArgs | undefined }
| { source: "signinSilent"; args: SigninSilentArgs | undefined }
| { source: "signinRedirect"; args: SigninRedirectArgs | undefined }
| { source: "signinResourceOwnerCredentials"; args: SigninResourceOwnerCredentialsArgs | undefined }
| { source: "signoutPopup"; args: SignoutPopupArgs | undefined }
| { source: "signoutRedirect"; args: SignoutRedirectArgs | undefined }
| { source: "signoutSilent"; args: SignoutSilentArgs | undefined }

| { source: "unknown" }
);

/**
* The initial auth state.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * from "./AuthContext";
export * from "./AuthProvider";
export type { AuthState } from "./AuthState";
export type { AuthState, ErrorContext } from "./AuthState";
export * from "./useAuth";
export * from "./useAutoSignin";
export { hasAuthParams } from "./utils";
Expand Down
25 changes: 19 additions & 6 deletions src/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { User } from "oidc-client-ts";

import type { AuthState } from "./AuthState";
import type { AuthState, ErrorContext } from "./AuthState";

type Action =
| { type: "INITIALISED" | "USER_LOADED"; user: User | null }
| { type: "USER_UNLOADED" }
| { type: "USER_SIGNED_OUT" }
| { type: "NAVIGATOR_INIT"; method: NonNullable<AuthState["activeNavigator"]> }
| { type: "NAVIGATOR_CLOSE" }
| { type: "ERROR"; error: Error };
| { type: "ERROR"; error: ErrorContext };

/**
* Handles how that state changes in the `useAuth` hook.
Expand Down Expand Up @@ -44,17 +44,30 @@ export const reducer = (state: AuthState, action: Action): AuthState => {
isLoading: false,
activeNavigator: undefined,
};
case "ERROR":
case "ERROR": {
const error = action.error;
error["toString"] = () => `${error.name}: ${error.message}`;
return {
...state,
isLoading: false,
error: action.error,
error,
};
default:
}
default: {
const innerError = new TypeError(`unknown type ${action["type"] as string}`);
const error = {
name: innerError.name,
message: innerError.message,
innerError,
stack: innerError.stack,
source: "unknown",
} satisfies ErrorContext;
error["toString"] = () => `${error.name}: ${error.message}`;
return {
...state,
isLoading: false,
error: new Error(`unknown type ${action["type"] as string}`),
error,
};
}
}
};
42 changes: 34 additions & 8 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ErrorContext } from "./AuthState";

/**
* @public
*/
Expand All @@ -19,12 +21,36 @@ export const hasAuthParams = (location = window.location): boolean => {
return false;
};

const normalizeErrorFn = (fallbackMessage: string) => (error: unknown): Error => {
if (error instanceof Error) {
return error;
}
return new Error(fallbackMessage);
};
export const signinError = normalizeErrorFn("signinCallback", "Sign-in failed");
export const signoutError = normalizeErrorFn("signoutCallback", "Sign-out failed");
export const renewSilentError = normalizeErrorFn("renewSilent", "Renew silent failed");

export const signinError = normalizeErrorFn("Sign-in failed");
export const signoutError = normalizeErrorFn("Sign-out failed");
export function normalizeError(error: unknown, fallbackMessage: string): Pick<ErrorContext, "name" | "message" | "innerError" | "stack"> {
return {
name: stringFieldOf(error, "name", () => "Error"),
message: stringFieldOf(error, "message", () => fallbackMessage),
stack: stringFieldOf(error, "stack", () => new Error().stack),
innerError: error,
};
}

function normalizeErrorFn(source: "signoutCallback" | "signinCallback" | "renewSilent", fallbackMessage: string) {
return (error: unknown): ErrorContext => {
return {
...normalizeError(error, fallbackMessage),
source: source,
};
};
}

function stringFieldOf(element: unknown, fieldName: string, or: () => string): string;
function stringFieldOf(element: unknown, fieldName: string, or: () => string | undefined): string | undefined;
function stringFieldOf(element: unknown, fieldName: string, or: () => string | undefined): string | undefined {
if (element && typeof element === "object") {
const value = (element as Record<string, unknown>)[fieldName];
if (typeof value === "string") {
return value;
}
}
return or();
}
Loading