-
Notifications
You must be signed in to change notification settings - Fork 247
feat(firebase): add Google Account auth #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d2f7e3e
76c1167
12f85cc
d9f868b
ad340eb
837ec09
bb65602
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| After the steps in "Run against a Firebase Project" have been completed: | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| - 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. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| - 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://<projectid>.firebaseapp.com/auth/google` | ||||||
|
|
||||||
| ## Details | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| requestUri: "http://localhost", | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @penx Should this stay |
||||||
| returnIdpCredential: true, | ||||||
| returnSecureToken: true, | ||||||
| }; | ||||||
| const response: SignInWithIdpResponse = await fetch( | ||||||
| `${restConfig.domain}/v1/accounts:signInWithIdp?key=${restConfig.apiKey}`, | ||||||
| { | ||||||
| method: "POST", | ||||||
| headers: { | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| }, | ||
| }); | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { | |||
| Link, | ||||
| useActionData, | ||||
| useLoaderData, | ||||
| useLocation, | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||
| 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<typeof loader>(); | ||||
| const submit = useSubmit(); | ||||
|
|
||||
| const { GOOGLE_CLIENT_ID, redirectUri } = restConfig; | ||||
|
|
||||
| const handleSubmit = useCallback( | ||||
| async (event: React.FormEvent<HTMLFormElement>) => { | ||||
| event.preventDefault(); | ||||
|
|
@@ -113,6 +132,17 @@ export default function Login() { | |||
| Login | ||||
| </button> | ||||
| </form> | ||||
| <p> | ||||
| <a | ||||
| href={`https://accounts.google.com/o/oauth2/v2/auth\ | ||||
| ?response_type=code\ | ||||
| &client_id=${GOOGLE_CLIENT_ID}\ | ||||
| &redirect_uri=${redirectUri}\ | ||||
| &scope=openid%20email%20profile`} | ||||
| > | ||||
| Login with Google | ||||
| </a> | ||||
| </p> | ||||
| <p> | ||||
| Do you want to <Link to="/join">join</Link>? | ||||
| </p> | ||||
|
|
||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.