[CORL-1048] Cookie Deprecation (#2944)

* feat: dropped cookie support due to ITP issues

* feat: added improved accessToken handling

* fix: linting

* fix: removed variadic part of JWT

* fix: bump long-settimeout version

* review: removed management classes

* fix: updated snaps

* review: renamed based on review

* review: removed guard clauses around errors surrounding auth
This commit is contained in:
Wyatt Johnson
2020-05-13 22:39:22 +00:00
committed by GitHub
parent f73597d7d1
commit ceb96dba75
31 changed files with 429 additions and 323 deletions
+5
View File
@@ -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",
+1
View File
@@ -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",
@@ -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);
}
-1
View File
@@ -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",
@@ -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
+7 -23
View File
@@ -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)!;
@@ -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<Props> = ({
},
[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 (
<div data-testid="invite-complete-form">
@@ -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<Props> = ({
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 (
<HorizontalGutter
@@ -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\\",
\\"view\\": \\"SIGN_IN\\",
\\"error\\": null
@@ -24,9 +23,6 @@ exports[`init local state 1`] = `
\\"client:root.local\\": {
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"accessToken\\": \\"\\",
\\"accessTokenExp\\": null,
\\"accessTokenJTI\\": null,
\\"view\\": \\"SIGN_IN\\",
\\"error\\": null
}
+10 -8
View File
@@ -1,8 +1,8 @@
/* eslint-disable prettier/prettier */
import { commitLocalUpdate, Environment } from "relay-runtime";
import { parseQuery } from "coral-common/utils";
import { getParamsFromHashAndClearIt } from "coral-framework/helpers";
import { AuthState, storeAccessToken } from "coral-framework/lib/auth";
import { CoralContext } from "coral-framework/lib/bootstrap";
import { initLocalBaseState, LOCAL_ID } from "coral-framework/lib/relay";
@@ -11,16 +11,18 @@ import { initLocalBaseState, LOCAL_ID } from "coral-framework/lib/relay";
*/
export default async function initLocalState(
environment: Environment,
context: CoralContext
context: CoralContext,
auth?: AuthState
) {
const {
error = null,
accessToken = null,
} = getParamsFromHashAndClearIt();
const { error = null, accessToken = null } = getParamsFromHashAndClearIt();
await initLocalBaseState(environment, context, accessToken);
if (accessToken) {
auth = storeAccessToken(accessToken);
}
commitLocalUpdate(environment, s => {
initLocalBaseState(environment, context, auth);
commitLocalUpdate(environment, (s) => {
const localRecord = s.get(LOCAL_ID)!;
// Parse query params
@@ -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;
}
@@ -0,0 +1,56 @@
const SKEW_TOLERANCE = 300;
export interface Claims {
jti?: string;
exp?: number;
}
export function parseAccessTokenClaims<T = {}>(
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;
}
@@ -0,0 +1 @@
export * from "./auth";
@@ -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<void>;
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.
-31
View File
@@ -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;
},
};
}
@@ -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({
@@ -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,
@@ -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 {
+1 -6
View File
@@ -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,
@@ -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");
});
}
+13 -4
View File
@@ -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<Partial<RequestInit>, { 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<T = {}>(
@@ -67,7 +72,8 @@ export class RestClient {
options: PartialRequestInit
): Promise<T> {
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);
}
}
@@ -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(".");
}
@@ -114,6 +114,7 @@ export default function createTestRenderer<
history: [],
},
};
let testRenderer: ReactTestRenderer;
TestRenderer.act(() => {
testRenderer = TestRenderer.create(
-1
View File
@@ -1 +0,0 @@
export const INSTALL_ACCESS_TOKEN_KEY = "coral:install:accessToken";
@@ -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);
}
@@ -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\\"
+10 -7
View File
@@ -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");
});
+3 -1
View File
@@ -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));
}
@@ -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 });
+4 -2
View File
@@ -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")) {
+33 -33
View File
@@ -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<T extends {}>(
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) {