From d2f7e3e59a3b90fef24f306ffbfcc33bb3f410f5 Mon Sep 17 00:00:00 2001 From: Alasdair McLeay Date: Mon, 31 Oct 2022 10:21:42 +0000 Subject: [PATCH 1/6] feat(examples/firebase): google account auth Ported from https://github.com/remix-run/remix/pull/4057/commits/e6022d2f5a0ccf70227bad282baf164f73f94b63 fix yarn lock on main --- firebase/README.md | 5 ++ firebase/app/firebase-rest.ts | 111 ++++++++++++++++++++++++- firebase/app/routes/auth/google.tsx | 33 ++++++++ firebase/app/routes/login.tsx | 18 +++- firebase/app/server/auth.server.ts | 5 ++ firebase/app/server/firebase.server.ts | 15 ++++ 6 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 firebase/app/routes/auth/google.tsx diff --git a/firebase/README.md b/firebase/README.md index ca58936e..d7b8efc9 100644 --- a/firebase/README.md +++ b/firebase/README.md @@ -39,6 +39,11 @@ When you run `npm run emulators`, an initial user is created with credentials `u 4. Run `firebase use --add` and choose the Firebase project you want to deploy to 5. Deploy with `firebase deploy` +## Integration with Google Sign-in Provider + +- In the [Firebase Console](https://console.firebase.google.com), navigate to Authentication > Sign-in method > Add new provider > Google. Make a note of the client ID and secret and add them to the .env file. +- In the [Google Cloud Credentials Console](https://console.cloud.google.com/apis/credentials), select the Web client (under OAuth 2.0 Client IDs) and add `http://localhost:3000/auth/google` and `https://.firebaseapp.com/auth/google` as authorised redirects. + ## Details ### Auth (`app/server/auth.server.ts`) diff --git a/firebase/app/firebase-rest.ts b/firebase/app/firebase-rest.ts index 67691ea3..5aa5cb75 100644 --- a/firebase/app/firebase-rest.ts +++ b/firebase/app/firebase-rest.ts @@ -54,9 +54,114 @@ export const signInWithPassword = async ( } ) => { const response: SignInWithPasswordResponse = await fetch( - `${restConfig!.domain}/v1/accounts:signInWithPassword?key=${ - restConfig!.apiKey - }`, + `${restConfig.domain}/v1/accounts:signInWithPassword?key=${restConfig.apiKey}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + } + ); + return response.json(); +}; + +// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-email-password +interface SignInWithIdpResponse extends Response { + json(): Promise< + | RestError + | { + /** + * The unique ID identifies the IdP account. + */ + federatedId: string; + /** + * The linked provider ID (e.g. "google.com" for the Google provider). + */ + providerId: string; + /** + * The uid of the authenticated user. + */ + localId: string; + /** + * Whether the sign-in email is verified. + */ + emailVerified: boolean; + /** + * The email of the account. + */ + email: string; + /** + * The OIDC id token if available. + */ + oauthIdToken: string; + /** + * The OAuth access token if available. + */ + oauthAccessToken: string; + /** + * The OAuth 1.0 token secret if available. + */ + oauthTokenSecret: string; + /** + * The stringified JSON response containing all the IdP data corresponding to the provided OAuth credential. + */ + rawUserInfo: string; + /** + * The first name for the account. + */ + firstName: string; + /** + * The last name for the account. + */ + lastName: string; + /** + * The full name for the account. + */ + fullName: string; + /** + * The display name for the account. + */ + displayName: string; + /** + * The photo Url for the account. + */ + photoUrl: string; + /** + * A Firebase Auth ID token for the authenticated user. + */ + idToken: string; + /** + * A Firebase Auth refresh token for the authenticated user. + */ + refreshToken: string; + /** + * The number of seconds in which the ID token expires. + */ + expiresIn: string; + /** + * Whether another account with the same credential already exists. The user will need to sign in to the original account and then link the current credential to it. + */ + needConfirmation: boolean; + } + >; +} +export const signInWithIdp = async ( + idToken: string, + providerId: string, + restConfig: { + apiKey: string; + domain: string; + } +) => { + const body = { + postBody: "id_token=" + idToken + "&providerId=" + providerId, + requestUri: "http://localhost", + returnIdpCredential: true, + returnSecureToken: true, + }; + const response: SignInWithIdpResponse = await fetch( + `${restConfig.domain}/v1/accounts:signInWithIdp?key=${restConfig.apiKey}`, { method: "POST", headers: { diff --git a/firebase/app/routes/auth/google.tsx b/firebase/app/routes/auth/google.tsx new file mode 100644 index 00000000..7b98b7ec --- /dev/null +++ b/firebase/app/routes/auth/google.tsx @@ -0,0 +1,33 @@ +import type { LoaderFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; + +import { signInWithIdp } from "~/server/auth.server"; +import { commitSession, getSession } from "~/sessions"; + +export const loader: LoaderFunction = async ({ request }) => { + // https://developers.google.com/identity/protocols/oauth2/openid-connect + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + code, + redirect_uri: "http://localhost:3000/auth/google", + grant_type: "authorization_code", + }), + }); + const token = await response.json(); + const sessionCookie = await signInWithIdp(token.id_token, "google.com"); + const session = await getSession(request.headers.get("cookie")); + session.set("session", sessionCookie); + return redirect("/", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); +}; diff --git a/firebase/app/routes/login.tsx b/firebase/app/routes/login.tsx index c1f2f49a..04d7577b 100644 --- a/firebase/app/routes/login.tsx +++ b/firebase/app/routes/login.tsx @@ -27,7 +27,10 @@ export const loader = async ({ request }: LoaderArgs) => { return redirect("/", { headers }); } const { apiKey, domain } = getRestConfig(); - return json({ apiKey, domain }, { headers }); + return json( + { apiKey, domain, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID }, + { headers } + ); }; type ActionData = { @@ -70,6 +73,8 @@ export default function Login() { const restConfig = useLoaderData(); const submit = useSubmit(); + const { GOOGLE_CLIENT_ID } = restConfig; + const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault(); @@ -113,6 +118,17 @@ export default function Login() { Login +

+ + Login with Google + +

Do you want to join?

diff --git a/firebase/app/server/auth.server.ts b/firebase/app/server/auth.server.ts index 9aadecc4..11d2f009 100644 --- a/firebase/app/server/auth.server.ts +++ b/firebase/app/server/auth.server.ts @@ -41,6 +41,11 @@ export const signInWithToken = async (idToken: string) => { return sessionCookie; }; +export const signInWithIdp = async (token: string, providerId: string) => { + const { idToken } = await auth.signInWithIdp(token, providerId); + return signInWithToken(idToken); +}; + export const signUp = async (name: string, email: string, password: string) => { await auth.server.createUser({ email, diff --git a/firebase/app/server/firebase.server.ts b/firebase/app/server/firebase.server.ts index 2dc6258d..725fd76d 100644 --- a/firebase/app/server/firebase.server.ts +++ b/firebase/app/server/firebase.server.ts @@ -72,7 +72,22 @@ const signInWithPassword = async (email: string, password: string) => { return signInResponse; }; +const signInWithIdp = async (token: string, providerId: string) => { + const signInResponse = await firebaseRest.signInWithIdp( + token, + providerId, + restConfig + ); + + if (firebaseRest.isError(signInResponse)) { + throw new Error(signInResponse.error.message); + } + + return signInResponse; +}; + export const auth = { server: getServerAuth(), signInWithPassword, + signInWithIdp, }; From 76c1167f29654cd0a8d6a2538d2770696db98807 Mon Sep 17 00:00:00 2001 From: Alasdair McLeay Date: Wed, 25 Jan 2023 16:39:51 +0000 Subject: [PATCH 2/6] Docs update --- firebase/.env.example | 3 +++ firebase/README.md | 30 ++++++++++++++++++------------ firebase/package.json | 1 + 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/firebase/.env.example b/firebase/.env.example index cf5f9c83..3f4b8ea4 100644 --- a/firebase/.env.example +++ b/firebase/.env.example @@ -3,3 +3,6 @@ SERVICE_ACCOUNT={"type":"service_account","project_id": "your-project-id","priva # Set the API_KEY value to the Web API Key, which can be obtained on the project settings page in your Firebase admin console API_KEY="abcdefg_1234567890" + +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" diff --git a/firebase/README.md b/firebase/README.md index d7b8efc9..e2d84b3a 100644 --- a/firebase/README.md +++ b/firebase/README.md @@ -12,15 +12,18 @@ See the screen recording at `./screen_recording.gif` or Open this example on [Co ## Running locally -To run it, you need to either: +To run this example, you either need to create a firebase proejct or use the emulators: ### 1. Run against a Firebase Project -1. [Create a Firebase Project](https://console.firebase.google.com) -2. Enable Auth (with email) and Firestore -3. Add a Web App -4. Get the [admin-sdk](https://firebase.google.com/docs/admin/setup#initialize-sdk) and [Web API Key](https://firebase.google.com/docs/reference/rest/auth) -5. Save them to SERVICE_ACCOUNT and API_KEY in the `.env`-file +1. [Create a Firebase Project](https://console.firebase.google.com). +2. Enable Auth (with email) and Firestore. +3. Add a Web App with hosting. +4. Copy `.env.example` to `.env`. +5. Set the API_KEY value to the [Web API Key](https://firebase.google.com/docs/reference/rest/auth), which can be obtained on the project settings page in your Firebase admin console. +6. Set SERVICE_ACCOUNT to the contents of your service account's private key JSON file: + - Go to: Project > Project Settings > Service Accounts. + - Click "Create Service Account" or "Generate New Private Key" to download the JSON file. ### 2. Use the Firebase emulators @@ -34,15 +37,18 @@ When you run `npm run emulators`, an initial user is created with credentials `u ## Deploying 1. Follow the "Run against a Firebase Project" steps above if not done already -2. Install the Firebase CLI with `npm i -g firebase-tools` -3. Log in to the CLI with `firebase login` -4. Run `firebase use --add` and choose the Firebase project you want to deploy to -5. Deploy with `firebase deploy` +2. Log in to the CLI with `npm run firebase -- login` +3. Run `npm run firebase -- use --add` and choose the Firebase project you want to deploy to +4. Deploy with `npm run firebase -- deploy` ## Integration with Google Sign-in Provider -- In the [Firebase Console](https://console.firebase.google.com), navigate to Authentication > Sign-in method > Add new provider > Google. Make a note of the client ID and secret and add them to the .env file. -- In the [Google Cloud Credentials Console](https://console.cloud.google.com/apis/credentials), select the Web client (under OAuth 2.0 Client IDs) and add `http://localhost:3000/auth/google` and `https://.firebaseapp.com/auth/google` as authorised redirects. +The "Login with Google" link will only work when running against a Firebase Project. + +After the steps in "Run against a Firebase Project" have been completed: + +- In the [Firebase Console](https://console.firebase.google.com), navigate to Authentication > Sign-in method > Add new provider > Google. Make a note of the client ID and secret and add them to the .env file as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. +- In the [Google Cloud Credentials Console](https://console.cloud.google.com/apis/credentials), select the Web client (under OAuth 2.0 Client IDs) and add `http://localhost:5002/auth/google`, `http://localhost:3000/auth/google` and `https://.firebaseapp.com/auth/google` as authorised redirects. ## Details diff --git a/firebase/package.json b/firebase/package.json index 1e43ab15..b14b9ae2 100644 --- a/firebase/package.json +++ b/firebase/package.json @@ -6,6 +6,7 @@ "build": "remix build", "dev": "remix dev", "emulators": "firebase emulators:start --project remix-emulator --import=firebase-fixtures", + "firebase": "firebase", "start": "remix-serve build", "typecheck": "tsc" }, From 12f85ccf2cf2302aaa9db3025b53a9ec7fd41031 Mon Sep 17 00:00:00 2001 From: Alasdair McLeay Date: Wed, 25 Jan 2023 16:41:56 +0000 Subject: [PATCH 3/6] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michaël De Boey --- firebase/app/routes/auth/google.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase/app/routes/auth/google.tsx b/firebase/app/routes/auth/google.tsx index 7b98b7ec..5bce3c1d 100644 --- a/firebase/app/routes/auth/google.tsx +++ b/firebase/app/routes/auth/google.tsx @@ -1,10 +1,10 @@ -import type { LoaderFunction } from "@remix-run/node"; +import type { LoaderArgs } from "@remix-run/node"; import { redirect } from "@remix-run/node"; import { signInWithIdp } from "~/server/auth.server"; import { commitSession, getSession } from "~/sessions"; -export const loader: LoaderFunction = async ({ request }) => { +export const loader = async ({ request }: LoaderArgs) => { // https://developers.google.com/identity/protocols/oauth2/openid-connect const url = new URL(request.url); const code = url.searchParams.get("code"); From d9f868b03ef88d3893213b0952e18ad38964b67d Mon Sep 17 00:00:00 2001 From: Alasdair McLeay Date: Wed, 25 Jan 2023 16:47:32 +0000 Subject: [PATCH 4/6] Update firebase/README.md --- firebase/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase/README.md b/firebase/README.md index e2d84b3a..dd4b16d7 100644 --- a/firebase/README.md +++ b/firebase/README.md @@ -12,7 +12,7 @@ See the screen recording at `./screen_recording.gif` or Open this example on [Co ## Running locally -To run this example, you either need to create a firebase proejct or use the emulators: +To run this example, you either need to create a Firebase project or use the emulators: ### 1. Run against a Firebase Project From ad340eb68c5faa41cf67a7bd4347e1212ea6c519 Mon Sep 17 00:00:00 2001 From: Alasdair McLeay Date: Wed, 25 Jan 2023 16:55:59 +0000 Subject: [PATCH 5/6] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michaël De Boey --- firebase/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/firebase/README.md b/firebase/README.md index dd4b16d7..75768636 100644 --- a/firebase/README.md +++ b/firebase/README.md @@ -12,16 +12,16 @@ See the screen recording at `./screen_recording.gif` or Open this example on [Co ## Running locally -To run this example, you either need to create a Firebase project or use the emulators: +To run this example, you either need to create a Firebase project or use [the emulators](https://firebase.google.com/docs/emulator-suite): -### 1. Run against a Firebase Project +### 1. Run against a Firebase project -1. [Create a Firebase Project](https://console.firebase.google.com). -2. Enable Auth (with email) and Firestore. +1. [Create a Firebase project](https://console.firebase.google.com). +2. Enable [Auth](https://firebase.google.com/docs/auth) (with email) and [Cloud Firestore](https://firebase.google.com/docs/firestore). 3. Add a Web App with hosting. 4. Copy `.env.example` to `.env`. -5. Set the API_KEY value to the [Web API Key](https://firebase.google.com/docs/reference/rest/auth), which can be obtained on the project settings page in your Firebase admin console. -6. Set SERVICE_ACCOUNT to the contents of your service account's private key JSON file: +5. Set the `API_KEY` value to the [Web API Key](https://firebase.google.com/docs/reference/rest/auth), which can be obtained on the project settings page in your Firebase admin console. +6. Set `SERVICE_ACCOUNT` to the contents of your service account's private key JSON file: - Go to: Project > Project Settings > Service Accounts. - Click "Create Service Account" or "Generate New Private Key" to download the JSON file. From 837ec09b0fb6248b7e2bf4df12443cea3074dfa5 Mon Sep 17 00:00:00 2001 From: Alasdair McLeay Date: Wed, 3 May 2023 11:47:02 +0100 Subject: [PATCH 6/6] Remove hard coded localhost, calculate redirectUri --- firebase/README.md | 7 ++++++- firebase/app/routes/auth/google.tsx | 12 +++++++++++- firebase/app/routes/login.tsx | 20 +++++++++++++++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/firebase/README.md b/firebase/README.md index 75768636..a73b62ef 100644 --- a/firebase/README.md +++ b/firebase/README.md @@ -48,7 +48,12 @@ The "Login with Google" link will only work when running against a Firebase Proj After the steps in "Run against a Firebase Project" have been completed: - In the [Firebase Console](https://console.firebase.google.com), navigate to Authentication > Sign-in method > Add new provider > Google. Make a note of the client ID and secret and add them to the .env file as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. -- In the [Google Cloud Credentials Console](https://console.cloud.google.com/apis/credentials), select the Web client (under OAuth 2.0 Client IDs) and add `http://localhost:5002/auth/google`, `http://localhost:3000/auth/google` and `https://.firebaseapp.com/auth/google` as authorised redirects. +- In the [Google Cloud Credentials Console](https://console.cloud.google.com/apis/credentials), select the Web client (under OAuth 2.0 Client IDs) and the following as authorised redirects: + - `http://localhost:3000/auth/google` + - `http://localhost:5002/auth/google` + - `http://127.0.0.1:3000/auth/google` + - `http://127.0.0.1:5002/auth/google` + - `https://.firebaseapp.com/auth/google` ## Details diff --git a/firebase/app/routes/auth/google.tsx b/firebase/app/routes/auth/google.tsx index 5bce3c1d..075a22b0 100644 --- a/firebase/app/routes/auth/google.tsx +++ b/firebase/app/routes/auth/google.tsx @@ -8,6 +8,16 @@ export const loader = async ({ request }: LoaderArgs) => { // https://developers.google.com/identity/protocols/oauth2/openid-connect const url = new URL(request.url); const code = url.searchParams.get("code"); + + const host = + request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); + if (!host) { + throw new Error("Could not determine domain URL."); + } + const protocol = + host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https"; + const redirectUri = `${protocol}://${host}/auth/google`; + const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { @@ -17,7 +27,7 @@ export const loader = async ({ request }: LoaderArgs) => { client_id: process.env.GOOGLE_CLIENT_ID, client_secret: process.env.GOOGLE_CLIENT_SECRET, code, - redirect_uri: "http://localhost:3000/auth/google", + redirect_uri: redirectUri, grant_type: "authorization_code", }), }); diff --git a/firebase/app/routes/login.tsx b/firebase/app/routes/login.tsx index 04d7577b..c3bfd775 100644 --- a/firebase/app/routes/login.tsx +++ b/firebase/app/routes/login.tsx @@ -4,6 +4,7 @@ import { Link, useActionData, useLoaderData, + useLocation, useSubmit, } from "@remix-run/react"; import { useCallback, useState } from "react"; @@ -27,8 +28,21 @@ export const loader = async ({ request }: LoaderArgs) => { return redirect("/", { headers }); } const { apiKey, domain } = getRestConfig(); + const host = + request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); + if (!host) { + throw new Error("Could not determine domain URL."); + } + const protocol = + host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https"; + const redirectUri = `${protocol}://${host}/auth/google`; return json( - { apiKey, domain, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID }, + { + apiKey, + domain, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + redirectUri, + }, { headers } ); }; @@ -73,7 +87,7 @@ export default function Login() { const restConfig = useLoaderData(); const submit = useSubmit(); - const { GOOGLE_CLIENT_ID } = restConfig; + const { GOOGLE_CLIENT_ID, redirectUri } = restConfig; const handleSubmit = useCallback( async (event: React.FormEvent) => { @@ -123,7 +137,7 @@ export default function Login() { href={`https://accounts.google.com/o/oauth2/v2/auth\ ?response_type=code\ &client_id=${GOOGLE_CLIENT_ID}\ -&redirect_uri=http://localhost:3000/auth/google\ +&redirect_uri=${redirectUri}\ &scope=openid%20email%20profile`} > Login with Google