Skip to content

Commit

Permalink
feat: /api/login?provider=providername endpoint redirects user to log…
Browse files Browse the repository at this point in the history
…in with specified provider
  • Loading branch information
activescott committed Jan 2, 2021
1 parent 2bf4a0f commit dc7a3f8
Show file tree
Hide file tree
Showing 11 changed files with 311 additions and 8 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/
server/src/http/**/*.js
server/src/lib/**/*.js
server/test/**/*.js
*.jic
.arc-env
sam.json
Expand Down
25 changes: 17 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) 🤔
Expand Down
2 changes: 2 additions & 0 deletions server/app.arc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions server/src/http/get-auth-login/.arc-config
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@aws
runtime nodejs12.x
# memory 1152
# timeout 30
# concurrency 1
8 changes: 8 additions & 0 deletions server/src/http/get-auth-login/index.ts
Original file line number Diff line number Diff line change
@@ -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)
54 changes: 54 additions & 0 deletions server/src/lib/architect/oauth/OAuth Notes.md
Original file line number Diff line number Diff line change
@@ -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://<API_GATEWAY_ID>.execute-api.<REGION>.amazonaws.com/<ENVIRONMENT?>/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_<PROVIDER_NAME>_CLIENT_ID` where\_<PROVIDER_NAME> ` 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_<PROVIDER_NAME>_CLIENT_SECRET` where\_<PROVIDER_NAME> ` 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_<PROVIDER_NAME>_ENDPOINT_AUTH` where\_<PROVIDER_NAME> ` 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_<PROVIDER_NAME>_ENDPOINT_TOKEN` where\_<PROVIDER_NAME> ` 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
51 changes: 51 additions & 0 deletions server/src/lib/architect/oauth/OAuthProviderConfig.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const requiredConfigs = [
Config.ClientID,
Config.ClientSecret,
Config.AuthorizationEndpoint,
Config.TokenEndpoint,
Config.RedirectURL
]
const missing: Array<string> = []
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}}"
1 change: 1 addition & 0 deletions server/src/lib/architect/oauth/README.md
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions server/src/lib/architect/oauth/handlers/login.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
65 changes: 65 additions & 0 deletions server/src/lib/architect/oauth/handlers/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {
ArchitectHttpRequestPayload,
ArchitectHttpResponsePayload,
} from "../../../../types/http"
import { OAuthProviderConfig, Config } from "../OAuthProviderConfig"

export default async function login(
req: ArchitectHttpRequestPayload
): Promise<ArchitectHttpResponsePayload> {
/**
* This is where we start the login flow. Uses the following steps:
* 1. Get the provider from query string (?provider=<provider name>)
* 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,
},
}
}
3 changes: 3 additions & 0 deletions web-app-stack.code-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
},
{
"path": "client"
},
{
"path": "."
}
],
"settings": {}
Expand Down

0 comments on commit dc7a3f8

Please sign in to comment.