Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Okta setup example for passing accessToken to RTK Query #236

Open
kenyee opened this issue Jun 13, 2022 · 5 comments
Open

Add Okta setup example for passing accessToken to RTK Query #236

kenyee opened this issue Jun 13, 2022 · 5 comments
Labels
enhancement New feature or request

Comments

@kenyee
Copy link

kenyee commented Jun 13, 2022

Describe the feature request?

The current samples are supposed to replace the okta-react SDK with examples on how to hook through various React router implementations but authentication of routes is only part of what's needed.

Devs also need examples of how to make network calls with the JWT accessToken. With the Axios networking library, it's relatively simple to add the accessToken since you have to do it for every call.

With the RTK Query library, it's more complicated because you have a prepareHeaders callback in the baseURL where you need to grab the token, so you end up having to stick in brower storage (probably a bad idea) or stick it into the Redux store, but then you have problems with excessive network calls as the Okta SDK returns two the accessToken and idToken via two separate React hooks.

What I've cobbled together so far is based on a LogRocket blog (https://blog.logrocket.com/using-typescript-with-redux-toolkit/)

Auth.ts:

import {createSlice, PayloadAction} from "@reduxjs/toolkit";
import {RootState} from "./store";

export interface AuthError {
    message: string
}

export interface AuthState {
    isAuth: boolean
    currentUser?: CurrentUser
    isLoading: boolean
    error: AuthError
}

export interface CurrentUser {
    id: string
    displayName: string
    accessToken: string
}
export const initialState: AuthState = {
    isAuth: false,
    isLoading: false,
    error: {message: 'An Error occurred'},
}

export const authSlice = createSlice({
    name: 'auth',
    initialState,
    reducers: {
        setLoading: (state: AuthState, {payload}: PayloadAction<boolean>) => {
            state.isLoading = payload;
        },
        setAuthSuccess: (state: AuthState, {payload}: PayloadAction<CurrentUser>) => {
            console.log("Setting current user to " + JSON.stringify(payload));
            state.currentUser = payload;
            state.isAuth = true;
        },
        setLogOut: (state: AuthState) => {
            state.isAuth = false;
            state.currentUser = undefined;
        },
        setAuthFailed: (state: AuthState, {payload}: PayloadAction<AuthError>) => {
            state.error = payload;
            state.isAuth = false;
        },
    },
})

export const { setAuthSuccess, setLogOut, setLoading, setAuthFailed } = authSlice.actions;

export const authSelector = (state: RootState) => state.auth;

useOktaAuth.ts:

import {WithCustomClaims} from "./useAuthUser";
import {AuthState} from "@okta/okta-auth-js";
import {useDispatch} from "react-redux";
import {useEffect} from "react";
import {CurrentUser, setAuthSuccess, setLogOut} from "../app/auth";

export const useOktaAuthState = (userInfo: WithCustomClaims | null, authState: AuthState | null) => {
    const dispatch = useDispatch();
    useEffect(() => {
        console.log("userinfo is currently " + JSON.stringify(userInfo))
        console.log("authState is currently " + JSON.stringify(authState))
        if (authState?.isAuthenticated) {
            const user: CurrentUser = {
                id: String(userInfo?.ldap),
                displayName: String(userInfo?.ldap),
                accessToken: String(authState.accessToken?.accessToken)
            }
            console.log("Calling setAuthSuccess with " + JSON.stringify(user));
            dispatch(setAuthSuccess(user));
        } else {
            dispatch(setLogOut());
        }
    }, [userInfo, authState])
};

Would like to update the store in my AppRoutes.tsx but can't because they're react component effects:

import { Route, Switch, useHistory } from "react-router-dom";
import {Security, SecureRoute, LoginCallback} from "@okta/okta-react";
import { OktaAuth, toRelativeUrl } from "@okta/okta-auth-js";
import Home from "./pages/home/Home";
import Profile from "./pages/profile/Profile";
import { oktaAuthConfig } from "./config";
import Navbar from "./components/Navbar/Navbar";

const oktaAuth = new OktaAuth(oktaAuthConfig);
const AppRoutes = () => {
    const history = useHistory();

    const restoreOriginalUri = async (_oktaAuth: any, originalUri: any) => {
        history.replace(toRelativeUrl(originalUri || "/", window.location.origin));
    };
    return (
        <Security oktaAuth={oktaAuth} restoreOriginalUri={restoreOriginalUri}>
            <Navbar />
            <Switch>
                <Route path="/" exact={true} component={Home} />
                <SecureRoute path="/profile" component={Profile} />
                <Route path="/login/callback" component={LoginCallback} />
            </Switch>
        </Security>
    );
};

export default AppRoutes;

So I ended up doing this in Home.tsx:

const Home = () => {
    const {authState} = useOktaAuth();
    const userInfo = useAuthUser();
    useOktaAuthState(userInfo, authState);

    const theme = createTheme();
    const authSliceState = useSelector(authSelector)
    const {data, error, isLoading, isSuccess} = useProjectsQuery(String(authSliceState?.currentUser?.accessToken));

but this results in two network calls (one when userInfo is not valid yet, and then one when both are valid).

The API call setup for prepareHeaders looks like this:

export const projectApi = createApi({
    reducerPath: "projectApi",
    baseQuery: fetchBaseQuery({
        baseUrl: "http://localhost:8080",
        prepareHeaders: (headers, { getState }) => {
            const state = (getState() as RootState)
            console.log("currentuser is " + JSON.stringify(state.auth))
            if (state.auth.currentUser) {
                headers.set('Authorization', `Bearer ${state.auth.currentUser.accessToken}`)
            }

            return headers
        },
    }),
    tagTypes: ["Project"],
    endpoints: (builder) => ({
        projects: builder.query<ProjectListInfo[], string>({
            query: () => "/v1/ui/projects",
            providesTags: ["Project"]
        }),
    })
});

export const {
    useProjectsQuery,
} = projectApi;

New or Affected Resource(s)

https://github.com/okta/okta-react/tree/master/samples/routing

Provide a documentation link

https://redux-toolkit.js.org/rtk-query/overview

Additional Information?

No response

@kenyee kenyee added the enhancement New feature or request label Jun 13, 2022
@kenyee kenyee mentioned this issue Jun 13, 2022
9 tasks
@jaredperreault-okta
Copy link
Contributor

I am not very familiar with redux or rtx-query, but it seems to me like part of the struggle is there are 2 sources of truth for authState, one from okta-react and one you've made internally via redux. I think it's reasonable to have redux sit between okta-react code and your application code. For this use case I think you'll have to implement your own SecureRoute component which interfaces with redux rather than okta-react's authState.

Something like this:
AppRoutes -> Security -> OktaToReduxBridge -> Routes
and any SecureRoute defined within Routes would interface with Redux, which has been updated via OktaToReduxBridge.

Lastly,

const Home = () => {
    const {authState} = useOktaAuth();
    const userInfo = useAuthUser();
    useOktaAuthState(userInfo, authState);
...

I'm not sure where useAuthUser is coming from (I assumed it's a custom hook/context), but I think it should depend on the authState. User info comes from tokens, so if your app is in an unauthenticated state, there are no tokens. So as written, a request for userInfo is made (which will result in a token request) simultaneously with the explicit request for tokens (auth flow). This is why you're seeing 2 requests

@kenyee
Copy link
Author

kenyee commented Jun 14, 2022

a request for userInfo is made (which will result in a token request) simultaneously with the explicit request for tokens (auth flow)

Yep, I think that's why that happens...the state changes because the authToken comes back, then a userinfo call is made to get the idToken. What I haven't figured out how to do is avoid sending a request unless I have both. This is easy to do when you use Axios....much harder when you try doing this via the Redux store and RTK Query because it feels like you're fighting everything 😂

After more fiddling, I got it to sort of work but I think you're right about needing to write my own SecureRoute. I currently have 3 requests go out to display a screen...one for each of the states of no authToken, no idToken, then both, but then asking for okta to get userinfo causes another idToken to be returned which makes 3 calls 😞

useAuthUser.ts looks like:

export type WithCustomClaims = UserClaims<{ TeamCodeHealth_claim: string[] , ldap: string }>;
export class AuthInfo {
    authState: AuthState;
    userInfo: WithCustomClaims | null;

    constructor(authState: AuthState, userInfo: WithCustomClaims) {
        this.authState = authState;
        this.userInfo = userInfo;
    }
}

const useAuthUser = () => {
    const { oktaAuth, authState } = useOktaAuth() || {};
    const [userInfo, setUserInfo] = useState<WithCustomClaims|null>(null) || {};

    console.log("idtoken is " + JSON.stringify(oktaAuth?.getIdToken()));
    useEffect(() => {
        const getUser = async () => {
            try {
                const res: WithCustomClaims = await oktaAuth.getUser();
                // @ts-ignore
                setUserInfo(res);
            } catch (error) {
                console.log(error);
            }
        };

        authState?.isAuthenticated && getUser();
    }, [authState, oktaAuth]);

    return new AuthInfo(authState!, userInfo!);
};

export default useAuthUser;

useOktaAuthState.ts:

export const useOktaAuthState = (authInfo: AuthInfo) => {
    const dispatch = useDispatch();
    useEffect(() => {
        console.log("userinfo is currently " + JSON.stringify(authInfo.userInfo));
        console.log("authState is currently " + JSON.stringify(authInfo.authState));
        if (authInfo.authState?.isAuthenticated) {
            const user: CurrentUser = {
                id: String(authInfo.userInfo?.ldap),
                displayName: String(authInfo.userInfo?.ldap),
                accessToken: String(authInfo.authState.accessToken?.accessToken)
            };
            console.log("Calling setAuthSuccess with " + JSON.stringify(user));
            dispatch(setAuthSuccess(user));
        } else {
            dispatch(setLogOut());
        }
    }, [authInfo.userInfo, authInfo.authState]);
};

AppRoutes.tsx:

const oktaAuth = new OktaAuth(oktaAuthConfig);
const AppRoutes = () => {
    const history = useHistory();

    useOktaAuthState(useAuthUser());

    const restoreOriginalUri = async (_oktaAuth: any, originalUri: any) => {
        history.replace(toRelativeUrl(originalUri || "/", window.location.origin));
    };
    return (
        <Security oktaAuth={oktaAuth} restoreOriginalUri={restoreOriginalUri}>
            <Navbar />
            <Switch>
                <Route path="/" exact={true} component={Home} />
                <SecureRoute path="/profile" component={Profile} />
                <Route path="/login/callback" component={LoginCallback} />
            </Switch>
        </Security>
    );
};

export default AppRoutes;

and Home.tsx:

const Home = () => {
    useOktaAuthState(useAuthUser());
    const authSliceState = useSelector(authSelector)

    const theme = createTheme();
    const {data, error, isLoading, isSuccess} = useProjectsQuery(authSliceState.currentUser?.displayName || "");

    return (
        <ThemeProvider theme={theme}>
            <Container component="main" maxWidth="lg">
...

I shouldn't need to do useOktaAuthState(useAuthUser()) in Home.tsx but nothing happens if I don't (the auth info isn't loaded into the redux store). Just using useOktaAuthState(useAuthUser()) in AppRoutes.tsx should have worked but doesn't.

@jaredperreault-okta
Copy link
Contributor

I think the reason useOktaAuthState doesn't work in AppRoutes is you're mounting <Security /> inside of AppRoutes. Under the hood useOktaAuth is a React.createContext and <Security /> contains the Context.Provider. The <Security /> component needs to be mounted above any useOktaAuth calls.

A structure like this:

<Main>
  <Security>
    <AppRoutes>
      <Route1>
      <Route2>
...

@kenyee
Copy link
Author

kenyee commented Jun 14, 2022

Thanks...that let me remove useOktaAuthState(useAuthUser()) from Home.tsx :-)

so for completeness, this now what App.tsx looks like:

const oktaAuth = new OktaAuth(oktaAuthConfig);
const App = () => {
    const history = useHistory();
    const restoreOriginalUri = async (_oktaAuth: any, originalUri: any) => {
        history.replace(toRelativeUrl(originalUri || "/", window.location.origin));
    };

    return (
      <Security oktaAuth={oktaAuth} restoreOriginalUri={restoreOriginalUri}>
          <BrowserRouter>
            <AppRoutes />
          </BrowserRouter>
      </Security>
    );
};

This is AppRoutes.tsx:

const AppRoutes = () => {
    useOktaAuthState(useAuthUser()); // saves authInfo in redux store

    return (
        <>
            <Navbar />
            <Switch>
                <Route path="/" exact={true} component={Home} />
                <SecureRoute path="/profile" component={Profile} />
                <Route path="/login/callback" component={LoginCallback} />
            </Switch>
        </>
    );
};

it still does 3 calls to the API when I load the home page though...first w/ no credentials, then two successful ones w/ the proper credentials...I think because the "await oktaAuth.getUser()" causes a reload and I haven't figured out how to get RTK Query to do the network call only if credentials are valid...

@whoami1201
Copy link

@kenyee I believe you can use conditional fetching in rtk-query to achieve that. Something like: { skip: !auth.isAuthenticated }?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants