Skip to content

Commit

Permalink
feat: partial support for Signin with Apple (mostly)
Browse files Browse the repository at this point in the history
* This is almost working but I can't get apple to return the email claim in the idtoken.
* Also requires a special build of @architect/functions to set ARC_SESSION_SAME_SITE=None to support apple's form_post scenario (which requires a lax SameSite cookie)
  • Loading branch information
activescott committed Jan 19, 2021
1 parent 0f29b29 commit 3f60f47
Show file tree
Hide file tree
Showing 17 changed files with 317 additions and 46 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ sam.yaml
/server/coverage/
/server/public/
/server/preferences.arc
!/server/public/readme.md
!/server/public/readme.md
/server/private-*
9 changes: 9 additions & 0 deletions client/src/components/auth/SignInWithApple.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from "react"
import SignInWithProvider from "./SignInWithProvider"

// TODO: Style https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/displaying_sign_in_with_apple_buttons
const SignInWithApple = (): JSX.Element => {
return <SignInWithProvider provider="APPLE" label="Apple" />
}

export default SignInWithApple
8 changes: 8 additions & 0 deletions client/src/components/auth/SignInWithGoogle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import React from "react"
import SignInWithProvider from "./SignInWithProvider"

const SignInWithGoogle = (): JSX.Element => {
return <SignInWithProvider provider="GOOGLE" label="Google" />
}

export default SignInWithGoogle
20 changes: 20 additions & 0 deletions client/src/components/auth/SignInWithProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from "react"

const SignInWithProvider = (props: {
provider: string,
label: string
}): JSX.Element => {
return (
<button
type="button"
className="btn btn-outline-primary d-block btn-signin m-1"
onClick={() => {
window.location.href = process.env.PUBLIC_URL + `/auth/login/${props.provider}`
}}
>
{`Sign in with ${props.label}`}
</button>
)
}

export default SignInWithProvider
8 changes: 4 additions & 4 deletions client/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React from "react"
import Layout from "../components/Layout"
import { Helmet } from "react-helmet"
import { useApiGet } from "../lib/useApiHooks"
import SignInWithGoogle from "../components/auth/SignInWithGoogle"
import SignInWithApple from "../components/auth/SignInWithApple"

const Page = (): JSX.Element => (
<Layout>
Expand All @@ -14,10 +16,8 @@ const Page = (): JSX.Element => (
<p>Welcome to our home.</p>

<p>
TODO:{" "}
<a href={process.env.PUBLIC_URL + "/auth/login/GOOGLE"}>
Login with Google
</a>
<SignInWithGoogle />
<SignInWithApple />
</p>

<div>
Expand Down
7 changes: 4 additions & 3 deletions server/app.arc
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ webappstack
# NOTE: These routes are /generally/ for APIs. Most pages are statically rendered using `../client`, which is built and deployed to `./public`
get /api/echo
post /api/echo
get /auth/redirect/:provider
get /auth/login/:provider
get /auth/me
get /auth/redirect/:provider
post /auth/redirect/:provider
get /auth/login/:provider
get /auth/me

@aws
region us-west-2
Expand Down
20 changes: 20 additions & 0 deletions server/src/http/post-api-echo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as arc from "@architect/functions"
import { HttpRequest, HttpResponse } from "@architect/functions"

const handlerImp = async function handlerImp(
req: HttpRequest
): Promise<HttpResponse> {
return {
headers: {
"content-type": "application/json",
},
statusCode: 200,
body: JSON.stringify({
message:
"This is a useful debugging tool just to see what a request payload looks like.",
request: req,
}),
}
}

export const handler = arc.http.async(handlerImp)
13 changes: 13 additions & 0 deletions server/src/http/post-auth-redirect-000provider/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as arc from "@architect/functions"
import oAuthRedirectHandlerFactory from "@architect/shared/architect/oauth/handlers/redirect"
import tokenRepositoryFactory from "@architect/shared/architect/oauth/repository/TokenRepository"
import userRepositoryFactory from "@architect/shared/architect/oauth/repository/UserRepository"
import { fetchJson } from "@architect/shared/fetch"

const impl = oAuthRedirectHandlerFactory(
fetchJson,
userRepositoryFactory(),
tokenRepositoryFactory()
)

export const handler = arc.http.async(impl)
2 changes: 1 addition & 1 deletion server/src/shared/architect/middleware/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function isTokenValid(token: string, sessionID: string): boolean {
const csrfSessionID = ater.getTokenValue(token)
if (csrfSessionID != sessionID) {
// eslint-disable-next-line no-console
console.warn("CSRF token does not match session")
console.warn("CSRF token does not match session:", csrfSessionID, "!=", sessionID)
return false
}
return true
Expand Down
21 changes: 17 additions & 4 deletions server/src/shared/architect/oauth/OAuth Notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
arc env staging OAUTH_GOOGLE_CLIENT_ID '235329004997-qbmh6kacitf56jtscckadmvd0qu9sqi6.apps.googleusercontent.com'
```

4. Add environment variable for Client Secret
4. Add environment variable for Client Secret (NOTE: NOT USED FOR "Sign in with Apple")
The environment variable is named like `OAUTH_<PROVIDER_NAME>_CLIENT_SECRET` where `<PROVIDER_NAME>` is the name of the provider you used above.

```sh
Expand Down Expand Up @@ -50,6 +50,12 @@ arc env staging OAUTH_GOOGLE_ENDPOINT_TOKEN 'https://oauth2.googleapis.com/token
arc env staging OAUTH_GOOGLE_SCOPE 'openid https://www.googleapis.com/auth/userinfo.email'
```

9. **ONLY FOR Sign in with Apple**: Sign in with Apple requires generating a client secret for the OAuth/Open ID Connect Token request. In order to do so you must provide the following additional values:

- `OAUTH_<PROVIDER_NAME>_APPLE_TEAM_ID`: The 10-character Team ID associated with your Apple developer account.
- `OAUTH_<PROVIDER_NAME>_APPLE_KEY_ID` They Key ID for your Apple private key. Get it from the Apple Developer console at https://developer.apple.com/account/resources/authkeys/list selecting your key and then find the 10-digit identifier under "Key ID".
- `OAUTH_<PROVIDER_NAME>_APPLE_PRIVATE_KEY` The private key contents you received from Apple (note this is the value inside of the file you downloaded from Apple, _not_ the file name).

## Known OAuth 2 Provider Endpoints

### Google
Expand All @@ -62,14 +68,21 @@ arc env staging OAUTH_GOOGLE_SCOPE 'openid https://www.googleapis.com/auth/useri
### Apple:

- OIDC compliant
- Docs: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
- Docs:
- https://developer.apple.com/sign-in-with-apple/get-started/
- https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple
- Authorization Endpoint & Token Endpoint available at https://appleid.apple.com/.well-known/openid-configuration
- Scopes: No scope configuration needed (the standard `"openid email"` scopes allow getting the id_token and email address claim inside of it).

### Github
#### Instructions for getting Client ID & Client Secret

Docs are at https://help.apple.com/developer-account/?lang=en#/dev1c0e25352.

NOTE: You must create an _App ID_ first, and then create a _Service ID_ after that... Although dated, [this article](https://medium.com/identity-beyond-borders/how-to-configure-sign-in-with-apple-77c61e336003) helped me realize that.

### Github

- NOT OIDC complaint @#$%$#@
- NOT OIDC complaint @#$%$#@
- 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
Expand Down
27 changes: 25 additions & 2 deletions server/src/shared/architect/oauth/OAuthProviderConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export class OAuthProviderConfig {
return process.env[this.name(template)] || ""
}

/**
* Returns true if the configuration appears to be for Sign In with Apple.
*/
public isSignInWithApple(): boolean {
const APPLE_TOKEN_ENDPOINT = "https://appleid.apple.com/auth/token"
return this.value(Config.TokenEndpoint) === APPLE_TOKEN_ENDPOINT
}

/**
* Validates that all the configuration settings are in the environment.
* If validation succeeds, returns an empty string.
Expand All @@ -38,14 +46,25 @@ export class OAuthProviderConfig {
}

private getMissingConfigNames(): Array<string> {
const requiredConfigs = [
let requiredConfigs = [
Config.ClientID,
Config.ClientSecret,
Config.AuthorizationEndpoint,
Config.TokenEndpoint,
Config.RedirectEndpoint,
// NOTE: Scope is optional.
]

if (this.isSignInWithApple()) {
// Apple keys only needed when this is for Sign in with Apple (SIWA):
requiredConfigs = requiredConfigs.concat([
Config.AppleTeamID,
Config.AppleKeyID,
Config.ApplePrivateKey,
])
// SIWA has a funky algorithm to generate ClientSecret, so its not longer required:
requiredConfigs.splice(requiredConfigs.indexOf(Config.ClientSecret), 1)
}
const missing: Array<string> = []
for (const cname of requiredConfigs) {
const val = this.value(cname)
Expand All @@ -64,7 +83,11 @@ export enum Config {
ClientID = "OAUTH_{{PROVIDER}}_CLIENT_ID",
ClientSecret = "OAUTH_{{PROVIDER}}_CLIENT_SECRET",
RedirectEndpoint = "OAUTH_{{PROVIDER}}_ENDPOINT_REDIRECT",
Scope = "OAUTH_{{PROVIDER}}_Scope",
Scope = "OAUTH_{{PROVIDER}}_SCOPE",
// The Apple keys are optional and only used if they're using "Sign in with Apple".
AppleTeamID = "OAUTH_{{PROVIDER}}_APPLE_TEAM_ID",
AppleKeyID = "OAUTH_{{PROVIDER}}_APPLE_KEY_ID",
ApplePrivateKey = "OAUTH_{{PROVIDER}}_APPLE_PRIVATE_KEY",
}

const PROVIDER_PLACEHOLDER = "{{PROVIDER}}"
37 changes: 37 additions & 0 deletions server/src/shared/architect/oauth/apple.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as jwt from "node-webtokens"

/**
* Generates a client secret for Sign in with Apple's OIDC Flow as described at https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
* @param appleTeamID The 10-character Team ID associated with your Apple developer account.
* @param appleClientID The client ID value associated with your Sign in with Apple service ID.
* @param appleKeyID They Key ID for the apple private key. Get it from the Apple Developer console at https://developer.apple.com/account/resources/authkeys/list selecting your key and then find the 10-digit identifier under "Key ID".
* @param applePrivateKey The private key contents you received from Apple (note this is the value of the key, inside of the file you downloaded from Apple).
*/
export function appleSecret(
appleTeamID: string,
appleClientID: string,
appleKeyID: string,
applePrivateKey: string
): string {
if (!applePrivateKey) throw new Error("applePrivateKey must be specified")
if (!appleKeyID) throw new Error("appleKeyID must be specified")

const kid = appleKeyID
const keyStore: Record<string, string> = {}
keyStore[kid] = applePrivateKey

const payload = {
iss: appleTeamID,
exp: fromMinutes(2),
aud: "https://appleid.apple.com",
sub: appleClientID,
}
const secret = jwt.generate("ES256", payload, keyStore, kid)
return secret
}

function fromMinutes(minutes: number): number {
const MS_PER_SECOND = 1000
const SECONDS_PER_MIN = 60
return minutes * SECONDS_PER_MIN + Date.now() / MS_PER_SECOND
}
50 changes: 46 additions & 4 deletions server/src/shared/architect/oauth/handlers/login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ describe("login handler", () => {
describe("configuration", () => {
it("should ensure environment variables: ClientID", async () => {
const req = createMockLoginRequest()
req.queryStringParameters["provider"] = "GOO"

// note no environment variable for Client ID, Client Secret in
const res = await login(req)
Expand All @@ -44,7 +43,6 @@ describe("login handler", () => {

it("should ensure environment variables: Client ID, Client Secret", async () => {
const req = createMockLoginRequest()
req.queryStringParameters["provider"] = "GOO"

// NOTE: environment variable for Client ID, but not Client Secret
process.env.OAUTH_GOO_CLIENT_ID = "googcid"
Expand All @@ -58,7 +56,6 @@ describe("login handler", () => {

it("should ensure environment variables: Client ID, Client Secret, Auth Endpoint", async () => {
const req = createMockLoginRequest()
req.queryStringParameters["provider"] = "GOO"

// NOTE: environment variable for Client ID, but not Client Secret
process.env.OAUTH_GOO_CLIENT_ID = "googcid"
Expand All @@ -73,7 +70,6 @@ describe("login handler", () => {

it("should ensure environment variables: Client ID, Client Secret, Auth Endpoint, Token Endpoint", async () => {
const req = createMockLoginRequest()
req.queryStringParameters["provider"] = "GOO"

// NOTE: environment variable for Client ID, but not Client Secret
process.env.OAUTH_GOO_CLIENT_ID = "googcid"
Expand All @@ -86,6 +82,33 @@ describe("login handler", () => {
expect.stringMatching(/AUTH_GOO_ENDPOINT_TOKEN/)
)
})

describe("apple", () => {
it("should require apple-specific config", async () => {
const req = createMockLoginRequest()

// NOTE: environment variable for Client ID, but not Client Secret
process.env.OAUTH_GOO_CLIENT_ID = "cid"
process.env.OAUTH_GOO_ENDPOINT_AUTH = "sec"
process.env.OAUTH_GOO_ENDPOINT_REDIRECT = "https://goo.foo/redir"
// impl note: This token endpoint value is what triggers impl to look for Apple-specific config
process.env.OAUTH_GOO_ENDPOINT_TOKEN =
"https://appleid.apple.com/auth/token"
const res = await login(req)
expect(res).toHaveProperty("statusCode", 400)
const expectedConfigs = [
"OAUTH_GOO_APPLE_TEAM_ID",
"OAUTH_GOO_APPLE_KEY_ID",
"OAUTH_GOO_APPLE_PRIVATE_KEY",
]
expectedConfigs.forEach((cname) =>
expect(res).toHaveProperty(
"html",
expect.stringMatching(new RegExp(cname))
)
)
})
})
})

it("should redirect", async () => {
Expand All @@ -103,6 +126,7 @@ describe("login handler", () => {
const location = new URL(res.headers.location)
expect(location.searchParams.get("response_type")).toEqual("code")
expect(location.searchParams.get("scope")?.split(" ")).toContain("openid")
expect(location.searchParams.get("scope")?.split(" ")).toContain("email")
expect(location.searchParams.get("client_id")).toEqual(
process.env.OAUTH_GOO_CLIENT_ID
)
Expand All @@ -112,6 +136,24 @@ describe("login handler", () => {
expect(location.searchParams.has("state")).toBeTruthy()
})

it("should redirect with configured scope", async () => {
const req = createMockLoginRequest()

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_ENDPOINT_REDIRECT = "https://mysite/auth/redir/goo"
process.env.OAUTH_GOO_SCOPE = "foo bar"
const res = await login(req)
expect(res).toHaveProperty("statusCode", 302)
expect(res).toHaveProperty("headers.location")
assert(res.headers)
const location = new URL(res.headers.location)
expect(location.searchParams.get("scope")?.split(" ")).toContain("foo")
expect(location.searchParams.get("scope")?.split(" ")).toContain("bar")
})

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

Expand Down
10 changes: 7 additions & 3 deletions server/src/shared/architect/oauth/handlers/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ import { BAD_REQUEST } from "./httpStatus"
import { URL } from "url"
import { HttpRequest, HttpResponse } from "@architect/functions"

export default async function login(
req: HttpRequest
): Promise<HttpResponse> {
export default async function login(req: HttpRequest): Promise<HttpResponse> {
/**
* This is where we start the login flow. Uses the following steps:
* 1. Get the provider from query string (?provider=<provider name>)
Expand Down Expand Up @@ -50,6 +48,12 @@ export default async function login(
"redirect_uri",
conf.value(Config.RedirectEndpoint)
)
// NOTE: If any scopes are requested then Sign in with Apple wants response_mode=form_post
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms
// https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html
if (conf.isSignInWithApple()) {
authUrl.searchParams.append("response_mode", "form_post")
}

const sessionID: string = readSessionID(req) || createAnonymousSessionID()
authUrl.searchParams.append("state", await createCSRFToken(sessionID))
Expand Down
Loading

0 comments on commit 3f60f47

Please sign in to comment.