diff --git a/src/frontend/AuthProvider.jsx b/src/frontend/AuthProvider.jsx deleted file mode 100644 index f8525568..00000000 --- a/src/frontend/AuthProvider.jsx +++ /dev/null @@ -1,327 +0,0 @@ -import {config} from '../config/index'; -import { - createContext, - useContext, - useState, - useCallback, - useEffect -} from 'react'; -import React from 'react'; -/** @type {Record} */ -export const flagDataTypeMap = { - s: 'string', - i: 'integer', - b: 'boolean' -}; - -const AuthContext = createContext({ - ...config.initialState -}); - -/** - * - * @returns {import('../../types').KindeState} - */ -export const useKindeAuth = () => useContext(AuthContext); - -/** - * - * @param {string} url - * @returns {Promise} - */ -const tokenFetcher = async (url) => { - let response; - try { - response = await fetch(url); - } catch { - throw new Error('Failed to fetch token'); - } - - if (response.ok) { - return await response.json(); - } else if (response.status === 401) { - throw new Error('Failed to fetch token'); - } -}; - -/** - * - * @param {children: import('react').ReactNode, options?: {apiPath: string} | undefined} props - * @returns - */ -export const KindeProvider = ({children}) => { - const setupUrl = `${config.apiPath}/setup`; - - const refreshData = useCallback(() => { - checkSession(); - }, ['checkSession']); - - const checkSession = useCallback(async () => { - try { - const tokens = await tokenFetcher(setupUrl); - - if (tokens == undefined) return; - - const { - accessToken, - accessTokenEncoded, - featureFlags, - idToken, - organization, - permissions, - user, - userOrganizations - } = tokens; - - const getAccessToken = () => accessToken; - const getAccessTokenRaw = () => accessTokenEncoded; - const getAccessTokenEncoded = () => accessTokenEncoded; - const getToken = () => accessTokenEncoded; - const getIdToken = () => idToken; - const getIdTokenRaw = () => idTokenRaw; - const getPermissions = () => permissions; - const getOrganization = () => organization; - const getUser = () => user; - const getUserOrganizations = () => userOrganizations; - - /** - * - * @param {string} claim - * @param {"access_token" | "id_token"} tokenKey - */ - const getClaim = (claim, tokenKey = 'access_token') => { - const token = - tokenKey === 'access_token' ? tokens.accessToken : tokens.idToken; - // @ts-ignore - return token ? {name: claim, value: token[claim]} : null; - }; - - /** - * - * @param {string} code - * @param {number | string | boolean} defaultValue - * @param {import('../../types').KindeFlagTypeCode} flagType - * @returns {import('../../types').KindeFlag} - */ - const getFlag = (code, defaultValue, flagType) => { - const flags = featureFlags; - const flag = flags && flags[code] ? flags[code] : {}; - - if (Object.keys(flag).length === 0 && defaultValue == undefined) { - throw Error( - `Flag ${code} was not found, and no default value has been provided` - ); - } - - // @ts-ignore - if (flagType && flag.t && flagType !== flag.t) { - throw Error( - `Flag ${code} is of type ${ - // @ts-ignore - flagDataTypeMap[flag.t] - } - requested type ${flagDataTypeMap[flagType]}` - ); - } - return { - // @ts-ignore - code, - // @ts-ignore - type: flagDataTypeMap[flag.t || flagType], - // @ts-ignore - value: flag.v == null ? defaultValue : flag.v, - // @ts-ignore - is_default: flag.v == null, - defaultValue: defaultValue - }; - }; - - /** - * - * @param {string} code - * @param {boolean} defaultValue - * @returns {boolean | undefined | null} - */ - const getBooleanFlag = (code, defaultValue) => { - try { - const flag = getFlag(code, defaultValue, 'b'); - return flag.value; - } catch (err) { - if (config.isDebugMode) { - console.error(err); - } - } - }; - - /** - * - * @param {string} code - * @param {string} defaultValue - * @returns {string | undefined | null} - */ - const getStringFlag = (code, defaultValue) => { - try { - const flag = getFlag(code, defaultValue, 's'); - return flag.value; - } catch (err) { - if (config.isDebugMode) { - console.error(err); - } - } - }; - - /** - * - * @param {string} code - * @param {number} defaultValue - * @returns {number | undefined | null} - */ - const getIntegerFlag = (code, defaultValue) => { - try { - const flag = getFlag(code, defaultValue, 'i'); - return flag.value; - } catch (err) { - if (config.isDebugMode) { - console.error(err); - } - } - }; - - /** - * - * @param {string} key - * @returns {import('../../types').KindePermission} - */ - const getPermission = (key) => { - return { - isGranted: permissions.permissions.some((p) => p === key), - orgCode: organization.orgCode - }; - }; - - setState((previous) => ({ - ...previous, - accessToken, - accessTokenEncoded, - accessTokenRaw: accessTokenEncoded, - idToken, - idTokenRaw, - idTokenEncoded: idTokenRaw, - isLoading: false, - organization, - permissions, - user, - userOrganizations, - getAccessToken, - getAccessTokenRaw, - getAccessTokenEncoded, - getBooleanFlag, - getClaim, - getFlag, - getIdToken, - getIdTokenRaw, - getIntegerFlag, - getOrganization, - getPermission, - getPermissions, - getStringFlag, - getToken, - getUser, - getUserOrganizations, - refreshData - })); - } catch (error) { - if (config.isDebugMode) { - console.error(error); - } - // @ts-ignore - setState((previous) => ({...previous, isLoading: false, error: error})); - } - }, [setupUrl]); - - const [state, setState] = useState({ - ...config.initialState - }); - - // if you get the user set loading false - useEffect(() => { - const checkLoading = async () => { - await checkSession(); - setState((previous) => ({ - ...previous, - isLoading: false - })); - }; - if (!state.user) { - checkLoading(); - } - }, [state.user]); - - // provide this stuff to the rest of your app - const { - user, - accessToken, - accessTokenRaw, - accessTokenEncoded, - idToken, - idTokenEncoded, - idTokenRaw, - getAccessToken, - getAccessTokenRaw, - getIdTokenRaw, - getToken, - getClaim, - getFlag, - getIdToken, - getBooleanFlag, - getStringFlag, - getIntegerFlag, - getOrganization, - getPermission, - getPermissions, - getUser, - getUserOrganizations, - permissions, - organization, - userOrganizations, - error, - isLoading - } = state; - - return ( - - {children} - - ); -}; diff --git a/src/frontend/AuthProvider.tsx b/src/frontend/AuthProvider.tsx new file mode 100644 index 00000000..8b40a84c --- /dev/null +++ b/src/frontend/AuthProvider.tsx @@ -0,0 +1,195 @@ +import {config} from '../config/index'; +import { + createContext, + useContext, + useState, + useCallback, + useEffect, + useMemo +} from 'react'; +import React from 'react'; +import {KindeFlagRaw, KindeSetupResponse, KindeState} from '../../types'; + +export const flagDataTypeMap = { + s: 'string', + i: 'integer', + b: 'boolean' +}; + +const AuthContext = createContext({ + ...config.initialState +}); + +export const useKindeAuth = (): KindeState => useContext(AuthContext); + +/** + * + * @param {string} url + * @returns {Promise} + */ + +const tokenFetcher = async ( + url: string +): Promise => { + let response: Response; + try { + response = await fetch(url); + } catch { + throw new Error('Network error'); + } + if (response.status == 204) return undefined; + if (response.ok) return response.json(); + throw new Error('Error fetching data'); +}; + +/** + * + * @param {children: import('react').ReactNode, options?: {apiPath: string} | undefined} props + * @returns + */ +export const KindeProvider = ({children}) => { + const setupUrl = `${config.apiPath}/setup`; + const [timer, setTimer] = useState(null); + + const [state, setState] = useState({ + ...config.initialState + }); + + const checkSession = useCallback(async (): Promise => { + try { + if (timer) clearTimeout(timer); + + const kindeData = await tokenFetcher(setupUrl); + + const getFlag = ( + code: string, + defaultValue: any, + flagType: 'b' | 'i' | 's' + ) => { + const flags = kindeData?.featureFlags; + if (!flags) { + throw Error('No flags found'); + } + + if (!flags[code]) { + throw Error(`Flag ${code} was not found`); + } else { + const flag = flags[code]; + + if (Object.keys(flag).length === 0 && defaultValue == undefined) { + throw Error( + `Flag ${code} was not found, and no default value has been provided` + ); + } + + if (flagType && flag.t && flagType !== flag.t) { + throw Error( + `Flag ${code} is of type ${ + flagDataTypeMap[flag.t] + } - requested type ${flagDataTypeMap[flagType]}` + ); + } + return { + code, + type: flagDataTypeMap[flag.t || flagType], + value: flag.v == null ? defaultValue : flag.v, + is_default: flag.v == null, + defaultValue: defaultValue + }; + } + }; + + setState((previous) => ({ + ...previous, + error: undefined, + accessToken: kindeData?.accessToken, + idToken: kindeData?.idToken, + permissions: kindeData?.permissions, + organization: kindeData?.organization, + featureFlags: kindeData?.featureFlags, + userOrganizations: kindeData?.userOrganizations, + accessTokenEncoded: kindeData?.accessTokenEncoded, + accessTokenRaw: kindeData?.accessTokenEncoded, + getAccessToken: () => kindeData?.accessToken, + idTokenEncoded: kindeData?.idTokenEncoded, + getAccessTokenRaw: () => kindeData?.accessTokenEncoded, + getBooleanFlag: (code: string, defaultValue: boolean) => { + try { + const flag = getFlag(code, defaultValue, 'b'); + return flag.value; + } catch (err) { + if (config.isDebugMode) { + console.error(err); + } + } + }, + getIntegerFlag: (code: string, defaultValue: number) => { + try { + const flag = getFlag(code, defaultValue, 'i'); + return flag.value; + } catch (err) { + if (config.isDebugMode) { + console.error(err); + } + } + }, + getStringFlag: (code: string, defaultValue: string) => { + try { + const flag = getFlag(code, defaultValue, 's'); + return flag.value; + } catch (err) { + if (config.isDebugMode) { + console.error(err); + } + } + }, + getOrganization: () => kindeData?.organization, + getPermission: (key: string) => { + return { + isGranted: kindeData.permissions.permissions.some((p) => p === key), + orgCode: kindeData.organization.orgCode + }; + }, + getPermissions: () => kindeData.permissions, + getUser: () => kindeData.user, + getToken: () => kindeData.accessTokenRaw, + getUserOrganizations: () => kindeData.userOrganizations, + idTokenRaw: kindeData?.idTokenRaw, + refreshData: checkSession, + getClaim: (claim: string, tokenKey = 'access_token') => { + const token = + tokenKey === 'access_token' + ? kindeData.accessToken + : kindeData.idToken; + return token ? {name: claim, value: token[claim]} : null; + }, + getFlag, + getIdTokenRaw: () => kindeData?.idTokenEncoded, + getIdToken: () => kindeData?.idToken, + isAuthenticated: true, + isLoading: false, + user: kindeData?.user + })); + + const t = setTimeout( + checkSession, + kindeData?.accessToken.exp * 1000 - Date.now() + ); + setTimer(t); + } catch (error) { + setState((previous) => ({...previous, error, isAuthenticated: false})); + } + }, [setupUrl]); + + useEffect((): void => { + if (state.accessToken) return; + (async (): Promise => { + await checkSession(); + setState((previous) => ({...previous, isLoading: false})); + })(); + }, [state.accessToken]); + + return ( + {children} + ); +}; diff --git a/src/frontend/KindeBrowserClient.js b/src/frontend/KindeBrowserClient.js index 0c62c0fb..aaedd3dd 100644 --- a/src/frontend/KindeBrowserClient.js +++ b/src/frontend/KindeBrowserClient.js @@ -1,5 +1,5 @@ import {useEffect, useState} from 'react'; -import {flagDataTypeMap} from './AuthProvider.jsx'; +import {flagDataTypeMap} from './AuthProvider'; import {config} from '../config/index.js'; /** diff --git a/src/frontend/index.js b/src/frontend/index.js deleted file mode 100644 index 7589515c..00000000 --- a/src/frontend/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export {KindeProvider, useKindeAuth} from './AuthProvider.jsx'; -export {useKindeBrowserClient} from './KindeBrowserClient.js'; diff --git a/src/frontend/index.ts b/src/frontend/index.ts new file mode 100644 index 00000000..06525dfa --- /dev/null +++ b/src/frontend/index.ts @@ -0,0 +1,2 @@ +export {KindeProvider, useKindeAuth} from './AuthProvider'; +export {useKindeBrowserClient} from './KindeBrowserClient.js'; diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts index 15ca2480..0f4518a1 100644 --- a/src/handlers/setup.ts +++ b/src/handlers/setup.ts @@ -1,6 +1,14 @@ import {jwtDecoder} from '@kinde/jwt-decoder'; -import {KindeAccessToken, KindeIdToken} from '../../types'; +import { + KindeAccessToken, + KindeFlagRaw, + KindeIdToken, + KindeSetupResponse, + KindeTokenOrganizationProperties +} from '../../types'; import {config} from '../config/index'; +import RouterClient from '../routerClients/RouterClient'; +import {getAccessTokenWithRefresh} from '../session/getAccessTokenWithRefresh'; import {generateUserObject} from '../utils/generateUserObject'; /** @@ -8,60 +16,62 @@ import {generateUserObject} from '../utils/generateUserObject'; * @param {RouterClient} routerClient * @returns */ -export const setup = async (routerClient) => { +export const setup = async (routerClient: RouterClient) => { try { - const user = await routerClient.kindeClient.getUser( - routerClient.sessionManager + const accessToken = await getAccessTokenWithRefresh( + routerClient.req, + routerClient.res ); const accessTokenEncoded = - await routerClient.sessionManager.getSessionItem('access_token'); + (await routerClient.sessionManager.getSessionItem( + 'access_token' + )) as string; - const idTokenEncoded = - await routerClient.sessionManager.getSessionItem('id_token'); - - const accessToken = jwtDecoder(accessTokenEncoded); + const idTokenEncoded = (await routerClient.sessionManager.getSessionItem( + 'id_token' + )) as string; - const idToken = jwtDecoder(idTokenEncoded); + const idToken = jwtDecoder(idTokenEncoded as string); - const permissions = await routerClient.kindeClient.getClaimValue( + const permissions = (await routerClient.kindeClient.getClaimValue( routerClient.sessionManager, 'permissions' - ); + )) as string[]; - const organization = await routerClient.kindeClient.getClaimValue( + const organization = (await routerClient.kindeClient.getClaimValue( routerClient.sessionManager, 'org_code' - ); + )) as string; - const featureFlags = await routerClient.kindeClient.getClaimValue( + const featureFlags = (await routerClient.kindeClient.getClaimValue( routerClient.sessionManager, 'feature_flags' - ); + )) as Record; - const userOrganizations = await routerClient.kindeClient.getClaimValue( + const userOrganizations = (await routerClient.kindeClient.getClaimValue( routerClient.sessionManager, 'org_codes', 'id_token' - ); + )) as string[]; - const orgName = await routerClient.kindeClient.getClaimValue( + const orgName = (await routerClient.kindeClient.getClaimValue( routerClient.sessionManager, 'org_name' - ); + )) as string; - const orgProperties = await routerClient.kindeClient.getClaimValue( + const orgProperties = (await routerClient.kindeClient.getClaimValue( routerClient.sessionManager, 'organization_properties' - ); + )) as KindeTokenOrganizationProperties; - const orgNames = await routerClient.kindeClient.getClaimValue( + const orgNames = (await routerClient.kindeClient.getClaimValue( routerClient.sessionManager, 'organizations', 'id_token' - ); + )) as Array<{id: string; name: string}>; - return routerClient.json({ + const res: KindeSetupResponse = { accessToken, accessTokenEncoded, accessTokenRaw: accessTokenEncoded, @@ -76,7 +86,6 @@ export const setup = async (routerClient) => { permissions, orgCode: organization }, - needsRefresh: false, organization: { orgCode: organization, orgName, @@ -97,10 +106,12 @@ export const setup = async (routerClient) => { name: org?.name })) } - }); + }; + + return routerClient.json(res, {status: 200}); } catch (error) { if (config.isDebugMode) { - console.debug(error); + console.debug('look here', error); } if (error.code == 'ERR_JWT_EXPIRED') { diff --git a/src/index.js b/src/index.js index b35514d2..61745838 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,7 @@ export { useKindeAuth, useKindeBrowserClient } from './frontend/index'; -export {isTokenValid} from './utils/pageRouter/isTokenValid'; +export {isTokenValid} from './utils/isTokenValid'; export { LoginLink, CreateOrgLink, diff --git a/src/session/getAccessTokenWithRefresh.ts b/src/session/getAccessTokenWithRefresh.ts new file mode 100644 index 00000000..65631cb6 --- /dev/null +++ b/src/session/getAccessTokenWithRefresh.ts @@ -0,0 +1,64 @@ +import {jwtDecoder} from '@kinde/jwt-decoder'; +import {KindeAccessToken} from '../../types'; +import {kindeClient} from '../session/kindeServerClient'; +import {sessionManager} from '../session/sessionManager'; +import {isTokenValid} from '../utils/isTokenValid'; + +export const getAccessTokenWithRefresh = async ( + req, + res, + forceRefresh = false +): Promise => { + const accessToken = (await ( + await sessionManager(req, res) + ).getSessionItem('access_token')) as string | null; + + const refreshToken = (await ( + await sessionManager(req, res) + ).getSessionItem('refresh_token')) as string | null; + + // no access token and no refresh token - error + if (!accessToken && !refreshToken) { + throw new Error('No access token and no refresh token'); + } + + // token expired but no refresh token - error + const validToken = isTokenValid(accessToken); + const decodedToken = jwtDecoder(accessToken) as KindeAccessToken; + + if (!validToken && !refreshToken) { + throw new Error( + 'The access token expired and a refresh token is not available. The user will need to sign in again.' + ); + } + + if (forceRefresh && !refreshToken) { + throw new Error( + 'A refresh token is required to refresh the access token, but none is present.' + ); + } + + // token expired and refresh token - refresh token + if ( + (decodedToken.exp * 1000 < Date.now() && refreshToken) || + (forceRefresh && refreshToken) + ) { + console.log('refreshing token'); + const {access_token} = await kindeClient.refreshTokens( + await sessionManager(req, res) + ); + + console.log( + 'new access token expires in', + jwtDecoder(access_token).exp * 1000 - Date.now() + ); + + return jwtDecoder(access_token) as KindeAccessToken; + } + + if (!validToken) { + return null; + } + + return decodedToken; +}; diff --git a/src/session/index.ts b/src/session/index.ts index 441b47d4..c5eb7f35 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -1,23 +1,23 @@ +import {NextApiRequest, NextApiResponse} from 'next'; +import {config} from '../config/index'; import {getAccessTokenFactory} from './getAccessToken'; +import {getAccessTokenRawFactory} from './getAccessTokenRaw'; import {getBooleanFlagFactory} from './getBooleanFlag'; +import {getClaimFactory} from './getClaim'; import {getFlagFactory} from './getFlag'; import {getIdTokenFactory} from './getIdToken'; +import {getIdTokenRawFactory} from './getIdTokenRaw'; import {getIntegerFlagFactory} from './getIntegerFlag'; import {getOrganizationFactory} from './getOrganization'; import {getPermissionFactory} from './getPermission'; import {getPermissionsFactory} from './getPermissions'; +import {getRolesFactory} from './getRoles'; import {getStringFlagFactory} from './getStringFlag'; import {getUserFactory} from './getUser'; import {getUserOrganizationsFactory} from './getUserOrganizations'; import {isAuthenticatedFactory} from './isAuthenticated'; -import {getAccessTokenRawFactory} from './getAccessTokenRaw'; -import {getIdTokenRawFactory} from './getIdTokenRaw'; import {kindeClient} from './kindeServerClient'; import {sessionManager} from './sessionManager'; -import {getRolesFactory} from './getRoles'; -import {getClaimFactory} from './getClaim'; -import {config} from '../config/index'; -import {NextApiRequest, NextApiResponse} from 'next'; export default function (req?: NextApiRequest, res?: NextApiResponse) { return { diff --git a/src/session/isAuthenticated.js b/src/session/isAuthenticated.js index 930896f8..a9db7d91 100644 --- a/src/session/isAuthenticated.js +++ b/src/session/isAuthenticated.js @@ -1,4 +1,4 @@ -import {isTokenValid} from '../utils/pageRouter/isTokenValid'; +import {isTokenValid} from '../utils/isTokenValid'; import {getUserFactory} from './getUser'; import {sessionManager} from './sessionManager'; diff --git a/src/utils/pageRouter/isTokenValid.js b/src/utils/isTokenValid.ts similarity index 63% rename from src/utils/pageRouter/isTokenValid.js rename to src/utils/isTokenValid.ts index 5632f51b..4b8a269e 100644 --- a/src/utils/pageRouter/isTokenValid.js +++ b/src/utils/isTokenValid.ts @@ -1,5 +1,5 @@ import {jwtDecoder, TokenPart} from '@kinde/jwt-decoder'; -import {config} from '../../config/index'; +import {config} from '../config/index'; const isTokenValid = (token) => { const accessToken = token?.access_token ?? token; @@ -9,9 +9,13 @@ const isTokenValid = (token) => { const accessTokenPayload = jwtDecoder(accessToken); let isAudienceValid = true; if (config.audience) - isAudienceValid = - accessTokenPayload.aud && - accessTokenPayload.aud.includes(...config.audience); + if (Array.isArray(config.audience)) { + isAudienceValid = + accessTokenPayload.aud && + config.audience.some((aud) => accessTokenPayload.aud.includes(aud)); + } else if (typeof config.audience === 'string') { + isAudienceValid = accessTokenPayload.aud.includes(config.audience); + } if ( accessTokenPayload.iss == config.issuerURL && diff --git a/tsconfig.json b/tsconfig.json index 548469ff..afa271f6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ // go to js file when using IDE functions like // "Go to Definition" in VSCode "declarationMap": true, - "esModuleInterop": true + "esModuleInterop": true, + "jsx": "react" // "checkJs": true, // "strict": true } diff --git a/types.d.ts b/types.d.ts index 0761ae04..005b2ca4 100644 --- a/types.d.ts +++ b/types.d.ts @@ -198,13 +198,13 @@ export type KindeOrganization = { orgCode: string | null; orgName?: string | null; properties?: { - org_city?: string; - org_country?: string; - org_industry?: string; - org_postcode?: string; - org_state_region?: string; - org_street_address?: string; - org_street_address_2?: string; + city?: string; + country?: string; + industry?: string; + postcode?: string; + state_region?: string; + street_address?: string; + street_address_2?: string; }; }; @@ -351,7 +351,10 @@ export type KindeState = { export type KindeSetupResponse = { accessToken: KindeAccessToken; accessTokenEncoded: string; + accessTokenRaw: string; idToken: KindeIdToken; + idTokenEncoded: string; + idTokenRaw: string; user: KindeUser>; permissions: KindePermissions; organization: KindeOrganization;