+ )
+}
+export default Page
diff --git a/client/src/rollbar-env.d.ts b/client/src/rollbar-env.d.ts
new file mode 100644
index 0000000..0708660
--- /dev/null
+++ b/client/src/rollbar-env.d.ts
@@ -0,0 +1,7 @@
+import Rollbar from "rollbar"
+
+declare global {
+ interface Window {
+ Rollbar: Rollbar
+ }
+}
diff --git a/server/app.arc b/server/app.arc
index e2aad03..6dcd41d 100644
--- a/server/app.arc
+++ b/server/app.arc
@@ -9,6 +9,7 @@ get /auth/redirect/:provider
post /auth/redirect/:provider
get /auth/login/:provider
get /auth/me
+get /auth/csrf
@aws
region us-west-2
diff --git a/server/src/http/get-auth-csrf/config.arc b/server/src/http/get-auth-csrf/config.arc
new file mode 100644
index 0000000..2091b85
--- /dev/null
+++ b/server/src/http/get-auth-csrf/config.arc
@@ -0,0 +1,5 @@
+@aws
+runtime nodejs12.x
+# memory 1152
+# timeout 30
+# concurrency 1
diff --git a/server/src/http/get-auth-csrf/index.ts b/server/src/http/get-auth-csrf/index.ts
new file mode 100644
index 0000000..83696e5
--- /dev/null
+++ b/server/src/http/get-auth-csrf/index.ts
@@ -0,0 +1,4 @@
+import csrfGetHandlerFactory from "@architect/shared/lambda/csrfHandler"
+
+const handlerImp = csrfGetHandlerFactory()
+export const handler = handlerImp
diff --git a/server/src/shared/README.md b/server/src/shared/README.md
index f6d0724..8b2f499 100644
--- a/server/src/shared/README.md
+++ b/server/src/shared/README.md
@@ -1,4 +1,4 @@
-NOTE: This is specific to Architect to allow each http handler to be bundled (since in essence each http handler is it's own completely isolated module).
+NOTE: This "shared" folder is specific to Architect to allow each http handler to be bundled (since in essence each http handler is it's own completely isolated module).
See https://arc.codes/docs/en/guides/developer-experience/sharing-code
NOTE: A potentially cleaner and less-specific approach to architect would be to put all code from shared into it's own package that is referenced by the web project itself (this is essentially what architect is doing but it kinda hides it).
diff --git a/server/src/shared/lambda/middleware/csrf.spec.ts b/server/src/shared/lambda/csrf.spec.ts
similarity index 74%
rename from server/src/shared/lambda/middleware/csrf.spec.ts
rename to server/src/shared/lambda/csrf.spec.ts
index 41f92b3..62c3d40 100644
--- a/server/src/shared/lambda/middleware/csrf.spec.ts
+++ b/server/src/shared/lambda/csrf.spec.ts
@@ -1,12 +1,6 @@
import sinon from "sinon"
-import { randomInt } from "../../../../test/support"
-import { LambdaHttpResponse } from "../../lambda"
-import {
- addCsrfTokenToResponse,
- createCSRFToken,
- CSRF_HEADER_NAME,
- isTokenValid,
-} from "./csrf"
+import { randomInt } from "../../../test/support"
+import { createCSRFToken, isTokenValid } from "./csrf"
describe("csrf", () => {
// preserve environment
@@ -23,19 +17,6 @@ describe("csrf", () => {
sinon.restore()
})
- it("should write csrf token to response", async () => {
- const res: LambdaHttpResponse = {}
- await addCsrfTokenToResponse("foo", res)
- expect(res).toHaveProperty("headers")
- expect(res.headers).toHaveProperty(CSRF_HEADER_NAME)
- })
-
- it("should require response", async () => {
- expect(
- addCsrfTokenToResponse("foo", (null as unknown) as LambdaHttpResponse)
- ).rejects.toThrowError(/response/)
- })
-
describe("isTokenValid", () => {
it("should accept valid token", async () => {
const testSessionID = `test-${randomInt()}`
diff --git a/server/src/shared/lambda/csrf.ts b/server/src/shared/lambda/csrf.ts
new file mode 100644
index 0000000..edf57dc
--- /dev/null
+++ b/server/src/shared/lambda/csrf.ts
@@ -0,0 +1,52 @@
+import { secretFromEnvironment } from "../secretEnvironment"
+import Tokenater from "../Tokenater"
+
+export const CSRF_HEADER_NAME = "X-CSRF-TOKEN"
+
+/**
+ * Creates a CSRF token that is matched to the specified session ID.
+ * @param sessionID The session id that the token should be matched to
+ */
+export async function createCSRFToken(sessionID: string): Promise {
+ const ater = createTokenater()
+ return ater.createToken(sessionID)
+}
+
+/**
+ * Indicates if the specified token is valid for the specified session id.
+ * @param token The CSRF token to validate.
+ * @param sessionID The session that this CSRF token should be matched to.
+ */
+export function isTokenValid(token: string, sessionID: string): boolean {
+ const ater = createTokenater()
+ if (!ater.isValid(token)) {
+ warn("CSRF token is expired or has been tampered with")
+ return false
+ }
+ // our CSRF token has the session id in it. Now that we've validated the token, extract the session id and make sure that it matches
+ const csrfSessionID = ater.getTokenValue(token)
+ if (csrfSessionID != sessionID) {
+ warn("CSRF token does not match session:", csrfSessionID, "!=", sessionID)
+ return false
+ }
+ return true
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function warn(message?: any, ...optionalParams: any[]): void {
+ if (!("CSRF_TOKEN_WARNING_DISABLE" in process.env)) {
+ // eslint-disable-next-line no-console
+ console.warn(message, optionalParams)
+ }
+}
+
+const createTokenater = (): Tokenater =>
+ new Tokenater(getSecret(), Tokenater.DAYS_IN_MS * 1)
+
+function getSecret(): string {
+ const KEY_LENGTH = 32
+ return secretFromEnvironment(
+ "WAS_CSRF_SECRET",
+ `${process.env.NODE_ENV}`
+ ).padEnd(KEY_LENGTH, ".")
+}
diff --git a/server/src/shared/lambda/csrfHandler.ts b/server/src/shared/lambda/csrfHandler.ts
new file mode 100644
index 0000000..c6f042d
--- /dev/null
+++ b/server/src/shared/lambda/csrfHandler.ts
@@ -0,0 +1,32 @@
+import {
+ TextResponse,
+ LambdaHttpHandler,
+ LambdaHttpResponse,
+ LambdaHttpRequest,
+} from "./lambda"
+import { readSessionID } from "./session"
+import * as STATUS from "./httpStatus"
+import { createCSRFToken } from "./csrf"
+
+/**
+ * A factory for a handler to return CSRF tokens in response to get requests.
+ * The user must have an active session (even as a anonymous session).
+ */
+export default function csrfGetHandlerFactory(): LambdaHttpHandler {
+ async function handlerImp(
+ req: LambdaHttpRequest
+ ): Promise {
+ const session = readSessionID(req)
+ if (!session) {
+ // NOTE: even an anonymous session is okay for us, and that will come back with a legit session
+ return TextResponse(
+ STATUS.UNAUTHENTICATED,
+ "error: request not authenticated"
+ )
+ }
+
+ const csrf = await createCSRFToken(session.userID)
+ return TextResponse(STATUS.OK, csrf)
+ }
+ return handlerImp
+}
diff --git a/server/src/shared/lambda/oauth/handlers/httpStatus.ts b/server/src/shared/lambda/httpStatus.ts
similarity index 100%
rename from server/src/shared/lambda/oauth/handlers/httpStatus.ts
rename to server/src/shared/lambda/httpStatus.ts
diff --git a/server/src/shared/lambda.ts b/server/src/shared/lambda/lambda.ts
similarity index 72%
rename from server/src/shared/lambda.ts
rename to server/src/shared/lambda/lambda.ts
index 67c4888..f9a461f 100644
--- a/server/src/shared/lambda.ts
+++ b/server/src/shared/lambda/lambda.ts
@@ -37,6 +37,23 @@ export function JsonResponse(
body: JSON.stringify(body),
}
}
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HttpJsonBody = any
+
+/**
+ * Helper to build an response for plain text data.
+ * @param httpStatusCode HttpStatus code
+ * @param body The boy as plain text. It will not be parsed or converted.
+ */
+export function TextResponse(
+ httpStatusCode: number,
+ body: string
+): LambdaHttpResponse {
+ return {
+ statusCode: httpStatusCode,
+ headers: {
+ "content-type": "text/plain",
+ },
+ body,
+ }
+}
diff --git a/server/src/shared/lambda/middleware/csrf.ts b/server/src/shared/lambda/middleware/csrf.ts
deleted file mode 100644
index 0c47136..0000000
--- a/server/src/shared/lambda/middleware/csrf.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import assert from "assert"
-import {
- LambdaHttpHandler,
- LambdaHttpRequest,
- LambdaHttpResponse,
-} from "../../lambda"
-import { secretFromEnvironment } from "../../secretEnvironment"
-import Tokenater from "../../Tokenater"
-import { readSessionID } from "./session"
-
-export const CSRF_HEADER_NAME = "X-CSRF-TOKEN-X"
-
-/**
- * Creates a CSRF token that is matched to the specified session ID.
- * @param sessionID The session id that the token should be matched to
- */
-export async function createCSRFToken(sessionID: string): Promise {
- const ater = createTokenater()
- return ater.createToken(sessionID)
-}
-
-/**
- * Indicates if the specified token is valid for the specified session id.
- * @param token The CSRF token to validate.
- * @param sessionID The session that this CSRF token should be matched to.
- */
-export function isTokenValid(token: string, sessionID: string): boolean {
- const ater = createTokenater()
- if (!ater.isValid(token)) {
- warn("CSRF token is expired or has been tampered with")
- return false
- }
- // our CSRF token has the session id in it. Now that we've validated the token, extract the session id and make sure that it matches
- const csrfSessionID = ater.getTokenValue(token)
- if (csrfSessionID != sessionID) {
- warn("CSRF token does not match session:", csrfSessionID, "!=", sessionID)
- return false
- }
- return true
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function warn(message?: any, ...optionalParams: any[]): void {
- if (!("CSRF_TOKEN_WARNING_DISABLE" in process.env)) {
- // eslint-disable-next-line no-console
- console.warn(message, optionalParams)
- }
-}
-
-/**
- * Response middleware to add a CSRF token to the response that can be read/validated with the @see expectCsrfTokenWithRequest request middleware function.
- * @param handler Your HTTP handler that should run before this middleware adds the CSRF token header to the response.
- */
-export function csrfResponseMiddleware(
- handler: LambdaHttpHandler
-): LambdaHttpHandler {
- async function thunk(req: LambdaHttpRequest): Promise {
- const response = await handler(req)
- assert(response, "response expected from handler")
- // get the current session id:
- const session = readSessionID(req)
- if (!session) {
- throw new Error("session not on request session!")
- }
- // add the CSRF token:
- await addCsrfTokenToResponse(session.userID, response)
- return response
- }
- return thunk
-}
-
-type HttpResponseLike = Pick
-
-/**
- * Adds a CSRF token to the specified response object according to the [HMAC Based Token Pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern)
- * The token can be validated in a subsequent request with `expectCsrfTokenWithRequest`.
- * The request must also have a session ID header as specified by
- * @param response The http response to add the token to
- */
-export async function addCsrfTokenToResponse(
- sessionID: string,
- response: HttpResponseLike
-): Promise {
- if (!response) {
- throw new Error("response must be provided")
- }
- response.headers = response.headers || {}
- response.headers[CSRF_HEADER_NAME] = await createCSRFToken(sessionID)
-}
-
-const createTokenater = (): Tokenater =>
- new Tokenater(getSecret(), Tokenater.DAYS_IN_MS * 1)
-
-function getSecret(): string {
- const KEY_LENGTH = 32
- return secretFromEnvironment(
- "WAS_CSRF_SECRET",
- `${process.env.NODE_ENV}`
- ).padEnd(KEY_LENGTH, ".")
-}
diff --git a/server/src/shared/lambda/oauth/handlers/common.ts b/server/src/shared/lambda/oauth/handlers/common.ts
index ee1a86d..b710051 100644
--- a/server/src/shared/lambda/oauth/handlers/common.ts
+++ b/server/src/shared/lambda/oauth/handlers/common.ts
@@ -1,6 +1,6 @@
-import { LambdaHttpRequest, LambdaHttpResponse } from "../../../lambda"
-import { UserSession, writeSessionID } from "../../middleware/session"
-import { BAD_REQUEST, INTERNAL_SERVER_ERROR } from "./httpStatus"
+import { LambdaHttpRequest, LambdaHttpResponse } from "../../lambda"
+import { UserSession, writeSessionID } from "../../session"
+import { BAD_REQUEST, INTERNAL_SERVER_ERROR } from "../../httpStatus"
/**
* Returns the name of the provider that should be used for authentication from the specified request.
diff --git a/server/src/shared/lambda/oauth/handlers/login.spec.ts b/server/src/shared/lambda/oauth/handlers/login.spec.ts
index acc74ba..c1b851e 100644
--- a/server/src/shared/lambda/oauth/handlers/login.spec.ts
+++ b/server/src/shared/lambda/oauth/handlers/login.spec.ts
@@ -2,7 +2,7 @@ import { createMockRequest } from "../../../../../test/support/lambda"
import loginHandlerFactory from "./login"
import { URL } from "url"
import assert from "assert"
-import { LambdaHttpRequest } from "../../../lambda"
+import { LambdaHttpRequest } from "../../lambda"
import { expectSession } from "../../../../../test/support"
import userRepositoryFactory from "../repository/UserRepository"
diff --git a/server/src/shared/lambda/oauth/handlers/login.ts b/server/src/shared/lambda/oauth/handlers/login.ts
index 75818e9..50f6409 100644
--- a/server/src/shared/lambda/oauth/handlers/login.ts
+++ b/server/src/shared/lambda/oauth/handlers/login.ts
@@ -1,18 +1,18 @@
-import { createCSRFToken } from "../../middleware/csrf"
+import { createCSRFToken } from "../../csrf"
import {
createAnonymousSessionID,
readSessionID,
UserSession,
-} from "../../middleware/session"
+} from "../../session"
import { OAuthProviderConfig, Config } from "../OAuthProviderConfig"
import { addResponseSession, errorResponse, getProviderName } from "./common"
-import { INTERNAL_SERVER_ERROR } from "./httpStatus"
+import { INTERNAL_SERVER_ERROR } from "../../httpStatus"
import { URL } from "url"
import {
LambdaHttpHandler,
LambdaHttpRequest,
LambdaHttpResponse,
-} from "../../../lambda"
+} from "../../lambda"
import { UserRepository } from "../repository/UserRepository"
export default function loginHandlerFactory(
diff --git a/server/src/shared/lambda/oauth/handlers/me.spec.ts b/server/src/shared/lambda/oauth/handlers/me.spec.ts
index 1ccca64..6043ad3 100644
--- a/server/src/shared/lambda/oauth/handlers/me.spec.ts
+++ b/server/src/shared/lambda/oauth/handlers/me.spec.ts
@@ -1,7 +1,7 @@
import sinon from "sinon"
import { randomInt } from "../../../../../test/support"
import { createMockRequest } from "../../../../../test/support/lambda"
-import { injectSessionToRequest } from "../../middleware/session"
+import { injectSessionToRequest } from "../../session"
import identityRepositoryFactory, {
StoredIdentity,
} from "../repository/IdentityRepository"
diff --git a/server/src/shared/lambda/oauth/handlers/me.ts b/server/src/shared/lambda/oauth/handlers/me.ts
index 7791d94..f1c2afb 100644
--- a/server/src/shared/lambda/oauth/handlers/me.ts
+++ b/server/src/shared/lambda/oauth/handlers/me.ts
@@ -4,12 +4,12 @@ import {
LambdaHttpHandler,
LambdaHttpRequest,
LambdaHttpResponse,
-} from "../../../lambda"
-import { readSessionID } from "../../middleware/session"
+} from "../../lambda"
+import { readSessionID } from "../../session"
import { IdentityRepository } from "../repository/IdentityRepository"
import { StoredUser, UserRepository } from "../repository/UserRepository"
-import * as STATUS from "./httpStatus"
+import * as STATUS from "../../httpStatus"
/**
* 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.
diff --git a/server/src/shared/lambda/oauth/handlers/redirect.spec.ts b/server/src/shared/lambda/oauth/handlers/redirect.spec.ts
index e5146c9..08f455a 100644
--- a/server/src/shared/lambda/oauth/handlers/redirect.spec.ts
+++ b/server/src/shared/lambda/oauth/handlers/redirect.spec.ts
@@ -1,11 +1,11 @@
import { randomBytes } from "crypto"
import { createMockRequest } from "../../../../../test/support/lambda"
-import { createCSRFToken } from "../../middleware/csrf"
+import { createCSRFToken } from "../../csrf"
import {
createAnonymousSessionID,
injectSessionToRequest,
readSessionID,
-} from "../../middleware/session"
+} from "../../session"
import identityRepositoryFactory, {
IdentityRepository,
StoredIdentityProposal,
@@ -24,7 +24,7 @@ import {
import sinon from "sinon"
import { URL, URLSearchParams } from "url"
import assert from "assert"
-import { LambdaHttpRequest } from "../../../lambda"
+import { LambdaHttpRequest } from "../../lambda"
import { APIGatewayProxyEventQueryStringParameters } from "aws-lambda"
// note to self: Jest's auto-mocking voodoo wastes more time than it saves. Just inject dependencies (e.g. w/ oAuthRedirectHandlerFactory)
diff --git a/server/src/shared/lambda/oauth/handlers/redirect.ts b/server/src/shared/lambda/oauth/handlers/redirect.ts
index 96c3037..c9e6056 100644
--- a/server/src/shared/lambda/oauth/handlers/redirect.ts
+++ b/server/src/shared/lambda/oauth/handlers/redirect.ts
@@ -1,6 +1,6 @@
import { fetchJson as fetchJsonImpl, FetchJsonFunc } from "../../../fetch"
-import { isTokenValid } from "../../middleware/csrf"
-import { readSessionID } from "../../middleware/session"
+import { isTokenValid } from "../../csrf"
+import { readSessionID } from "../../session"
import { Config, OAuthProviderConfig } from "../OAuthProviderConfig"
import { IdentityRepository } from "../repository/IdentityRepository"
import { StoredUser, UserRepository } from "../repository/UserRepository"
@@ -9,7 +9,7 @@ import {
INTERNAL_SERVER_ERROR,
UNAUTHENTICATED,
FORBIDDEN,
-} from "./httpStatus"
+} from "../../httpStatus"
import * as jwt from "node-webtokens"
import { addResponseSession, errorResponse, getProviderName } from "./common"
import { URL, URLSearchParams } from "url"
@@ -19,7 +19,7 @@ import {
LambdaHttpHandler,
LambdaHttpRequest,
LambdaHttpResponse,
-} from "../../../lambda"
+} from "../../lambda"
import { secondsToMilliseconds } from "../../../time"
import { fromBase64 } from "../../../encoding"
@@ -94,6 +94,13 @@ export default function oAuthRedirectHandlerFactory(
)
}
+ if (!("UNIT_TESTING" in process.env)) {
+ // eslint-disable-next-line no-console
+ console.log(
+ `Found the following claims for provider '${providerName}':`,
+ Object.keys(parsed.payload)
+ )
+ }
const claimsError = validateClaims(parsed.payload, providerName)
if (claimsError) {
return claimsError
diff --git a/server/src/shared/lambda/middleware/session.spec.ts b/server/src/shared/lambda/session.spec.ts
similarity index 91%
rename from server/src/shared/lambda/middleware/session.spec.ts
rename to server/src/shared/lambda/session.spec.ts
index c301f60..37b9bb2 100644
--- a/server/src/shared/lambda/middleware/session.spec.ts
+++ b/server/src/shared/lambda/session.spec.ts
@@ -4,7 +4,7 @@ import {
UserSession,
createAnonymousSessionID,
} from "./session"
-import { createMockRequest } from "../../../../test/support/lambda"
+import { createMockRequest } from "../../../test/support/lambda"
describe("session", () => {
let testSesson: UserSession
diff --git a/server/src/shared/lambda/middleware/session.ts b/server/src/shared/lambda/session.ts
similarity index 97%
rename from server/src/shared/lambda/middleware/session.ts
rename to server/src/shared/lambda/session.ts
index 42f13e9..d469398 100644
--- a/server/src/shared/lambda/middleware/session.ts
+++ b/server/src/shared/lambda/session.ts
@@ -4,14 +4,14 @@ import {
parse as parseCookie,
serialize as serializeCookie,
} from "cookie"
-import { LambdaHttpRequest, LambdaHttpResponse } from "../../lambda"
+import { LambdaHttpRequest, LambdaHttpResponse } from "./lambda"
import { assert } from "console"
import {
daysToSeconds,
millisecondsToSeconds,
secondsToMilliseconds,
-} from "../../time"
-import { secretFromEnvironment } from "../../secretEnvironment"
+} from "../time"
+import { secretFromEnvironment } from "../secretEnvironment"
import * as jwt from "node-webtokens"
/** The name of the session key to get the session ID value.
diff --git a/server/test/support/index.ts b/server/test/support/index.ts
index 3311804..d133ab7 100644
--- a/server/test/support/index.ts
+++ b/server/test/support/index.ts
@@ -1,5 +1,5 @@
-import { SESSION_COOKIE_NAME } from "../../src/shared/lambda/middleware/session"
-import { LambdaHttpResponse } from "../../src/shared/lambda"
+import { SESSION_COOKIE_NAME } from "../../src/shared/lambda/session"
+import { LambdaHttpResponse } from "../../src/shared/lambda/lambda"
export function randomEmail(): string {
return `${randomInt()}@foo.bar`
diff --git a/server/test/support/setup.ts b/server/test/support/setup.ts
index d512014..a5730d4 100644
--- a/server/test/support/setup.ts
+++ b/server/test/support/setup.ts
@@ -1,4 +1,5 @@
process.env.DEBUG = ""
+process.env.UNIT_TESTING = ""
process.env.WAS_CSRF_SECRET = "jest-secret"
process.env.SESSION_TOKEN_SECRET = "jest-secret"
process.env.CSRF_TOKEN_WARNING_DISABLE = ""