Skip to content

Commit

Permalink
Merge pull request #1481 from HHS/OPS-1462_refactor_tokens_frontend
Browse files Browse the repository at this point in the history
Ops 1462 refactor tokens frontend
  • Loading branch information
tdonaworth authored Sep 25, 2023
2 parents cd3b975 + 7b20a4f commit 0d20fad
Show file tree
Hide file tree
Showing 12 changed files with 210 additions and 30 deletions.
4 changes: 4 additions & 0 deletions backend/ops_api/ops/environment/default_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=30) # FedRAMP AC-12 Control is 30 min
JWT_REFRESH_TOKEN_EXPIRES = timedelta(hours=12)

# OPS-API JWT
JWT_ENCODE_ISSUER = "https://opre-ops-backend-dev"
JWT_ENCODE_AUDIENCE = "https://opre-ops-frontend-dev"

AUTHLIB_OAUTH_CLIENTS = {
"logingov": {
"server_metadata_url": "https://idp.int.identitysandbox.gov/.well-known/openid-configuration",
Expand Down
30 changes: 20 additions & 10 deletions backend/ops_api/ops/utils/auth_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
import requests
from authlib.integrations.requests_client import OAuth2Session
from flask import Response, current_app, request
from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, jwt_required
from flask_jwt_extended import (
create_access_token,
create_refresh_token,
get_current_user,
get_jwt_identity,
jwt_required,
)
from models.events import OpsEventType
from ops_api.ops.utils.auth import create_oauth_jwt, decode_user
from ops_api.ops.utils.authentication import AuthenticationGateway
Expand Down Expand Up @@ -168,13 +174,17 @@ def _get_token_and_user_data_from_oauth_provider(provider: str, auth_code: str):

# We are using the `refresh=True` options in jwt_required to only allow
# refresh tokens to access this route.
@jwt_required(refresh=True)
@jwt_required(refresh=True, verify_type=True, locations=["headers", "cookies"])
def refresh() -> Response:
identity = get_jwt_identity()
additional_claims = {}
if identity.roles:
additional_claims["roles"] = [role.name for role in identity.roles]
access_token = create_access_token(
identity=identity, expires_delta=None, additional_claims=additional_claims, fresh=False
)
return make_response_with_headers({"access_token": access_token})
user = get_current_user()
if user:
additional_claims = {"roles": []}
current_app.logger.debug(f"user {user}")
if user.roles:
additional_claims["roles"] = [role.name for role in user.roles]
access_token = create_access_token(
identity=user, expires_delta=None, additional_claims=additional_claims, fresh=False
)
return make_response_with_headers({"access_token": access_token})
else:
return make_response_with_headers({"message": "Invalid User"}, 401)
1 change: 1 addition & 0 deletions frontend/cypress/e2e/agreementDelete.cy.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference types="cypress" />
import { terminalLog, testLogin } from "./utils";

// eslint-disable-next-line no-unused-vars
const testAgreements = [
{
agreement: 1,
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/api/opsAPI.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { getAccessToken } from "../components/Auth/auth";

const BACKEND_DOMAIN = process.env.REACT_APP_BACKEND_DOMAIN;

Expand All @@ -17,7 +18,7 @@ export const opsApi = createApi({
baseQuery: fetchBaseQuery({
baseUrl: `${BACKEND_DOMAIN}/api/v1/`,
prepareHeaders: (headers) => {
const access_token = localStorage.getItem("access_token");
const access_token = getAccessToken();

if (access_token) {
headers.set("Authorization", `Bearer ${access_token}`);
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Auth/AuthSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useDispatch, useSelector } from "react-redux";
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import cryptoRandomString from "crypto-random-string";
import { getAuthorizationCode } from "./auth";
import { getAccessToken, getAuthorizationCode } from "./auth";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { solid } from "@fortawesome/fontawesome-svg-core/import.macro";
import { User } from "../UI/Header/User";
Expand Down Expand Up @@ -35,7 +35,7 @@ const AuthSection = () => {
);

useEffect(() => {
const currentJWT = localStorage.getItem("access_token");
const currentJWT = getAccessToken();

if (currentJWT) {
// TODO: we should validate the JWT here and set it on state if valid else logout
Expand Down
9 changes: 3 additions & 6 deletions frontend/src/components/Auth/MultiAuthSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { login } from "./authSlice";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import cryptoRandomString from "crypto-random-string";
import { getAuthorizationCode } from "./auth";
import { getAccessToken, getAuthorizationCode } from "./auth";
import { apiLogin } from "../../api/apiLogin";
import ContainerModal from "../UI/Modals/ContainerModal";
import { setActiveUser } from "./auth";
Expand All @@ -24,13 +24,12 @@ const MultiAuthSection = () => {
}

const response = await apiLogin(activeProvider, authCode);
// console.debug(`API Login Response = ${JSON.stringify(response)}`);
if (response.access_token === null || response.access_token === undefined) {
console.error("API Login Failed!");
navigate("/login");
} else {
console.log(`DEBUG:::ACCESS_TOKEN: ${response.access_token}`);
localStorage.setItem("access_token", response.access_token);
localStorage.setItem("refresh_token", response.refresh_token);
dispatch(login());

if (response.is_new_user) {
Expand All @@ -47,7 +46,7 @@ const MultiAuthSection = () => {
);

React.useEffect(() => {
const currentJWT = localStorage.getItem("access_token");
const currentJWT = getAccessToken();
if (currentJWT) {
// TODO: we should validate the JWT here and set it on state if valid else logout
dispatch(login());
Expand All @@ -71,7 +70,6 @@ const MultiAuthSection = () => {
throw new Error("Response from OIDC provider is invalid.");
} else {
const authCode = queryParams.get("code");
console.log(`Received Authentication Code = ${authCode}`);
callBackend(authCode).catch(console.error);
}
}
Expand All @@ -84,7 +82,6 @@ const MultiAuthSection = () => {
// TODO: Replace these tokens with config variables, that can be passed in at deploy-time,
// So that we don't actually store anything in code.
const handleFakeAuthLogin = (user_type) => {
// console.debug(`Logging in with FakeAuth: ${user_type}`);
localStorage.setItem("activeProvider", "fakeauth");
callBackend(user_type).catch(console.error);

Expand Down
131 changes: 128 additions & 3 deletions frontend/src/components/Auth/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,33 @@ import ApplicationContext from "../../applicationContext/ApplicationContext";
import cryptoRandomString from "crypto-random-string";
import jwt_decode from "jwt-decode";
import { getUserByOidc } from "../../api/getUser";
import { setUserDetails } from "../Auth/authSlice";
import { logout, setUserDetails } from "../Auth/authSlice";
import { callBackend } from "../../helpers/backend";

const authConfig = ApplicationContext.get().helpers().authConfig;
/**
* Represents the status of a token.
*/
class TokenValidationStatus {
constructor(isValid, msg) {
this.isValid = isValid;
this.msg = msg;
}
}

/**
* Generates the authorization code URL for the specified provider and state token.
* @function getAuthorizationCode
* @param {string} provider - The name of the provider to generate the authorization code URL for.
* @param {string} stateToken - The state token to include in the authorization code URL.
* @returns {URL} The authorization code URL for the specified provider and state token.
*
* @example
* const provider = "login.gov";
* const stateToken = "12345";
* const authUrl = getAuthorizationCode(provider, stateToken);
*/
export const getAuthorizationCode = (provider, stateToken) => {
const authConfig = ApplicationContext.get().helpers().authConfig;
const authProvider = authConfig[provider];
const providerUrl = new URL(authProvider.auth_endpoint);
providerUrl.searchParams.set("acr_values", authProvider.acr_values);
Expand All @@ -19,6 +41,11 @@ export const getAuthorizationCode = (provider, stateToken) => {
return providerUrl;
};

/**
* Logs out the user and returns the URL to redirect to for logout.
* @param {string} stateToken - The state token to include in the logout URL.
* @returns {URL} - The URL to redirect to for logout.
*/
export const logoutUser = async (stateToken) => {
// As documented here: https://developers.login.gov/oidc/
// Example:
Expand All @@ -27,26 +54,48 @@ export const logoutUser = async (stateToken) => {
// client_id=${CLIENT_ID}&
// post_logout_redirect_uri=${REDIRECT_URI}&
// state=abcdefghijklmnopabcdefghijklmnop
const authConfig = ApplicationContext.get().helpers().authConfig;
const providerLogout = new URL(authConfig.logout_endpoint);
providerLogout.searchParams.set("client_id", authConfig.client_id);
providerLogout.searchParams.set("post_logout_redirect_uri", window.location.hostname);
providerLogout.searchParams.set("state", stateToken);
return providerLogout;
};

/**
* Checks if the user is authenticated and authorized.
* @todo Implement token signature validation
* @todo Implement token claims validation
* @todo Implement token expiration validation
* @todo Implement Authorization checks.
* @returns {boolean} Returns true if the user is authenticated and authorized, otherwise false.
*/
export const CheckAuth = () => {
// TODO: We'll most likely want to include multiple checks here to determine if
// the user is correctly authenticated and authorized. Hook into the Auth service
// at some point.
// const isLoggedIn = useSelector((state) => state.auth.isLoggedIn) || false;
const tokenExists = localStorage.getItem("access_token");
const tokenExists = getAccessToken() !== null;
// TODO: Verify access_token's signature
// TODO: Verify access_token's claims
// TODO: Verify access_token's expiration - maybe perform a refresh()?
// TODO: Check Authorization
return tokenExists; // && payload;
};

/**
* Sets the active user details in the Redux store by decoding the JWT token and fetching user details from the API.
* @async
* @function setActiveUser
* @param {string} token - The JWT token to decode and fetch user details.
* @param {function} dispatch - The Redux dispatch function to set the user details in the store.
* @returns {Promise<void>} A Promise that resolves when the user details are set in the store.
*
* @example
* const token = "<token>";
* const dispatch = useDispatch();
* setActiveUser(token, dispatch);
*/
export async function setActiveUser(token, dispatch) {
// TODO: Vefiry the Token!
//const isValidToken = validateTooken(token);
Expand All @@ -56,3 +105,79 @@ export async function setActiveUser(token, dispatch) {

dispatch(setUserDetails(userDetails));
}

/**
* Retrieves the access token.
* @returns {string|null} The access token, or null if it is not found.
*
* @example
* const accessToken = getAccessToken();
*/
export const getAccessToken = () => {
const token = localStorage.getItem("access_token");
const validToken = isValidToken(token);
if (validToken.isValid) {
return token;
} else if (validToken.msg == "EXPIRED") {
// lets try to get a new token
// is the refresh token still valid?
callBackend("/api/v1/auth/refresh/", "POST", {}, null, true)
.then((response) => {
console.log(response);
localStorage.setItem("access_token", response.access_token);
return response.access_token;
})
.catch((error) => {
console.log(error);
logout();
});
} else {
return null;
}
};

/**
* Retrieves the refresh token.
* @returns {string|null} The refresh token, or null if it is not found.
*
* @example
* const refreshToken = getRefreshToken();
*/
export const getRefreshToken = () => {
const token = localStorage.getItem("refresh_token");
return token;
};

/**
* Checks if the access token is valid by decoding the JWT and comparing the expiration time with the current time.
* @returns {boolean|str} Returns true if the access token is valid, false otherwise.
*/
export const isValidToken = (token) => {
if (!token) {
return new TokenValidationStatus(false, "NOT_FOUND");
}

const decodedJwt = jwt_decode(token);

// Check expiration time
const exp = decodedJwt["exp"];
const now = Date.now() / 1000;
if (exp < now) {
return new TokenValidationStatus(false, "EXPIRED");
}

// TODO: Check signature
// const signature = decodedJwt["signature"];
// if (!verifySignature(token, signature)) {
// throw new InvalidSignatureException("Token signature is invalid");
// }

// Check issuer
const issuer = decodedJwt["iss"];
// TODO: Update this when we have a real issuer value
if (issuer !== "https://opre-ops-backend-dev") {
return new TokenValidationStatus(false, "ISSUER");
}

return new TokenValidationStatus(true, "VALID");
};
32 changes: 31 additions & 1 deletion frontend/src/components/Auth/auth.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { getAuthorizationCode } from "./auth";
import { getAuthorizationCode, isValidToken } from "./auth";
// nosemgrep - test tokens only
const expiredToken =
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6dHJ1ZSwiaWF0IjoxNjk1MTQ5NTkxLCJqdGkiOiJlOGUwMTY2ZS1lNTYyLTQ3N2UtOWJiMy05MjA1OTFiNmEyMjUiLCJ0eXBlIjoiYWNjZXNzIiwic3ViIjoiMDAwMDAwMDAtMDAwMC0xMTExLWExMTEtMDAwMDAwMDAwMDE4IiwibmJmIjoxNjk1MTQ5NTkxLCJleHAiOjEwMDAwMDAwMDEsImlzcyI6Imh0dHBzOi8vb3ByZS1vcHMtYmFja2VuZC1kZXYiLCJhdWQiOiJodHRwczovL29wcmUtb3BzLWZyb250ZW5kLWRldiIsInJvbGVzIjpbImFkbWluIl19.S55CU9Kuhnz-Z5xvaX4fNJYJz0iY1JRJuRZ4LmTAAUCSvepXAIT3B5hAcl97-HH21LN5D1TpOAPE4OP5QADZG7h_8ISX3STRRL_fmmQZcPczvaNsNUW2UNT5RcFUcOprjM683TXIPp66ZLnLk6NA2j_MJMJC0wt-YPF2ZKC57NrxMhoCR-dYBc78KojrqLAQx1bG4KDBTtHq2HJIb1tuYsXbNy2gk3Wghp-8xKJK6-fJS2c4xG7-Dxiyvg7oukzzClBpeA-KYiyUW8zdxeMthekRabvkwdjHMhm1ixs11UOKxv6iV32ueV_kYz3MAIuHEsnR07oVnZ4wQHsOk5lwAw"; // nosemgrep
// nosemgrep - test tokens only
const badIssToken =
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6dHJ1ZSwiaWF0IjoxNjk1MTQ5NTkxLCJqdGkiOiJlOGUwMTY2ZS1lNTYyLTQ3N2UtOWJiMy05MjA1OTFiNmEyMjUiLCJ0eXBlIjoiYWNjZXNzIiwic3ViIjoiMDAwMDAwMDAtMDAwMC0xMTExLWExMTEtMDAwMDAwMDAwMDE4IiwibmJmIjoxNjk1MTQ5NTkxLCJleHAiOjE5NjUxNTEzOTEsImlzcyI6Imh0dHA6Ly9vcHMtaW52YWxpZC1pc3N1ZXIiLCJhdWQiOiJodHRwczovL29wcmUtb3BzLWZyb250ZW5kIiwicm9sZXMiOlsiYWRtaW4iXX0.MQG1wzAEZV4Aq-KPXeT0E0NFGB-2d1Xm9ZkhUz15BGna-c37VredS-r75zA9OkK5r5pPvjdJU5mNPrr1co4SdEtnZK8PW4Ilvi_XMHwTflBV8cOhoz74jEbf0Hj_CDPX3PCsH4Surxun7CELTR775QYRa5EdEgxUX7LREJXZj1PhHissr8tQpr30LWAKLqNUr0KXJGauXN-YxfbuT_fxlV_P6Q_mY0RqEZAdvgmZs3KB3L_hqb7tj6TCtieXXIEkZICZGIPCq9rd3kYAQoDjGO8Qw5hnePTK_focZ46Rj1gcrLa_Ot-qg0L6GzJdv_Qmby5akIGc8i7kCDzmL_BHZw"; // nosemgrep
// nosemgrep - test tokens only
const validToken =
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6dHJ1ZSwiaWF0IjoxNjk1MTQ5NTkxLCJqdGkiOiJlOGUwMTY2ZS1lNTYyLTQ3N2UtOWJiMy05MjA1OTFiNmEyMjUiLCJ0eXBlIjoiYWNjZXNzIiwic3ViIjoiMDAwMDAwMDAtMDAwMC0xMTExLWExMTEtMDAwMDAwMDAwMDE4IiwibmJmIjoxNjk1MTQ5NTkxLCJleHAiOjE5NjUxNTEzOTEsImlzcyI6Imh0dHBzOi8vb3ByZS1vcHMtYmFja2VuZC1kZXYiLCJhdWQiOiJodHRwczovL29wcmUtb3BzLWZyb250ZW5kLWRldiIsInJvbGVzIjpbImFkbWluIl19.D2hsHXvRIq5ALbkrRP5DKtYqhVcaO0ooTZwF_Y9YXYP1WswZNVI5ZrCD3ez-WHqPbcrOKCzpHhBzo-WFw2zMMq0txybU3AtNvog1n49k3xOT4CMcSS0DwCnv9RJetJsxeBIbR1kGHlsip71aXbsDbxmZ5pRgxPxMtUYRjmqafIMZfmWoLgDA3Mk0EaJBwfJj9Ruy3oyzzNG6Ce7EF5-MunPZzfre6rHTcSWzPzIjo5RNFtm5_y8yOTci0Xzl8iqdFi6Gr30ZZbSoxE6KSwuudC8pWldlsg8zkdcXWLhRmgfMroFqjB0SKp655e1OvohKegm-FMdEQqrY6PBE25hwhg"; // nosemgrep

test("construct the URL to get the authentication code to send to the backend", async () => {
// the nonce is generated at runtime so do not test here
Expand All @@ -14,3 +23,24 @@ test("construct the URL to get the authentication code to send to the backend",
expect(actualProviderUrl.searchParams.get("redirect_uri")).toEqual("http://uri/login");
expect(actualProviderUrl.searchParams.get("state")).toEqual(stateToken);
});

describe("isValidToken", () => {
it("returns false if token is not provided", () => {
expect(isValidToken().isValid).toBe(false);
});

it("returns false if token is expired", () => {
expect(isValidToken(expiredToken).isValid).toBe(false);
expect(isValidToken(expiredToken).msg).toBe("EXPIRED");
});

it("returns false if token is not issued by the backend", () => {
expect(isValidToken(badIssToken).isValid).toBe(false);
expect(isValidToken(badIssToken).msg).toBe("ISSUER");
});

it("returns true if token is valid", () => {
expect(isValidToken(validToken).isValid).toBe(true);
expect(isValidToken(validToken).msg).toBe("VALID");
});
});
4 changes: 4 additions & 0 deletions frontend/src/components/Auth/authSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export const authSlice = createSlice({
logout: (state) => {
state.isLoggedIn = false;
state.activeUser = null;
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("ops-state-key");
localStorage.removeItem("activeProvider");
},
setUserDetails: (state, action) => {
state.activeUser = action.payload;
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/UI/Header/User.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Link } from "react-router-dom";
import { CheckAuth } from "../../Auth/auth";
import { CheckAuth, getAccessToken } from "../../Auth/auth";
import { useGetUserByOIDCIdQuery } from "../../../api/opsAPI";
import jwt_decode from "jwt-decode";

export const User = () => {
const currentJWT = localStorage.getItem("access_token");
const currentJWT = getAccessToken();
const decodedJwt = jwt_decode(currentJWT);
const userId = decodedJwt["sub"];
const { data: user } = useGetUserByOIDCIdQuery(userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import jwt_decode from "jwt-decode";
import icons from "../../../uswds/img/sprite.svg";
import customStyles from "./NotificationCenter.module.css";
import LogItem from "../LogItem";
import { getAccessToken } from "../../Auth/auth";

const NotificationCenter = () => {
const [showModal, setShowModal] = React.useState(false);
const currentJWT = localStorage.getItem("access_token");
const currentJWT = getAccessToken();
let userId = "";

if (currentJWT) {
Expand Down
Loading

0 comments on commit 0d20fad

Please sign in to comment.