[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:
Wyatt Johnson
2019-06-25 21:48:58 +02:00
committed by Vinh
parent 3576b6a943
commit e72b15c505
79 changed files with 375 additions and 278 deletions
+47 -3
View File
@@ -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": {
+4
View File
@@ -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()}`
);
-1
View File
@@ -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: {
+10 -4
View File
@@ -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();
});
+9 -4
View File
@@ -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" });
@@ -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
);
@@ -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 {
+1 -1
View File
@@ -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);
}
+5
View File
@@ -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.
+7 -1
View File
@@ -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")) {
+9
View File
@@ -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({
+1
View File
@@ -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(
+111 -7
View File
@@ -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);
}
}