mirror of
https://github.com/wassname/talk.git
synced 2026-07-16 11:22:16 +08:00
[next] Cookie Support (#2339)
* feat: added cookie support to coral * feat: adapt client to use cookies * fix: safari input styles * fix: lint * fix: linting * fix: support clearing cookies properly, oauth * feat: support cookies for websocket upgrade requests * fix: lint * fix: tests
This commit is contained in:
Generated
+47
-3
@@ -3574,6 +3574,21 @@
|
||||
"integrity": "sha512-p+gNRe4RPjpl1lTBUomFJ42P8ymArH/P93DFJ0iY873BJ4ZmogcKc6TbHgZQmtQMsy3jxcAo0HcTjidXwo8uKg==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/cookie": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz",
|
||||
"integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/cookie-parser": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.1.tgz",
|
||||
"integrity": "sha512-iJY6B3ZGufLiDf2OCAgiAAQuj1sMKC/wz/7XCEjZ+/MDuultfFJuSwrBKcLSmJ5iYApLzCCYBYJZs0Ws8GPmwA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"@types/cors": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.4.tgz",
|
||||
@@ -8174,9 +8189,25 @@
|
||||
}
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
|
||||
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
|
||||
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
|
||||
},
|
||||
"cookie-parser": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.4.tgz",
|
||||
"integrity": "sha512-lo13tqF3JEtFO7FyA49CqbhaFkskRJ0u/UAiINgrIXeRCY41c88/zxtrECl8AKH3B0hj9q10+h3Kt8I7KlW4tw==",
|
||||
"requires": {
|
||||
"cookie": "0.3.1",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
|
||||
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
|
||||
}
|
||||
}
|
||||
},
|
||||
"cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
@@ -10720,6 +10751,12 @@
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
|
||||
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=",
|
||||
"dev": true
|
||||
},
|
||||
"debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -12866,6 +12903,13 @@
|
||||
"type-is": "~1.6.16",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
|
||||
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
|
||||
}
|
||||
}
|
||||
},
|
||||
"express-static-gzip": {
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"consolidate": "0.14.0",
|
||||
"content-security-policy-builder": "^2.0.0",
|
||||
"convict": "^4.3.1",
|
||||
"cookie": "^0.4.0",
|
||||
"cookie-parser": "^1.4.4",
|
||||
"cors": "^2.8.4",
|
||||
"dataloader": "^1.4.0",
|
||||
"dompurify": "^1.0.8",
|
||||
@@ -153,6 +155,8 @@
|
||||
"@types/compression-webpack-plugin": "^2.0.0",
|
||||
"@types/consolidate": "0.0.34",
|
||||
"@types/convict": "^4.2.0",
|
||||
"@types/cookie": "^0.3.3",
|
||||
"@types/cookie-parser": "^1.4.1",
|
||||
"@types/cors": "^2.8.4",
|
||||
"@types/cross-spawn": "^6.0.0",
|
||||
"@types/dotenv": "^4.0.3",
|
||||
|
||||
@@ -2,7 +2,6 @@ type Local {
|
||||
accessToken: String
|
||||
accessTokenExp: Int
|
||||
accessTokenJTI: String
|
||||
loggedIn: Boolean!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
|
||||
@@ -28,7 +28,6 @@ type Local {
|
||||
accessToken: String
|
||||
accessTokenExp: Int
|
||||
accessTokenJTI: String
|
||||
loggedIn: Boolean!
|
||||
redirectPath: String
|
||||
authView: View
|
||||
authError: String
|
||||
|
||||
@@ -12,13 +12,7 @@ const SignInMutation = createMutation(
|
||||
context: CoralContext
|
||||
) => {
|
||||
const result = await signIn(context.rest, input);
|
||||
|
||||
// Put the token on the hash and clean the session.
|
||||
// It'll be picked up by initLocalState.
|
||||
location.hash = `accessToken=${result.token}`;
|
||||
await context.clearSession();
|
||||
// TODO: (cvle) A better way would be if `context.clearSession` would return the new session and
|
||||
// we set the accessToken directly in there.
|
||||
await context.clearSession(result.token);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -764,4 +764,4 @@ exports[`shows server error 1`] = `
|
||||
</form>
|
||||
`;
|
||||
|
||||
exports[`submits form successfully 1`] = `"http://localhost/admin/login#accessToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ=="`;
|
||||
exports[`submits form successfully 1`] = `"http://localhost/admin/login"`;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createAccessToken,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
@@ -40,8 +39,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue("SIGN_IN", "authView");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(createAccessToken(), "accessToken");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createAccessToken,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
@@ -37,8 +36,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue("ADD_EMAIL_ADDRESS", "authView");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(createAccessToken(), "accessToken");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createAccessToken,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
@@ -37,8 +36,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue("CREATE_PASSWORD", "authView");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(createAccessToken(), "accessToken");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createAccessToken,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
@@ -31,8 +30,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue("CREATE_USERNAME", "authView");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(createAccessToken(), "accessToken");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createAccessToken,
|
||||
createQueryResolverStub,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
@@ -43,8 +42,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(createAccessToken(), "accessToken");
|
||||
localRecord.setValue("SIGN_IN", "authView");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
|
||||
@@ -39,7 +39,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(false, "loggedIn");
|
||||
localRecord.setValue("SIGN_IN", "authView");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { pureMerge } from "coral-common/utils";
|
||||
import { LOCAL_ID, lookup } from "coral-framework/lib/relay";
|
||||
import { GQLResolver, GQLUSER_ROLE } from "coral-framework/schema";
|
||||
import {
|
||||
createAccessToken,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
@@ -41,8 +40,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(createAccessToken(), "accessToken");
|
||||
localRecord.setValue("SIGN_IN", "authView");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
@@ -105,6 +102,5 @@ it("sign out when clicking on sign in as", async () => {
|
||||
"/admin/moderate/reported"
|
||||
);
|
||||
});
|
||||
|
||||
expect(lookup(context.relayEnvironment, LOCAL_ID)!.loggedIn).toBeFalsy();
|
||||
restMock.verify();
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(false, "loggedIn");
|
||||
localRecord.setValue("SIGN_IN", "authView");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import sinon from "sinon";
|
||||
import sinon, { SinonStub } from "sinon";
|
||||
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { LOCAL_ID, lookup } from "coral-framework/lib/relay";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
createResolversStub,
|
||||
@@ -40,7 +39,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
@@ -77,6 +75,8 @@ it("logs out", async () => {
|
||||
signOutButton.props.onClick();
|
||||
|
||||
await wait(() => {
|
||||
expect(lookup(context.relayEnvironment, LOCAL_ID)!.loggedIn).toBeFalsy();
|
||||
restMock.verify();
|
||||
// Wait for new session to start.
|
||||
expect((context.clearSession as SinonStub).called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,6 @@ const createTestRenderer = async (
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ async function createTestRenderer(
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`get auth token from url 1`] = `
|
||||
exports[`get access token from url 1`] = `
|
||||
"{
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": \\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==\\",
|
||||
\\"accessTokenJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
|
||||
\\"loggedIn\\": true,
|
||||
\\"view\\": \\"SIGN_IN\\",
|
||||
\\"error\\": null
|
||||
}"
|
||||
@@ -27,7 +26,6 @@ exports[`init local state 1`] = `
|
||||
\\"accessToken\\": \\"\\",
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": null,
|
||||
\\"loggedIn\\": false,
|
||||
\\"view\\": \\"SIGN_IN\\",
|
||||
\\"error\\": null
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ it("set view from query", async () => {
|
||||
restoreHistoryLocation();
|
||||
});
|
||||
|
||||
it("get auth token from url", async () => {
|
||||
it("get access token from url", async () => {
|
||||
const restoreHistoryLocation = replaceHistoryLocation(
|
||||
`http://localhost/#accessToken=${createAccessToken()}`
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ type Local {
|
||||
accessToken: String
|
||||
accessTokenExp: Int
|
||||
accessTokenJTI: String
|
||||
loggedIn: Boolean!
|
||||
view: View!
|
||||
error: String
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ exports[`auth configuration renders all auth enabled 1`] = `
|
||||
>
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="http://localhost/?view=FORGOT_PASSWORD#accessToken=access-token"
|
||||
href="http://localhost/?view=FORGOT_PASSWORD"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Forgot your password?
|
||||
|
||||
@@ -122,7 +122,7 @@ it("do not render createPassword view when local auth is disabled", async () =>
|
||||
await wait(() => expect(windowMock.closeStub.called).toBe(true));
|
||||
});
|
||||
|
||||
it("send back auth token", async () => {
|
||||
it("send back access token", async () => {
|
||||
const { context } = await createTestRenderer({
|
||||
Query: {
|
||||
viewer: {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { get } from "lodash";
|
||||
import sinon from "sinon";
|
||||
import sinon, { SinonStub } from "sinon";
|
||||
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import {
|
||||
createAccessToken,
|
||||
toJSON,
|
||||
wait,
|
||||
waitForElement,
|
||||
@@ -165,7 +166,7 @@ it("shows server error", async () => {
|
||||
it("submits form successfully", async () => {
|
||||
const { form, context } = await createTestRenderer();
|
||||
const { getByLabelText } = within(form!);
|
||||
const accessToken = "access-token";
|
||||
const accessToken = createAccessToken();
|
||||
const emailAddressField = getByLabelText("Email Address");
|
||||
const passwordField = getByLabelText("Password");
|
||||
const submitButton = form!.find(
|
||||
@@ -198,8 +199,13 @@ it("submits form successfully", async () => {
|
||||
|
||||
expect(toJSON(form!)).toMatchSnapshot();
|
||||
|
||||
// Wait for window hash to contain a token.
|
||||
await wait(() => expect(location.hash).toBe(`#accessToken=${accessToken}`));
|
||||
// Wait for new session to start.
|
||||
await wait(() =>
|
||||
expect((context.clearSession as SinonStub).calledWith(accessToken)).toBe(
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
restMock.verify();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { get, merge } from "lodash";
|
||||
import sinon from "sinon";
|
||||
import sinon, { SinonStub } from "sinon";
|
||||
|
||||
import {
|
||||
createAccessToken,
|
||||
toJSON,
|
||||
wait,
|
||||
waitForElement,
|
||||
@@ -186,7 +187,7 @@ it("shows server error", async () => {
|
||||
it("submits form successfully", async () => {
|
||||
const { context, main, form } = await createTestRenderer();
|
||||
const { getByLabelText } = within(form!);
|
||||
const accessToken = "access-token";
|
||||
const accessToken = createAccessToken();
|
||||
const emailAddressField = getByLabelText("Email Address");
|
||||
const usernameField = getByLabelText("Username");
|
||||
const passwordField = getByLabelText("Password");
|
||||
@@ -223,8 +224,12 @@ it("submits form successfully", async () => {
|
||||
|
||||
expect(toJSON(main)).toMatchSnapshot();
|
||||
|
||||
// Wait for window hash to contain a token.
|
||||
await wait(() => expect(location.hash).toBe(`#accessToken=${accessToken}`));
|
||||
// Wait for new session to start.
|
||||
await wait(() =>
|
||||
expect((context.clearSession as SinonStub).calledWith(accessToken)).toBe(
|
||||
true
|
||||
)
|
||||
);
|
||||
restMock.verify();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,12 +13,7 @@ export async function commit(
|
||||
{ rest, clearSession }: CoralContext
|
||||
) {
|
||||
const result = await signIn(rest, pick(input, ["email", "password"]));
|
||||
// Put the token on the hash and clean the session.
|
||||
// It'll be picked up by initLocalState.
|
||||
location.hash = `accessToken=${result.token}`;
|
||||
await clearSession();
|
||||
// TODO: (cvle) A better way would be if `context.clearSession` would return the new session and
|
||||
// we set the accessToken directly in there.
|
||||
await clearSession(result.token);
|
||||
}
|
||||
|
||||
export const withSignInMutation = createMutationContainer("signIn", commit);
|
||||
|
||||
@@ -16,10 +16,7 @@ export async function commit(
|
||||
rest,
|
||||
pick(input, ["email", "password", "username"])
|
||||
);
|
||||
// Put the token on the hash and clean the session.
|
||||
// It'll be picked up by initLocalState.
|
||||
location.hash = `accessToken=${result.token}`;
|
||||
await clearSession();
|
||||
await clearSession(result.token);
|
||||
}
|
||||
|
||||
export const withSignUpMutation = createMutationContainer("signUp", commit);
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface CoralContext {
|
||||
eventEmitter: EventEmitter2;
|
||||
|
||||
/** Clear session data. */
|
||||
clearSession: () => Promise<void>;
|
||||
clearSession: (nextAccessToken?: string | null) => Promise<void>;
|
||||
|
||||
/** Controls router transitions (for tests) */
|
||||
transitionControl?: TransitionControlData;
|
||||
|
||||
@@ -8,7 +8,11 @@ import { Environment, RecordSource, Store } from "relay-runtime";
|
||||
import uuid from "uuid/v1";
|
||||
|
||||
import { getBrowserInfo } from "coral-framework/lib/browserInfo";
|
||||
import { LOCAL_ID } from "coral-framework/lib/relay";
|
||||
import {
|
||||
commitLocalUpdatePromisified,
|
||||
LOCAL_ID,
|
||||
setAccessTokenInLocalState,
|
||||
} from "coral-framework/lib/relay";
|
||||
import {
|
||||
createLocalStorage,
|
||||
createPromisifiedStorage,
|
||||
@@ -105,7 +109,7 @@ function createRelayEnvironment(
|
||||
const tokenGetter: TokenGetter = () => {
|
||||
const localState = source.get(LOCAL_ID);
|
||||
if (localState) {
|
||||
return (localState.loggedIn && localState.accessToken) || "";
|
||||
return localState.accessToken || "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -145,7 +149,7 @@ function createMangedCoralContextProvider(
|
||||
}
|
||||
|
||||
// This is called every time a user session starts or ends.
|
||||
private clearSession = async () => {
|
||||
private clearSession = async (nextAccessToken?: string | null) => {
|
||||
// Clear session storage.
|
||||
this.state.context.sessionStorage.clear();
|
||||
|
||||
@@ -173,6 +177,13 @@ function createMangedCoralContextProvider(
|
||||
// a new token.
|
||||
subscriptionClient.setAccessToken(newTokenGetter());
|
||||
|
||||
// Set next access token.
|
||||
if (nextAccessToken) {
|
||||
await commitLocalUpdatePromisified(newEnvironment, async store => {
|
||||
setAccessTokenInLocalState(nextAccessToken, store);
|
||||
});
|
||||
}
|
||||
|
||||
// Propagate new context.
|
||||
this.setState(
|
||||
{
|
||||
@@ -292,7 +303,7 @@ export default async function createManaged({
|
||||
uuidGenerator: uuid,
|
||||
// Noop, this is later replaced by the
|
||||
// managed CoralContextProvider.
|
||||
clearSession: () => Promise.resolve(),
|
||||
clearSession: (nextAccessToken?: string | null) => Promise.resolve(),
|
||||
};
|
||||
|
||||
// Initialize local state.
|
||||
|
||||
@@ -25,14 +25,13 @@ export function setAccessTokenInLocalState(
|
||||
const localRecord = source.get(LOCAL_ID)!;
|
||||
localRecord.setValue(accessToken || "", "accessToken");
|
||||
if (accessToken) {
|
||||
const { payload, expired } = parseJWT(accessToken);
|
||||
const { payload } = parseJWT(accessToken);
|
||||
localRecord.setValue(payload.exp, "accessTokenExp");
|
||||
localRecord.setValue(payload.jti, "accessTokenJTI");
|
||||
localRecord.setValue(!expired, "loggedIn");
|
||||
// TODO: (cvle) maybe a timer to detect when accessToken has expired?
|
||||
} else {
|
||||
localRecord.setValue(null, "accessTokenExp");
|
||||
localRecord.setValue(null, "accessTokenJTI");
|
||||
localRecord.setValue(false, "loggedIn");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +40,6 @@ export async function initLocalBaseState(
|
||||
{ localStorage }: CoralContext,
|
||||
accessToken?: string | null
|
||||
) {
|
||||
if (accessToken === undefined) {
|
||||
accessToken = await localStorage!.getItem("accessToken");
|
||||
}
|
||||
|
||||
commitLocalUpdate(environment, s => {
|
||||
const root = s.getRoot();
|
||||
|
||||
@@ -52,7 +47,7 @@ export async function initLocalBaseState(
|
||||
const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE);
|
||||
root.setLinkedRecord(localRecord, "local");
|
||||
|
||||
// Set auth token
|
||||
// Set access token
|
||||
setAccessTokenInLocalState(accessToken || null, s);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Environment, RecordSource } from "relay-runtime";
|
||||
import sinon from "sinon";
|
||||
|
||||
import { CoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { LOCAL_ID } from "coral-framework/lib/relay";
|
||||
import { createPromisifiedStorage } from "coral-framework/lib/storage";
|
||||
import {
|
||||
createAccessToken,
|
||||
createRelayEnvironment,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import SetAccessTokenMutation from "./SetAccessTokenMutation";
|
||||
|
||||
let environment: Environment;
|
||||
const source: RecordSource = new RecordSource();
|
||||
|
||||
beforeAll(() => {
|
||||
environment = createRelayEnvironment({
|
||||
source,
|
||||
});
|
||||
});
|
||||
|
||||
const accessToken = createAccessToken();
|
||||
|
||||
it("Sets auth token to localStorage", async () => {
|
||||
const clearSessionStub = sinon.stub();
|
||||
const context: Partial<CoralContext> = {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
clearSession: clearSessionStub,
|
||||
};
|
||||
await SetAccessTokenMutation.commit(
|
||||
environment,
|
||||
{ accessToken },
|
||||
context as any
|
||||
);
|
||||
expect(source.get(LOCAL_ID)!.accessToken).toEqual(accessToken);
|
||||
await expect(context.localStorage!.getItem("accessToken")).resolves.toEqual(
|
||||
accessToken
|
||||
);
|
||||
expect(clearSessionStub.calledOnce).toBe(true);
|
||||
});
|
||||
|
||||
it("Removes auth token from localStorage", async () => {
|
||||
const clearSessionStub = sinon.stub();
|
||||
const context: Partial<CoralContext> = {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
clearSession: clearSessionStub,
|
||||
};
|
||||
localStorage.setItem("accessToken", accessToken);
|
||||
await SetAccessTokenMutation.commit(
|
||||
environment,
|
||||
{ accessToken: null },
|
||||
context as any
|
||||
);
|
||||
await expect(
|
||||
context.localStorage!.getItem("accessToken")
|
||||
).resolves.toBeNull();
|
||||
expect(clearSessionStub.calledOnce).toBe(true);
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { CoralContext } from "coral-framework/lib/bootstrap";
|
||||
import {
|
||||
commitLocalUpdatePromisified,
|
||||
createMutation,
|
||||
setAccessTokenInLocalState,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { createMutation } from "coral-framework/lib/relay";
|
||||
|
||||
interface SetAccessTokenInput {
|
||||
accessToken: string | null;
|
||||
@@ -16,18 +12,10 @@ const SetAccessTokenMutation = createMutation(
|
||||
async (
|
||||
environment: Environment,
|
||||
input: SetAccessTokenInput,
|
||||
{ localStorage, clearSession }: CoralContext
|
||||
{ clearSession }: CoralContext
|
||||
) => {
|
||||
await commitLocalUpdatePromisified(environment, async store => {
|
||||
setAccessTokenInLocalState(input.accessToken, store);
|
||||
if (input.accessToken) {
|
||||
await localStorage.setItem("accessToken", input.accessToken);
|
||||
} else {
|
||||
await localStorage.removeItem("accessToken");
|
||||
}
|
||||
// Clear current session, as we are starting a new one.
|
||||
await clearSession();
|
||||
});
|
||||
// Clear current session, as we are starting a new one.
|
||||
await clearSession(input.accessToken);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RestClient } from "coral-framework/lib/rest";
|
||||
import { createPromisifiedStorage } from "coral-framework/lib/storage";
|
||||
import { createUUIDGenerator } from "coral-framework/testHelpers";
|
||||
|
||||
import sinon from "sinon";
|
||||
import createFluentBundle from "./createFluentBundle";
|
||||
import createRelayEnvironment from "./createRelayEnvironment";
|
||||
import createSubscriptionHandler, {
|
||||
@@ -102,13 +103,12 @@ export default function createTestRenderer<
|
||||
browserInfo: params.browserInfo || { ios: false },
|
||||
uuidGenerator: createUUIDGenerator(),
|
||||
eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
|
||||
clearSession: () => Promise.resolve(),
|
||||
clearSession: sinon.stub(),
|
||||
transitionControl: {
|
||||
allowTransition: true,
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
|
||||
let testRenderer: ReactTestRenderer;
|
||||
TestRenderer.act(() => {
|
||||
testRenderer = TestRenderer.create(
|
||||
|
||||
@@ -31,16 +31,16 @@ export class TabBarContainer extends Component<Props> {
|
||||
|
||||
public render() {
|
||||
const {
|
||||
local: { loggedIn, activeTab },
|
||||
local: { activeTab },
|
||||
viewer,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TabBar
|
||||
activeTab={activeTab}
|
||||
showProfileTab={loggedIn}
|
||||
showProfileTab={Boolean(viewer)}
|
||||
showConfigureTab={
|
||||
!!this.props.viewer &&
|
||||
can(this.props.viewer, Ability.CHANGE_STORY_CONFIGURATION)
|
||||
!!viewer && can(viewer, Ability.CHANGE_STORY_CONFIGURATION)
|
||||
}
|
||||
onTabClick={this.handleSetActiveTab}
|
||||
/>
|
||||
@@ -52,7 +52,6 @@ const enhanced = withSetActiveTabMutation(
|
||||
withLocalStateContainer(
|
||||
graphql`
|
||||
fragment TabBarContainerLocal on Local {
|
||||
loggedIn
|
||||
activeTab
|
||||
}
|
||||
`
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createSinonStub } from "coral-framework/testHelpers";
|
||||
|
||||
import { OnPostMessageSetAccessToken } from "./OnPostMessageSetAccessToken";
|
||||
|
||||
it("Listens to event and sets auth token", () => {
|
||||
it("Listens to event and sets access token", () => {
|
||||
const token = "access-token";
|
||||
const postMessage: any = {
|
||||
on: (name: string, cb: (token: string) => void) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ it("renders fully", () => {
|
||||
focus: false,
|
||||
view: "SIGN_IN",
|
||||
},
|
||||
accessToken: "access-token",
|
||||
accessTokenJTI: "JTI",
|
||||
},
|
||||
viewer: null,
|
||||
@@ -74,6 +75,7 @@ it("renders without logout button", () => {
|
||||
focus: false,
|
||||
view: "SIGN_IN",
|
||||
},
|
||||
accessToken: "access-token",
|
||||
accessTokenJTI: null,
|
||||
},
|
||||
viewer: null,
|
||||
@@ -131,6 +133,7 @@ it("renders sso only", () => {
|
||||
focus: false,
|
||||
view: "SIGN_IN",
|
||||
},
|
||||
accessToken: "access-token",
|
||||
accessTokenJTI: "JTI",
|
||||
},
|
||||
viewer: null,
|
||||
@@ -188,6 +191,7 @@ it("renders sso only without logout button", () => {
|
||||
focus: false,
|
||||
view: "SIGN_IN",
|
||||
},
|
||||
accessToken: "access-token",
|
||||
accessTokenJTI: "JTI",
|
||||
},
|
||||
viewer: null,
|
||||
@@ -245,6 +249,7 @@ it("renders without register button", () => {
|
||||
focus: false,
|
||||
view: "SIGN_IN",
|
||||
},
|
||||
accessToken: "access-token",
|
||||
accessTokenJTI: "JTI",
|
||||
},
|
||||
viewer: null,
|
||||
|
||||
@@ -45,7 +45,9 @@ export class UserBoxContainer extends Component<Props> {
|
||||
private handleSignOut = () => this.props.signOut();
|
||||
|
||||
private get supportsLogout() {
|
||||
return !!this.props.local.accessTokenJTI;
|
||||
return Boolean(
|
||||
!this.props.local.accessToken || this.props.local.accessTokenJTI
|
||||
);
|
||||
}
|
||||
|
||||
private get supportsRegister() {
|
||||
@@ -125,6 +127,7 @@ const enhanced = withSignOutMutation(
|
||||
focus
|
||||
view
|
||||
}
|
||||
accessToken
|
||||
accessTokenJTI
|
||||
}
|
||||
`
|
||||
|
||||
@@ -15,7 +15,6 @@ exports[`init local state 1`] = `
|
||||
\\"accessToken\\": \\"\\",
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": null,
|
||||
\\"loggedIn\\": false,
|
||||
\\"commentsOrderBy\\": \\"CREATED_AT_DESC\\",
|
||||
\\"authPopup\\": {
|
||||
\\"__ref\\": \\"client:root.local.authPopup\\"
|
||||
|
||||
@@ -5,7 +5,6 @@ import { CoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { LOCAL_ID } from "coral-framework/lib/relay";
|
||||
import { createPromisifiedStorage } from "coral-framework/lib/storage";
|
||||
import {
|
||||
createAccessToken,
|
||||
createRelayEnvironment,
|
||||
replaceHistoryLocation,
|
||||
} from "coral-framework/testHelpers";
|
||||
@@ -57,13 +56,3 @@ it("set commentID from query", async () => {
|
||||
expect(source.get(LOCAL_ID)!.commentID).toBe(commentID);
|
||||
restoreHistoryLocation();
|
||||
});
|
||||
|
||||
it("set accessToken from localStorage", async () => {
|
||||
const context: Partial<CoralContext> = {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
};
|
||||
const accessToken = createAccessToken();
|
||||
context.localStorage!.setItem("accessToken", accessToken);
|
||||
await initLocalState(environment, context as any);
|
||||
expect(source.get(LOCAL_ID)!.accessToken).toBe(accessToken);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,6 @@ type Local {
|
||||
accessToken: String
|
||||
accessTokenExp: Int
|
||||
accessTokenJTI: String
|
||||
loggedIn: Boolean!
|
||||
activeTab: TAB!
|
||||
authPopup: AuthPopup!
|
||||
storyID: String
|
||||
|
||||
@@ -23,7 +23,7 @@ class ReportButtonContainer extends React.Component<
|
||||
ReportButtonContainerProps
|
||||
> {
|
||||
private get loggedIn() {
|
||||
return this.props.viewer;
|
||||
return Boolean(this.props.viewer);
|
||||
}
|
||||
private handleSignIn = () => this.props.showAuthPopup({ view: "SIGN_IN" });
|
||||
|
||||
|
||||
+3
-3
@@ -19,9 +19,6 @@ type Props = PropTypesOf<typeof PostCommentFormContainerN>;
|
||||
function createDefaultProps(add: DeepPartial<Props> = {}): Props {
|
||||
return pureMerge(
|
||||
{
|
||||
local: {
|
||||
loggedIn: true,
|
||||
},
|
||||
showAuthPopup: noop as any,
|
||||
createComment: noop as any,
|
||||
refreshSettings: noop as any,
|
||||
@@ -49,6 +46,9 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
|
||||
message: "",
|
||||
},
|
||||
},
|
||||
viewer: {
|
||||
id: "viewer-id",
|
||||
},
|
||||
},
|
||||
add
|
||||
);
|
||||
|
||||
+39
-43
@@ -11,13 +11,12 @@ import {
|
||||
graphql,
|
||||
withFetch,
|
||||
withFragmentContainer,
|
||||
withLocalStateContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { PromisifiedStorage } from "coral-framework/lib/storage";
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import { PostCommentFormContainer_settings as SettingsData } from "coral-stream/__generated__/PostCommentFormContainer_settings.graphql";
|
||||
import { PostCommentFormContainer_story as StoryData } from "coral-stream/__generated__/PostCommentFormContainer_story.graphql";
|
||||
import { PostCommentFormContainerLocal as Local } from "coral-stream/__generated__/PostCommentFormContainerLocal.graphql";
|
||||
import { PostCommentFormContainer_settings } from "coral-stream/__generated__/PostCommentFormContainer_settings.graphql";
|
||||
import { PostCommentFormContainer_story } from "coral-stream/__generated__/PostCommentFormContainer_story.graphql";
|
||||
import { PostCommentFormContainer_viewer } from "coral-stream/__generated__/PostCommentFormContainer_viewer.graphql";
|
||||
import {
|
||||
ShowAuthPopupMutation,
|
||||
withShowAuthPopupMutation,
|
||||
@@ -42,9 +41,9 @@ interface Props {
|
||||
createComment: CreateCommentMutation;
|
||||
refreshSettings: FetchProp<typeof RefreshSettingsFetch>;
|
||||
sessionStorage: PromisifiedStorage;
|
||||
settings: SettingsData;
|
||||
local: Local;
|
||||
story: StoryData;
|
||||
settings: PostCommentFormContainer_settings;
|
||||
viewer: PostCommentFormContainer_viewer | null;
|
||||
story: PostCommentFormContainer_story;
|
||||
showAuthPopup: ShowAuthPopupMutation;
|
||||
}
|
||||
|
||||
@@ -71,7 +70,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
|
||||
initialized: false,
|
||||
nudge: true,
|
||||
keepFormWhenClosed:
|
||||
this.props.local.loggedIn &&
|
||||
!!this.props.viewer &&
|
||||
!this.props.story.isClosed &&
|
||||
!this.props.settings.disableCommenting.enabled,
|
||||
submitStatus: null,
|
||||
@@ -194,7 +193,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!this.props.local.loggedIn) {
|
||||
if (!this.props.viewer) {
|
||||
return (
|
||||
<PostCommentFormFake
|
||||
draft={this.state.notLoggedInDraft}
|
||||
@@ -243,44 +242,41 @@ const enhanced = withContext(({ sessionStorage }) => ({
|
||||
withShowAuthPopupMutation(
|
||||
withCreateCommentMutation(
|
||||
withFetch(RefreshSettingsFetch)(
|
||||
withLocalStateContainer(
|
||||
graphql`
|
||||
fragment PostCommentFormContainerLocal on Local {
|
||||
loggedIn
|
||||
withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment PostCommentFormContainer_settings on Settings {
|
||||
charCount {
|
||||
enabled
|
||||
min
|
||||
max
|
||||
}
|
||||
disableCommenting {
|
||||
enabled
|
||||
message
|
||||
}
|
||||
closeCommenting {
|
||||
message
|
||||
}
|
||||
}
|
||||
`
|
||||
)(
|
||||
withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment PostCommentFormContainer_settings on Settings {
|
||||
charCount {
|
||||
`,
|
||||
story: graphql`
|
||||
fragment PostCommentFormContainer_story on Story {
|
||||
id
|
||||
isClosed
|
||||
...MessageBoxContainer_story
|
||||
settings {
|
||||
messageBox {
|
||||
enabled
|
||||
min
|
||||
max
|
||||
}
|
||||
disableCommenting {
|
||||
enabled
|
||||
message
|
||||
}
|
||||
closeCommenting {
|
||||
message
|
||||
}
|
||||
}
|
||||
`,
|
||||
story: graphql`
|
||||
fragment PostCommentFormContainer_story on Story {
|
||||
id
|
||||
isClosed
|
||||
...MessageBoxContainer_story
|
||||
settings {
|
||||
messageBox {
|
||||
enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(PostCommentFormContainer)
|
||||
)
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment PostCommentFormContainer_viewer on User {
|
||||
id
|
||||
}
|
||||
`,
|
||||
})(PostCommentFormContainer)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -85,6 +85,7 @@ export const StreamContainer: FunctionComponent<Props> = props => {
|
||||
<PostCommentFormContainer
|
||||
settings={props.settings}
|
||||
story={props.story}
|
||||
viewer={props.viewer}
|
||||
/>
|
||||
)}
|
||||
{banned && <BannedInfo />}
|
||||
@@ -170,6 +171,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...UserBoxContainer_viewer
|
||||
...CreateCommentReplyMutation_viewer
|
||||
...CreateCommentMutation_viewer
|
||||
...PostCommentFormContainer_viewer
|
||||
status {
|
||||
current
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export default function create(params: CreateTestRendererParams) {
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue("COMMENTS", "activeTab");
|
||||
localRecord.setValue("ALL_COMMENTS", "commentsTab");
|
||||
localRecord.setValue(false, "loggedIn");
|
||||
localRecord.setValue("jti", "accessTokenJTI");
|
||||
localRecord.setValue("CREATED_AT_DESC", "commentsOrderBy");
|
||||
if (params.initLocalState) {
|
||||
|
||||
@@ -60,7 +60,6 @@ const createTestRenderer = async (
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(storyFixture.id, "storyID");
|
||||
localRecord.setValue(commentFixture.id, "commentID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(story.id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ async function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ async function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ async function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(storyWithReplies.id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
return testRenderer;
|
||||
|
||||
@@ -38,7 +38,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(story.id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(story.id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ async function createTestRenderer(
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue(story.id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ async function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ beforeEach(() => {
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(storyWithDeepestReplies.id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ async function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ function createTestRenderer() {
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
return { testRenderer, resolvers };
|
||||
|
||||
@@ -32,7 +32,6 @@ async function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(storyWithNoComments.id, "storyID");
|
||||
localRecord.setValue(Boolean(data.loggedIn), "loggedIn");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ function createTestRenderer(
|
||||
resolvers,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
return { testRenderer, resolvers };
|
||||
|
||||
@@ -11,7 +11,6 @@ export default function create(params: CreateTestRendererParams) {
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue("PROFILE", "activeTab");
|
||||
localRecord.setValue("jti", "accessTokenJTI");
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue(story.id, "storyID");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--round-corners);
|
||||
width: 100%;
|
||||
line-height: 36px;
|
||||
align-self: stretch;
|
||||
|
||||
&:read-only {
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-size: calc(16rem / var(--rem-base));
|
||||
line-height: calc(18em / 16);
|
||||
line-height: 1;
|
||||
letter-spacing: calc(0.2em / 16);
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
@@ -261,4 +261,9 @@ export enum ERROR_CODES {
|
||||
* times by the same user.
|
||||
*/
|
||||
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
|
||||
|
||||
/**
|
||||
* JWT_REVOKED is returned when the token referenced has been revoked.
|
||||
*/
|
||||
JWT_REVOKED = "JWT_REVOKED",
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextFunction, RequestHandler, Response } from "express";
|
||||
import { CookieOptions, NextFunction, RequestHandler, Response } from "express";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import jwt from "jsonwebtoken";
|
||||
@@ -14,12 +14,14 @@ 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,
|
||||
signTokenString,
|
||||
} from "coral-server/services/jwt";
|
||||
import { Request } from "coral-server/types/express";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export type VerifyCallback = (
|
||||
err?: Error | null,
|
||||
@@ -70,20 +72,18 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
|
||||
// Extract the token from the request.
|
||||
const token = extractTokenFromRequest(req);
|
||||
if (!token) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("logout requires a token on the request, none was found");
|
||||
// No token on the request, indicate that this was successful.
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
// Coral is guarenteed at this point.
|
||||
// Coral is guaranteed at this point.
|
||||
const { now } = req.coral!;
|
||||
|
||||
// Decode the token.
|
||||
const decoded = jwt.decode(token, {});
|
||||
if (!decoded) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error(
|
||||
"logout requires a token on the request, token was invalid"
|
||||
);
|
||||
// Invalid token on request, indicate that this was successful.
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
// Grab the JTI from the decoded token.
|
||||
@@ -94,9 +94,12 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
|
||||
if (validFor > 0) {
|
||||
// Invalidate the token, the expiry is in the future and it needs to be
|
||||
// revoked.
|
||||
await revokeJWT(redis, jti, validFor);
|
||||
await revokeJWT(redis, jti, validFor, now);
|
||||
}
|
||||
|
||||
// Clear the cookie.
|
||||
res.clearCookie(COOKIE_NAME, generateCookieOptions(req, new Date(0)));
|
||||
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
@@ -108,16 +111,29 @@ export async function handleSuccessfulLogin(
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
// Coral is guaranteed at this point.
|
||||
const coral = req.coral!;
|
||||
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.coral!.tenant!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Compute the expiry date.
|
||||
const expiresIn = DateTime.fromJSDate(coral.now).plus({ days: 1 });
|
||||
|
||||
// Grab the token.
|
||||
const token = await signTokenString(signingConfig, user, tenant);
|
||||
const token = await signTokenString(signingConfig, user, tenant, {
|
||||
expiresIn: Math.floor(expiresIn.toSeconds()),
|
||||
});
|
||||
|
||||
// Set the cache control headers.
|
||||
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())
|
||||
);
|
||||
|
||||
// Send back the details!
|
||||
res.json({ token });
|
||||
@@ -126,6 +142,16 @@ export async function handleSuccessfulLogin(
|
||||
}
|
||||
}
|
||||
|
||||
const generateCookieOptions = (
|
||||
req: Request,
|
||||
expiresIn: Date
|
||||
): CookieOptions => ({
|
||||
path: "/api",
|
||||
httpOnly: true,
|
||||
secure: req.secure,
|
||||
expires: expiresIn,
|
||||
});
|
||||
|
||||
export async function handleOAuth2Callback(
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
@@ -147,8 +173,18 @@ export async function handleOAuth2Callback(
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.coral!.tenant!;
|
||||
|
||||
// Compute the expiry date.
|
||||
const expiresIn = DateTime.fromJSDate(req.coral!.now).plus({ days: 1 });
|
||||
|
||||
// Grab the token.
|
||||
const token = await signTokenString(signingConfig, user, tenant);
|
||||
const token = await signTokenString(signingConfig, user, tenant, {
|
||||
expiresIn: Math.floor(expiresIn.toSeconds()),
|
||||
});
|
||||
res.cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
generateCookieOptions(req, expiresIn.toJSDate())
|
||||
);
|
||||
|
||||
// Send back the details!
|
||||
res.redirect(path + `#accessToken=${token}`);
|
||||
|
||||
@@ -2,7 +2,11 @@ import jwt from "jsonwebtoken";
|
||||
import { Strategy } from "passport-strategy";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { TenantNotFoundError, TokenInvalidError } from "coral-server/errors";
|
||||
import {
|
||||
JWTRevokedError,
|
||||
TenantNotFoundError,
|
||||
TokenInvalidError,
|
||||
} from "coral-server/errors";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
@@ -54,7 +58,7 @@ export function createVerifiers(
|
||||
];
|
||||
}
|
||||
|
||||
export function verifyAndRetrieveUser(
|
||||
export async function verifyAndRetrieveUser(
|
||||
verifiers: Array<Verifier<Token>>,
|
||||
tenant: Tenant,
|
||||
tokenString: string,
|
||||
@@ -65,11 +69,21 @@ export function verifyAndRetrieveUser(
|
||||
throw new TokenInvalidError(tokenString, "token could not be decoded");
|
||||
}
|
||||
|
||||
// Try to verify the token.
|
||||
for (const verifier of verifiers) {
|
||||
if (verifier.supports(token, tenant)) {
|
||||
return verifier.verify(tokenString, token, tenant, now);
|
||||
try {
|
||||
// Try to verify the token.
|
||||
for (const verifier of verifiers) {
|
||||
if (verifier.supports(token, tenant)) {
|
||||
return await verifier.verify(tokenString, token, tenant, now);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// When the JWT was revoked, just indicate that there is no user on the
|
||||
// request rather than erroring out.
|
||||
if (err instanceof JWTRevokedError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
// No verifier could be found.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Db } from "mongodb";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { retrieveUser } from "coral-server/models/user";
|
||||
import {
|
||||
checkJWTRevoked,
|
||||
isJWTRevoked,
|
||||
JWTSigningConfig,
|
||||
StandardClaims,
|
||||
verifyJWT,
|
||||
@@ -74,7 +74,9 @@ export class JWTVerifier implements Verifier<JWTToken> {
|
||||
if (!token.pat) {
|
||||
// Check to see if the token has been revoked, as these tokens can be
|
||||
// revoked.
|
||||
await checkJWTRevoked(this.redis, token.jti);
|
||||
if (await isJWTRevoked(this.redis, token.jti)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the user.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import cookies from "cookie-parser";
|
||||
import express, { Router } from "express";
|
||||
import { register } from "prom-client";
|
||||
|
||||
@@ -16,7 +17,12 @@ export function createRouter(app: AppOptions, options: RouterOptions) {
|
||||
const router = express.Router();
|
||||
|
||||
// Attach the API router.
|
||||
router.use("/api", noCacheMiddleware, createAPIRouter(app, options));
|
||||
router.use(
|
||||
"/api",
|
||||
noCacheMiddleware,
|
||||
cookies(),
|
||||
createAPIRouter(app, options)
|
||||
);
|
||||
|
||||
// Attach the GraphiQL if enabled.
|
||||
if (app.config.get("enable_graphiql")) {
|
||||
|
||||
@@ -497,6 +497,15 @@ export class InvalidCredentialsError extends CoralError {
|
||||
}
|
||||
}
|
||||
|
||||
export class JWTRevokedError extends CoralError {
|
||||
constructor(jti: string) {
|
||||
super({
|
||||
code: ERROR_CODES.AUTHENTICATION_ERROR,
|
||||
context: { pvt: { jti } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends CoralError {
|
||||
constructor(reason: string) {
|
||||
super({
|
||||
|
||||
@@ -45,4 +45,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
PASSWORD_RESET_TOKEN_EXPIRED: "error-passwordResetTokenExpired",
|
||||
EMAIL_CONFIRM_TOKEN_EXPIRED: "error-emailConfirmTokenExpired",
|
||||
RATE_LIMIT_EXCEEDED: "error-rateLimitExceeded",
|
||||
JWT_REVOKED: "error-jwtRevoked",
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("extractJWTFromRequest", () => {
|
||||
it("extracts the token from query string", () => {
|
||||
const req = {
|
||||
url: "",
|
||||
headers: {},
|
||||
};
|
||||
expect(extractTokenFromRequest((req as any) as Request)).toEqual(null);
|
||||
|
||||
@@ -37,6 +38,7 @@ describe("extractJWTFromRequest", () => {
|
||||
it("does not extract the token from query string when it's disabled", () => {
|
||||
const req = {
|
||||
url: "https://coral.coralproject.net/api?accessToken=token",
|
||||
headers: {},
|
||||
};
|
||||
|
||||
expect(extractTokenFromRequest((req as any) as Request, true)).toEqual(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import cookie from "cookie";
|
||||
import { IncomingMessage } from "http";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import jwt, { SignOptions, VerifyOptions } from "jsonwebtoken";
|
||||
@@ -6,11 +8,14 @@ import uuid from "uuid/v4";
|
||||
|
||||
import { Omit } from "coral-common/types";
|
||||
import { Config } from "coral-server/config";
|
||||
import { AuthenticationError, TokenInvalidError } from "coral-server/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
JWTRevokedError,
|
||||
TokenInvalidError,
|
||||
} from "coral-server/errors";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { Request } from "coral-server/types/express";
|
||||
import { IncomingMessage } from "http";
|
||||
|
||||
/**
|
||||
* The following Claim Names are registered in the IANA "JSON Web Token
|
||||
@@ -213,7 +218,6 @@ export const signTokenString = async (
|
||||
) =>
|
||||
jwt.sign({}, secret, {
|
||||
jwtid: uuid(),
|
||||
// TODO: (wyattjoh) evaluate allowing configuration?
|
||||
expiresIn: "1 day",
|
||||
...options,
|
||||
issuer: tenant.id,
|
||||
@@ -241,6 +245,8 @@ export async function signString<T extends {}>(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param req the request to extract the JWT from
|
||||
* @param excludeQuery when true, does not pull from the query params
|
||||
@@ -248,6 +254,69 @@ export async function signString<T extends {}>(
|
||||
export function extractTokenFromRequest(
|
||||
req: Request | IncomingMessage,
|
||||
excludeQuery: boolean = false
|
||||
): string | null {
|
||||
return (
|
||||
extractJWTFromRequestHeaders(req, excludeQuery) ||
|
||||
extractJWTFromRequestCookie(req)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* IncomingMessage.
|
||||
*
|
||||
* @param req a request to test if it is an Express Request or not.
|
||||
*/
|
||||
export function isExpressRequest(
|
||||
req: Request | IncomingMessage
|
||||
): req is Request {
|
||||
// Only Express Request objects contain an `app` field.
|
||||
if (typeof (req as Request).app === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param req the request to extract the JWT from
|
||||
* @param excludeQuery when true, does not pull from the query params
|
||||
*/
|
||||
function extractJWTFromRequestHeaders(
|
||||
req: Request | IncomingMessage,
|
||||
excludeQuery: boolean = false
|
||||
) {
|
||||
const options: BearerOptions = {
|
||||
basic: "password",
|
||||
@@ -267,18 +336,53 @@ function generateJTIRevokedKey(jti: string) {
|
||||
return `jtir:${jti}`;
|
||||
}
|
||||
|
||||
export async function revokeJWT(redis: Redis, jti: string, validFor: number) {
|
||||
/**
|
||||
* revokeJWT will place the token into a blacklist until it expires.
|
||||
*
|
||||
* @param redis the Redis instance to revoke the JWT with
|
||||
* @param jti the JTI claim of the JWT token being revoked
|
||||
* @param validFor number of seconds that the token was valid for
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function revokeJWT(
|
||||
redis: Redis,
|
||||
jti: string,
|
||||
validFor: number,
|
||||
now = new Date()
|
||||
) {
|
||||
await redis.setex(
|
||||
generateJTIRevokedKey(jti),
|
||||
Math.ceil(validFor),
|
||||
Date.now()
|
||||
now.valueOf()
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkJWTRevoked(redis: Redis, jti: string) {
|
||||
/**
|
||||
* isJWTRevoked will check to see if the given token referenced by the JWT has
|
||||
* been revoked or not.
|
||||
*
|
||||
* @param redis the Redis instance to check to see if the token was revoked
|
||||
* @param jti the JTI claim of the JWT token being tested
|
||||
*/
|
||||
export async function isJWTRevoked(redis: Redis, jti: string) {
|
||||
const expiredAtString = await redis.get(generateJTIRevokedKey(jti));
|
||||
if (expiredAtString) {
|
||||
throw new AuthenticationError("JWT was revoked");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* checkJWTRevoked will test the JWT's JTI to see if it's revoked, if it is, it
|
||||
* will throw an error.
|
||||
*
|
||||
* @param redis the Redis instance to check to see if the token was revoked
|
||||
* @param jti the JTI claim of the JWT token being tested
|
||||
*/
|
||||
export async function checkJWTRevoked(redis: Redis, jti: string) {
|
||||
if (await isJWTRevoked(redis, jti)) {
|
||||
throw new JWTRevokedError(jti);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user