diff --git a/server/package-lock.json b/server/package-lock.json index 68af4e3..6b62990 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2046,6 +2046,12 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-y7mImlc/rNkvCRmg8gC3/lj87S7pTUIJ6QGjwHR9WQJcFs+ZMTOaoPrkdFA/YdbuqVEmEbb5RdhVxMkAcgOnpg==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.4.tgz", diff --git a/server/package.json b/server/package.json index 71224ef..2a1e00b 100644 --- a/server/package.json +++ b/server/package.json @@ -24,6 +24,7 @@ "license": "ISC", "devDependencies": { "@architect/architect": "^6.5.2", + "@types/cookie": "^0.4.0", "@types/jest": "^26.0.19", "@types/node": "^14.14.19", "@types/node-fetch": "^2.5.7", @@ -36,6 +37,7 @@ "dependencies": { "@architect/functions": "^3.12.1", "aws-sdk": "^2.820.0", + "cookie": "^0.4.1", "irritable-iterable": "^1.0.0", "node-fetch": "^2.6.1", "node-webtokens": "^1.0.4", diff --git a/server/src/lib/Tokenater.spec.ts b/server/src/lib/Tokenater.spec.ts index c7724da..4281db1 100644 --- a/server/src/lib/Tokenater.spec.ts +++ b/server/src/lib/Tokenater.spec.ts @@ -44,10 +44,18 @@ describe("Tokenater", () => { }) it("should not permit periods in value", async () => { - await expect(ater.createToken("has.period")).rejects.toThrowError(/must not contain a period/) - await expect(ater.createToken(".")).rejects.toThrowError(/must not contain a period/) - await expect(ater.createToken(".begin")).rejects.toThrowError(/must not contain a period/) - await expect(ater.createToken("end.")).rejects.toThrowError(/must not contain a period/) + await expect(ater.createToken("has.period")).rejects.toThrowError( + /must not contain a period/ + ) + await expect(ater.createToken(".")).rejects.toThrowError( + /must not contain a period/ + ) + await expect(ater.createToken(".begin")).rejects.toThrowError( + /must not contain a period/ + ) + await expect(ater.createToken("end.")).rejects.toThrowError( + /must not contain a period/ + ) }) }) diff --git a/server/src/lib/Tokenater.ts b/server/src/lib/Tokenater.ts index c0500c0..819e210 100644 --- a/server/src/lib/Tokenater.ts +++ b/server/src/lib/Tokenater.ts @@ -93,14 +93,18 @@ export default class Tokenater { const expiresAtMillis = this.decodeExpiresAt(encodedExpiresAt) const expectedSignature = this.sign(value, expiresAtMillis) if (expectedSignature != signature) { - // eslint-disable-next-line no-console - console.warn("token has invalid signature") + if (!("TOKENATER_WARNING_DISABLE" in process.env)) { + // eslint-disable-next-line no-console + console.warn("token has invalid signature") + } return false } // check expiration if (expiresAtMillis < Date.now()) { - // eslint-disable-next-line no-console - console.warn("token has expired") + if (!("TOKENATER_WARNING_DISABLE" in process.env)) { + // eslint-disable-next-line no-console + console.warn("token has expired") + } return false } return true diff --git a/server/src/lib/architect/oauth/handlers/redirect.spec.ts b/server/src/lib/architect/oauth/handlers/redirect.spec.ts index d481b11..c6de322 100644 --- a/server/src/lib/architect/oauth/handlers/redirect.spec.ts +++ b/server/src/lib/architect/oauth/handlers/redirect.spec.ts @@ -232,9 +232,22 @@ describe("redirect", () => { expect(actualToken.expires_at).toBeGreaterThan(Date.now()) }) - it.todo( - "should write cookie, session-key, or header (which one?) to indicate the user is indeed logged in" - ) + it("should write session cookie to indicate the user is indeed logged in", async () => { + const oauthRedirectHandler = oAuthRedirectHandlerFactory( + mockFetchJson(), + await userRepositoryFactory(), + await tokenRepositoryFactory() + ) + const req = await mockAuthorizationCodeResponseRequest() + + req.queryStringParameters.provider = PROVIDER_NAME + mockProviderConfigInEnvironment() + + // invoke handler + const res = await oauthRedirectHandler(req) + expect(res).toHaveProperty("statusCode", 302) + expect(res).toHaveProperty("headers.Set-Cookie", expect.anything()) + }) it.todo( "should redirect the user to the after-login redirect page in query params" diff --git a/server/src/lib/architect/oauth/handlers/redirect.ts b/server/src/lib/architect/oauth/handlers/redirect.ts index 4820c4c..dc92b70 100644 --- a/server/src/lib/architect/oauth/handlers/redirect.ts +++ b/server/src/lib/architect/oauth/handlers/redirect.ts @@ -16,6 +16,8 @@ import { } from "./httpStatus" import * as jwt from "node-webtokens" import { assert } from "console" +import * as cookie from "cookie" +import Tokenater from "../../../Tokenater" /** * Factory to create a handler for the [Authorization Response](https://tools.ietf.org/html/rfc6749#section-4.1.2) when the user is directed with a `code` from the OAuth Authorization Server back to the OAuth client application. @@ -112,6 +114,7 @@ export default function oAuthRedirectHandlerFactory( return { headers: { location: process.env.NODE_ENV === "staging" ? "/staging" : "/", + "Set-Cookie": await createSessionCookie(user.id), }, statusCode: 302, } @@ -120,8 +123,47 @@ export default function oAuthRedirectHandlerFactory( return oauthRedirectHandler } -const MSPERSECOND = 1000 -const secondsToMilliseconds = (seconds: number): number => seconds * MSPERSECOND +async function createSessionCookie(userID: string): Promise { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies + // eslint-disable-next-line no-magic-numbers + const EXPIRES_IN = 7 * Tokenater.DAYS_IN_MS + const cookieOptions: cookie.CookieSerializeOptions = { + maxAge: EXPIRES_IN, + expires: new Date(Date.now() + EXPIRES_IN), + secure: true, + httpOnly: true, + path: "/", + sameSite: "lax", + } + if (process.env.NODE_ENV === "testing") { + delete cookieOptions.secure + } + const ater = new Tokenater(getSessionCookieSecret(), EXPIRES_IN) + const sessionVal = await ater.createToken(userID) + const SESSION_COOKIE_NAME = "WAS_SES" + return cookie.serialize(SESSION_COOKIE_NAME, sessionVal, cookieOptions) +} + +function getSessionCookieSecret(): string { + let secret = process.env.SESSION_TOKEN_SECRET + if (!secret) { + if (process.env.NODE_ENV == "production") { + throw new Error( + "SESSION_TOKEN_SECRET environment variable MUST be provided in production environments" + ) + } + // eslint-disable-next-line no-console + console.warn( + "SESSION_TOKEN_SECRET environment variable SHOULD be provided in pre-production environments" + ) + secret = `${process.env.ARC_APP_NAME} not so secret` + } + return secret +} + +const MS_PER_SECOND = 1000 +const secondsToMilliseconds = (seconds: number): number => + seconds * MS_PER_SECOND function validateState( req: ArchitectHttpRequestPayload diff --git a/server/test/support/setup.ts b/server/test/support/setup.ts index 4e60577..e611799 100644 --- a/server/test/support/setup.ts +++ b/server/test/support/setup.ts @@ -1,4 +1,5 @@ process.env.DEBUG = "" process.env.CSRF_SECRET = "jest-secret" process.env.SESSION_TOKEN_SECRET = "jest-secret" -process.env.CSRF_TOKEN_WARNING_DISABLE = "" \ No newline at end of file +process.env.CSRF_TOKEN_WARNING_DISABLE = "" +process.env.TOKENATER_WARNING_DISABLE = ""