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 ca58936e..a73b62ef 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 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 -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](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: + - 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,10 +37,23 @@ 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 + +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 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/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..075a22b0 --- /dev/null +++ b/firebase/app/routes/auth/google.tsx @@ -0,0 +1,43 @@ +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 = 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: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + code, + redirect_uri: redirectUri, + 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 3a208e86..0f89bd7d 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,7 +28,23 @@ export const loader = async ({ request }: LoaderArgs) => { return redirect("/", { headers }); } const { apiKey, domain } = getRestConfig(); - return json({ apiKey, domain }, { headers }); + 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, + redirectUri, + }, + { headers } + ); }; type ActionData = { @@ -70,6 +87,8 @@ export default function Login() { const restConfig = useLoaderData(); const submit = useSubmit(); + const { GOOGLE_CLIENT_ID, redirectUri } = restConfig; + const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault(); @@ -113,6 +132,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, }; diff --git a/firebase/package.json b/firebase/package.json index e13e6b6c..228eecc6 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" },