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

feat: stronger security level for JWT verification with RSA keys #21

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/implicit-flow/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const App: React.FC = () => {
variant="danger"
disabled={!isAuthenticated}
>
Logout (valid)
Logout
</Button>
</FlexgridItem>
</Flexgrid>
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-provider/bundlesize.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default {
*/
{
path: "dist/index.js",
limit: "4 kb",
limit: "9 kb",
},
],
};
6 changes: 2 additions & 4 deletions packages/auth-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
Expand Down
14 changes: 14 additions & 0 deletions packages/auth-provider/src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
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 <AuthProvider>.";

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-----`;
21 changes: 8 additions & 13 deletions packages/auth-provider/src/common/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,23 @@ 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;
clientId: string;
accessType?: string;
};

export type AuthContextProps = {
login: (username: string, password: string) => Promise<boolean>;
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<boolean>;
logout: () => void;
} & AuthState;
58 changes: 56 additions & 2 deletions packages/auth-provider/src/common/utilities.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
}
};
aversini marked this conversation as resolved.
Show resolved Hide resolved

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,
};
}
};
aversini marked this conversation as resolved.
Show resolved Hide resolved
146 changes: 62 additions & 84 deletions packages/auth-provider/src/components/AuthProvider/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,114 +1,92 @@
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";

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<AuthState>({
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<boolean> => {
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: "",
});
aversini marked this conversation as resolved.
Show resolved Hide resolved
removeIdToken();
};

Expand Down
Loading