From d140e2449f8a510bc740492e44d9643cbbc3eae0 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 9 Aug 2018 16:14:35 +0200 Subject: [PATCH] Support authentication --- package-lock.json | 6 ++ package.json | 1 + .../framework/lib/bootstrap/TalkContext.tsx | 6 ++ .../framework/lib/bootstrap/createContext.tsx | 21 ++++- .../framework/lib/network/fetchQuery.ts | 23 +++-- .../client/framework/lib/network/index.ts | 2 +- .../lib/storage/InMemoryStorage.spec.ts | 25 ++++++ .../framework/lib/storage/InMemoryStorage.ts | 45 ++++++++++ .../framework/lib/storage/LocalStorage.ts | 5 ++ .../framework/lib/storage/SessionStorage.ts | 5 ++ .../client/framework/lib/storage/index.ts | 3 + .../lib/storage/prefixStorage.spec.ts | 86 +++++++++++++++++++ .../framework/lib/storage/prefixStorage.ts | 41 +++++++++ .../mutations/SetAuthTokenMutation.spec.ts | 34 ++++++++ .../mutations/SetAuthTokenMutation.ts | 34 ++++++++ src/core/client/framework/mutations/index.ts | 5 ++ src/core/client/stream/index.tsx | 2 +- .../__snapshots__/initLocalState.spec.ts.snap | 1 + .../stream/local/initLocalState.spec.ts | 16 +++- .../client/stream/local/initLocalState.ts | 9 +- src/core/client/stream/local/local.graphql | 1 + src/core/client/test/setup.ts | 1 + 22 files changed, 357 insertions(+), 15 deletions(-) create mode 100644 src/core/client/framework/lib/storage/InMemoryStorage.spec.ts create mode 100644 src/core/client/framework/lib/storage/InMemoryStorage.ts create mode 100644 src/core/client/framework/lib/storage/LocalStorage.ts create mode 100644 src/core/client/framework/lib/storage/SessionStorage.ts create mode 100644 src/core/client/framework/lib/storage/index.ts create mode 100644 src/core/client/framework/lib/storage/prefixStorage.spec.ts create mode 100644 src/core/client/framework/lib/storage/prefixStorage.ts create mode 100644 src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts create mode 100644 src/core/client/framework/mutations/SetAuthTokenMutation.ts create mode 100644 src/core/client/framework/mutations/index.ts diff --git a/package-lock.json b/package-lock.json index 100de84ee..c504d8d7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12988,6 +12988,12 @@ "pretty-format": "^23.2.0" } }, + "jest-localstorage-mock": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.2.0.tgz", + "integrity": "sha512-x+P0vcwr4540bCAYzTEpiD9rs+zh/QZzyiABV+MU6yM2OPwPlrrLyUx/6gValMyt6tg5lX6Z53o2rHWfUht5Xw==", + "dev": true + }, "jest-matcher-utils": { "version": "23.2.0", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-23.2.0.tgz", diff --git a/package.json b/package.json index d546b9c17..5e5d7b210 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "html-webpack-plugin": "^3.2.0", "jest": "^23.4.1", "jest-junit": "^5.1.0", + "jest-localstorage-mock": "^2.2.0", "jsdom": "^11.11.0", "loader-utils": "^1.1.0", "material-design-icons": "^3.0.1", diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index 90ed10530..f7987877c 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -18,6 +18,12 @@ export interface TalkContext { /** formatter for timeago. */ timeagoFormatter?: Formatter; + /** Session Storage */ + localStorage: Storage; + + /** Session storage */ + sessionStorage: Storage; + /** * A way to listen for clicks that are e.g. outside of the * current frame for `ClickOutside` diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 2e6bd2aff..5c71cb386 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -5,11 +5,16 @@ import { Child as PymChild } from "pym.js"; import React from "react"; import { Formatter } from "react-timeago"; import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { + createLocalStorage, + createSessionStorage, +} from "talk-framework/lib/storage"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; import { generateMessages, LocalesData, negotiateLanguages } from "../i18n"; -import { fetchQuery } from "../network"; +import { createFetch, TokenGetter } from "../network"; import { TalkContext } from "./TalkContext"; interface CreateContextArguments { @@ -61,9 +66,17 @@ export default async function createContext({ eventEmitter = new EventEmitter2({ wildcard: true }), }: CreateContextArguments): Promise { // Initialize Relay. + const source = new RecordSource(); + const tokenGetter: TokenGetter = () => { + const localState = source.get(LOCAL_ID); + if (localState) { + return localState.authToken || ""; + } + return ""; + }; const relayEnvironment = new Environment({ - network: Network.create(fetchQuery), - store: new Store(new RecordSource()), + network: Network.create(createFetch(tokenGetter)), + store: new Store(source), }); // Listen for outside clicks. @@ -99,6 +112,8 @@ export default async function createContext({ pym, eventEmitter, registerClickFarAway, + localStorage: createLocalStorage(), + sessionStorage: createSessionStorage(), }; // Run custom initializations. diff --git a/src/core/client/framework/lib/network/fetchQuery.ts b/src/core/client/framework/lib/network/fetchQuery.ts index 6a5d4bb82..bc6b734d3 100644 --- a/src/core/client/framework/lib/network/fetchQuery.ts +++ b/src/core/client/framework/lib/network/fetchQuery.ts @@ -26,17 +26,28 @@ function getError(errors: Error[]): Error { return new GraphQLError(errors as any); } +export type TokenGetter = () => string; +type CreateFetch = (token?: TokenGetter) => FetchFunction; + /** - * fetchQuery is a simple implementation of the `FetchFunction` + * createFetch returns a simple implementation of the `FetchFunction` * required by Relay. It'll return a `NetworkError` on failure. */ -const fetchQuery: FetchFunction = async (operation, variables) => { +const createFetch: CreateFetch = tokenGetter => async ( + operation, + variables +) => { + const token = tokenGetter && tokenGetter(); + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } try { const response = await fetch("/api/tenant/graphql", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers, body: JSON.stringify({ query: operation.text, variables, @@ -58,4 +69,4 @@ const fetchQuery: FetchFunction = async (operation, variables) => { } }; -export default fetchQuery; +export default createFetch; diff --git a/src/core/client/framework/lib/network/index.ts b/src/core/client/framework/lib/network/index.ts index 57943dc38..75b935dce 100644 --- a/src/core/client/framework/lib/network/index.ts +++ b/src/core/client/framework/lib/network/index.ts @@ -1 +1 @@ -export { default as fetchQuery } from "./fetchQuery"; +export { default as createFetch, TokenGetter } from "./fetchQuery"; diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts b/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts new file mode 100644 index 000000000..8c141ed61 --- /dev/null +++ b/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts @@ -0,0 +1,25 @@ +import createInMemoryStorage from "./InMemoryStorage"; + +it("should set and unset values", () => { + const storage = createInMemoryStorage(); + storage.setItem("test", "value"); + expect(storage.getItem("test")).toBe("value"); + storage.removeItem("test"); + expect(storage.getItem("test")).toBeUndefined(); +}); + +it("should return length", () => { + const storage = createInMemoryStorage(); + storage.setItem("a", "value"); + storage.setItem("b", "value"); + storage.setItem("c", "value"); + expect(storage.length).toBe(3); +}); + +it("should nth value", () => { + const storage = createInMemoryStorage(); + storage.setItem("a", "a"); + storage.setItem("b", "b"); + storage.setItem("c", "c"); + expect(storage.key(2)).toBe("c"); +}); diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.ts b/src/core/client/framework/lib/storage/InMemoryStorage.ts new file mode 100644 index 000000000..3c5b15d7c --- /dev/null +++ b/src/core/client/framework/lib/storage/InMemoryStorage.ts @@ -0,0 +1,45 @@ +/** + * InMemoryStorage is a dumb implementation of the Storage interface that will + * not persist the data at all. It implements the Storage interface found: + * + * https://developer.mozilla.org/en-US/docs/Web/API/Storage + */ +class InMemoryStorage implements Storage { + private storage: Record; + + constructor() { + this.storage = {}; + } + + get length() { + return Object.keys(this.storage).length; + } + + public clear() { + this.storage = {}; + } + + public key(n: number) { + if (this.length <= n) { + return null; + } + + return this.storage[Object.keys(this.storage)[n]]; + } + + public getItem(key: string) { + return this.storage[key]; + } + + public setItem(key: string, value: string) { + this.storage[key] = value; + } + + public removeItem(key: string) { + delete this.storage[key]; + } +} + +export default function createInMemoryStorage() { + return new InMemoryStorage(); +} diff --git a/src/core/client/framework/lib/storage/LocalStorage.ts b/src/core/client/framework/lib/storage/LocalStorage.ts new file mode 100644 index 000000000..72588d880 --- /dev/null +++ b/src/core/client/framework/lib/storage/LocalStorage.ts @@ -0,0 +1,5 @@ +import prefixStorage from "./prefixStorage"; + +export default function createLocalStorage(): Storage { + return prefixStorage(window.localStorage, "talk"); +} diff --git a/src/core/client/framework/lib/storage/SessionStorage.ts b/src/core/client/framework/lib/storage/SessionStorage.ts new file mode 100644 index 000000000..7b45e09a3 --- /dev/null +++ b/src/core/client/framework/lib/storage/SessionStorage.ts @@ -0,0 +1,5 @@ +import prefixStorage from "./prefixStorage"; + +export default function createSessionStorage(): Storage { + return prefixStorage(window.sessionStorage, "talk"); +} diff --git a/src/core/client/framework/lib/storage/index.ts b/src/core/client/framework/lib/storage/index.ts new file mode 100644 index 000000000..a558f693b --- /dev/null +++ b/src/core/client/framework/lib/storage/index.ts @@ -0,0 +1,3 @@ +export { default as createInMemoryStorage } from "./InMemoryStorage"; +export { default as createLocalStorage } from "./LocalStorage"; +export { default as createSessionStorage } from "./SessionStorage"; diff --git a/src/core/client/framework/lib/storage/prefixStorage.spec.ts b/src/core/client/framework/lib/storage/prefixStorage.spec.ts new file mode 100644 index 000000000..6e8e8088d --- /dev/null +++ b/src/core/client/framework/lib/storage/prefixStorage.spec.ts @@ -0,0 +1,86 @@ +import sinon from "sinon"; +import prefixStorage from "./prefixStorage"; + +it("should call clear", () => { + const storage = { + clear: sinon.mock().once(), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + prefixed.clear(); + storage.clear.verify(); +}); + +it("should call length", () => { + const ret = 10; + const storage = { + get length() { + return ret; + }, + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.length).toBe(ret); +}); + +it("should call key", () => { + const ret = "value"; + const storage = { + key: sinon + .mock() + .withArgs(3) + .returns(ret), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.key(3)).toBe(ret); + (storage.key as any).verify(); +}); + +it("should call key", () => { + const ret = "value"; + const storage = { + key: sinon + .mock() + .withArgs(3) + .returns(ret), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.key(3)).toBe(ret); + (storage.key as any).verify(); +}); + +it("should prefix setItem", () => { + const storage = { + setItem: sinon.mock().withArgs("talk:key", "value"), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + prefixed.setItem("key", "value"); + storage.setItem.verify(); +}); + +it("should prefix removeItem", () => { + const storage = { + removeItem: sinon.mock().withArgs("talk:key"), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + prefixed.removeItem("key"); + storage.removeItem.verify(); +}); + +it("should prefix getItem", () => { + const ret = "value"; + const storage = { + getItem: sinon + .mock() + .withArgs("talk:key") + .returns(ret), + }; + + const prefixed = prefixStorage(storage as any, "talk"); + expect(prefixed.getItem("key")).toBe(ret); + (storage.getItem as any).verify(); +}); diff --git a/src/core/client/framework/lib/storage/prefixStorage.ts b/src/core/client/framework/lib/storage/prefixStorage.ts new file mode 100644 index 000000000..c44f7c364 --- /dev/null +++ b/src/core/client/framework/lib/storage/prefixStorage.ts @@ -0,0 +1,41 @@ +/** + * PrefixedStorage decorates a Storage and prefixes keys in + * getItem, setItem and removeItem with given prefix. + */ +class PrefixedStorage implements Storage { + private storage: Storage; + private prefix: string; + + constructor(storage: Storage, prefix: string) { + this.storage = storage; + this.prefix = prefix; + } + + get length() { + return this.storage.length; + } + + public clear() { + this.storage.clear(); + } + + public key(n: number) { + return this.storage.key(n); + } + + public getItem(key: string) { + return this.storage.getItem(`${this.prefix}:${key}`); + } + + public setItem(key: string, value: string) { + return this.storage.setItem(`${this.prefix}:${key}`, value); + } + + public removeItem(key: string) { + return this.storage.removeItem(`${this.prefix}:${key}`); + } +} + +export default function prefixStorage(storage: Storage, prefix: string) { + return new PrefixedStorage(storage, prefix); +} diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts new file mode 100644 index 000000000..7440078ad --- /dev/null +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts @@ -0,0 +1,34 @@ +import { commitLocalUpdate, Environment, RecordSource } from "relay-runtime"; + +import { timeout } from "talk-common/utils"; +import { LOCAL_ID } from "talk-framework/lib/relay"; +import { createRelayEnvironment } from "talk-framework/testHelpers"; + +import { commit } from "./SetAuthTokenMutation"; + +let environment: Environment; +const source: RecordSource = new RecordSource(); + +beforeAll(() => { + environment = createRelayEnvironment({ + source, + }); +}); + +it("Sets auth token", async () => { + const authToken = "auth token"; + commit(environment, { authToken }); + expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken); +}); + +it("Should call gc", async () => { + commitLocalUpdate(environment, store => { + store.create("should-disappear", "tmp"); + }); + const authToken = null; + expect(source.get("should-disappear")).not.toBeUndefined(); + commit(environment, { authToken }); + await timeout(); + expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken); + expect(source.get("should-disappear")).toBeUndefined(); +}); diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.ts new file mode 100644 index 000000000..38ecde460 --- /dev/null +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.ts @@ -0,0 +1,34 @@ +import { commitLocalUpdate, Environment } from "relay-runtime"; + +import { createMutationContainer } from "talk-framework/lib/relay"; +import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer"; + +export interface SetAuthTokenInput { + authToken: string | null; +} + +export type SetAuthTokenMutation = (input: SetAuthTokenInput) => Promise; + +export async function commit( + environment: Environment, + input: SetAuthTokenInput +) { + return commitLocalUpdate(environment, store => { + const record = store.get(LOCAL_ID)!; + record.setValue(input.authToken, "authToken"); + + // Force gc to trigger. + environment + .retain({ + dataID: "tmp", + node: { selections: [] }, + variables: {}, + }) + .dispose(); + }); +} + +export const withSetAuthTokenMutation = createMutationContainer( + "setCommentID", + commit +); diff --git a/src/core/client/framework/mutations/index.ts b/src/core/client/framework/mutations/index.ts new file mode 100644 index 000000000..21875e6d8 --- /dev/null +++ b/src/core/client/framework/mutations/index.ts @@ -0,0 +1,5 @@ +export { + withSetAuthTokenMutation, + SetAuthTokenMutation, + SetAuthTokenInput, +} from "./SetAuthTokenMutation"; diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 6d622216e..d6619f706 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -18,7 +18,7 @@ const pymFeatures = [withSetCommentID]; // This is called when the context is first initialized. async function init(context: TalkContext) { - await initLocalState(context.relayEnvironment); + await initLocalState(context.relayEnvironment, context); pymFeatures.forEach(f => f(context)); } 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 52ffec402..c8dda11c7 100644 --- a/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap +++ b/src/core/client/stream/local/__snapshots__/initLocalState.spec.ts.snap @@ -12,6 +12,7 @@ exports[`init local state 1`] = ` \\"client:root.local\\": { \\"__id\\": \\"client:root.local\\", \\"__typename\\": \\"Local\\", + \\"authToken\\": \\"\\", \\"network\\": { \\"__ref\\": \\"client:root.local.network\\" }, diff --git a/src/core/client/stream/local/initLocalState.spec.ts b/src/core/client/stream/local/initLocalState.spec.ts index d1842c9a5..2d2a3b486 100644 --- a/src/core/client/stream/local/initLocalState.spec.ts +++ b/src/core/client/stream/local/initLocalState.spec.ts @@ -2,6 +2,7 @@ import { Environment, RecordSource } from "relay-runtime"; import { timeout } from "talk-common/utils"; import { LOCAL_ID } from "talk-framework/lib/relay"; +import { createInMemoryStorage } from "talk-framework/lib/storage"; import { createRelayEnvironment } from "talk-framework/testHelpers"; import initLocalState from "./initLocalState"; @@ -18,7 +19,7 @@ beforeEach(() => { }); it("init local state", async () => { - initLocalState(environment); + initLocalState(environment, { localStorage: createInMemoryStorage() } as any); await timeout(); expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot(); }); @@ -32,7 +33,7 @@ it("set assetID from query", () => { document.title, `http://localhost/?assetID=${assetID}` ); - initLocalState(environment); + initLocalState(environment, { localStorage: createInMemoryStorage() } as any); expect(source.get(LOCAL_ID)!.assetID).toBe(assetID); window.history.replaceState(previousState, document.title, previousLocation); }); @@ -46,7 +47,16 @@ it("set commentID from query", () => { document.title, `http://localhost/?commentID=${commentID}` ); - initLocalState(environment); + initLocalState(environment, { localStorage: createInMemoryStorage() } as any); expect(source.get(LOCAL_ID)!.commentID).toBe(commentID); window.history.replaceState(previousState, document.title, previousLocation); }); + +it("set authToken from localStorage", () => { + const authToken = "auth-token"; + const localStorage = createInMemoryStorage(); + localStorage.setItem("authToken", authToken); + initLocalState(environment, { localStorage } as any); + expect(source.get(LOCAL_ID)!.authToken).toBe(authToken); + localStorage.removeItem("authToken"); +}); diff --git a/src/core/client/stream/local/initLocalState.ts b/src/core/client/stream/local/initLocalState.ts index 827e90c50..dd021f756 100644 --- a/src/core/client/stream/local/initLocalState.ts +++ b/src/core/client/stream/local/initLocalState.ts @@ -1,6 +1,7 @@ import qs from "query-string"; import { commitLocalUpdate, Environment } from "relay-runtime"; +import { TalkContext } from "talk-framework/lib/bootstrap"; import { createAndRetain, LOCAL_ID, @@ -17,7 +18,10 @@ import { /** * Initializes the local state, before we start the App. */ -export default async function initLocalState(environment: Environment) { +export default async function initLocalState( + environment: Environment, + { localStorage }: TalkContext +) { commitLocalUpdate(environment, s => { const root = s.getRoot(); @@ -25,6 +29,9 @@ export default async function initLocalState(environment: Environment) { const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE); root.setLinkedRecord(localRecord, "local"); + // Set auth token + localRecord.setValue(localStorage.getItem("authToken") || "", "authToken"); + // Parse query params const query = qs.parse(location.search); diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 52604d35b..37426b867 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -21,6 +21,7 @@ type Local { assetURL: String commentID: String authPopup: AuthPopup! + authToken: String } extend type Query { diff --git a/src/core/client/test/setup.ts b/src/core/client/test/setup.ts index 1609f2653..de1ba3c31 100644 --- a/src/core/client/test/setup.ts +++ b/src/core/client/test/setup.ts @@ -1,3 +1,4 @@ +import "jest-localstorage-mock"; import "./enzyme"; import "./jsdom";