From 27ff010006537e5bf0ec692229f6dd8a071cb7a7 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Wed, 23 Oct 2024 22:58:24 +0200 Subject: [PATCH] feat: add react `AuthProvider` and `useAuth()` hook The AuthProvider works in conjunction with the rest of the package, and makes it easy to leverage it in for example NextJS based projects --- .changeset/tiny-spoons-arrive.md | 5 + packages/react/.eslintignore | 1 + packages/react/.eslintrc.cjs | 3 + packages/react/package.json | 65 ++++++ packages/react/src/index.ts | 4 + packages/react/src/provider.tsx | 372 +++++++++++++++++++++++++++++++ packages/react/tsconfig.json | 8 + packages/react/tsup.config.js | 13 ++ 8 files changed, 471 insertions(+) create mode 100644 .changeset/tiny-spoons-arrive.md create mode 100644 packages/react/.eslintignore create mode 100644 packages/react/.eslintrc.cjs create mode 100644 packages/react/package.json create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/provider.tsx create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/tsup.config.js diff --git a/.changeset/tiny-spoons-arrive.md b/.changeset/tiny-spoons-arrive.md new file mode 100644 index 0000000..9786386 --- /dev/null +++ b/.changeset/tiny-spoons-arrive.md @@ -0,0 +1,5 @@ +--- +"@labdigital/federated-token-react": patch +--- + +Initial version diff --git a/packages/react/.eslintignore b/packages/react/.eslintignore new file mode 100644 index 0000000..06c5e16 --- /dev/null +++ b/packages/react/.eslintignore @@ -0,0 +1 @@ +dist/** diff --git a/packages/react/.eslintrc.cjs b/packages/react/.eslintrc.cjs new file mode 100644 index 0000000..8a3a442 --- /dev/null +++ b/packages/react/.eslintrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + extends: ["../../.eslintrc.cjs"], +}; diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..2058b8b --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,65 @@ +{ + "name": "@labdigital/federated-token-react", + "version": "0.13.2", + "description": "Federate JWT tokens for React clients", + "module": "./dist/index.js", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "keywords": [ + "graphql", + "authentication", + "react" + ], + "author": "Lab Digital ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/labd/node-federated-token" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "test": "vitest run", + "test:ci": "vitest run --coverage", + "tsc": "tsc --noEmit", + "format": "eslint src --fix && prettier --write .", + "lint": "eslint src && prettier --check ." + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "js-cookie": "3.0.5", + "jsonwebtoken": "9.0.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/js-cookie": "3.0.6", + "@types/jsonwebtoken": "9.0.6", + "@types/react": "18.3.3", + "@typescript-eslint/eslint-plugin": "^7.12.0", + "@vitest/coverage-v8": "1.6.0", + "eslint": "^8.57.0", + "eslint-plugin-unused-imports": "^4.0.0", + "node-mocks-http": "^1.14.1", + "prettier": "^3.3.1", + "tsup": "^8.1.0", + "typescript": "^5.4.5", + "vitest": "1.6.0" + }, + "peerDependencies": { + "react": ">= 18.0.0", + "graphql": ">= 16.6.0" + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 0000000..28e7672 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,4 @@ +"use client"; +export { AuthProvider } from "./provider"; +export { useAuth } from "./provider"; +export { type AuthProviderProps } from "./provider"; diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx new file mode 100644 index 0000000..98c9ecc --- /dev/null +++ b/packages/react/src/provider.tsx @@ -0,0 +1,372 @@ +// Auth context for the site +"use client"; +import Cookie from "js-cookie"; +import { decode as jwtDecode } from "jsonwebtoken"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +/** + * Buffer time in seconds before token expiration. + * Set to 5 minutes to allow ample time for token refresh before it becomes invalid. + */ +const TOKEN_VALID_BUFFER = 5 * 60; + +type CookieNames = { + guestData: string; + userData: string; + guestRefreshTokenExists: string; + userRefreshTokenExists: string; +}; + +const DEFAULT_COOKIE_NAMES: CookieNames = { + userData: "userData", + guestData: "guestData", + userRefreshTokenExists: "userRefreshTokenExists", + guestRefreshTokenExists: "guestRefreshTokenExists", +}; + +export type TokenData = { + exp: number; + isAuthenticated: boolean; + values: TokenValues; +}; + +export type TokenValues = Record; + +type TokenPayload = { + exp: number; +} & TokenValues; + +type AuthContextType = { + isAuthenticated: boolean; + hasToken: boolean; + values: TokenValues | null; + loading: boolean; + logout: () => Promise; + validateLocalToken: () => void; + checkToken: () => Promise; +}; + +const AuthContext = createContext(undefined); + +export type AuthProviderProps = { + authEndpoint: string; + refreshTokenMutation: string; + logoutMutation: string; + cookieNames?: CookieNames; +}; + +/** + * Provider that handles authentication state. + * + * @remarks + * This component manages the client side authentication state and flow for the application. + * + * @flow + * 1. Component Mount: + * - The `validateLocalToken` function is called. + * - It checks the local token without making a request. + * - The auth state is updated based on the local token. + * + * 2. Request Handling: + * - Before making a request, `checkToken` is called to verify token status with the server. + * - If the access token is expired or invalid: + * a. The server attempts to use the refresh token to obtain a new access token. + * b. If successful, a new access token is returned and the auth state is updated. + * c. If unsuccessful, the auth state is updated to unauthenticated. + * - The request is then made with the current token state. + * - The auth state is updated based on the server's response. + * + * @param props.apiHostname - api hostname used to fetch token status from the api + */ +export function AuthProvider({ + children, + options, +}: { + children: React.ReactNode; + options: AuthProviderProps; +}) { + const [authState, setAuthState] = useState< + Omit + >({ + isAuthenticated: false, + hasToken: false, + values: null, + loading: true, + }); + + const cookieNames = DEFAULT_COOKIE_NAMES; + + const updateAuthState = useCallback((token?: TokenData) => { + if (token?.isAuthenticated) { + setAuthState({ + isAuthenticated: true, + hasToken: true, + values: token.values, + loading: false, + }); + } else { + setAuthState({ + isAuthenticated: false, + hasToken: Boolean(token), + values: null, + loading: false, + }); + } + }, []); + + /** + * All this does is validate the local token in the cookie, it will not do any + * request to the server. This is best used right after a request so that we + * can check if the token is still valid afterwards. + * + * @remarks Valid use case is that the API does not accept the given cookie + * and empties it as a response + */ + const validateLocalToken = useCallback(() => { + const token = checkLocalToken(); + updateAuthState(token); + }, [updateAuthState]); + + /** + * Load initial token when mounting the application. Doesn't do any checks + * because we don't want to hammer the server when the user first loads the + * application + * + * It will get checked when the user makes a request. + */ + const loadToken = useCallback(() => { + const token = getJWT(); + updateAuthState(token); + }, [updateAuthState]); + + /** + * Checks the token status with the server. + * + * - If the access token is expired or invalid, the server attempts to use the refresh token to obtain a new access token. + * - If successful, a new access token is returned and the auth state is updated. + * - If unsuccessful, the auth state is updated to unauthenticated. + */ + const checkToken = useCallback(async () => { + const token = await getAccessToken(); + updateAuthState(token); + }, [options.authEndpoint, updateAuthState]); + + // Load initial auth state when mounting the application + useEffect(() => { + loadToken(); + }, [loadToken]); + + /** + * Log out the user + * + * @throws {Error} If the COOKIE_DOMAIN environment variable is not set. Catch + * this function and handle it in the UI. + */ + const logout = async () => { + setAuthState({ + isAuthenticated: false, + hasToken: false, + values: null, + loading: false, + }); + + await clearTokens(); + + validateLocalToken(); + }; + + /** + * Checks the local JWT token stored in cookies. + * + * This function retrieves the JWT token using the getJWT function and validates its expiration. + * It ensures that the token is still valid for at least 5 more minutes. + * + * @returns {Token | undefined} The decoded token if it's valid, or undefined if the token is expired or doesn't exist. + * + * @example + * const token = checkLocalToken(); + * if (token) { + * console.log('User is authenticated:', token.authenticated); + * } else { + * console.log('Token is expired or not present'); + * } + */ + const checkLocalToken = (): TokenData | undefined => { + const token = getJWT(); + + if (!token) { + return undefined; + } + + return checkTokenValidity(token) ? token : undefined; + }; + + /** + * Checks if the token is still valid for at least 5 more minutes. + * + * @param token The JWT token to check. + * @returns {boolean} True if the token is still valid, false otherwise. + */ + const checkTokenValidity = (token: TokenData): boolean => { + // Get the current time in seconds + const timeSec = Math.floor(Date.now() / 1000); + + return Boolean(token?.exp && token.exp - TOKEN_VALID_BUFFER > timeSec); + }; + + const getJWT = (): TokenData | undefined => { + const userToken = Cookie.get(cookieNames.userData); + + const extractValues = (tokenPayload: TokenPayload) => { + const skipKeys = ["exp"]; + return Object.keys(tokenPayload).reduce( + (acc, key) => + skipKeys.includes(key) ? acc : { ...acc, [key]: tokenPayload[key] }, + {}, + ); + }; + if (userToken) { + const tokenPayload = decodeToken(userToken); + + if (tokenPayload) { + if (tokenPayload) { + return { + exp: tokenPayload.exp, + isAuthenticated: true, + values: extractValues(tokenPayload), + }; + } + } + } + + const guestToken = Cookie.get(cookieNames.guestData); + if (guestToken) { + const tokenPayload = decodeToken(guestToken); + if (tokenPayload) { + return { + exp: tokenPayload.exp, + isAuthenticated: false, + values: extractValues(tokenPayload), + }; + } + } + }; + + const getAccessToken = async (): Promise => { + const token = getJWT(); + + // Check if the token is still valid for more than 5 minutes. The JWT times + // are in seconds, so we need to convert the current time to seconds as well. + const timeSec = Math.floor(Date.now() / 1000); + const buffer = 5 * 60; + if (token?.exp && token.exp - buffer > timeSec) { + return token; + } + + // Do we have a refresh token? This can be either a user (authenticated) or + // guest refresh token + const hasRefreshToken = + Cookie.get(cookieNames.userRefreshTokenExists) ?? + Cookie.get(cookieNames.guestRefreshTokenExists); + if (hasRefreshToken) { + await refreshAccessToken(); + return getJWT(); + } + + // No token exists + return undefined; + }; + + const refreshAccessToken = async () => { + // Since we are storing the refresh token in a cookie this will be sent + // automatically by the browser. + const response = await fetch(options.authEndpoint, { + method: "POST", + body: options.refreshTokenMutation, + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`Failed to refresh token: ${response.statusText}`); + } + }; + + const clearTokens = async () => { + // Since we are storing the refresh token in a cookie this will be sent + // automatically by the browser. + const response = await fetch(options.authEndpoint, { + method: "POST", + body: options.logoutMutation, + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Failed to clear token: ${response.statusText}`); + } + }; + + return ( + + {children} + + ); +} + +/** + * Custom hook to access authentication context. + * + * @returns {Object} An object containing: + * - isAuthenticated: boolean indicating if the user is authenticated + * - values: object containing all values from the token + * - loading: boolean indicating if the authentication state is being loaded + * - logout: function to log out the current user + * - refreshToken: function to refresh the authentication token + * + * @throws {Error} If used outside of an AuthProvider + * + * @example + * const { isAuthenticated, values, logout } = useAuth(); + * + * if (isAuthenticated) { + * console.log(`Welcome, ${values.givenName}!`); + * } else { + * console.log('Please log in.'); + * } + */ +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +export const decodeToken = (token: string): TokenPayload | undefined => { + const decodedToken = jwtDecode(token, { complete: true }); + if ( + !decodedToken || + !decodedToken.payload || + typeof decodedToken.payload === "string" + ) { + return undefined; + } + return decodedToken.payload as TokenPayload; +}; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000..e4e95a2 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["DOM", "ESNext", "DOM.Iterable"] + }, + "include": ["src/**/*"] +} diff --git a/packages/react/tsup.config.js b/packages/react/tsup.config.js new file mode 100644 index 0000000..f82cf2f --- /dev/null +++ b/packages/react/tsup.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig([ + { + entry: ["src/index.ts"], + clean: true, + splitting: false, + dts: true, + sourcemap: true, + format: ["esm", "cjs"], + outDir: "dist", + }, +]);