From 4cd7caa90eaf985fe2758b46f0b1e374cd00a3a2 Mon Sep 17 00:00:00 2001 From: andy_gallagher Date: Wed, 1 Jun 2022 17:04:22 +0100 Subject: [PATCH 01/22] adds PKCE as requested by Azure and makes `resource` optional --- .../components/Context/TestOAuthContext.tsx | 43 +++++++++++++++++-- jestSetup.jsx | 11 ++++- src/components/Context/OAuthContext.tsx | 36 +++++++++++++--- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/__tests__/components/Context/TestOAuthContext.tsx b/__tests__/components/Context/TestOAuthContext.tsx index 156c3f0..a07ecb2 100644 --- a/__tests__/components/Context/TestOAuthContext.tsx +++ b/__tests__/components/Context/TestOAuthContext.tsx @@ -1,6 +1,6 @@ import React, {useContext} from "react"; import fetchMock from "jest-fetch-mock"; -import {OAuthContext, OAuthContextProvider} from "../../../src"; +import {generateCodeChallenge, OAuthContext, OAuthContextProvider} from "../../../src/components/Context/OAuthContext"; import {mount} from "enzyme"; import {act} from "react-dom/test-utils"; import sinon from "sinon"; @@ -96,6 +96,43 @@ describe("makeLoginUrl", ()=>{ }; const result = makeLoginUrl(sampleData); - expect(result).toEqual("some-oauth-uri?response_type=code&client_id=some-client&resource=some-resource&redirect_uri=https%3A%2F%2Fsome-redirect%2Furi&state=%2F"); - }) + const removeRandomPart = /code_challenge=[a-fA-F0-9]{16,}&/; + const resultToTest = result.replace(removeRandomPart, ""); + + expect(resultToTest).toEqual("some-oauth-uri?response_type=code&client_id=some-client&redirect_uri=https%3A%2F%2Fsome-redirect%2Furi&state=%2F&resource=some-resource"); + expect(removeRandomPart.test(result)).toBeTruthy(); + }); + + it("should not include the resource parameter if it's not given", ()=>{ + const sampleData:OAuthContextData = { + clientId: "some-client", + oAuthUri: "some-oauth-uri", + tokenUri: "some-token-uri", + redirectUri: "https://some-redirect/uri" + }; + + const result = makeLoginUrl(sampleData); + const removeRandomPart = /&code_challenge=[a-fA-F0-9]{16,}/; + const resultToTest = result.replace(removeRandomPart, ""); + + expect(resultToTest).toEqual("some-oauth-uri?response_type=code&client_id=some-client&redirect_uri=https%3A%2F%2Fsome-redirect%2Furi&state=%2F"); + expect(removeRandomPart.test(result)).toBeTruthy(); + }); + + it("should include the scope parameter if it is given", ()=>{ + const sampleData:OAuthContextData = { + clientId: "some-client", + oAuthUri: "some-oauth-uri", + scope: "https://graph.microsoft.com/openid", + tokenUri: "some-token-uri", + redirectUri: "https://some-redirect/uri" + }; + + const result = makeLoginUrl(sampleData); + const removeRandomPart = /code_challenge=[a-fA-F0-9]{16,}&/; + const resultToTest = result.replace(removeRandomPart, ""); + + expect(resultToTest).toEqual("some-oauth-uri?response_type=code&client_id=some-client&redirect_uri=https%3A%2F%2Fsome-redirect%2Furi&state=%2F&scope=https%3A%2F%2Fgraph.microsoft.com%2Fopenid"); + expect(removeRandomPart.test(result)).toBeTruthy(); + }); }) \ No newline at end of file diff --git a/jestSetup.jsx b/jestSetup.jsx index c042346..ae73eda 100644 --- a/jestSetup.jsx +++ b/jestSetup.jsx @@ -1,8 +1,17 @@ // setup file import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; +import nodeCrypto from 'crypto'; require("jest-localstorage-mock"); require("jest-fetch-mock").enableFetchMocks(); -configure({ adapter: new Adapter() }); \ No newline at end of file +configure({ adapter: new Adapter() }); + +//polyfills the browser window.crypto object so we can use it in tests + +window.crypto = { + getRandomValues: function(buffer) { + return nodeCrypto.randomFillSync(buffer); + } +} \ No newline at end of file diff --git a/src/components/Context/OAuthContext.tsx b/src/components/Context/OAuthContext.tsx index 6017374..82306b7 100644 --- a/src/components/Context/OAuthContext.tsx +++ b/src/components/Context/OAuthContext.tsx @@ -3,7 +3,8 @@ import { red } from "@material-ui/core/colors"; interface OAuthContextData { clientId: string; - resource: string; + resource?: string; + scope?: string; oAuthUri: string; tokenUri: string; redirectUri: string; @@ -18,9 +19,10 @@ const OAuthContextProvider: React.FC<{ onError?: (desc: string) => void; }> = (props) => { const [clientId, setClientId] = useState(""); - const [resource, setResource] = useState(""); + const [resource, setResource] = useState(undefined); const [oAuthUri, setOAuthUri] = useState(""); const [tokenUri, setTokenUri] = useState(""); + const [scope, setScope] = useState(undefined); const [haveData, setHaveData] = useState(false); const currentUri = new URL(window.location.href); @@ -37,6 +39,7 @@ const OAuthContextProvider: React.FC<{ setResource(content.resource); setOAuthUri(content.oAuthUri); setTokenUri(content.tokenUri); + setScope(content.scope); setHaveData(true); break; case 404: @@ -70,6 +73,7 @@ const OAuthContextProvider: React.FC<{ oAuthUri: oAuthUri, tokenUri: tokenUri, redirectUri: redirectUrl, + scope: scope, } : undefined } @@ -79,22 +83,44 @@ const OAuthContextProvider: React.FC<{ ); }; +/* +Generates a cryptographic random string and stores it in the session storage. +This is called by makeLoginUrl, you should not need to call it directly. + */ +function generateCodeChallenge() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const str = array.reduce((acc:string, x) => acc + x.toString(16).padStart(2, '0'), ""); + sessionStorage.setItem("cx", str); + return str; +} + function makeLoginUrl(oAuthContext: OAuthContextData) { const args = { response_type: "code", client_id: oAuthContext.clientId, - resource: oAuthContext.resource, redirect_uri: oAuthContext.redirectUri, state: "/", + code_challenge: generateCodeChallenge() }; - const encoded = Object.entries(args).map( + let encoded = Object.entries(args).map( ([k, v]) => `${k}=${encodeURIComponent(v)}` ); + if(oAuthContext.resource && oAuthContext.resource != "") { + encoded.push(`resource=${encodeURIComponent(oAuthContext.resource)}`); + } + console.log(oAuthContext.scope); + console.log(encodeURIComponent(oAuthContext.scope ?? "")); + + if(oAuthContext.scope && oAuthContext.scope != "") { + encoded.push(`scope=${encodeURIComponent(oAuthContext.scope)}`) + } + return oAuthContext.oAuthUri + "?" + encoded.join("&"); } export type { OAuthContextData }; -export {OAuthContext, OAuthContextProvider, makeLoginUrl}; +export {OAuthContext, OAuthContextProvider, makeLoginUrl, generateCodeChallenge}; From 30f06c491a070b7fc4a7719911709d1df755c87d Mon Sep 17 00:00:00 2001 From: andy_gallagher Date: Tue, 7 Jun 2022 17:13:11 +0100 Subject: [PATCH 02/22] WIP --- package.json | 5 +- rollup.config.js | 1 + src/components/Context/OAuthContext.tsx | 37 ++++++++--- src/utils/JwtHelpers.ts | 55 ++++++++++++---- src/utils/jwks.ts | 51 +++++++++++++++ yarn.lock | 85 ++----------------------- 6 files changed, 132 insertions(+), 102 deletions(-) create mode 100644 src/utils/jwks.ts diff --git a/package.json b/package.json index 76e5607..a3ac195 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "@material-ui/lab": "^4.0.0-alpha.58", "axios": "^0.21.2", "date-fns": "^2.22.1", - "jsonwebtoken": "^8.5.1", "query-string": "^6.13.1", "react": "^16.8.0 || ^17.0.0", "react-dom": "^16.8.0 || ^17.0.0", @@ -84,7 +83,6 @@ "jest-fetch-mock": "^3.0.3", "jest-junit": "^4.0.0", "jest-localstorage-mock": "^2.4.3", - "jsonwebtoken": "^8.5.1", "moxios": "^0.4.0", "object.entries": "^1.1.1", "prettier": "^2.0.5", @@ -103,5 +101,8 @@ "ts-jest": "^26.1.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7" + }, + "dependencies": { + "jose": "^4.8.1" } } diff --git a/rollup.config.js b/rollup.config.js index 5d4fb18..7f68911 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -31,5 +31,6 @@ export default [ image({ exclude: /\.(svg)$/ }), svgr(), ], + external: ["jwks-rsa"] }, ]; diff --git a/src/components/Context/OAuthContext.tsx b/src/components/Context/OAuthContext.tsx index 82306b7..4d7cb4b 100644 --- a/src/components/Context/OAuthContext.tsx +++ b/src/components/Context/OAuthContext.tsx @@ -8,15 +8,28 @@ interface OAuthContextData { oAuthUri: string; tokenUri: string; redirectUri: string; + jwksUri?: string; } const OAuthContext = React.createContext( undefined ); +/** + * Creates an OAuthContextProvider. This will load in the configuration from the expected path /meta/oauth/config.json + * and propagate that data to all child components. + * Properties: + * - onError - takes a string description of the error and returns nothing + * - onLoaded - called when the data has been successfully loaded. Passed a copy of the context data and returns nothing. Use this + * to verify a pre-existing token on initial load. + * - children - use this as a child container + * @param props + * @constructor + */ const OAuthContextProvider: React.FC<{ children: React.ReactFragment; onError?: (desc: string) => void; + onLoaded?: (config:OAuthContextData) => void; }> = (props) => { const [clientId, setClientId] = useState(""); const [resource, setResource] = useState(undefined); @@ -24,6 +37,7 @@ const OAuthContextProvider: React.FC<{ const [tokenUri, setTokenUri] = useState(""); const [scope, setScope] = useState(undefined); const [haveData, setHaveData] = useState(false); + const [jwksUri, setJwksUri] = useState(undefined); const currentUri = new URL(window.location.href); const redirectUrl = @@ -40,7 +54,11 @@ const OAuthContextProvider: React.FC<{ setOAuthUri(content.oAuthUri); setTokenUri(content.tokenUri); setScope(content.scope); + setJwksUri(content.jwksUri); setHaveData(true); + if(props.onLoaded) { + props.onLoaded(makeContext()) + } break; case 404: await response.text(); //consume body and discard it @@ -63,18 +81,21 @@ const OAuthContextProvider: React.FC<{ loadOauthData(); }, []); + const makeContext = () => ({ + clientId: clientId, + resource: resource, + oAuthUri: oAuthUri, + tokenUri: tokenUri, + redirectUri: redirectUrl, + scope: scope, + jwksUri: jwksUri + }) + return ( diff --git a/src/utils/JwtHelpers.ts b/src/utils/JwtHelpers.ts index f90fb0a..d72720d 100644 --- a/src/utils/JwtHelpers.ts +++ b/src/utils/JwtHelpers.ts @@ -1,26 +1,58 @@ import jwt, {JwtPayload} from "jsonwebtoken"; import {JwtData, JwtDataShape} from "./DecodedProfile"; - +import {OAuthContextData} from "../components/Context/OAuthContext"; +import {jwksFromUri} from "./jwks"; /** * perform the validation of the token via jsonwebtoken library. * if validation fails then the returned promise is rejected * if validation succeeds, then the promise only completes once the decoded content has been set into the state. * @returns {Promise} Decoded JWT content or rejects with an error */ -function verifyJwt(token: string, signingKey: string, refreshToken?: string) { +function verifyJwt(oauthConfig: OAuthContextData, token: string, refreshToken?: string) { + /** + * "insert" function for the JWT library to obtain a signing key for verification. + * Either gets the contents of the configurations `jwksUri` for JWKS verification or loads a static signing key + * as given by the configuration + * @param header JwtHeader provided by the library + * @param callback callback function provided by the library + */ + const getKey:jwt.GetPublicKeyOrSecret = (header, callback) => { + console.log(oauthConfig); + console.log(header); + if(oauthConfig.jwksUri && header.kid) { //if we have a jwksUri, then use that + // const client = jwksRSA({ + // jwksUri: oauthConfig.jwksUri, + // }); + // client.getSigningKey(header.kid, (err:Error, key: { getPublicKey: () => string | Buffer | { key: string | Buffer; passphrase: string; } | undefined; }) => { + // callback(err, key?.getPublicKey()) + // }) + + jwksFromUri(header.kid, header.alg, oauthConfig.jwksUri) + .then(rawKey=>{ + callback(null, new Buffer(rawKey)) + }) + .catch(err=>callback(err, undefined)) + + } else { //otherwise, fall back to a static signing key + console.log("Falling back to static key verification. Either oauthConfig.jwksUri is not set, or the JWT has no 'kid' parameter") + loadInSigningKey() + .then(key=>callback(null, key)) + .catch(err=>callback(err, "")) + } + } + return new Promise((resolve, reject) => { - jwt.verify(token, signingKey, (err, decoded) => { + jwt.verify(token, getKey, (err, decoded) => { if (err) { console.log("token: ", token); - console.log("signingKey: ", signingKey); console.error("could not verify JWT: ", err); reject(err); + } else { + window.localStorage.setItem("pluto:access-token", token); //it validates so save the token + if (refreshToken) + window.localStorage.setItem("pluto:refresh-token", refreshToken); + resolve(decoded); } - - window.localStorage.setItem("pluto:access-token", token); //it validates so save the token - if (refreshToken) - window.localStorage.setItem("pluto:refresh-token", refreshToken); - resolve(decoded); }); }); } @@ -78,11 +110,10 @@ function getRawToken() { /** * helper function that validates and decodes into a user profile a token already existing in the localstorage */ -async function verifyExistingLogin(): Promise { +async function verifyExistingLogin(oAuthData:OAuthContextData): Promise { const token = getRawToken(); if (token) { - const signingKey = await loadInSigningKey(); - const jwtPayload = await verifyJwt(token, signingKey); + const jwtPayload = await verifyJwt(oAuthData, token); return jwtPayload ? JwtData(jwtPayload) : undefined; } } diff --git a/src/utils/jwks.ts b/src/utils/jwks.ts new file mode 100644 index 0000000..fc029e4 --- /dev/null +++ b/src/utils/jwks.ts @@ -0,0 +1,51 @@ +interface JWKS { + keys: JWK[]; +} + +interface JWK { + alg: AlgorithmIdentifier; + kty: string; + use: string; + n: string; + e: string; + kid: string; + x5t: string; + x5c: string[]; +} + +function digestFor(algorithm: string):string { + switch(algorithm) { + case "RS256": + return "SHA-256"; + case "RS384": + return "SHA-384"; + case "RS512": + return "SHA-512"; + default: + throw `Unknown algorithm "${algorithm}"` + } +} + +export async function jwksFromUri(kid:string, algorithm: string, baseUrl:string) { + const rawDataResponse = await fetch(baseUrl); + if(rawDataResponse.status!=200) throw `Could not download JWKS from ${baseUrl}`; + if(!algorithm.startsWith("RS")) throw `Only RSA keys are supported at present`; + + const jwks = await rawDataResponse.json() as JWKS; + + //find the matching key + const matches = jwks.keys.filter(k=>k.kid==kid); + if(matches.length==0) { + throw `No verification found for key ID "${kid}"` + } else { + const k = matches[0] as JsonWebKey; + const algo:RsaHashedImportParams = { + hash: digestFor(algorithm), + name: "RSA-PSS", + } + console.log(algo); + const cryptoKey = await crypto.subtle.importKey("jwk", k, algo, true, ["verify"]); + const rawKey = await crypto.subtle.exportKey("spki", cryptoKey) as ArrayBuffer; + + } +} diff --git a/yarn.lock b/yarn.lock index a7d7c6e..953dcec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2280,11 +2280,6 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -buffer-equal-constant-time@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" - integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= - buffer-from@1.x, buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -3089,13 +3084,6 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -ecdsa-sig-formatter@1.0.11: - version "1.0.11" - resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" - integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== - dependencies: - safe-buffer "^5.0.1" - electron-to-chromium@^1.3.523: version "1.3.537" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.537.tgz#dfe595f5283d3113df897158810e40f6c2355283" @@ -5111,6 +5099,11 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.4.0" +jose@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.8.1.tgz#dc7c2660b115ba29b44880e588c5ac313c158247" + integrity sha512-+/hpTbRcCw9YC0TOfN1W47pej4a9lRmltdOVdRLz5FP5UvUq3CenhXjQK7u/8NdMIIShMXYAh9VLPhc7TjhvFw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -5244,22 +5237,6 @@ jsonfile@^4.0.0: optionalDependencies: graceful-fs "^4.1.6" -jsonwebtoken@^8.5.1: - version "8.5.1" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" - integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== - dependencies: - jws "^3.2.2" - lodash.includes "^4.3.0" - lodash.isboolean "^3.0.3" - lodash.isinteger "^4.0.4" - lodash.isnumber "^3.0.3" - lodash.isplainobject "^4.0.6" - lodash.isstring "^4.0.1" - lodash.once "^4.0.0" - ms "^2.1.1" - semver "^5.6.0" - jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -5346,23 +5323,6 @@ just-extend@^4.0.2: resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.0.tgz#7278a4027d889601640ee0ce0e5a00b992467da4" integrity sha512-ApcjaOdVTJ7y4r08xI5wIqpvwS48Q0PBG4DJROcEkH1f8MdAiNFyFxz3xoL0LWAVwjrwPYZdVHHxhRHcx/uGLA== -jwa@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" - integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== - dependencies: - buffer-equal-constant-time "1.0.1" - ecdsa-sig-formatter "1.0.11" - safe-buffer "^5.0.1" - -jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== - dependencies: - jwa "^1.4.1" - safe-buffer "^5.0.1" - kind-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-2.0.1.tgz#018ec7a4ce7e3a86cb9141be519d24c8faa981b5" @@ -5493,51 +5453,16 @@ lodash.get@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= -lodash.includes@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" - integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8= - -lodash.isboolean@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" - integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= - lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= -lodash.isinteger@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" - integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M= - -lodash.isnumber@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" - integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w= - -lodash.isplainobject@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" - integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= - -lodash.isstring@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" - integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= - lodash.memoize@4.x, lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= -lodash.once@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" - integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" From 89968b33eca5e9d83aa47c7f0f2e0a3268a8a551 Mon Sep 17 00:00:00 2001 From: andy_gallagher Date: Tue, 7 Jun 2022 20:52:17 +0100 Subject: [PATCH 03/22] work in progress on updating internal components to properly use newer contexts instead of prop drilling --- .../components/Context/TestUserContext.tsx | 2 + package.json | 5 +- src/components/AppSwitcher/AppSwitcher.tsx | 161 +++--------------- src/components/AppSwitcher/LoginComponent.tsx | 44 ++--- src/components/Context/OAuthContext.tsx | 37 ++-- src/components/MenuButton/MenuButton.tsx | 16 +- src/index.ts | 2 +- src/utils/DecodedProfile.ts | 1 + src/utils/JwtHelpers.ts | 150 +++++++++------- src/utils/OAuth2Helper.ts | 31 +++- src/utils/OAuthConfiguration.ts | 10 +- src/utils/jwks.ts | 43 +++-- 12 files changed, 228 insertions(+), 274 deletions(-) diff --git a/__tests__/components/Context/TestUserContext.tsx b/__tests__/components/Context/TestUserContext.tsx index 6c5034e..55905fd 100644 --- a/__tests__/components/Context/TestUserContext.tsx +++ b/__tests__/components/Context/TestUserContext.tsx @@ -26,6 +26,7 @@ describe("UserContext", ()=>{ const rendered = mount(
false, updateProfile: (newValue) => {} }}> @@ -39,6 +40,7 @@ describe("UserContext", ()=>{ const rendered = mount(
false, updateProfile: (newValue) => {} }}> diff --git a/package.json b/package.json index a3ac195..f361938 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "axios": "^0.21.2", "date-fns": "^2.22.1", "query-string": "^6.13.1", + "jose": "^4.8.1", "react": "^16.8.0 || ^17.0.0", "react-dom": "^16.8.0 || ^17.0.0", "react-router-dom": "^5.2.0" @@ -83,6 +84,7 @@ "jest-fetch-mock": "^3.0.3", "jest-junit": "^4.0.0", "jest-localstorage-mock": "^2.4.3", + "jose": "^4.8.1", "moxios": "^0.4.0", "object.entries": "^1.1.1", "prettier": "^2.0.5", @@ -101,8 +103,5 @@ "ts-jest": "^26.1.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7" - }, - "dependencies": { - "jose": "^4.8.1" } } diff --git a/src/components/AppSwitcher/AppSwitcher.tsx b/src/components/AppSwitcher/AppSwitcher.tsx index fec7713..34b0ca3 100644 --- a/src/components/AppSwitcher/AppSwitcher.tsx +++ b/src/components/AppSwitcher/AppSwitcher.tsx @@ -1,17 +1,14 @@ -import React, {useState, useEffect, useContext} from "react"; -import { Link } from "react-router-dom"; +import React, {useContext, useState} from "react"; +import {Link} from "react-router-dom"; import "./AppSwitcher.css"; -import { Button } from "@material-ui/core"; -import { loadInSigningKey, validateAndDecode } from "../../utils/JwtHelpers"; -import { JwtData, JwtDataShape } from "../../utils/DecodedProfile"; -import { - hrefIsTheSameDeploymentRootPath, - getDeploymentRootPathLink, -} from "../../utils/AppLinks"; -import { MenuButton } from "../MenuButton/MenuButton"; -import OAuthConfiguration from "../../utils/OAuthConfiguration"; -import { VError } from "ts-interface-checker"; +import {Button} from "@material-ui/core"; +import {JwtDataShape} from "../../utils/DecodedProfile"; +import {getDeploymentRootPathLink, hrefIsTheSameDeploymentRootPath,} from "../../utils/AppLinks"; +import {MenuButton} from "../MenuButton/MenuButton"; import LoginComponent from "./LoginComponent"; +import {makeLoginUrl, OAuthContext} from "../Context/OAuthContext"; +import {SystemNotifcationKind, SystemNotification} from "../SystemNotification/SystemNotification"; +import {UserContext} from "../Context/UserContext"; interface AppSwitcherProps { onLoggedIn?: () => void; @@ -21,131 +18,15 @@ interface AppSwitcherProps { export const AppSwitcher: React.FC = (props) => { const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); - const [loginData, setLoginData] = useState(null); const [expired, setExpired] = useState(false); // config const [menuSettings, setMenuSettings] = useState( [] ); - const [clientId, setClientId] = useState(""); - const [resource, setResource] = useState(""); - const [oAuthUri, setOAuthUri] = useState(""); - const [adminClaimName, setAdminClaimName] = useState(""); - const [tokenUri, setTokenUri] = useState(""); - - const loadConfig: () => Promise = async () => { - try { - const response = await fetch("/meta/menu-config/menu.json"); - - if (response.status === 200) { - const data = await response.json(); - - setMenuSettings(data); - } - } catch (error) { - console.error(error); - } - - const response = await fetch("/meta/oauth/config.json"); - if (response.status === 200) { - const data = await response.json(); - const config = new OAuthConfiguration(data); //validates the configuration and throws a VError if it fails - setClientId(config.clientId); - setResource(config.resource); - setOAuthUri(config.oAuthUri); - setTokenUri(config.tokenUri); - setAdminClaimName(config.adminClaimName); - return config; - } else { - throw `Server returned ${response.status}`; - } - }; - - const validateToken: (config: OAuthConfiguration) => Promise = async ( - config: OAuthConfiguration - ) => { - const token = window.localStorage.getItem("pluto:access-token"); - if (!token) return; - - try { - const signingKey = await loadInSigningKey(); - - const decodedData = await validateAndDecode(token, signingKey); - if(decodedData) { - const loginData = JwtData(decodedData); - setLoginData(loginData); - - // Login valid callback if provided - if (props.onLoginValid) { - props.onLoginValid(true, loginData); - } - - setIsLoggedIn(true); - - setIsAdmin(config.isAdmin(loginData)); - } else { - throw "Got no user profile" - } - } catch (error) { - // Login valid callback if provided - if (props.onLoginValid) { - props.onLoginValid(false); - } - - setIsLoggedIn(false); - setIsAdmin(false); - - if (error.hasOwnProperty("name") && error.name === "TokenExpiredError") { - console.error("Token has already expired"); - setExpired(true); - } else { - console.error("existing login token was not valid: ", error); - } - } - }; - - /** - * load in the oauth config and validate the loaded in token - */ - const refresh = async () => { - try { - const config = await loadConfig(); - await validateToken(config); - } catch(err) { - if (err instanceof VError) { - console.log("OAuth configuration was not valid: ", err); - } else { - console.log("Could not load oauth configuration: ", err); - } - } - } - - useEffect(() => { - refresh(); - }, []); - - const makeLoginUrl = () => { - const currentUri = new URL(window.location.href); - const redirectUri = - currentUri.protocol + "//" + currentUri.host + "/oauth2/callback"; - - const args: Record = { - response_type: "code", - client_id: clientId, - resource: resource, - redirect_uri: redirectUri, - state: currentUri.pathname, - }; - - const encoded = Object.entries(args).map( - ([k, v]) => `${k}=${encodeURIComponent(v)}` - ); - - return oAuthUri + "?" + encoded.join("&"); - }; + const oAuthContext = useContext(OAuthContext); + const userContext = useContext(UserContext); const getLink = ( text: string, @@ -156,7 +37,7 @@ export const AppSwitcher: React.FC = (props) => {
  • {hrefIsTheSameDeploymentRootPath(href) ? ( @@ -169,7 +50,7 @@ export const AppSwitcher: React.FC = (props) => { return ( <> - {isLoggedIn && loginData ? ( + {isLoggedIn ? (
      {( @@ -181,7 +62,6 @@ export const AppSwitcher: React.FC = (props) => { = (props) => { ) )}
    - { - refresh(); - }} + { setExpired(true); setIsLoggedIn(false); }} - tokenUri={tokenUri} />
    ) : ( @@ -217,8 +92,12 @@ export const AppSwitcher: React.FC = (props) => { return; } - // Perform login - window.location.assign(makeLoginUrl()); + if(oAuthContext) { + // Perform login + window.location.assign(makeLoginUrl(oAuthContext)); + } else { + SystemNotification.open(SystemNotifcationKind.Error, "Could not load authentication configuration") + } }} > Login {expired ? "again" : ""} diff --git a/src/components/AppSwitcher/LoginComponent.tsx b/src/components/AppSwitcher/LoginComponent.tsx index 0ff2859..5eeb1ac 100644 --- a/src/components/AppSwitcher/LoginComponent.tsx +++ b/src/components/AppSwitcher/LoginComponent.tsx @@ -1,22 +1,21 @@ import React, {useState, useEffect, useRef, useContext} from "react"; import {Button, Grid, IconButton, Tooltip, Typography} from "@material-ui/core"; -import {JwtDataShape} from "../../utils/DecodedProfile"; import {CircularProgress} from "@material-ui/core"; import {Error, CheckCircle, Person, Brightness7, Brightness4, HelpOutline} from "@material-ui/icons"; import {refreshLogin} from "../../utils/OAuth2Helper"; import {makeStyles} from "@material-ui/core/styles"; import CustomisingThemeContext from "../Theme/CustomisingThemeContext"; +import {OAuthContext} from "../Context/OAuthContext"; +import {UserContext} from "../Context/UserContext"; interface LoginComponentProps { refreshToken?: string; checkInterval?:number; - loginData: JwtDataShape; onLoginRefreshed?: ()=>void; onLoginCantRefresh?: (reason:string)=>void; onLoginExpired: ()=>void; onLoggedOut?: ()=>void; overrideRefreshLogin?: (tokenUri:string)=>Promise; //only used for testing - tokenUri: string; } const useStyles = makeStyles({ @@ -44,12 +43,13 @@ const LoginComponent:React.FC = (props) => { const [refreshed, setRefreshed] = useState(false); const [loginExpiryCount, setLoginExpiryCount] = useState(""); - let loginDataRef = useRef(props.loginData); - const tokenUriRef = useRef(props.tokenUri); const overrideRefreshLoginRef = useRef(props.overrideRefreshLogin); const classes = useStyles(); + const oAuthContext = useContext(OAuthContext); + const userContext = useContext(UserContext); + const themeContext = useContext(CustomisingThemeContext); useEffect(()=>{ @@ -69,23 +69,25 @@ const LoginComponent:React.FC = (props) => { } }, [refreshFailed]); - useEffect(()=>{ - loginDataRef.current = props.loginData; - }, [props.loginData]); + // useEffect(()=>{ + // loginDataRef.current = props.loginData; + // }, [props.loginData]); /** * called periodically every second once a refresh has failed to alert the user how long they have left */ const updateCountdownHandler = () => { - const nowTime = new Date().getTime() / 1000; //assume time is in seconds - const expiry = loginDataRef.current.exp; - const timeToGo = expiry - nowTime; + if(userContext.profile) { + const nowTime = new Date().getTime() / 1000; //assume time is in seconds + const expiry = userContext.profile.exp; + const timeToGo = expiry - nowTime; - if(timeToGo>1) { - setLoginExpiryCount(`expires in ${Math.ceil(timeToGo)}s`); - } else { - if(props.onLoginExpired) props.onLoginExpired(); - setLoginExpiryCount("has expired"); + if (timeToGo > 1) { + setLoginExpiryCount(`expires in ${Math.ceil(timeToGo)}s`); + } else { + if (props.onLoginExpired) props.onLoginExpired(); + setLoginExpiryCount("has expired"); + } } } @@ -95,10 +97,10 @@ const LoginComponent:React.FC = (props) => { * is ignored but it is used in testing to ensure that the component state is only checked after it has been set. */ const checkExpiryHandler = () => { - if (loginDataRef.current) { + if (userContext.profile && oAuthContext) { const nowTime = new Date().getTime() / 1000; //assume time is in seconds //we know that it is not null due to above check - const expiry = loginDataRef.current.exp; + const expiry = userContext.profile.exp; const timeToGo = expiry - nowTime; if (timeToGo <= 120) { @@ -108,9 +110,9 @@ const LoginComponent:React.FC = (props) => { let refreshedPromise; if(overrideRefreshLoginRef.current){ - refreshedPromise = overrideRefreshLoginRef.current(tokenUriRef.current); + refreshedPromise = overrideRefreshLoginRef.current(oAuthContext.tokenUri); } else { - refreshedPromise = refreshLogin(tokenUriRef.current); + refreshedPromise = refreshLogin(oAuthContext, userContext); } refreshedPromise.then(()=>{ @@ -146,7 +148,7 @@ const LoginComponent:React.FC = (props) => { You are logged in as - {props.loginData.preferred_username ?? props.loginData.username} + {userContext.profile?.preferred_username ?? userContext.profile?.username} diff --git a/src/components/Context/OAuthContext.tsx b/src/components/Context/OAuthContext.tsx index 4d7cb4b..e62ed8d 100644 --- a/src/components/Context/OAuthContext.tsx +++ b/src/components/Context/OAuthContext.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from "react"; import { red } from "@material-ui/core/colors"; +import OAuthConfiguration from "../../utils/OAuthConfiguration"; +import {UserContext} from "./UserContext"; interface OAuthContextData { clientId: string; @@ -9,6 +11,7 @@ interface OAuthContextData { tokenUri: string; redirectUri: string; jwksUri?: string; + adminClaimName?: string; } const OAuthContext = React.createContext( @@ -32,10 +35,11 @@ const OAuthContextProvider: React.FC<{ onLoaded?: (config:OAuthContextData) => void; }> = (props) => { const [clientId, setClientId] = useState(""); - const [resource, setResource] = useState(undefined); + const [resource, setResource] = useState(undefined); const [oAuthUri, setOAuthUri] = useState(""); const [tokenUri, setTokenUri] = useState(""); - const [scope, setScope] = useState(undefined); + const [scope, setScope] = useState(undefined); + const [adminClaimName, setAdminClaimName] = useState(""); const [haveData, setHaveData] = useState(false); const [jwksUri, setJwksUri] = useState(undefined); @@ -47,14 +51,16 @@ const OAuthContextProvider: React.FC<{ const response = await fetch("/meta/oauth/config.json"); switch (response.status) { case 200: - const content = await response.json(); - - setClientId(content.clientId); - setResource(content.resource); - setOAuthUri(content.oAuthUri); - setTokenUri(content.tokenUri); - setScope(content.scope); - setJwksUri(content.jwksUri); + const data = await response.json(); + const config = new OAuthConfiguration(data); //validates the configuration and throws a VError if it fails + + setClientId(config.clientId); + setResource(config.resource); + setOAuthUri(config.oAuthUri); + setTokenUri(config.tokenUri); + setScope(config.scope); + setJwksUri(config.jwksUri); + setAdminClaimName(config.adminClaimName); setHaveData(true); if(props.onLoaded) { props.onLoaded(makeContext()) @@ -88,7 +94,8 @@ const OAuthContextProvider: React.FC<{ tokenUri: tokenUri, redirectUri: redirectUrl, scope: scope, - jwksUri: jwksUri + jwksUri: jwksUri, + adminClaimName: adminClaimName, }) return ( @@ -142,6 +149,12 @@ function makeLoginUrl(oAuthContext: OAuthContextData) { return oAuthContext.oAuthUri + "?" + encoded.join("&"); } +function isAdmin(oAuthContext:OAuthContextData, userProfile:UserContext) { + if(userProfile.profile && oAuthContext.adminClaimName) { + const maybeValue = userProfile.profile.hasOwnProperty(oAuthContext.adminClaimName); + return maybeValue && userProfile.profile.get(oAuthContext.adminClaimName).toLowerCase() == "true" + } +} export type { OAuthContextData }; -export {OAuthContext, OAuthContextProvider, makeLoginUrl, generateCodeChallenge}; +export {OAuthContext, OAuthContextProvider, makeLoginUrl, generateCodeChallenge, isAdmin}; diff --git a/src/components/MenuButton/MenuButton.tsx b/src/components/MenuButton/MenuButton.tsx index 63626d0..d76cd89 100644 --- a/src/components/MenuButton/MenuButton.tsx +++ b/src/components/MenuButton/MenuButton.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, {useContext, useState} from "react"; import { Link } from "react-router-dom"; import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown"; import { Menu, MenuItem } from "@material-ui/core"; @@ -7,9 +7,9 @@ import { getDeploymentRootPathLink, } from "../../utils/AppLinks"; import "./MenuButton.css"; +import {UserContext} from "../Context/UserContext"; interface MenuButtonProps { - isAdmin: boolean; index: number; text: string; adminOnly: boolean | undefined; @@ -17,7 +17,7 @@ interface MenuButtonProps { } export const MenuButton: React.FC = (props) => { - const { index, isAdmin, text, adminOnly, content } = props; + const { index, text, adminOnly, content } = props; const [anchorEl, setAnchorEl] = useState(null); const openSubmenu = (event: React.MouseEvent) => { @@ -28,10 +28,12 @@ export const MenuButton: React.FC = (props) => { setAnchorEl(null); }; + const userContext = useContext(UserContext); + return (
  • = (props) => { key={`${index}-menu-item`} style={{ display: adminOnly - ? isAdmin + ? userContext.isAdmin() ? "inherit" : "none" : "inherit", @@ -92,7 +94,7 @@ export const MenuButton: React.FC = (props) => { { closeMenu(); diff --git a/src/index.ts b/src/index.ts index f91082f..105f0b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ export { SystemNotification, SystemNotifcationKind } from "./components/SystemNo export { JwtData } from "./utils/DecodedProfile"; export type {JwtDataShape} from "./utils/DecodedProfile"; -export { validateAndDecode, loadInSigningKey, getRawToken, verifyJwt, verifyExistingLogin } from "./utils/JwtHelpers"; +export { loadInSigningKey, getRawToken, verifyJwt, verifyExistingLogin } from "./utils/JwtHelpers"; export {OAuthContext, OAuthContextProvider, makeLoginUrl, OAuthContextData} from "./components/Context/OAuthContext"; export {UserContext, UserContextProvider} from "./components/Context/UserContext"; export {defaultPlutoTheme} from "./components/Theme/DefaultPlutoTheme"; diff --git a/src/utils/DecodedProfile.ts b/src/utils/DecodedProfile.ts index 0fc7927..905a9ca 100644 --- a/src/utils/DecodedProfile.ts +++ b/src/utils/DecodedProfile.ts @@ -44,6 +44,7 @@ function JwtData(jwtData: object) { return (target)[prop] ?? null; } }, + }); } diff --git a/src/utils/JwtHelpers.ts b/src/utils/JwtHelpers.ts index d72720d..43fdceb 100644 --- a/src/utils/JwtHelpers.ts +++ b/src/utils/JwtHelpers.ts @@ -1,85 +1,103 @@ -import jwt, {JwtPayload} from "jsonwebtoken"; import {JwtData, JwtDataShape} from "./DecodedProfile"; import {OAuthContextData} from "../components/Context/OAuthContext"; +import {createRemoteJWKSet, jwtVerify, JWTVerifyGetKey} from "jose"; import {jwksFromUri} from "./jwks"; + /** * perform the validation of the token via jsonwebtoken library. * if validation fails then the returned promise is rejected * if validation succeeds, then the promise only completes once the decoded content has been set into the state. * @returns {Promise} Decoded JWT content or rejects with an error */ -function verifyJwt(oauthConfig: OAuthContextData, token: string, refreshToken?: string) { - /** - * "insert" function for the JWT library to obtain a signing key for verification. - * Either gets the contents of the configurations `jwksUri` for JWKS verification or loads a static signing key - * as given by the configuration - * @param header JwtHeader provided by the library - * @param callback callback function provided by the library - */ - const getKey:jwt.GetPublicKeyOrSecret = (header, callback) => { - console.log(oauthConfig); - console.log(header); - if(oauthConfig.jwksUri && header.kid) { //if we have a jwksUri, then use that - // const client = jwksRSA({ - // jwksUri: oauthConfig.jwksUri, - // }); - // client.getSigningKey(header.kid, (err:Error, key: { getPublicKey: () => string | Buffer | { key: string | Buffer; passphrase: string; } | undefined; }) => { - // callback(err, key?.getPublicKey()) - // }) - - jwksFromUri(header.kid, header.alg, oauthConfig.jwksUri) - .then(rawKey=>{ - callback(null, new Buffer(rawKey)) - }) - .catch(err=>callback(err, undefined)) - - } else { //otherwise, fall back to a static signing key - console.log("Falling back to static key verification. Either oauthConfig.jwksUri is not set, or the JWT has no 'kid' parameter") - loadInSigningKey() - .then(key=>callback(null, key)) - .catch(err=>callback(err, "")) - } - } +// function verifyJwt(oauthConfig: OAuthContextData, token: string, refreshToken?: string) { +// /** +// * "insert" function for the JWT library to obtain a signing key for verification. +// * Either gets the contents of the configurations `jwksUri` for JWKS verification or loads a static signing key +// * as given by the configuration +// * @param header JwtHeader provided by the library +// * @param callback callback function provided by the library +// */ +// const getKey:jwt.GetPublicKeyOrSecret = (header, callback) => { +// console.log(oauthConfig); +// console.log(header); +// if(oauthConfig.jwksUri && header.kid) { //if we have a jwksUri, then use that +// // const client = jwksRSA({ +// // jwksUri: oauthConfig.jwksUri, +// // }); +// // client.getSigningKey(header.kid, (err:Error, key: { getPublicKey: () => string | Buffer | { key: string | Buffer; passphrase: string; } | undefined; }) => { +// // callback(err, key?.getPublicKey()) +// // }) +// +// jwksFromUri(header.kid, header.alg, oauthConfig.jwksUri) +// .then(rawKey=>{ +// callback(null, new Buffer(rawKey)) +// }) +// .catch(err=>callback(err, undefined)) +// +// } else { //otherwise, fall back to a static signing key +// console.log("Falling back to static key verification. Either oauthConfig.jwksUri is not set, or the JWT has no 'kid' parameter") +// loadInSigningKey() +// .then(key=>callback(null, key)) +// .catch(err=>callback(err, "")) +// } +// } +// +// return new Promise((resolve, reject) => { +// jwt.verify(token, getKey, (err, decoded) => { +// if (err) { +// console.log("token: ", token); +// console.error("could not verify JWT: ", err); +// reject(err); +// } else { +// window.localStorage.setItem("pluto:access-token", token); //it validates so save the token +// if (refreshToken) +// window.localStorage.setItem("pluto:refresh-token", refreshToken); +// resolve(decoded); +// } +// }); +// }); +// } +async function verifyJwt(oauthConfig:OAuthContextData, token: string, refreshToken?: string) { + if(oauthConfig.jwksUri) { + const JWKS = createRemoteJWKSet(new URL(oauthConfig.jwksUri)); + const {payload, protectedHeader} = await jwtVerify(token, JWKS); + console.log("verification successful: "); + console.log(payload); + console.log(protectedHeader); + window.localStorage.setItem("pluto:access-token", token); //it validates so save the token + if(refreshToken) window.localStorage.setItem("pluto:refresh-token", refreshToken); + return payload; - return new Promise((resolve, reject) => { - jwt.verify(token, getKey, (err, decoded) => { - if (err) { - console.log("token: ", token); - console.error("could not verify JWT: ", err); - reject(err); - } else { - window.localStorage.setItem("pluto:access-token", token); //it validates so save the token - if (refreshToken) - window.localStorage.setItem("pluto:refresh-token", refreshToken); - resolve(decoded); - } - }); - }); + } else { //otherwise, fall back to a static signing key + console.log("Falling back to static key verification. Either oauthConfig.jwksUri is not set, or the JWT has no 'kid' parameter") + // const rawKey = await loadInSigningKey(); + // const {payload, protectedHeader} = await jwtVerify(token, rawKey) + throw "not implemented" + } } - /** * perform the validation of the token via jsonwebtoken library. * if validation fails then the returned promise is rejected * if validation succeeds, then the promise only completes once the decoded content has been set into the state. * @returns {Promise} Decoded JWT content or rejects with an error */ -function validateAndDecode(token:string, signingKey:string, refreshToken?:string):Promise { - return new Promise((resolve, reject) => { - jwt.verify(token, signingKey, (err, decoded) => { - if (err) { - console.log("token: ", token); - console.log("signingKey: ", signingKey); - console.error("could not verify JWT: ", err); - reject(err); - } - - window.localStorage.setItem("pluto:access-token", token); //it validates so save the token - if (refreshToken) - window.localStorage.setItem("pluto:refresh-token", refreshToken); - resolve(decoded); - }); - }); -} +// function validateAndDecode(token:string, signingKey:string, refreshToken?:string):Promise { +// return new Promise((resolve, reject) => { +// jwt.verify(token, signingKey, (err, decoded) => { +// if (err) { +// console.log("token: ", token); +// console.log("signingKey: ", signingKey); +// console.error("could not verify JWT: ", err); +// reject(err); +// } +// +// window.localStorage.setItem("pluto:access-token", token); //it validates so save the token +// if (refreshToken) +// window.localStorage.setItem("pluto:refresh-token", refreshToken); +// resolve(decoded); +// }); +// }); +// } /** * gets the signing key from the server @@ -120,4 +138,4 @@ async function verifyExistingLogin(oAuthData:OAuthContextData): PromisePromise = (tokenUri) => new Promise((resolve,reject)=>{ +export const refreshLogin:(oAuthConfig:OAuthContextData, userContext:UserContext)=>Promise = (oAuthConfig, userContext) => new Promise((resolve,reject)=>{ const refreshToken = localStorage.getItem("pluto:refresh-token"); if(!refreshToken) { reject("No refresh token"); @@ -27,7 +34,7 @@ export const refreshLogin:(tokenUri:string)=>Promise = (tokenUri) => new P const body_content = content_elements.join("&"); const performRefresh = async ()=> { - const response = await fetch(tokenUri, { + const response = await fetch(oAuthConfig.tokenUri, { method: "POST", body: body_content, headers: { @@ -37,11 +44,19 @@ export const refreshLogin:(tokenUri:string)=>Promise = (tokenUri) => new P }); switch (response.status) { case 200: - const content = await response.json(); - console.log("Server response: ", content); - localStorage.setItem("pluto:access-token", content.access_token); - if (content.refresh_token) localStorage.setItem("pluto:refresh-token", content.refresh_token); - resolve(); + try { + const content = await response.json(); + console.log("Server response: ", content); + const result = await verifyJwt(oAuthConfig, content.id_token ?? content.access_token, content.refresh_token); + const updatedProfile = JwtData(result); + userContext.updateProfile(updatedProfile); + + // localStorage.setItem("pluto:access-token", content.id_token ?? content.access_token); + // if (content.refresh_token) localStorage.setItem("pluto:refresh-token", content.refresh_token); + resolve(); + } catch(err) { + reject(err); + } break; case 403: case 401: diff --git a/src/utils/OAuthConfiguration.ts b/src/utils/OAuthConfiguration.ts index 86f1a5b..222a08d 100644 --- a/src/utils/OAuthConfiguration.ts +++ b/src/utils/OAuthConfiguration.ts @@ -3,9 +3,11 @@ import {createCheckers} from "ts-interface-checker"; interface OAuthConfigurationIF { clientId: string; - resource: string; + resource?: string; oAuthUri: string; tokenUri: string; + jwksUri?: string; + scope?: string; adminClaimName: string; } @@ -15,9 +17,11 @@ const { class OAuthConfiguration implements OAuthConfigurationIF { clientId: string; - resource: string; + resource?: string; oAuthUri: string; tokenUri: string; + jwksUri?: string; + scope?: string; adminClaimName: string; constructor(from:any, validate=true) { @@ -29,6 +33,8 @@ class OAuthConfiguration implements OAuthConfigurationIF { this.resource = from.resource; this.oAuthUri = from.oAuthUri; this.tokenUri = from.tokenUri; + this.jwksUri = from.jwksUri; + this.scope = from.scope; this.adminClaimName = from.adminClaimName; } diff --git a/src/utils/jwks.ts b/src/utils/jwks.ts index fc029e4..f10877f 100644 --- a/src/utils/jwks.ts +++ b/src/utils/jwks.ts @@ -1,3 +1,5 @@ +import {importJWK, JWK as joseJWK} from "jose"; + interface JWKS { keys: JWK[]; } @@ -26,26 +28,41 @@ function digestFor(algorithm: string):string { } } -export async function jwksFromUri(kid:string, algorithm: string, baseUrl:string) { +export async function jwksFromUri(kid: string, algorithm: string, baseUrl:string) { const rawDataResponse = await fetch(baseUrl); if(rawDataResponse.status!=200) throw `Could not download JWKS from ${baseUrl}`; - if(!algorithm.startsWith("RS")) throw `Only RSA keys are supported at present`; const jwks = await rawDataResponse.json() as JWKS; - - //find the matching key const matches = jwks.keys.filter(k=>k.kid==kid); + if(matches.length==0) { throw `No verification found for key ID "${kid}"` } else { - const k = matches[0] as JsonWebKey; - const algo:RsaHashedImportParams = { - hash: digestFor(algorithm), - name: "RSA-PSS", - } - console.log(algo); - const cryptoKey = await crypto.subtle.importKey("jwk", k, algo, true, ["verify"]); - const rawKey = await crypto.subtle.exportKey("spki", cryptoKey) as ArrayBuffer; - + const k = matches[0] as joseJWK; + return await importJWK(k); } } + +// export async function jwksFromUri(kid:string, algorithm: string, baseUrl:string) { +// const rawDataResponse = await fetch(baseUrl); +// if(rawDataResponse.status!=200) throw `Could not download JWKS from ${baseUrl}`; +// if(!algorithm.startsWith("RS")) throw `Only RSA keys are supported at present`; +// +// const jwks = await rawDataResponse.json() as JWKS; +// +// //find the matching key +// const matches = jwks.keys.filter(k=>k.kid==kid); +// if(matches.length==0) { +// throw `No verification found for key ID "${kid}"` +// } else { +// const k = matches[0] as JsonWebKey; +// const algo:RsaHashedImportParams = { +// hash: digestFor(algorithm), +// name: "RSA-PSS", +// } +// console.log(algo); +// const cryptoKey = await crypto.subtle.importKey("jwk", k, algo, true, ["verify"]); +// const rawKey = await crypto.subtle.exportKey("spki", cryptoKey) as ArrayBuffer; +// +// } +// } From be2f3e63a8205606f259515bdcb3f1cd3aaff198 Mon Sep 17 00:00:00 2001 From: Andy Gallagher Date: Wed, 8 Jun 2022 08:40:49 +0100 Subject: [PATCH 04/22] reworking isAdmin functionality --- __tests__/components/Context/TestUserContext.tsx | 2 -- src/components/Context/OAuthContext.tsx | 10 +++++++--- src/components/MenuButton/MenuButton.tsx | 9 ++++++--- src/utils/DecodedProfile.ts | 4 +++- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/__tests__/components/Context/TestUserContext.tsx b/__tests__/components/Context/TestUserContext.tsx index 55905fd..6c5034e 100644 --- a/__tests__/components/Context/TestUserContext.tsx +++ b/__tests__/components/Context/TestUserContext.tsx @@ -26,7 +26,6 @@ describe("UserContext", ()=>{ const rendered = mount(
    false, updateProfile: (newValue) => {} }}> @@ -40,7 +39,6 @@ describe("UserContext", ()=>{ const rendered = mount(
    false, updateProfile: (newValue) => {} }}> diff --git a/src/components/Context/OAuthContext.tsx b/src/components/Context/OAuthContext.tsx index e62ed8d..c0c348a 100644 --- a/src/components/Context/OAuthContext.tsx +++ b/src/components/Context/OAuthContext.tsx @@ -149,12 +149,16 @@ function makeLoginUrl(oAuthContext: OAuthContextData) { return oAuthContext.oAuthUri + "?" + encoded.join("&"); } -function isAdmin(oAuthContext:OAuthContextData, userProfile:UserContext) { - if(userProfile.profile && oAuthContext.adminClaimName) { +function isAdmin(oAuthContext:OAuthContextData|undefined, userProfile:UserContext) { + if(userProfile.profile && oAuthContext?.adminClaimName) { const maybeValue = userProfile.profile.hasOwnProperty(oAuthContext.adminClaimName); - return maybeValue && userProfile.profile.get(oAuthContext.adminClaimName).toLowerCase() == "true" + return maybeValue && userProfile.profile[oAuthContext.adminClaimName].toLowerCase() == "true" + } else { + console.warn("Can't check admin status because user profile is not loaded or oAuth config incomplete"); + return false; } } + export type { OAuthContextData }; export {OAuthContext, OAuthContextProvider, makeLoginUrl, generateCodeChallenge, isAdmin}; diff --git a/src/components/MenuButton/MenuButton.tsx b/src/components/MenuButton/MenuButton.tsx index d76cd89..b7198b1 100644 --- a/src/components/MenuButton/MenuButton.tsx +++ b/src/components/MenuButton/MenuButton.tsx @@ -8,6 +8,7 @@ import { } from "../../utils/AppLinks"; import "./MenuButton.css"; import {UserContext} from "../Context/UserContext"; +import {isAdmin, OAuthContext} from "../Context/OAuthContext"; interface MenuButtonProps { index: number; @@ -28,12 +29,14 @@ export const MenuButton: React.FC = (props) => { setAnchorEl(null); }; + const oAuthContext = useContext(OAuthContext); const userContext = useContext(UserContext); + const displayAdmin = ()=>isAdmin(oAuthContext, userContext) return (