4

I want to add OIDC to my React application and I am using oidc-client-ts since it seems popular and is still being maintained. My problem is that I miss some React examples.

What I want is all but one routes to be protected. If the user is not authenticated, they should be redirected to the login screen which has a button to activate the auth-flow using a custom provider.

I have tried to use these two examples, but I am unsure how to glue them together and how to convert the Angular code to React.

So far I have wrapped the entire application in an AuthContext and made all but one route private as in the first example:

index.tsx:

<StrictMode>
    <AuthProvider>
        <BrowserRouter>
            <Routes>
                <Route path={routes.LOGIN} element={<LoginContainer />} />
                <Route element={<Layout />}>
                    <Route index element={<Home />} />
                    <Route path="/openid/callback" element={<AuthCallback />} />
                        // Other pages
                </Route>
                <Route path="*" element={<ErrorPage />} />
            </Routes>
        </BrowserRouter>
    </AuthProvider>
</StrictMode>

The Layout-component with a private route, to make all paths but "/login" private:

function RequireAuth({ children }: { children: JSX.Element }) {
    const auth = useAuth();

    if (!auth.user) {
        return <Navigate to="/login" replace />;
    }

    return children;
}

function Layout() {
    return (
        <RequireAuth>
            <>
                <Header />
                <Main />
                <Footer />
            </>
        </RequireAuth>
    );
}

AuthProvider:

const AuthContext = createContext<AuthContextType>(null!);

const useAuth = () => useContext(AuthContext);

function AuthProvider({ children }: { children: React.ReactNode }) {
    const [user, setUser] = useState<any>(null);
    const authService = new AuthService();

    const login = () => authService.login().then(user1 => setUser(user1));

    const loginCallback = async () => {
        const authedUser = await authService.loginCallback();
        setUser(authedUser);
    };

    const logout = () => authService.login().then(() => setUser(null));
    const value = { user, login, logout };

    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export { AuthProvider, useAuth };

The authService is just copied from the angular example:

import { User, UserManager } from "oidc-client-ts";

export default class AuthService {
    userManager: UserManager;

    constructor() {
        const settings = {
            authority: "...",
            client_id: "...",
            redirect_uri: "http://localhost:3000/openid/callback",
            client_secret: "...",
            post_logout_redirect_uri: "http://localhost:3000/login"
        };
        this.userManager = new UserManager(settings);
    }

    public getUser(): Promise<User | null> {
        return this.userManager.getUser();
    }

    public login(): Promise<void> {
        return this.userManager.signinRedirect();
    }

    public loginCallback(): Promise<User> {
        return this.userManager.signinRedirectCallback();
    }

    public logout(): Promise<void> {
        return this.userManager.signoutRedirect();
    }
}

My issue is that I do not know how to set the user in the AuthProvider so I can check if I am auth'ed in the RequireAuth-component. It is not set in my then in the AuthProviders login and logout functions, so I just get redirected to the login-page whenever I try to login.

Can someone tell me how I can make the authentication flow using OIDC and restrict all my paths but one to authenticated users only?

Furthermore this answer says that there should be an AuthorizationCallback-component to parse the URL. When I use oidc-client-ts which seems to parse the data for me, do I really need this extra step or can I just have the redirect URL be "/" or "/home"?

Edit:

I found out that signinRedirect goes to a new URL which means that the rest of the script is never run. signinRedirectCallback is the call that returns the user. I will post it as an answer when I have figured out how to protect the routes properly. The check in RequireAuth is done before the user is set. How do I postpone the check until the user has been set so I do not redirect to login even though I am signed in? And if I refresh the page I lose the user state from AuthProvider and I will be sent to the login page even though there is an active session. I am unsure where and how I check if I have a session running when I load the app in a clean way.

Kikkomann
  • 386
  • 5
  • 20
  • What is the value of `user1` in the `login` handler where you are trying to update the `user` state? How do you know the `useState` isn't updated? It seems highly unlikely that React state updates aren't working. – Drew Reese May 09 '22 at 16:30
  • I have tried to log `user1`, but I never get to the `then`-part. I think `signinRedirect` in the `authService` is the issue. It doesn't seem to come back to the `login`-call from the `AuthProvider`. – Kikkomann May 09 '22 at 17:20
  • Can you share your `authService` code then so we can see what it's doing and returning in these methods? – Drew Reese May 10 '22 at 07:15
  • I have updated the question so it includes the `authService` – Kikkomann May 10 '22 at 09:56

2 Answers2

2

I managed to solve it with inspiration from Drew Reese's answer. The thing is that oidc-client-ts' signinRedirect will redirect to the authentication server and thus the React code will stop it's execution and the then block is never run. The trick is to use signinRedirectCallback which processes the response from the authorization endpoint after the login-call. So when I hit the redirect url (http://localhost:3000/openid/callback), I call the signinRedirectCallback to find out if I should go to the Home or Login-component. So all routes but/login and the post-login-redirect-url will be auth-protected:

<Routes>
  <Route path={routes.LOGIN} element={<LoginContainer />} />
  <Route path="/openid/callback" element={<AuthCallback />} />
  <Route element={<LayoutWithAuth />}>
    <Route index element={<Home />} />
    ...
  </Route>
  <Route path="*" element={<ErrorPage />} />
</Routes>;

Then on the redirect back to the app, loginCallback/signingRedirectCallback (the first is just forwarding the call to the authService), sets the user in the AuthContext (see it below) and I navigate to the home page:

AuthCallback:

function AuthCallback() {
  const auth = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    auth.loginCallback().then(() => {
      navigate(routes.INDEX);
    });
  }, []);

  return <div>Processing signin...</div>;
}

By making loginCallback async, I make sure than when I redirect to the login page, the user will be set when the LayoutWithAuth-component does the auth-check:

function RequireAuth({ children }: { children: JSX.Element }) {
  const auth = useAuth();
  return auth.user === undefined ? <Navigate to="/login" replace /> : children;
}

function LayoutWithAuth() {
  return (
    <RequireAuth>
      <>
        <Header />
        <Body />
        <Footer />
      </>
    </RequireAuth>
  );
}

oidc-client-ts saves the user in the sessionStorage, so if the page is refreshed, the AuthContext will first check the sessionStorage to see if the user is auth'ed:

AuthContext:

const AuthContext = createContext<AuthContextType>(null!);

const useAuth = () => useContext(AuthContext);

function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null | undefined>(
    JSON.parse(
      sessionStorage.getItem(process.env.REACT_APP_SESSION_ID!) || "null"
    ) || undefined
  );

  const authService = new AuthService();

  const loginCallback = async (): Promise<void> => {
    const authedUser = await authService.loginCallback();
    setUser(authedUser);
  };

  // Login and logout methods

  const value = { user, login, logout };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export { AuthProvider, useAuth };

Update: Adding the LoginContainer:

function LoginContainer() {
  const auth = useAuth();

  const login = () => {
    auth.login();
  };
  return (
    <Container>
      <Headline>Some headline</Headline>
      <Button variant="contained" onClick={() => login()}>
        {texts.BUTTON_LOGIN}
      </Button>
    </Container>
  );
}

export default LoginContainer;

Kikkomann
  • 386
  • 5
  • 20
0

Issue

The login utility only returns a resolved Promise<void>, no user value, so while you can chain from the returned Promise there is no resolved value returned in the chain.

public login(): Promise<void> {
  return this.userManager.signinRedirect();
}

Solution

After a successful authentication it seems you must call getUser to get the current user value, a User object or null.

public getUser(): Promise<User | null> {
  return this.userManager.getUser();
}

Here's a login implementation that gets the user after logging in.

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<any>(null);
  const authService = new AuthService();

  const login = async () => {
    try {
      await authService.login();
      const user = await authService.getUser();
      setUser(user);
    } catch(error) {
      // log error, set error state, ignore, etc...
      setUser(null);
    }
  };

  const logout = () => {
    try {
      await authService.logout();
    } catch(error) {
      // log error, set error state, ignore, etc...
    } finally {
      setUser(null);
    }
  };

  const value = { user, login, logout };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

You could, of course, update your AuthService to return the authenticated user directly.

export default class AuthService {
  ...

  public login(): Promise<User | null> {
    return this.userManager.signinRedirect()
      .then(getUser)
      .catch(error => {
        // handle any errors, rejected Promises, etc...
        return null;
      });
  }

  // or

  public async login(): Promise<User | null> {
    try {
      await this.userManager.signinRedirect();
      return getUser();
    } catch(error) {
      // handle any errors, rejected Promises, etc...
      return null;
    }
  }

  ...
}

Now the original AuthContext login function will work as expected.

const login = () => authService.login()
  .then(user1 => setUser(user1))
  .catch((error) => {
    // log error, set error state, ignore, etc...
    setUser(null);
  });

or

  const login = async () => {
    try {
      const user = await authService.login();
      setUser(user);
    } catch(error) {
      // log error, set error state, ignore, etc...
      setUser(null);
    }
  };

Updates

The check in RequireAuth is done before the user is set. How do I postpone the check until the user has been set so I do not redirect to login even though I am signed in?

For this you can use a sort of "loading" state. I usually suggest either added an explicit loading state or using an initial auth state value that doesn't match either the authenticated or unauthenticated state.

For example, if null is the unauthenticated user, and some user object is an authenticated user, use an undefined initial value.

Auth context

const [user, setUser] = useState<any>();

...

function RequireAuth({ children }: { children: JSX.Element }) {
  const auth = useAuth();

  if (auth === undefined) {
    return null; // or loading spinner, etc...
  }

  return auth.user ? children : <Navigate to="/login" replace />;
}

And if I refresh the page I lose the user state from AuthProvider and I will be sent to the login page even though there is an active session.

This is because when the page is reloaded, the entire app is remounted. Any local component state is lost. You can try initializing/persisting state to localStorage.

Example:

function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<any>(
    JSON.parse(localStorage.getItem('user')) || undefined
  );

  useEffect(() => {
    localStorage.setItem('user', JSON.stringify(user));
  }, [user]);

  ...
}
Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • Unfortunately it doesn't work because `signinRedirect` leaves the page and thus the rest of the script is not run. I think I have fixed this part, but I still have issues with checking the session and protecting routes. I have updated my question with some more details. – Kikkomann May 11 '22 at 22:00
  • @Kikkomann Ok, I saw your edit and updated my answer to address at least the questions regarding the route protection and auth state persistence. I don't know what `signinRedirectCallback` is though. Are you saying this function correctly redirects back to your app with the user object, and this is working? – Drew Reese May 11 '22 at 22:26
  • It works all fine. Two things though: I know I set my `user` object to be of type any, but I want it to be of type `User | null`, but then I get issues with using undefined. My `value` in `AuthProvider` complains about a type mismatch. How do I fix this? The other thing is that the `UserManager` (https://authts.github.io/oidc-client-ts/classes/UserManager.html) already keeps track of the session state in the app. If I start using `localStorage` manually, isn't it just doing the same things two times? The nice thing is that localStorage is not async. – Kikkomann May 12 '22 at 06:00
  • @Kikkomann You can probably include undefined, i.e. `useState(.....)`. I don't follow your other question about localStorage and doing something twice. Can you clarify? – Drew Reese May 12 '22 at 06:07
  • Ah I forgot to add it on the interface type for `AuthContext`. `AuthService` uses the `UserManager` (from `oidc-client-ts`) which creates and manages the `sessionStorage`. The thing is that `authService.getUser()` is an async function, which is what gives me problems to check if I am logged in on a page reload. When I use `localStorage` it works, but then I have the user object stored in two places: `localStorage` and `sessionStorage`. I could perhaps just save a flag in the local storage, but the user user is still managed in two locations. I hope that was more clear. – Kikkomann May 12 '22 at 06:41
  • 1
    @Kikkomann Oh, I see. Does just using the sessionStorage work for persisting component state? – Drew Reese May 12 '22 at 07:04
  • Oh wow, since I have not used sessionStorage before, I didn't even think about using it for the check. It sounds like that is the trick. I will get back if it doesn't work or add a solution/accept your answer. – Kikkomann May 12 '22 at 07:13
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/244681/discussion-between-kikkomann-and-drew-reese). – Kikkomann May 12 '22 at 07:20
  • @Kikkomann Your above answers helped a lot. What is your login container look like? Also, my RequireAuth function is getting called twice. Could you please help with that – DSR Jun 05 '22 at 15:01
  • @DSR Are you asking a question specific to code you are using? You might consider asking it as a new post. You can reference this question/answer if it helps clarify your specific issue. If you do post a new question feel free to ping me here in a comment with a link to it and I can take a look when available. – Drew Reese Jun 05 '22 at 20:21
  • @DrewReese As I checked, it is a valid behaviour in React.StrictMode. The problem is, due to React.StrictMode double render, signinRedirectCallback is getting called two times and getting error - **Uncaught (in promise) Error: No matching state found in storage at OidcClient.readSigninResponseState (OidcClient.ts:148:1) at async OidcClient.processSigninResponse (OidcClient.ts:159:1) at async UserManager._signinEnd (UserManager.ts:402:1) at async UserManager.signinRedirectCallback (UserManager.ts:163:1) at async Object.loginCallback (App.tsx:93:1)** – DSR Jun 06 '22 at 00:51
  • @DSR Sounds like you might have an unexpected/unintentional side-effect then. – Drew Reese Jun 06 '22 at 16:33
  • @DSR I have added the `LoginContainer` and as Drew Reese, I will suggest that you post a new question for your issue. – Kikkomann Jun 08 '22 at 08:12
  • I am trying to implement Auth in my app using oidc-client-ts with an Auth service and a provider but I am dealing with the same issue that was mentioned in this post. After authentication, I don't get the user. So I modified my login function just like suggested in this post to call getUser after signinRedirect but still I'm getting back the user or seeing it in sessionStorage. Any idea what I cold be doing wrong? – sayayin Sep 03 '22 at 02:31
  • How did you guys update the user after a silentRenew updated the accesstoken? – user2236165 Apr 14 '23 at 08:34