diff --git a/package-lock.json b/package-lock.json index 8bd2c9b6f..e2aa10d63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31743,6 +31743,11 @@ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" }, + "long-settimeout": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/long-settimeout/-/long-settimeout-1.0.1.tgz", + "integrity": "sha512-+Riw1FKJ5Aotk2WatDq2U3HCZEUxKRAeC0TOEbj4mnR+m3zhleQ5BuWsKu+Vb6kHoDQqq+jLKIcCqOODC7CIyg==" + }, "longest-streak": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-1.0.0.tgz", diff --git a/package.json b/package.json index 59225f4a2..4965462c5 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "jwks-rsa": "^1.7.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.15", + "long-settimeout": "^1.0.1", "lru-cache": "^5.1.1", "luxon": "^1.22.2", "metascraper-author": "^5.11.6", diff --git a/src/core/client/account/local/initLocalState.ts b/src/core/client/account/local/initLocalState.ts index 1b7ac36fe..aba8b459a 100644 --- a/src/core/client/account/local/initLocalState.ts +++ b/src/core/client/account/local/initLocalState.ts @@ -1,5 +1,6 @@ import { Environment } from "relay-runtime"; +import { AuthState } from "coral-framework/lib/auth"; import { CoralContext } from "coral-framework/lib/bootstrap"; import { initLocalBaseState } from "coral-framework/lib/relay"; @@ -8,7 +9,8 @@ import { initLocalBaseState } from "coral-framework/lib/relay"; */ export default async function initLocalState( environment: Environment, - context: CoralContext + context: CoralContext, + auth?: AuthState ) { - await initLocalBaseState(environment, context); + initLocalBaseState(environment, context, auth); } diff --git a/src/core/client/admin/constants.ts b/src/core/client/admin/constants.ts index 13aa45435..917be9cc4 100644 --- a/src/core/client/admin/constants.ts +++ b/src/core/client/admin/constants.ts @@ -1,5 +1,4 @@ export const REDIRECT_PATH_KEY = "coral:adminRedirectPath"; -export const ACCESS_TOKEN_KEY = "coral:accessToken"; export const HOTKEYS = { NEXT: "j", PREV: "k", diff --git a/src/core/client/admin/local/__snapshots__/initLocalState.spec.ts.snap b/src/core/client/admin/local/__snapshots__/initLocalState.spec.ts.snap index 2ed5c0035..b60535cd6 100644 --- a/src/core/client/admin/local/__snapshots__/initLocalState.spec.ts.snap +++ b/src/core/client/admin/local/__snapshots__/initLocalState.spec.ts.snap @@ -4,8 +4,7 @@ exports[`get access token from url 1`] = ` "{ \\"__id\\": \\"client:root.local\\", \\"__typename\\": \\"Local\\", - \\"accessToken\\": \\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==\\", - \\"accessTokenExp\\": null, + \\"accessToken\\": \\"eyJraWQiOiI5NmM4MDY2YS1kOTg3LTQyODItODNmOS1kYTUxNjc5N2Y5ZmMiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==.\\", \\"accessTokenJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\", \\"redirectPath\\": null, \\"authView\\": \\"SIGN_IN\\", @@ -25,9 +24,6 @@ exports[`init local state 1`] = ` \\"client:root.local\\": { \\"__id\\": \\"client:root.local\\", \\"__typename\\": \\"Local\\", - \\"accessToken\\": \\"\\", - \\"accessTokenExp\\": null, - \\"accessTokenJTI\\": null, \\"redirectPath\\": null, \\"authView\\": \\"SIGN_IN\\", \\"authError\\": null diff --git a/src/core/client/admin/local/initLocalState.ts b/src/core/client/admin/local/initLocalState.ts index bb2e49fb1..a9eeac42d 100644 --- a/src/core/client/admin/local/initLocalState.ts +++ b/src/core/client/admin/local/initLocalState.ts @@ -1,9 +1,9 @@ import { commitLocalUpdate, Environment } from "relay-runtime"; -import { ACCESS_TOKEN_KEY, REDIRECT_PATH_KEY } from "coral-admin/constants"; +import { REDIRECT_PATH_KEY } from "coral-admin/constants"; import { clearHash, getParamsFromHash } from "coral-framework/helpers"; +import { AuthState, storeAccessToken } from "coral-framework/lib/auth"; import { CoralContext } from "coral-framework/lib/bootstrap"; -import { parseJWT } from "coral-framework/lib/jwt"; import { initLocalBaseState, LOCAL_ID } from "coral-framework/lib/relay"; /** @@ -11,11 +11,9 @@ import { initLocalBaseState, LOCAL_ID } from "coral-framework/lib/relay"; */ export default async function initLocalState( environment: Environment, - context: CoralContext + context: CoralContext, + auth?: AuthState ) { - // Get the access token from the session storage. - let accessToken = await context.sessionStorage.getItem(ACCESS_TOKEN_KEY); - // Initialize the redirect path in case we don't need to redirect somewhere. let redirectPath: string | null = null; let error: string | null = null; @@ -31,11 +29,9 @@ export default async function initLocalState( error = params.error; } - // If there was an access token, store it and replace the one that was in - // the session storage before. + // If there was an access token, store it. if (params.accessToken) { - accessToken = params.accessToken; - await context.sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + auth = storeAccessToken(params.accessToken); } // As we are in the middle of an auth flow (given that there was something @@ -48,19 +44,7 @@ export default async function initLocalState( await context.localStorage.setItem(REDIRECT_PATH_KEY, ""); } - if (accessToken) { - // As there's a token on the request, decode it, and check to see if it's - // expired already. If it is, this will send them back to the error page. - const { payload } = parseJWT(accessToken); - if (payload && payload.exp) { - if (payload.exp - Date.now() / 1000 <= 0) { - accessToken = null; - await context.sessionStorage.removeItem(ACCESS_TOKEN_KEY); - } - } - } - - await initLocalBaseState(environment, context, accessToken); + initLocalBaseState(environment, context, auth); commitLocalUpdate(environment, (s) => { const localRecord = s.get(LOCAL_ID)!; diff --git a/src/core/client/admin/routes/Invite/InviteCompleteForm.tsx b/src/core/client/admin/routes/Invite/InviteCompleteForm.tsx index 83b947881..c1b5c8f94 100644 --- a/src/core/client/admin/routes/Invite/InviteCompleteForm.tsx +++ b/src/core/client/admin/routes/Invite/InviteCompleteForm.tsx @@ -3,8 +3,8 @@ import { FORM_ERROR } from "final-form"; import React, { useCallback, useMemo } from "react"; import { Form } from "react-final-form"; +import { parseAccessTokenClaims } from "coral-framework/lib/auth/helpers"; import { InvalidRequestError } from "coral-framework/lib/errors"; -import { parseJWT } from "coral-framework/lib/jwt"; import { useMutation } from "coral-framework/lib/relay"; import { Button, @@ -52,7 +52,14 @@ const InviteCompleteForm: React.FunctionComponent = ({ }, [token] ); - const email = useMemo(() => parseJWT(token).payload.email, [token]); + const email = useMemo(() => { + const claims = parseAccessTokenClaims<{ email?: string }>(token); + if (!claims) { + return null; + } + + return claims.email; + }, [token]); return (
diff --git a/src/core/client/admin/routes/Invite/Success.tsx b/src/core/client/admin/routes/Invite/Success.tsx index 9e7e07118..7138af322 100644 --- a/src/core/client/admin/routes/Invite/Success.tsx +++ b/src/core/client/admin/routes/Invite/Success.tsx @@ -2,8 +2,8 @@ import { Localized } from "@fluent/react/compat"; import { Link } from "found"; import React, { useMemo } from "react"; +import { parseAccessTokenClaims } from "coral-framework/lib/auth/helpers"; import { ExternalLink } from "coral-framework/lib/i18n/components"; -import { parseJWT } from "coral-framework/lib/jwt"; import { HorizontalGutter, Typography } from "coral-ui/components"; import styles from "./Success.css"; @@ -19,7 +19,14 @@ const Success: React.FunctionComponent = ({ organizationName, organizationURL, }) => { - const email = useMemo(() => parseJWT(token).payload.email, [token]); + const email = useMemo(() => { + const claims = parseAccessTokenClaims<{ email?: string }>(token); + if (!claims) { + return null; + } + + return claims.email; + }, [token]); return ( { + initLocalBaseState(environment, context, auth); + + commitLocalUpdate(environment, (s) => { const localRecord = s.get(LOCAL_ID)!; // Parse query params diff --git a/src/core/client/framework/lib/auth/auth.ts b/src/core/client/framework/lib/auth/auth.ts new file mode 100644 index 000000000..584250811 --- /dev/null +++ b/src/core/client/framework/lib/auth/auth.ts @@ -0,0 +1,90 @@ +import { Claims, computeExpiresIn, parseAccessTokenClaims } from "./helpers"; + +/** + * ACCESS_TOKEN_KEY is the key in storage where the accessToken is stored. + */ +const ACCESS_TOKEN_KEY = "coral:v1:accessToken"; + +/** + * storage is the Storage used to retrieve/update/delete access tokens on. + */ +const storage = localStorage; + +export interface AuthState { + /** + * accessToken is the access token issued by the server. + */ + accessToken: string; + + /** + * claims are the parsed claims from the access token. + */ + claims: Claims; +} + +export type AccessTokenProvider = () => string | undefined; + +function parseAccessToken(accessToken: string) { + // Try to parse the access token claims. + const claims = parseAccessTokenClaims(accessToken); + if (!claims) { + // Claims couldn't be parsed. + return; + } + + if (claims.exp) { + const expiresIn = computeExpiresIn(claims.exp); + if (!expiresIn) { + // Looks like the access token has expired. + return; + } + } + + return { accessToken, claims }; +} + +export function retrieveAccessToken() { + try { + // Get the access token from storage. + const accessToken = storage.getItem(ACCESS_TOKEN_KEY); + if (!accessToken) { + // Looks like the access token wasn't in storage. + return; + } + + // Return the parsed access token. + return parseAccessToken(accessToken); + } catch (err) { + // TODO: (wyattjoh) add error reporting around this error + // eslint-disable-next-line no-console + console.error("could not get access token from storage", err); + + return; + } +} + +export function storeAccessToken(accessToken: string) { + try { + // Update the access token in storage. + storage.setItem(ACCESS_TOKEN_KEY, accessToken); + } catch (err) { + // TODO: (wyattjoh) add error reporting around this error + // eslint-disable-next-line no-console + console.error("could not set access token in storage", err); + } + + // Return the parsed access token. + return parseAccessToken(accessToken); +} + +export function deleteAccessToken() { + try { + storage.removeItem(ACCESS_TOKEN_KEY); + } catch (err) { + // TODO: (wyattjoh) add error reporting around this error + // eslint-disable-next-line no-console + console.error("could not remove access token from storage", err); + } + + return undefined; +} diff --git a/src/core/client/framework/lib/auth/helpers.ts b/src/core/client/framework/lib/auth/helpers.ts new file mode 100644 index 000000000..c755c31ec --- /dev/null +++ b/src/core/client/framework/lib/auth/helpers.ts @@ -0,0 +1,56 @@ +const SKEW_TOLERANCE = 300; + +export interface Claims { + jti?: string; + exp?: number; +} + +export function parseAccessTokenClaims( + accessToken: string +): (Claims & T) | null { + const parts = accessToken.split("."); + if (parts.length !== 3) { + // TODO: (wyattjoh) add error reporting around this error + // eslint-disable-next-line no-console + console.warn("access token does not have the right number of parts"); + + return null; + } + + try { + const claims = JSON.parse(atob(parts[1])); + + // Validate `jti` claim. + if (!claims.jti || typeof claims.jti !== "string") { + delete claims.jti; + } + + // Validate `exp` claim. + if (!claims.exp || typeof claims.exp !== "number") { + delete claims.exp; + } + + return claims; + } catch (err) { + // TODO: (wyattjoh) add error reporting around this error + // eslint-disable-next-line no-console + console.error("access token can not be parsed:", err); + + return null; + } +} + +/** + * computeExpiresIn will return null if we are already expired, or the time in + * milliseconds from now that we are expired. + * + * @param expiredAt the epoch timestamp that we're considered expired + */ +export function computeExpiresIn(expiredAt: number) { + const expiresIn = expiredAt * 1000 - Date.now(); + if (expiresIn + SKEW_TOLERANCE <= 0) { + return null; + } + + return expiresIn; +} diff --git a/src/core/client/framework/lib/auth/index.ts b/src/core/client/framework/lib/auth/index.ts new file mode 100644 index 000000000..97ccf7649 --- /dev/null +++ b/src/core/client/framework/lib/auth/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx index 3f3bebf2a..3aff6596a 100644 --- a/src/core/client/framework/lib/bootstrap/createManaged.tsx +++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx @@ -9,11 +9,6 @@ import { v1 as uuid } from "uuid"; import { LanguageCode } from "coral-common/helpers/i18n"; import { getBrowserInfo } from "coral-framework/lib/browserInfo"; -import { - commitLocalUpdatePromisified, - LOCAL_ID, - setAccessTokenInLocalState, -} from "coral-framework/lib/relay"; import { RestClient } from "coral-framework/lib/rest"; import { createLocalStorage, @@ -24,20 +19,28 @@ import { } from "coral-framework/lib/storage"; import { ClickFarAwayRegister } from "coral-ui/components/ClickOutside"; +import { + AccessTokenProvider, + AuthState, + deleteAccessToken, + retrieveAccessToken, + storeAccessToken, +} from "../auth"; import { generateBundles, LocalesData } from "../i18n"; import { createManagedSubscriptionClient, createNetwork, ManagedSubscriptionClient, - TokenGetter, } from "../network"; import { PostMessageService } from "../postMessage"; +import { LOCAL_ID } from "../relay"; import { CoralContext, CoralContextProvider } from "./CoralContext"; import SendPymReady from "./SendPymReady"; export type InitLocalState = ( environment: Environment, - context: CoralContext + context: CoralContext, + auth?: AuthState ) => void | Promise; interface CreateContextArguments { @@ -47,6 +50,9 @@ interface CreateContextArguments { /** Init will be called after the context has been created. */ initLocalState?: InitLocalState; + /** Access token that should be used instead of what's currently in storage */ + accessToken?: string; + /** A pym child that interacts with the pym parent. */ pym?: PymChild; @@ -100,25 +106,31 @@ function areWeInIframe() { function createRelayEnvironment( subscriptionClient: ManagedSubscriptionClient, - clientID: string + clientID: string, + accessToken?: string ) { const source = new RecordSource(); - const tokenGetter: TokenGetter = () => { - const localState = source.get(LOCAL_ID); - if (localState) { - return (localState.accessToken as string) || ""; + const accessTokenProvider: AccessTokenProvider = () => { + const local = source.get(LOCAL_ID); + if (!local) { + return; } - return ""; + + return local.accessToken as string | undefined; }; const environment = new Environment({ - network: createNetwork(subscriptionClient, tokenGetter, clientID), + network: createNetwork(subscriptionClient, clientID, accessTokenProvider), store: new Store(source), }); - return { environment, tokenGetter, subscriptionClient }; + + return { environment, accessTokenProvider }; } -function createRestClient(tokenGetter: () => string, clientID: string) { - return new RestClient("/api", tokenGetter, clientID); +function createRestClient( + clientID: string, + accessTokenProvider: AccessTokenProvider +) { + return new RestClient("/api", clientID, accessTokenProvider); } /** @@ -148,51 +160,43 @@ function createManagedCoralContextProvider( } // This is called every time a user session starts or ends. - private clearSession = async (nextAccessToken?: string | null) => { + private clearSession = async (nextAccessToken?: string) => { // Clear session storage. this.state.context.sessionStorage.clear(); // Pause subscriptions. subscriptionClient.pause(); - // Create a new context with a new Relay Environment. - const { - environment: newEnvironment, - tokenGetter: newTokenGetter, - } = createRelayEnvironment(subscriptionClient, clientID); + // Parse the claims/token and update storage. + const auth = nextAccessToken + ? storeAccessToken(nextAccessToken) + : deleteAccessToken(); - const newContext = { + // Create the new environment. + const { environment, accessTokenProvider } = createRelayEnvironment( + subscriptionClient, + clientID, + auth?.accessToken + ); + + // Create the new context. + const newContext: CoralContext = { ...this.state.context, - relayEnvironment: newEnvironment, - rest: createRestClient(newTokenGetter, clientID), + relayEnvironment: environment, + rest: createRestClient(clientID, accessTokenProvider), }; // Initialize local state. - await initLocalState(newContext.relayEnvironment, newContext); + await initLocalState(newContext.relayEnvironment, newContext, auth); - // Set new token for the websocket connection. - // TODO: (cvle) dynamically reset when token changes. - // ^ only necessary when we can prolong existing session using - // a new token. - subscriptionClient.setAccessToken(newTokenGetter()); - - // Set next access token. - if (nextAccessToken) { - await commitLocalUpdatePromisified(newEnvironment, async (store) => { - setAccessTokenInLocalState(nextAccessToken, store); - }); - } + // Update the subscription client access token. + subscriptionClient.setAccessToken(accessTokenProvider()); // Propagate new context. - this.setState( - { - context: newContext, - }, - () => { - // Resume subscriptions after context has changed. - subscriptionClient.resume(); - } - ); + this.setState({ context: newContext }, () => { + // Resume subscriptions after context has changed. + subscriptionClient.resume(); + }); }; // This is called when the locale should change. @@ -300,8 +304,8 @@ export default async function createManaged({ const localeBundles = await generateBundles(locales, localesData); - const localStorage = resolveLocalStorage(pym); - const sessionStorage = resolveSessionStorage(pym); + // Get the access token from storage. + const auth = retrieveAccessToken(); /** clientID is sent to the server with every request */ const clientID = uuid(); @@ -311,9 +315,10 @@ export default async function createManaged({ clientID ); - const { environment, tokenGetter } = createRelayEnvironment( + const { environment, accessTokenProvider } = createRelayEnvironment( subscriptionClient, - clientID + clientID, + auth?.accessToken ); // Assemble context. @@ -325,10 +330,10 @@ export default async function createManaged({ pym, eventEmitter, registerClickFarAway, - rest: createRestClient(tokenGetter, clientID), + rest: createRestClient(clientID, accessTokenProvider), postMessage: new PostMessageService(), - localStorage, - sessionStorage, + localStorage: resolveLocalStorage(pym), + sessionStorage: resolveSessionStorage(pym), browserInfo: getBrowserInfo(), uuidGenerator: uuid, // Noop, this is later replaced by the @@ -340,13 +345,12 @@ export default async function createManaged({ }; // Initialize local state. - await initLocalState(context.relayEnvironment, context); + await initLocalState(context.relayEnvironment, context, auth); - // Set current token for the websocket connection. + // Set new token for the websocket connection. // TODO: (cvle) dynamically reset when token changes. - // ^ only necessary when we can prolong existing session using - // a new token. - subscriptionClient.setAccessToken(tokenGetter()); + // ^ only necessary when we can prolong existing session using a new token. + subscriptionClient.setAccessToken(accessTokenProvider()); // Returns a managed CoralContextProvider, that includes the above // context and handles context changes, e.g. when a user session changes. diff --git a/src/core/client/framework/lib/jwt.ts b/src/core/client/framework/lib/jwt.ts deleted file mode 100644 index 6e09a172b..000000000 --- a/src/core/client/framework/lib/jwt.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface JWT { - header: { - alg: string; - typ: string; - }; - payload: { - [_: string]: any; - iat?: number; - exp?: number; - iss?: string; - sub?: string; - jti?: string; - }; - expired: boolean; -} - -export function parseJWT(token: string, skewTolerance = 300): JWT { - const [headerBase64, payloadBase64] = token.split("."); - if (!headerBase64 && !payloadBase64) { - throw new Error("invalid jwt token"); - } - const header = JSON.parse(atob(headerBase64)); - const payload = JSON.parse(atob(payloadBase64)); - return { - header, - payload, - get expired() { - return Date.now() / 1000 + skewTolerance >= payload.exp; - }, - }; -} diff --git a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts index 1049facd6..06d57ba78 100644 --- a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts +++ b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts @@ -26,14 +26,14 @@ export interface SubscriptionRequest { } /** - * ManagedSubscriptionClient builts on top of `SubscriptionClient` + * ManagedSubscriptionClient builds on top of `SubscriptionClient` * and manages the websocket connection economically. A connection is - * only establish when there is at least 1 active susbcription and closes + * only establish when there is at least 1 active subscription and closes * when there is no more active subscriptions. */ export interface ManagedSubscriptionClient { /** - * Susbcribe to a GraphQL subscription, this is usually called from + * Subscribe to a GraphQL subscription, this is usually called from * the SubscriptionFunction provided to Relay. */ subscribe( @@ -47,7 +47,7 @@ export interface ManagedSubscriptionClient { /** Resume all subscriptions eventually causing websocket to start with new connection parameters */ resume(): void; /** Sets access token and restarts the websocket connection */ - setAccessToken(accessToken: string): void; + setAccessToken(accessToken?: string): void; } /** @@ -63,7 +63,7 @@ export default function createManagedSubscriptionClient( const requests: SubscriptionRequest[] = []; let subscriptionClient: SubscriptionClient | null = null; let paused = false; - let accessToken = ""; + let accessToken: string | undefined; const closeClient = () => { if (subscriptionClient) { @@ -143,7 +143,7 @@ export default function createManagedSubscriptionClient( // Register the request. requests.push(request as SubscriptionRequest); - // Start susbcription if we are not paused. + // Start subscription if we are not paused. if (!paused) { request.subscribe(); } @@ -179,7 +179,7 @@ export default function createManagedSubscriptionClient( r.unsubscribe = null; } } - // Close websocket conncetion. + // Close websocket connection. closeClient(); }; @@ -193,12 +193,8 @@ export default function createManagedSubscriptionClient( paused = false; }; - const setAccessToken = (t: string) => { - accessToken = t; - if (!paused) { - pause(); - resume(); - } + const setAccessToken = (nextAccessToken?: string) => { + accessToken = nextAccessToken; }; return Object.freeze({ diff --git a/src/core/client/framework/lib/network/createNetwork.ts b/src/core/client/framework/lib/network/createNetwork.ts index 0014ea262..f85f82fa9 100644 --- a/src/core/client/framework/lib/network/createNetwork.ts +++ b/src/core/client/framework/lib/network/createNetwork.ts @@ -10,13 +10,12 @@ import { GraphQLResponse, Observable, SubscribeFunction } from "relay-runtime"; import TIME from "coral-common/time"; import getLocationOrigin from "coral-framework/utils/getLocationOrigin"; +import { AccessTokenProvider } from "../auth"; import clientIDMiddleware from "./clientIDMiddleware"; import { ManagedSubscriptionClient } from "./createManagedSubscriptionClient"; import customErrorMiddleware from "./customErrorMiddleware"; import persistedQueriesGetMethodMiddleware from "./persistedQueriesGetMethodMiddleware"; -export type TokenGetter = () => string; - const graphqlURL = `${getLocationOrigin()}/api/graphql`; function createSubscriptionFunction( @@ -44,8 +43,8 @@ function createSubscriptionFunction( export default function createNetwork( subscriptionClient: ManagedSubscriptionClient, - tokenGetter: TokenGetter, - clientID: string + clientID: string, + accessTokenProvider: AccessTokenProvider ) { return new RelayNetworkLayer( [ @@ -70,7 +69,9 @@ export default function createNetwork( }, }), authMiddleware({ - token: tokenGetter, + token: () => { + return accessTokenProvider() || ""; + }, }), clientIDMiddleware(clientID), persistedQueriesGetMethodMiddleware, diff --git a/src/core/client/framework/lib/network/index.ts b/src/core/client/framework/lib/network/index.ts index aa8c38332..ad90a4978 100644 --- a/src/core/client/framework/lib/network/index.ts +++ b/src/core/client/framework/lib/network/index.ts @@ -1,4 +1,4 @@ -export { default as createNetwork, TokenGetter } from "./createNetwork"; +export { default as createNetwork } from "./createNetwork"; export { default as extractGraphQLError } from "./extractGraphQLError"; export { default as extractError } from "./extractError"; export { diff --git a/src/core/client/framework/lib/relay/index.ts b/src/core/client/framework/lib/relay/index.ts index 2e54c667d..b4d4628fe 100644 --- a/src/core/client/framework/lib/relay/index.ts +++ b/src/core/client/framework/lib/relay/index.ts @@ -22,12 +22,7 @@ export { commitMutationPromiseNormalized, } from "./commitMutationPromise"; export { default as commitLocalUpdatePromisified } from "./commitLocalUpdatePromisified"; -export { - initLocalBaseState, - setAccessTokenInLocalState, - LOCAL_ID, - LOCAL_TYPE, -} from "./localState"; +export { initLocalBaseState, LOCAL_ID, LOCAL_TYPE } from "./localState"; export { fetchQuery, createFetch, diff --git a/src/core/client/framework/lib/relay/localState.ts b/src/core/client/framework/lib/relay/localState.ts index 3c04ba520..295e05858 100644 --- a/src/core/client/framework/lib/relay/localState.ts +++ b/src/core/client/framework/lib/relay/localState.ts @@ -1,13 +1,10 @@ -import { - commitLocalUpdate, - Environment, - RecordSourceProxy, -} from "relay-runtime"; +import { commitLocalUpdate, Environment } from "relay-runtime"; -import { CoralContext } from "coral-framework/lib/bootstrap"; -import { parseJWT } from "coral-framework/lib/jwt"; import { createAndRetain } from "coral-framework/lib/relay"; +import { AuthState } from "../auth"; +import { CoralContext } from "../bootstrap"; + /** * The Root Record of Client-Side Schema Extension must be of this type. */ @@ -18,48 +15,33 @@ export const LOCAL_TYPE = "Local"; */ export const LOCAL_ID = "client:root.local"; -export function setAccessTokenInLocalState( - accessToken: string | null, - source: RecordSourceProxy -) { - const localRecord = source.get(LOCAL_ID)!; - localRecord.setValue(accessToken || "", "accessToken"); - if (accessToken) { - const { payload } = parseJWT(accessToken); - // TODO: (cvle) maybe a timer to detect when accessToken has expired? - - // Set the exp if it's valid. - if (typeof payload.exp === "number") { - localRecord.setValue(payload.exp, "accessTokenExp"); - } else { - localRecord.setValue(null, "accessTokenExp"); - } - - // Set the jti if it's valid. - if (typeof payload.jti === "string" && payload.jti.length > 0) { - localRecord.setValue(payload.jti, "accessTokenJTI"); - } else { - localRecord.setValue(null, "accessTokenJTI"); - } - } else { - localRecord.setValue(null, "accessTokenExp"); - localRecord.setValue(null, "accessTokenJTI"); - } -} - -export async function initLocalBaseState( +/** + * initLocalBaseState will initialize the local base relay state. If as a part + * of your target you need to change the auth state, you can do so by passing a + * new auth state object into this function when committing. + * + * @param environment the initialized relay environment + * @param context application context + * @param auth application auth state + */ +export function initLocalBaseState( environment: Environment, - { localStorage }: CoralContext, - accessToken?: string | null + context: CoralContext, + auth?: AuthState ) { - commitLocalUpdate(environment, (s) => { - const root = s.getRoot(); + commitLocalUpdate(environment, (source) => { + const root = source.getRoot(); // Create the Local Record which is the Root for the client states. - const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE); - root.setLinkedRecord(localRecord, "local"); + const local = createAndRetain(environment, source, LOCAL_ID, LOCAL_TYPE); - // Set access token - setAccessTokenInLocalState(accessToken || null, s); + root.setLinkedRecord(local, "local"); + + // Update the access token properties. + local.setValue(auth?.accessToken, "accessToken"); + + // Update the claims. + local.setValue(auth?.claims.exp, "accessTokenExp"); + local.setValue(auth?.claims.jti, "accessTokenJTI"); }); } diff --git a/src/core/client/framework/lib/rest.ts b/src/core/client/framework/lib/rest.ts index 4bf21af6e..96fbed9d8 100644 --- a/src/core/client/framework/lib/rest.ts +++ b/src/core/client/framework/lib/rest.ts @@ -3,6 +3,7 @@ import { merge } from "lodash"; import { CLIENT_ID_HEADER } from "coral-common/constants"; import { Overwrite } from "coral-framework/types"; +import { AccessTokenProvider } from "./auth"; import { extractError } from "./network"; const buildOptions = (inputOptions: RequestInit = {}) => { @@ -53,13 +54,17 @@ type PartialRequestInit = Overwrite, { body?: any }> & { export class RestClient { public readonly uri: string; - private tokenGetter?: () => string; private clientID?: string; + private accessTokenProvider?: AccessTokenProvider; - constructor(uri: string, tokenGetter?: () => string, clientID?: string) { + constructor( + uri: string, + clientID?: string, + accessTokenProvider?: AccessTokenProvider + ) { this.uri = uri; - this.tokenGetter = tokenGetter; this.clientID = clientID; + this.accessTokenProvider = accessTokenProvider; } public async fetch( @@ -67,7 +72,8 @@ export class RestClient { options: PartialRequestInit ): Promise { let opts = options; - const token = options.token || (this.tokenGetter && this.tokenGetter()); + const token = + options.token || (this.accessTokenProvider && this.accessTokenProvider()); if (token) { opts = merge({}, options, { headers: { @@ -75,6 +81,7 @@ export class RestClient { }, }); } + if (this.clientID) { opts = merge({}, opts, { headers: { @@ -82,7 +89,9 @@ export class RestClient { }, }); } + const response = await fetch(`${this.uri}${path}`, buildOptions(opts)); + return handleResp(response); } } diff --git a/src/core/client/framework/testHelpers/createAccessToken.ts b/src/core/client/framework/testHelpers/createAccessToken.ts index ee5b845cd..83278d22e 100644 --- a/src/core/client/framework/testHelpers/createAccessToken.ts +++ b/src/core/client/framework/testHelpers/createAccessToken.ts @@ -1,13 +1,15 @@ -export default function createAccessToken(payload = {}) { - return `${btoa( - JSON.stringify({ - alg: "HS256", - typ: "JWT", - }) - )}.${btoa( - JSON.stringify({ - jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce", - ...payload, - }) - )}`; +const TOKEN_JTI = "31b26591-4e9a-4388-a7ff-e1bdc5d97cce"; + +function encodePart(obj: object): string { + return btoa(JSON.stringify(obj)); +} + +export default function createAccessToken(payload = {}) { + return [ + { kid: "96c8066a-d987-4282-83f9-da516797f9fc", alg: "HS256" }, + { jti: TOKEN_JTI, ...payload }, + null, + ] + .map((obj) => (obj ? encodePart(obj) : obj)) + .join("."); } diff --git a/src/core/client/framework/testHelpers/createTestRenderer.tsx b/src/core/client/framework/testHelpers/createTestRenderer.tsx index 5f3235f00..e7a0d2b9e 100644 --- a/src/core/client/framework/testHelpers/createTestRenderer.tsx +++ b/src/core/client/framework/testHelpers/createTestRenderer.tsx @@ -114,6 +114,7 @@ export default function createTestRenderer< history: [], }, }; + let testRenderer: ReactTestRenderer; TestRenderer.act(() => { testRenderer = TestRenderer.create( diff --git a/src/core/client/install/constants.ts b/src/core/client/install/constants.ts index a051132e2..e69de29bb 100644 --- a/src/core/client/install/constants.ts +++ b/src/core/client/install/constants.ts @@ -1 +0,0 @@ -export const INSTALL_ACCESS_TOKEN_KEY = "coral:install:accessToken"; diff --git a/src/core/client/install/local/initLocalState.ts b/src/core/client/install/local/initLocalState.ts index 7c33a89c9..68fb81a8a 100644 --- a/src/core/client/install/local/initLocalState.ts +++ b/src/core/client/install/local/initLocalState.ts @@ -1,33 +1,27 @@ import { Environment } from "relay-runtime"; import { clearHash, getParamsFromHash } from "coral-framework/helpers"; +import { AuthState, storeAccessToken } from "coral-framework/lib/auth"; import { CoralContext } from "coral-framework/lib/bootstrap"; import { initLocalBaseState } from "coral-framework/lib/relay"; -import { INSTALL_ACCESS_TOKEN_KEY } from "../constants"; - /** * Initializes the local state, before we start the App. */ export default async function initLocalState( environment: Environment, - context: CoralContext + context: CoralContext, + auth?: AuthState ) { - // Get the access token from the session storage. - let accessToken = await context.sessionStorage.getItem( - INSTALL_ACCESS_TOKEN_KEY - ); - // Get all the parameters from the hash. const params = getParamsFromHash(); if (params && params.accessToken) { // As there's an access token in the hash, let's clear it. clearHash(); - // Save the token in session storage to override what we found. - accessToken = params.accessToken; - await context.sessionStorage.setItem(INSTALL_ACCESS_TOKEN_KEY, accessToken); + // Save the token in storage. + auth = storeAccessToken(params.accessToken); } - await initLocalBaseState(environment, context, accessToken); + initLocalBaseState(environment, context, auth); } diff --git a/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap b/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap index b59accafe..c49db3c35 100644 --- a/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap +++ b/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap @@ -12,9 +12,6 @@ exports[`init local state 1`] = ` \\"client:root.local\\": { \\"__id\\": \\"client:root.local\\", \\"__typename\\": \\"Local\\", - \\"accessToken\\": \\"\\", - \\"accessTokenExp\\": null, - \\"accessTokenJTI\\": null, \\"commentsOrderBy\\": \\"CREATED_AT_DESC\\", \\"authPopup\\": { \\"__ref\\": \\"client:root.local.authPopup\\" diff --git a/src/core/client/stream/local/initLocalState.ts b/src/core/client/stream/local/initLocalState.ts index 80a27b353..5100b3ef1 100644 --- a/src/core/client/stream/local/initLocalState.ts +++ b/src/core/client/stream/local/initLocalState.ts @@ -1,6 +1,7 @@ import { commitLocalUpdate, Environment } from "relay-runtime"; import { parseQuery } from "coral-common/utils"; +import { AuthState, storeAccessToken } from "coral-framework/lib/auth"; import { CoralContext } from "coral-framework/lib/bootstrap"; import { getExternalConfig } from "coral-framework/lib/externalConfig"; import { createAndRetain, initLocalBaseState } from "coral-framework/lib/relay"; @@ -13,14 +14,15 @@ import { AUTH_POPUP_ID, AUTH_POPUP_TYPE } from "./constants"; */ export default async function initLocalState( environment: Environment, - context: CoralContext + context: CoralContext, + auth?: AuthState ) { const config = await getExternalConfig(context.pym); - await initLocalBaseState( - environment, - context, - config ? config.accessToken : undefined - ); + if (config && config.accessToken) { + auth = storeAccessToken(config.accessToken); + } + + initLocalBaseState(environment, context, auth); const commentsOrderBy = (await context.localStorage.getItem(COMMENTS_ORDER_BY)) || @@ -44,6 +46,7 @@ export default async function initLocalState( if (query.commentID) { localRecord.setValue(query.commentID, "commentID"); } + // Set sort localRecord.setValue(commentsOrderBy, "commentsOrderBy"); @@ -63,7 +66,7 @@ export default async function initLocalState( localRecord.setValue("COMMENTS", "activeTab"); localRecord.setValue("MY_COMMENTS", "profileTab"); - // Initilzie the comments tab to NONE for now, it will be initialized to an + // Initialize the comments tab to NONE for now, it will be initialized to an // actual tab when we find out how many feature comments there are. localRecord.setValue("NONE", "commentsTab"); }); diff --git a/src/core/common/utils/timeout.ts b/src/core/common/utils/timeout.ts index 98fdf356c..1978aa485 100644 --- a/src/core/common/utils/timeout.ts +++ b/src/core/common/utils/timeout.ts @@ -1,4 +1,6 @@ +import { setLongTimeout } from "long-settimeout"; + /** A promisified timeout. */ export default function timeout(ms = 0) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setLongTimeout(resolve, ms)); } diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index 8ac8b0044..f42917601 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,8 +1,7 @@ import Joi from "@hapi/joi"; -import { CookieOptions, NextFunction, RequestHandler, Response } from "express"; +import { NextFunction, RequestHandler, Response } from "express"; import { Redis } from "ioredis"; import jwt from "jsonwebtoken"; -import { DateTime } from "luxon"; import passport, { Authenticator } from "passport"; import { stringifyQuery } from "coral-common/utils"; @@ -16,7 +15,6 @@ import { validate } from "coral-server/app/request/body"; import { AuthenticationError } from "coral-server/errors"; import { User } from "coral-server/models/user"; import { - COOKIE_NAME, extractTokenFromRequest, JWTSigningConfig, revokeJWT, @@ -95,8 +93,9 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) { await revokeJWT(redis, jti, exp, now); } - // Clear the cookie. - res.clearCookie(COOKIE_NAME, generateCookieOptions(req, new Date(0))); + // NOTE: disabled cookie support due to ITP/First Party Cookie bugs + // // Clear the cookie. + // res.clearCookie(COOKIE_NAME, generateCookieOptions(req, new Date(0))); return res.sendStatus(204); } @@ -115,11 +114,6 @@ export async function handleSuccessfulLogin( // Tenant is guaranteed at this point. const tenant = coral.tenant!; - // Compute the expiry date. - const expiresIn = DateTime.fromJSDate(coral.now).plus({ - seconds: tenant.auth.sessionDuration, - }); - // Grab the token. const token = await signTokenString( signingConfig, @@ -133,11 +127,18 @@ export async function handleSuccessfulLogin( res.header("Cache-Control", "private, no-cache, no-store, must-revalidate"); res.header("Expires", "-1"); res.header("Pragma", "no-cache"); - res.cookie( - COOKIE_NAME, - token, - generateCookieOptions(req, expiresIn.toJSDate()) - ); + + // NOTE: disabled cookie support due to ITP/First Party Cookie bugs + // // Compute the expiry date. + // const expiresIn = DateTime.fromJSDate(coral.now).plus({ + // seconds: tenant.auth.sessionDuration, + // }); + // + // res.cookie( + // COOKIE_NAME, + // token, + // generateCookieOptions(req, expiresIn.toJSDate()) + // ); // Send back the details! res.json({ token }); @@ -146,20 +147,21 @@ export async function handleSuccessfulLogin( } } -const generateCookieOptions = ( - req: Request, - expiresIn: Date -): CookieOptions => ({ - path: "/api", - httpOnly: true, - secure: req.secure, - // Chrome will ignore `SameSite: None` when not used in a secure context - // anyways, so don't bother setting `None` when we're not secure. The only - // time we aren't behind HTTPS is when we're testing/in development where the - // the setting for `SameSite: Lax` would be OK. - sameSite: req.secure ? "none" : "lax", - expires: expiresIn, -}); +// NOTE: disabled cookie support due to ITP/First Party Cookie bugs +// const generateCookieOptions = ( +// req: Request, +// expiresIn: Date +// ): CookieOptions => ({ +// path: "/api", +// httpOnly: true, +// secure: req.secure, +// // Chrome will ignore `SameSite: None` when not used in a secure context +// // anyways, so don't bother setting `None` when we're not secure. The only +// // time we aren't behind HTTPS is when we're testing/in development where the +// // the setting for `SameSite: Lax` would be OK. +// sameSite: req.secure ? "none" : "lax", +// expires: expiresIn, +// }); function redirectWithHash( res: Response, @@ -191,11 +193,6 @@ export async function handleOAuth2Callback( const coral = req.coral!; const tenant = coral.tenant!; - // Compute the expiry date. - const expiresIn = DateTime.fromJSDate(coral.now).plus({ - seconds: tenant.auth.sessionDuration, - }); - // Grab the token. const accessToken = await signTokenString( signingConfig, @@ -204,11 +201,18 @@ export async function handleOAuth2Callback( {}, coral.now ); - res.cookie( - COOKIE_NAME, - accessToken, - generateCookieOptions(req, expiresIn.toJSDate()) - ); + + // NOTE: disabled cookie support due to ITP/First Party Cookie bugs + // // Compute the expiry date. + // const expiresIn = DateTime.fromJSDate(coral.now).plus({ + // seconds: tenant.auth.sessionDuration, + // }); + // + // res.cookie( + // COOKIE_NAME, + // accessToken, + // generateCookieOptions(req, expiresIn.toJSDate()) + // ); // Send back the details! return redirectWithHash(res, path, { accessToken }); diff --git a/src/core/server/app/router/index.ts b/src/core/server/app/router/index.ts index 9e92763e2..687598bae 100644 --- a/src/core/server/app/router/index.ts +++ b/src/core/server/app/router/index.ts @@ -1,4 +1,3 @@ -import cookies from "cookie-parser"; import express, { Router } from "express"; import { LanguageCode } from "coral-common/helpers/i18n/locales"; @@ -16,7 +15,10 @@ export function createRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Attach the API router. - router.use("/api", cookies(), createAPIRouter(app, options)); + + // NOTE: disabled cookie support due to ITP/First Party Cookie bugs + // router.use("/api", cookies(), createAPIRouter(app, options)); + router.use("/api", createAPIRouter(app, options)); // Attach the GraphiQL if enabled. if (app.config.get("enable_graphiql")) { diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index 54049924d..23874e59d 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -1,5 +1,4 @@ import Joi from "@hapi/joi"; -import cookie from "cookie"; import { IncomingMessage } from "http"; import { Redis } from "ioredis"; import jwt, { KeyFunction, SignOptions, VerifyOptions } from "jsonwebtoken"; @@ -293,10 +292,11 @@ export async function signString( return jwt.sign(payload, secret, { ...options, algorithm }); } -/** - * COOKIE_NAME is the name of the authorization cookie used by Coral. - */ -export const COOKIE_NAME = "authorization"; +// NOTE: disabled cookie support due to ITP/First Party Cookie bugs +// /** +// * COOKIE_NAME is the name of the authorization cookie used by Coral. +// */ +// export const COOKIE_NAME = "authorization"; /** * isExpressRequest will check to see if this is a Request or an @@ -315,31 +315,32 @@ export function isExpressRequest( return true; } -/** - * extractJWTFromRequestCookie will parse the cookies off of the request if it - * can. - * - * @param req the incoming request possibly containing a cookie - */ -function extractJWTFromRequestCookie( - req: Request | IncomingMessage -): string | null { - if (!isExpressRequest(req)) { - // Grab the cookie header. - const header = req.headers.cookie; - if (typeof header !== "string" || header.length === 0) { - return null; - } +// NOTE: disabled cookie support due to ITP/First Party Cookie bugs +// /** +// * extractJWTFromRequestCookie will parse the cookies off of the request if it +// * can. +// * +// * @param req the incoming request possibly containing a cookie +// */ +// function extractJWTFromRequestCookie( +// req: Request | IncomingMessage +// ): string | null { +// if (!isExpressRequest(req)) { +// // Grab the cookie header. +// const header = req.headers.cookie; +// if (typeof header !== "string" || header.length === 0) { +// return null; +// } - // Parse the cookies from that header. - const cookies = cookie.parse(header); - return cookies[COOKIE_NAME] || null; - } +// // Parse the cookies from that header. +// const cookies = cookie.parse(header); +// return cookies[COOKIE_NAME] || null; +// } - return req.cookies && req.cookies[COOKIE_NAME] - ? req.cookies[COOKIE_NAME] - : null; -} +// return req.cookies && req.cookies[COOKIE_NAME] +// ? req.cookies[COOKIE_NAME] +// : null; +// } /** * @@ -365,7 +366,7 @@ function extractJWTFromRequestHeaders( /** * extractJWTFromRequest will extract the token from the request if it can find - * it. It first tries to get the token from the headers, then from the cookie. + * it. It will try to extract the token from the headers. * * @param req the request to extract the JWT from * @param excludeQuery when true, does not pull from the query params @@ -374,10 +375,9 @@ export function extractTokenFromRequest( req: Request | IncomingMessage, excludeQuery = false ): string | null { - return ( - extractJWTFromRequestHeaders(req, excludeQuery) || - extractJWTFromRequestCookie(req) - ); + // NOTE: disabled cookie support due to ITP/First Party Cookie bugs + // return extractJWTFromRequestHeaders(req, excludeQuery)|| extractJWTFromRequestCookie(req) + return extractJWTFromRequestHeaders(req, excludeQuery); } function generateJTIRevokedKey(jti: string) {