Skip to content

Commit

Permalink
feat: logout endpoint (clears the session)
Browse files Browse the repository at this point in the history
* Added /auth/logout route
* Misc refactoring and cleanup
  • Loading branch information
activescott committed Feb 15, 2021
1 parent 78a186e commit 6db3663
Show file tree
Hide file tree
Showing 16 changed files with 182 additions and 143 deletions.
10 changes: 3 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Some super helpful references to keep handy:

- [+] feat: profile menu w/ login/logout

- [ ] feat: logout endpoint (clears the session)
- [+] feat: logout endpoint (clears the session)

- [ ] feat: extract lambda/middleware into new package (@web-app-stack/lambda-auth)

Expand All @@ -109,12 +109,8 @@ Some super helpful references to keep handy:

- UserContext:

- [ ] feat: UserContext available as a react context so that client side app always has access to user/auth when authenticated (see alert genie, but no need for auth0)
- [ ] feat: when serving index.html always return a signed cookie that also has an accessToken claim in it (HOW??)
- See the session stuff [here](https://arc.codes/reference/functions/http/node/session) and [here](https://docs.begin.com/en/http-functions/sessions) (which one??) the `requireLogin` example at https://arc.codes/reference/functions/http/node/async
- The "Greedy Root" behavior means we can inject cookies: https://docs.begin.com/en/http-functions/provisioning#greedy-root. Should we?
- CSRF: See https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#xmlhttprequest-native-javascript to include it in the fetch client by default. See https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#hmac-based-token-pattern for HMAC-based CRSF. Needed on all "state changing requests".
- [ ] feat: all local API requests in `client/src/lib/useApiHooks.ts` use accessToken
- [+] feat: UserContext available as a react context so that client side app always has access to user/auth when authenticated (see alert genie, but no need for auth0)
- [+] feat: all local API requests in `client/src/lib/useApiHooks.ts` use accessToken
- [ ] feat: login/logout pages
- [ ] feat: Avatar and login/logout/profile stuff in header

Expand Down
1 change: 1 addition & 0 deletions server/app.arc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ post /api/echo
get /auth/redirect/:provider
post /auth/redirect/:provider
get /auth/login/:provider
get /auth/logout
get /auth/me
get /auth/csrf

Expand Down
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"description": "",
"scripts": {
"//build": "Building react locally for sandbox is just like production. Staging is the outlier",
"build": "echo 'Please execute `npm run build-server` to just build the server or `npm run build-staging or build-production` to build client and server for a deployment\n'",
"build-production": "npm run -s build-react-production && npm run -s build-server",
"build-staging": "npm run -s build-react-staging && npm run -s build-server",
"build-testing": "npm run -s build-production",
Expand Down
5 changes: 5 additions & 0 deletions server/src/http/get-auth-logout/config.arc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@aws
runtime nodejs12.x
# memory 1152
# timeout 30
# concurrency 1
3 changes: 3 additions & 0 deletions server/src/http/get-auth-logout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import logoutHandlerFactory from "@architect/shared/lambda/oauth/handlers/logout"

export const handler = logoutHandlerFactory()
10 changes: 5 additions & 5 deletions server/src/shared/lambda/csrfHandler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
TextResponse,
textResponse,
LambdaHttpHandler,
LambdaHttpResponse,
LambdaHttpRequest,
} from "./lambda"
import { readSessionID } from "./session"
import { readSession } from "./session"
import * as STATUS from "./httpStatus"
import { createCSRFToken } from "./csrf"

Expand All @@ -16,17 +16,17 @@ export default function csrfGetHandlerFactory(): LambdaHttpHandler {
async function handlerImp(
req: LambdaHttpRequest
): Promise<LambdaHttpResponse> {
const session = readSessionID(req)
const session = readSession(req)
if (!session) {
// NOTE: even an anonymous session is okay for us, and that will come back with a legit session
return TextResponse(
return textResponse(
STATUS.UNAUTHENTICATED,
"error: request not authenticated"
)
}

const csrf = await createCSRFToken(session.userID)
return TextResponse(STATUS.OK, csrf)
return textResponse(STATUS.OK, csrf)
}
return handlerImp
}
68 changes: 66 additions & 2 deletions server/src/shared/lambda/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
APIGatewayProxyEventV2,
APIGatewayProxyStructuredResultV2,
} from "aws-lambda"
import { INTERNAL_SERVER_ERROR } from "./httpStatus"

/**
* Defines the type for an AWS Lambda/API Gateway request.
Expand All @@ -25,7 +26,7 @@ export type LambdaHttpHandler = (
* @param httpStatusCode HttpStatus code
* @param body The boy as an object. It will be converted to json.
*/
export function JsonResponse(
export function jsonResponse(
httpStatusCode: number,
body: HttpJsonBody
): LambdaHttpResponse {
Expand All @@ -45,7 +46,7 @@ type HttpJsonBody = any
* @param httpStatusCode HttpStatus code
* @param body The boy as plain text. It will not be parsed or converted.
*/
export function TextResponse(
export function textResponse(
httpStatusCode: number,
body: string
): LambdaHttpResponse {
Expand All @@ -57,3 +58,66 @@ export function TextResponse(
body,
}
}

/**
* Returns a response to the browser in HTML with the specified error information.
* @param httpStatusCode
* @param message A description about the error
* @param heading A heading for the error.
*/
export function htmlErrorResponse(
httpStatusCode = INTERNAL_SERVER_ERROR,
message: string,
heading: string = "Login Error"
): LambdaHttpResponse {
return {
statusCode: httpStatusCode,
headers: {
"Content-Type": "text/html; charset=utf8",
},
body: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${heading}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, sans-serif;
}
.padding-32 {
padding: 2rem;
}
.max-width-320 {
max-width: 20rem;
}
.margin-left-8 {
margin-left: 0.5rem;
}
.margin-bottom-16 {
margin-bottom: 1rem;
}
</style>
</head>
<body class="padding-32">
<div class="max-width-320">
<div class="margin-left-8">
<h1 class="margin-bottom-16">
${heading}
</h1>
<p class="margin-bottom-8">
${message}
</p>
</div>
</div>
</body>
</html>
`,
}
}
86 changes: 7 additions & 79 deletions server/src/shared/lambda/oauth/handlers/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { LambdaHttpRequest, LambdaHttpResponse } from "../../lambda"
import { UserSession, writeSessionID } from "../../session"
import { BAD_REQUEST, INTERNAL_SERVER_ERROR } from "../../httpStatus"
import {
htmlErrorResponse,
LambdaHttpRequest,
LambdaHttpResponse,
} from "../../lambda"
import { BAD_REQUEST } from "../../httpStatus"

/**
* Returns the name of the provider that should be used for authentication from the specified request.
Expand All @@ -13,86 +16,11 @@ export function getProviderName(
? req.pathParameters[PROVIDER_NAME_PARAM]
: ""
if (!provider) {
const err = errorResponse(
const err = htmlErrorResponse(
BAD_REQUEST,
"provider path parameter must be specified"
)
return ["", err]
}
return [provider, null]
}

/**
* Creates a session by recording it in the response Arc Session Middleware.
* NOTE: This expects arc.async request/response/http middleware to be used.
*/
export function addResponseSession(
res: LambdaHttpResponse,
session: Pick<UserSession, "userID">
): LambdaHttpResponse {
writeSessionID(res, session)
return res
}

/**
* Returns a response to the browser in HTML with the specified error information.
* @param httpStatusCode
* @param message A description about the error
* @param heading A heading for the error.
*/
export function errorResponse(
httpStatusCode = INTERNAL_SERVER_ERROR,
message: string,
heading: string = "Login Error"
): LambdaHttpResponse {
return {
statusCode: httpStatusCode,
headers: {
"Content-Type": "text/html; charset=utf8",
},
body: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Architect</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, sans-serif;
}
.padding-32 {
padding: 2rem;
}
.max-width-320 {
max-width: 20rem;
}
.margin-left-8 {
margin-left: 0.5rem;
}
.margin-bottom-16 {
margin-bottom: 1rem;
}
</style>
</head>
<body class="padding-32">
<div class="max-width-320">
<div class="margin-left-8">
<h1 class="margin-bottom-16">
${heading}
</h1>
<p class="margin-bottom-8">
${message}
</p>
</div>
</div>
</body>
</html>
`,
}
}
20 changes: 11 additions & 9 deletions server/src/shared/lambda/oauth/handlers/login.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { createCSRFToken } from "../../csrf"
import {
createAnonymousSessionID,
readSessionID,
createAnonymousSession,
readSession,
UserSession,
writeSession,
} from "../../session"
import { OAuthProviderConfig, Config } from "../OAuthProviderConfig"
import { addResponseSession, errorResponse, getProviderName } from "./common"
import { getProviderName } from "./common"
import { INTERNAL_SERVER_ERROR } from "../../httpStatus"
import { URL } from "url"
import {
htmlErrorResponse,
LambdaHttpHandler,
LambdaHttpRequest,
LambdaHttpResponse,
Expand Down Expand Up @@ -38,14 +40,14 @@ export default function loginHandlerFactory(
const conf = new OAuthProviderConfig(providerName)
const error = conf.validate()
if (error) {
return errorResponse(INTERNAL_SERVER_ERROR, error)
return htmlErrorResponse(INTERNAL_SERVER_ERROR, error)
}

let authUrl: URL
try {
authUrl = new URL(conf.value(Config.AuthorizationEndpoint))
} catch (err) {
return errorResponse(
return htmlErrorResponse(
INTERNAL_SERVER_ERROR,
`the ${conf.name(Config.AuthorizationEndpoint)} value ${conf.value(
Config.AuthorizationEndpoint
Expand Down Expand Up @@ -73,7 +75,7 @@ export default function loginHandlerFactory(
)
}

let session: UserSession | null = readSessionID(req)
let session: UserSession | null = readSession(req)
// if we got a valid session from the request, lets make sure it's also a valid user we know about (e.g. it isn't anonymous and the user hasn't been deleted):
if (session) {
const user = await userRepository.get(session.userID)
Expand All @@ -88,19 +90,19 @@ export default function loginHandlerFactory(
}

if (!session) {
session = createAnonymousSessionID()
session = createAnonymousSession()
}

authUrl.searchParams.append("state", await createCSRFToken(session.userID))

let res: LambdaHttpResponse = {
const res: LambdaHttpResponse = {
statusCode: 302,
headers: {
location: authUrl.toString(),
},
body: "",
}
res = addResponseSession(res, session)
writeSession(res, session)
return res
}
return loginHandler
Expand Down
22 changes: 22 additions & 0 deletions server/src/shared/lambda/oauth/handlers/logout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expectSession } from "../../../../../test/support"
import { createMockRequest } from "../../../../../test/support/lambda"
import logoutHandlerFactory from "./logout"

it("should redirect", async () => {
const req = createMockRequest()
const handler = logoutHandlerFactory()

const res = await handler(req)
expect(res).toHaveProperty("statusCode", 302)
})

it("should create a browser session", async () => {
const req = createMockRequest()
const handler = logoutHandlerFactory()

const res = await handler(req)

expect(res).toHaveProperty("statusCode", 302)
// make sure it created a session
expectSession(res)
})
18 changes: 18 additions & 0 deletions server/src/shared/lambda/oauth/handlers/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { LambdaHttpHandler, LambdaHttpResponse } from "../../lambda"
import { createAnonymousSession, writeSession } from "../../session"

/** Creates a @see LambdaHttpHandler that handles logout. */
export default function logoutHandlerFactory(): LambdaHttpHandler {
async function logoutHandler(): Promise<LambdaHttpResponse> {
const res: LambdaHttpResponse = {
statusCode: 302,
headers: {
location: "/",
},
body: "",
}
writeSession(res, createAnonymousSession())
return res
}
return logoutHandler
}
Loading

0 comments on commit 6db3663

Please sign in to comment.