Skip to content

Commit

Permalink
feat(OAuth): Redirect handler writes session cookie to indicate the u…
Browse files Browse the repository at this point in the history
…ser is indeed logged in
  • Loading branch information
activescott committed Jan 11, 2021
1 parent 7945a3f commit ce2e4f7
Show file tree
Hide file tree
Showing 7 changed files with 90 additions and 14 deletions.
6 changes: 6 additions & 0 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 12 additions & 4 deletions server/src/lib/Tokenater.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/
)
})
})

Expand Down
12 changes: 8 additions & 4 deletions server/src/lib/Tokenater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions server/src/lib/architect/oauth/handlers/redirect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
46 changes: 44 additions & 2 deletions server/src/lib/architect/oauth/handlers/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
Expand All @@ -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<string> {
// 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
Expand Down
3 changes: 2 additions & 1 deletion server/test/support/setup.ts
Original file line number Diff line number Diff line change
@@ -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 = ""
process.env.CSRF_TOKEN_WARNING_DISABLE = ""
process.env.TOKENATER_WARNING_DISABLE = ""

0 comments on commit ce2e4f7

Please sign in to comment.