From dc7a3f878be5cda669f7ffc8335b9af360812d7f Mon Sep 17 00:00:00 2001 From: Scott Willeke Date: Fri, 1 Jan 2021 23:40:20 -0800 Subject: [PATCH] feat: /api/login?provider=providername endpoint redirects user to login with specified provider --- .gitignore | 1 + README.md | 25 +++-- server/app.arc | 2 + server/src/http/get-auth-login/.arc-config | 5 + server/src/http/get-auth-login/index.ts | 8 ++ server/src/lib/architect/oauth/OAuth Notes.md | 54 +++++++++ .../architect/oauth/OAuthProviderConfig.ts | 51 +++++++++ server/src/lib/architect/oauth/README.md | 1 + .../architect/oauth/handlers/login.spec.ts | 104 ++++++++++++++++++ .../src/lib/architect/oauth/handlers/login.ts | 65 +++++++++++ web-app-stack.code-workspace | 3 + 11 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 server/src/http/get-auth-login/.arc-config create mode 100644 server/src/http/get-auth-login/index.ts create mode 100644 server/src/lib/architect/oauth/OAuth Notes.md create mode 100644 server/src/lib/architect/oauth/OAuthProviderConfig.ts create mode 100644 server/src/lib/architect/oauth/README.md create mode 100644 server/src/lib/architect/oauth/handlers/login.spec.ts create mode 100644 server/src/lib/architect/oauth/handlers/login.ts diff --git a/.gitignore b/.gitignore index 0198e06..b0ca138 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ server/src/http/**/*.js server/src/lib/**/*.js +server/test/**/*.js *.jic .arc-env sam.json diff --git a/README.md b/README.md index 40eefc1..1fe2cf4 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,12 @@ To add a new variable to test name `FOO` with value `myvalue`: arc env staging FOO myvalue ``` +## Quick References + +Some super helpful references to keep handy: + +- https://arc.codes/docs/en/reference/runtime/node and https://arc.codes/docs/en/reference/runtime/node#arc.http.async in particular as arc.http.async has a few handy tidbits like support for `request.session`. + ## Roadmap - [+] Bootstrap @@ -73,22 +79,19 @@ arc env staging FOO myvalue - Allow adding multiple OAuth Authorization servers to allow a user to authenticate: + - [+] feat: CSRF tokens to protect against login attacks: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html - [ ] feat(authentication): configuration for client ID & secret for google - - [ ] CSRF tokens to protect against login attacks: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern - [ ] feat: DDB tables to store user and table to store tokens by provider - - [ ] feat: user can use one or more OAuth providers + - [ ] feat: google OAuth working (with unit tests that mock google & user interactions) + - [ ] feat: user can use one or more OAuth providers with simple configuration - [ ] feat: CSRF token middleware in all state-changing APIs: - - [ ] CSRF server support: automatic detection/rejection + - [+] CSRF server support: automatic detection/rejection - [ ] CSRF client support: Automatic inclusion of the token -- [ ] chore: basic unit tests -- [ ] chore: git hooks for linting -- [ ] chore: git hooks for unit tests -- [ ] chore: move useApiHooks and useScript hooks into new package @activescott/react-hooks - - 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 @@ -98,6 +101,12 @@ arc env staging FOO myvalue - [ ] feat: login/logout pages - [ ] feat: Avatar and login/logout/profile stuff in header +- [ ] chore: upgrade architect +- [ ] chore: basic unit tests +- [ ] chore: git hooks for linting +- [ ] chore: git hooks for unit tests +- [ ] chore: move useApiHooks and ~~useScript hooks~~ into new package @activescott/react-hooks + ### Future - [ ] feat: HMR for react-app while using architect's sandbox (so API's still work) 🤔 diff --git a/server/app.arc b/server/app.arc index 4744c5b..24dce81 100644 --- a/server/app.arc +++ b/server/app.arc @@ -5,6 +5,8 @@ webappstack # NOTE: These routes are /generally/ for APIs. Most pages are statically rendered using `src/react-app` get /api/echo post /api/echo +get /auth/redirection +get /auth/login @aws region us-west-2 diff --git a/server/src/http/get-auth-login/.arc-config b/server/src/http/get-auth-login/.arc-config new file mode 100644 index 0000000..2091b85 --- /dev/null +++ b/server/src/http/get-auth-login/.arc-config @@ -0,0 +1,5 @@ +@aws +runtime nodejs12.x +# memory 1152 +# timeout 30 +# concurrency 1 diff --git a/server/src/http/get-auth-login/index.ts b/server/src/http/get-auth-login/index.ts new file mode 100644 index 0000000..51e46bc --- /dev/null +++ b/server/src/http/get-auth-login/index.ts @@ -0,0 +1,8 @@ +import { + ArchitectHttpRequestPayload, + ArchitectHttpResponsePayload, +} from "../../types/http" +import * as arc from "@architect/functions" +import login from "../../lib/architect/oauth/handlers/login" + +export const handler = arc.http.async(login) diff --git a/server/src/lib/architect/oauth/OAuth Notes.md b/server/src/lib/architect/oauth/OAuth Notes.md new file mode 100644 index 0000000..23e750e --- /dev/null +++ b/server/src/lib/architect/oauth/OAuth Notes.md @@ -0,0 +1,54 @@ +# OAuth Configuration Notes + +1. Add a comma-delimited list of provider names to environment variable `OAUTH_PROVIDERS`. For example, the following adds two providers to the arc staging environment: + +```sh +arc env staging OAUTH_PROVIDERS 'GOOGLE,GITHUB' +``` + +NOTE: THe provider names can be any thing. THey are used is a postfix in subsequent steps: + +2. Get a OAuth Client ID and OAuth Client Secret from the provider. Here are [instructions for Google](https://developers.google.com/identity/protocols/oauth2/openid-connect). + Use the redirect url of `https://.execute-api..amazonaws.com//auth/redirection` like `https://o92pvgjal2.execute-api.us-west-2.amazonaws.com/staging/auth/redirection` + +3. Add environment variable for Client ID: + The environment variable is named like `OAUTH__CLIENT_ID` where\_ ` is the name of the provider you used above. + +```sh +arc env staging OAUTH_GOOGLE_CLIENT_ID '235329004997-qbmh6kacitf56jtscckadmvd0qu9sqi6.apps.googleusercontent.com' +``` + +4. Add environment variable for Client Secret + The environment variable is named like `OAUTH__CLIENT_SECRET` where\_ ` is the name of the provider you used above. + +```sh +arc env staging OAUTH_GOOGLE_CLIENT_SECRET '3maj6RZcZ1UqcbRv2zV0I782' +``` + +5. Add environment variable for OAuth 2 [Authorization Endpoint](https://tools.ietf.org/html/rfc6749#section-3.1): + The environment variable is named like `OAUTH__ENDPOINT_AUTH` where\_ ` is the name of the provider you used above. + +```sh +arc env staging OAUTH_GOOGLE_ENDPOINT_AUTH 'https://accounts.google.com/o/oauth2/v2/auth' +``` + +6. Add environment variable for OAuth 2 [Token Endpoint](https://tools.ietf.org/html/rfc6749#section-3.2): + The environment variable is named like `OAUTH__ENDPOINT_TOKEN` where\_ ` is the name of the provider you used above. + +```sh +arc env staging OAUTH_GOOGLE_ENDPOINT_TOKEN 'https://github.com/login/oauth/access_token' +``` + +## Known OAuth 2 Provider Endpoints + +### Google + +- Docs: https://developers.google.com/identity/protocols/oauth2/web-server +- Authorization Endpoint: https://accounts.google.com/o/oauth2/v2/auth +- Token Endpoint: https://oauth2.googleapis.com/token + +### Github + +- Docs: https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps +- Authorization Endpoint: https://github.com/login/oauth/authorize +- Token Endpoint: https://github.com/login/oauth/access_token diff --git a/server/src/lib/architect/oauth/OAuthProviderConfig.ts b/server/src/lib/architect/oauth/OAuthProviderConfig.ts new file mode 100644 index 0000000..3a5cc85 --- /dev/null +++ b/server/src/lib/architect/oauth/OAuthProviderConfig.ts @@ -0,0 +1,51 @@ +/** + * Reads configuration for a OAuth provider. + */ +export class OAuthProviderConfig { + public constructor(private readonly providerName: string) { + if (!providerName) throw new Error("providerName must be specified") + } + + /** + * Returns the name of the config setting for this provider. + */ + public name(template: Config): string { + return template.replace(PROVIDER_PLACEHOLDER, this.providerName) + } + + /** + * Returns the value of the config setting for this provider. + */ + public value(template: Config): string { + return process.env[this.name(template)] + } + + public getMissingConfigNames(): Array { + const requiredConfigs = [ + Config.ClientID, + Config.ClientSecret, + Config.AuthorizationEndpoint, + Config.TokenEndpoint, + Config.RedirectURL + ] + const missing: Array = [] + for (const cname of requiredConfigs) { + const val = this.value(cname) + if (!val) { + missing.push(this.name(cname)) + } + } + if (missing.length > 0) return missing + else return null + } +} + +export enum Config { + AuthorizationEndpoint = "OAUTH_{{PROVIDER}}_ENDPOINT_AUTH", + TokenEndpoint = "OAUTH_{{PROVIDER}}_ENDPOINT_TOKEN", + ClientID = "OAUTH_{{PROVIDER}}_CLIENT_ID", + ClientSecret = "OAUTH_{{PROVIDER}}_CLIENT_SECRET", + RedirectURL = "OAUTH_{{PROVIDER}}_REDIRECT_URL", +} + +const PROVIDER_PLACEHOLDER = "{{PROVIDER}}" diff --git a/server/src/lib/architect/oauth/README.md b/server/src/lib/architect/oauth/README.md new file mode 100644 index 0000000..591c920 --- /dev/null +++ b/server/src/lib/architect/oauth/README.md @@ -0,0 +1 @@ +This folder contains implementation for an Architect app to implement OAuth client logic to allow users to login via for OAuth 2 providers such as Google, GitHub, Azure AD, etc. diff --git a/server/src/lib/architect/oauth/handlers/login.spec.ts b/server/src/lib/architect/oauth/handlers/login.spec.ts new file mode 100644 index 0000000..121c153 --- /dev/null +++ b/server/src/lib/architect/oauth/handlers/login.spec.ts @@ -0,0 +1,104 @@ +import { createMockRequest } from "../../../../../test/support/architect" +import login from "./login" + +describe("login handler", () => { + // preserve environment + const OLD_ENV = process.env + afterAll(() => { + process.env = OLD_ENV + }) + + afterEach(() => { + process.env = OLD_ENV + }) + + it("should fail if no provider is in query string", async () => { + const req = createMockRequest() + // NOTE: no query string for provider + const res = await login(req) + expect(res).toHaveProperty("statusCode", 400) + expect(res).toHaveProperty( + "json.message", + expect.stringMatching(/provider/) + ) + }) + + it("should ensure environment variables: ClientID", async () => { + const req = createMockRequest() + req.queryStringParameters["provider"] = "GOO" + + // note no environment variable for Client ID, Client Secret in + const res = await login(req) + expect(res).toHaveProperty("statusCode", 400) + expect(res).toHaveProperty( + "json.message", + expect.stringMatching(/OAUTH_GOO_CLIENT_ID/) + ) + }) + + it("should ensure environment variables: Client ID, Client Secret", async () => { + const req = createMockRequest() + req.queryStringParameters["provider"] = "GOO" + + // NOTE: environment variable for Client ID, but not Client Secret + process.env.OAUTH_GOO_CLIENT_ID = "googcid" + const res = await login(req) + expect(res).toHaveProperty("statusCode", 400) + expect(res).toHaveProperty( + "json.message", + expect.stringMatching(/AUTH_GOO_CLIENT_SECRET/) + ) + }) + + it("should ensure environment variables: Client ID, Client Secret, Auth Endpoint", async () => { + const req = createMockRequest() + req.queryStringParameters["provider"] = "GOO" + + // NOTE: environment variable for Client ID, but not Client Secret + process.env.OAUTH_GOO_CLIENT_ID = "googcid" + process.env.OAUTH_GOO_CLIENT_SECRET = "googsec" + const res = await login(req) + expect(res).toHaveProperty("statusCode", 400) + expect(res).toHaveProperty( + "json.message", + expect.stringMatching(/AUTH_GOO_ENDPOINT_AUTH/) + ) + }) + + it("should ensure environment variables: Client ID, Client Secret, Auth Endpoint, Token Endpoint", async () => { + const req = createMockRequest() + req.queryStringParameters["provider"] = "GOO" + + // NOTE: environment variable for Client ID, but not Client Secret + process.env.OAUTH_GOO_CLIENT_ID = "googcid" + process.env.OAUTH_GOO_CLIENT_SECRET = "googsec" + process.env.OAUTH_GOO_ENDPOINT_AUTH = "https://goo.foo/auth" + const res = await login(req) + expect(res).toHaveProperty("statusCode", 400) + expect(res).toHaveProperty( + "json.message", + expect.stringMatching(/AUTH_GOO_ENDPOINT_TOKEN/) + ) + }) + + it("should redirect", async () => { + const req = createMockRequest() + req.queryStringParameters["provider"] = "GOO" + + process.env.OAUTH_GOO_CLIENT_ID = "googcid" + process.env.OAUTH_GOO_CLIENT_SECRET = "googsec" + process.env.OAUTH_GOO_ENDPOINT_AUTH = "https://goo.foo/auth" + process.env.OAUTH_GOO_ENDPOINT_TOKEN = "https://goo.foo/tok" + process.env.OAUTH_GOO_REDIRECT_URL = "https://mysite/auth/redir/goo" + const res = await login(req) + expect(res).toHaveProperty("statusCode", 302) + expect(res).toHaveProperty("headers.location") + const location = new URL(res.headers.location) + expect(location.searchParams.get("response_type")).toEqual("code") + expect(location.searchParams.get("scope")).toEqual("profile email") + expect(location.searchParams.get("client_id")).toEqual(process.env.OAUTH_GOO_CLIENT_ID) + expect(location.searchParams.get("redirect_uri")).toEqual(process.env.OAUTH_GOO_REDIRECT_URL) + // TODO: also implement state param (make sure at least exists) + expect(location.searchParams.has("state")).toBeTruthy() + }) +}) diff --git a/server/src/lib/architect/oauth/handlers/login.ts b/server/src/lib/architect/oauth/handlers/login.ts new file mode 100644 index 0000000..d0000d5 --- /dev/null +++ b/server/src/lib/architect/oauth/handlers/login.ts @@ -0,0 +1,65 @@ +import { + ArchitectHttpRequestPayload, + ArchitectHttpResponsePayload, +} from "../../../../types/http" +import { OAuthProviderConfig, Config } from "../OAuthProviderConfig" + +export default async function login( + req: ArchitectHttpRequestPayload +): Promise { + /** + * This is where we start the login flow. Uses the following steps: + * 1. Get the provider from query string (?provider=) + * 2. Ensure we have client id, secret, etc. in environment variables from provider name + * 3. Build URL for provider's authorization endpoint and redirect the user there. + */ + const provider = req.queryStringParameters["provider"] + if (!provider) { + return errorResponse("provider query string must be provided") + } + const conf = new OAuthProviderConfig(provider) + const validationResponse = validateProviderConfig(conf) + if (validationResponse) { + return validationResponse + } + + let authUrl: URL + try { + authUrl = new URL(conf.value(Config.AuthorizationEndpoint)) + } catch (err) { + return errorResponse(`the ${conf.name(Config.AuthorizationEndpoint)} value ${conf.value(Config.AuthorizationEndpoint)} is not a valid URL`) + } + authUrl.searchParams.append("response_type", "code") + authUrl.searchParams.append("scope", "profile email") + authUrl.searchParams.append("client_id", conf.value(Config.ClientID)) + authUrl.searchParams.append("redirect_uri", conf.value(Config.RedirectURL)) + + return { + statusCode: 302, + headers: { + location: authUrl.toString(), + }, + } +} + +function validateProviderConfig( + conf: OAuthProviderConfig +): ArchitectHttpResponsePayload | null { + const missingConfigs = conf.getMissingConfigNames() + if (missingConfigs) { + const joined = missingConfigs.join(", ") + return errorResponse( + `The following environment settings must be provided: ${joined}` + ) + } +} + +function errorResponse(errorMessage: string): ArchitectHttpResponsePayload { + const HTTP_STATUS_ERROR = 400 + return { + statusCode: HTTP_STATUS_ERROR, + json: { + message: errorMessage, + }, + } +} diff --git a/web-app-stack.code-workspace b/web-app-stack.code-workspace index 82a44f6..0af6c7d 100644 --- a/web-app-stack.code-workspace +++ b/web-app-stack.code-workspace @@ -5,6 +5,9 @@ }, { "path": "client" + }, + { + "path": "." } ], "settings": {}