From 0750b75026d4870e632e1cbca6dcef0f851d8d83 Mon Sep 17 00:00:00 2001 From: Arno V Date: Mon, 24 Jun 2024 20:42:49 +0200 Subject: [PATCH] feat: stronger security level for JWT verification with RSA keys (#21) * feat: stronger security level for JWT verification with RSA keys * Update bundlesize.config.js * more refactor --- examples/implicit-flow/src/main.tsx | 2 +- packages/auth-provider/bundlesize.config.js | 2 +- packages/auth-provider/package.json | 6 +- .../auth-provider/src/common/constants.ts | 14 ++ packages/auth-provider/src/common/types.d.ts | 21 +-- .../auth-provider/src/common/utilities.ts | 58 ++++++- .../components/AuthProvider/AuthProvider.tsx | 146 ++++++++---------- pnpm-lock.yaml | 27 +--- 8 files changed, 149 insertions(+), 127 deletions(-) diff --git a/examples/implicit-flow/src/main.tsx b/examples/implicit-flow/src/main.tsx index b383b8b..9c94c4a 100644 --- a/examples/implicit-flow/src/main.tsx +++ b/examples/implicit-flow/src/main.tsx @@ -65,7 +65,7 @@ export const App: React.FC = () => { variant="danger" disabled={!isAuthenticated} > - Logout (valid) + Logout diff --git a/packages/auth-provider/bundlesize.config.js b/packages/auth-provider/bundlesize.config.js index 26f8d32..e13837e 100644 --- a/packages/auth-provider/bundlesize.config.js +++ b/packages/auth-provider/bundlesize.config.js @@ -9,7 +9,7 @@ export default { */ { path: "dist/index.js", - limit: "4 kb", + limit: "9 kb", }, ], }; diff --git a/packages/auth-provider/package.json b/packages/auth-provider/package.json index 493d145..0338a7f 100644 --- a/packages/auth-provider/package.json +++ b/packages/auth-provider/package.json @@ -14,9 +14,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "scripts": { "build:check": "tsc", "build:js": "vite build", @@ -45,7 +43,7 @@ }, "dependencies": { "@versini/auth-common": "workspace:../auth-common", - "@versini/ui-hooks": "3.0.0", + "@versini/ui-hooks": "4.0.0", "jose": "5.4.1", "uuid": "10.0.0" } diff --git a/packages/auth-provider/src/common/constants.ts b/packages/auth-provider/src/common/constants.ts index 938e059..dfccc95 100644 --- a/packages/auth-provider/src/common/constants.ts +++ b/packages/auth-provider/src/common/constants.ts @@ -1,5 +1,7 @@ export const EXPIRED_SESSION = "Oops! It looks like your session has expired. For your security, please log in again to continue."; +export const LOGOUT_SESSION = "Your session has been successfully terminated."; + export const AUTH_CONTEXT_ERROR = "You forgot to wrap your component in ."; @@ -7,3 +9,15 @@ export const API_ENDPOINT = { dev: "https://auth.gizmette.local.com:3003", prod: "https://mylogin.gizmette.com", }; + +export const LOCAL_STORAGE_PREFIX = "@@auth@@"; + +export const JWT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsF6i3Jd9fY/3COqCw/m7 +w5PKyTYLGAI2I6SIIdpe6i6DOCbEkmDz7LdVsBqwNtVi8gvWYIj+8ol6rU3qu1v5 +i1Jd45GSK4kzkVdgCmQZbM5ak0KI99q5wsrAIzUd+LRJ2HRvWtr5IYdsIiXaQjle +aMwPFOIcJH+rKfFgNcHLcaS5syp7zU1ANwZ+trgR+DifBr8TLVkBynmNeTyhDm2+ +l0haqjMk0UoNPPE8iYBWUHQJJE1Dqstj65d6Eh5g64Pao25y4cmYJbKjiblIGEkE +sjqybA9mARAqh9k/eiIopecWSiffNQTwVQVd2I9ZH3BalhEXHlqFgrjz51kFqg81 +awIDAQAB +-----END PUBLIC KEY-----`; diff --git a/packages/auth-provider/src/common/types.d.ts b/packages/auth-provider/src/common/types.d.ts index eea4529..1c258c2 100644 --- a/packages/auth-provider/src/common/types.d.ts +++ b/packages/auth-provider/src/common/types.d.ts @@ -2,15 +2,6 @@ export type ServiceCallProps = { params: any; }; -export type AuthState = { - isAuthenticated: boolean; - idToken: string; - logoutReason: string; - userId: string; - accessToken?: string; - refreshToken?: string; -}; - export type AuthProviderProps = { children: React.ReactNode; sessionExpiration?: string; @@ -18,12 +9,16 @@ export type AuthProviderProps = { accessType?: string; }; -export type AuthContextProps = { - login: (username: string, password: string) => Promise; - logout: () => void; +export type AuthState = { isAuthenticated: boolean; + idToken?: string; accessToken?: string; refreshToken?: string; - idToken?: string; logoutReason?: string; + userId?: string; }; + +export type AuthContextProps = { + login: (username: string, password: string) => Promise; + logout: () => void; +} & AuthState; diff --git a/packages/auth-provider/src/common/utilities.ts b/packages/auth-provider/src/common/utilities.ts index 9ba63e8..caa1816 100644 --- a/packages/auth-provider/src/common/utilities.ts +++ b/packages/auth-provider/src/common/utilities.ts @@ -1,7 +1,8 @@ -import { HEADERS } from "@versini/auth-common"; +import { AUTH_TYPES, HEADERS, JWT } from "@versini/auth-common"; +import * as jose from "jose"; import { v4 as uuidv4 } from "uuid"; -import { API_ENDPOINT } from "./constants"; +import { API_ENDPOINT, JWT_PUBLIC_KEY } from "./constants"; import type { ServiceCallProps } from "./types"; export const isProd = process.env.NODE_ENV === "production"; @@ -43,3 +44,56 @@ export const serviceCall = async ({ params = {} }: ServiceCallProps) => { return { status: 500, data: [] }; } }; + +export const verifyAndExtractToken = async (token: string) => { + try { + const alg = JWT.ALG; + const spki = JWT_PUBLIC_KEY; + const publicKey = await jose.importSPKI(spki, alg); + return await jose.jwtVerify(token, publicKey, { + issuer: JWT.ISSUER, + }); + } catch (_error) { + return undefined; + } +}; + +export const authenticateUser = async ({ + username, + password, + clientId, + sessionExpiration, +}: { + username: string; + password: string; + clientId: string; + sessionExpiration?: string; +}) => { + try { + const response = await serviceCall({ + params: { + type: AUTH_TYPES.ID_TOKEN, + username, + password, + sessionExpiration, + clientId, + }, + }); + const jwt = await verifyAndExtractToken(response.data.idToken); + if (jwt && jwt.payload[JWT.USER_ID_KEY] !== "") { + return { + idToken: response.data.idToken, + userId: jwt.payload[JWT.USER_ID_KEY] as string, + status: true, + }; + } else { + return { + status: false, + }; + } + } catch (_error) { + return { + status: false, + }; + } +}; diff --git a/packages/auth-provider/src/components/AuthProvider/AuthProvider.tsx b/packages/auth-provider/src/components/AuthProvider/AuthProvider.tsx index d71f1a3..cddeb4a 100644 --- a/packages/auth-provider/src/components/AuthProvider/AuthProvider.tsx +++ b/packages/auth-provider/src/components/AuthProvider/AuthProvider.tsx @@ -1,11 +1,17 @@ -import { AUTH_TYPES } from "@versini/auth-common"; +import { JWT } from "@versini/auth-common"; import { useLocalStorage } from "@versini/ui-hooks"; -import * as jose from "jose"; import { useEffect, useState } from "react"; -import { EXPIRED_SESSION } from "../../common/constants"; +import { + EXPIRED_SESSION, + LOCAL_STORAGE_PREFIX, + LOGOUT_SESSION, +} from "../../common/constants"; import type { AuthProviderProps, AuthState } from "../../common/types"; -import { serviceCall } from "../../common/utilities"; +import { + authenticateUser, + verifyAndExtractToken, +} from "../../common/utilities"; import { usePrevious } from "../hooks/usePrevious"; import { AuthContext } from "./AuthContext"; @@ -13,102 +19,74 @@ export const AuthProvider = ({ children, sessionExpiration, clientId, - accessType, }: AuthProviderProps) => { - const [accessToken, setAccessToken, removeAccessToken] = useLocalStorage( - `@@auth@@::${clientId}::@@access@@`, - "", - ); - const [refreshToken, setRefreshToken, removeRefreshToken] = useLocalStorage( - `@@auth@@::${clientId}::@@refresh@@`, - "", - ); - const [idToken, setIdToken, removeIdToken] = useLocalStorage( - `@@auth@@::${clientId}::@@user@@`, - "", - ); + const [idToken, setIdToken, , removeIdToken] = useLocalStorage({ + key: `${LOCAL_STORAGE_PREFIX}::${clientId}::@@user@@`, + }); + const [authState, setAuthState] = useState({ - isAuthenticated: !!idToken, - accessToken, - refreshToken, - idToken, + isAuthenticated: Boolean(idToken), logoutReason: "", userId: "", }); const previousIdToken = usePrevious(idToken) || ""; + /** + * This effect is responsible to set the authentication state based on the + * idToken stored in the local storage. It is used when the page is being + * refreshed. + */ useEffect(() => { - if (previousIdToken !== idToken && idToken !== "") { - try { - const { _id }: { _id: string } = jose.decodeJwt(idToken); - setAuthState({ - isAuthenticated: true, - accessToken, - refreshToken, - idToken, - logoutReason: "", - userId: _id || "", - }); - } catch (_error) { - setAuthState({ - isAuthenticated: false, - accessToken: "", - refreshToken: "", - idToken: "", - logoutReason: EXPIRED_SESSION, - userId: "", - }); - } - } else if (previousIdToken !== idToken && idToken === "") { - setAuthState({ - isAuthenticated: false, - accessToken: "", - refreshToken: "", - idToken: "", - logoutReason: EXPIRED_SESSION, - userId: "", - }); + if (previousIdToken !== idToken && idToken !== null) { + (async () => { + try { + const jwt = await verifyAndExtractToken(idToken); + if (jwt && jwt.payload[JWT.USER_ID_KEY] !== "") { + setAuthState({ + isAuthenticated: true, + logoutReason: "", + userId: jwt.payload[JWT.USER_ID_KEY] as string, + }); + } + } catch (_error) { + setAuthState({ + isAuthenticated: false, + logoutReason: EXPIRED_SESSION, + userId: "", + }); + } + })(); } - }, [accessToken, refreshToken, idToken, previousIdToken]); + }, [idToken, previousIdToken]); - const login = async (username: string, password: string) => { - const response = await serviceCall({ - params: { - type: accessType || AUTH_TYPES.ID_TOKEN, - username, - password, - sessionExpiration, - clientId, - }, + const login = async ( + username: string, + password: string, + ): Promise => { + const response = await authenticateUser({ + username, + password, + clientId, + sessionExpiration, }); - - try { - const { _id }: { _id: string } = jose.decodeJwt(response.data.idToken); - if (_id) { - setIdToken(response.data.idToken); - response.data.accessToken && setAccessToken(response.data.accessToken); - response.data.refreshToken && - setRefreshToken(response.data.refreshToken); - setAuthState({ - isAuthenticated: true, - idToken: response.data.idToken, - accessToken: response.data.accessToken, - refreshToken: response.data.refreshToken, - userId: _id, - logoutReason: "", - }); - return true; - } - return false; - } catch (_error) { - return false; + if (response.status) { + setIdToken(response.idToken); + setAuthState({ + isAuthenticated: true, + userId: response.userId, + }); + return true; } + return false; }; const logout = () => { - removeAccessToken(); - removeRefreshToken(); + setAuthState({ + isAuthenticated: false, + logoutReason: LOGOUT_SESSION, + userId: "", + }); removeIdToken(); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a64cef8..1c896ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,8 +47,8 @@ importers: specifier: workspace:../auth-common version: link:../auth-common '@versini/ui-hooks': - specifier: 3.0.0 - version: 3.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 4.0.0 + version: 4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) jose: specifier: 5.4.1 version: 5.4.1 @@ -1301,8 +1301,8 @@ packages: react: ^18.3.1 react-dom: ^18.3.1 - '@versini/ui-hooks@3.0.0': - resolution: {integrity: sha512-/iaLQrehnZFwi/j7by4X3V4G0L9Tw+anlzQq10Bln1OAZP23XlA/GuLZYyvqY0gaMFL0TSNoT084bhEyn+LWbA==} + '@versini/ui-hooks@4.0.0': + resolution: {integrity: sha512-YnFToKieKB+3txMCnz8cqji0Nc1PFZ3uEWIcyE48TZjDz9rX3/XZzF8pYO4GlA33UWfZtN/ihwvMkm5PNmVQng==} peerDependencies: react: ^18.3.1 react-dom: ^18.3.1 @@ -2960,9 +2960,6 @@ packages: lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} @@ -4473,12 +4470,6 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - usehooks-ts@3.1.0: - resolution: {integrity: sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==} - engines: {node: '>=16.15.0'} - peerDependencies: - react: ^16.8.0 || ^17 || ^18 - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6055,11 +6046,10 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@versini/ui-hooks@3.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@versini/ui-hooks@4.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - usehooks-ts: 3.1.0(react@18.3.1) '@versini/ui-icons@1.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -8015,8 +8005,6 @@ snapshots: lodash.castarray@4.4.0: {} - lodash.debounce@4.0.8: {} - lodash.get@4.4.2: {} lodash.isequal@4.5.0: {} @@ -9640,11 +9628,6 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 - usehooks-ts@3.1.0(react@18.3.1): - dependencies: - lodash.debounce: 4.0.8 - react: 18.3.1 - util-deprecate@1.0.2: {} uuid@10.0.0: {}