Tutorial 26 of 54 · for anyone whose visitors should sign in with their Google account · Markdown

Sign in with Google

You'll switch the sign-in to Google, keep working against a stand-in form until the client secret exists and see the redirect the host sends once it does. No body changes, since a body only reads identity.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start from the app you built in 350: the middleware guarding /account, the /account page with its sign-out form and config/auth.toml with alice.

How an OIDC sign-in runs#

Google speaks OpenID Connect. The host sends the browser to Google with a state, a nonce and a PKCE challenge. Google sends it back to /auth/callback with a code. The host exchanges the code for tokens, checks the ID token's signature against Google's published keys and reads who signed in from its sub claim. That becomes identity.subject. The other claims of the ID token, such as email, arrive in identity.claims.

The access token and the refresh token go into custody. A body never sees them.

Configure the provider#

Replace the [auth] section:

config/app.toml
[auth]
provider = "oidc"
preset = "google"
client_id = "1234.apps.googleusercontent.com"
client_secret = "google"
login = "/sign-in"

[secrets.google]
description = "the Google OAuth client secret"

preset = "google" names Google's issuer and its quirks. microsoft and apple are presets too. Any other issuer takes issuer with its base URL instead. client_id is the ID from the Google Cloud console.

client_secret does not hold the secret. It names a [secrets] entry, so the value never sits in app.toml. The value comes from the environment variable C5_SECRETS__GOOGLE__VALUE or an encrypted overlay.

Sign in before the secret exists#

In development, while secrets.google has no value, the method is a stand-in: a "sign in as" form over the users of config/auth.toml. Run fsr dev app and the boot report says so:

$ fsr dev app
auth      oidc google, a stand-in until secrets.google has a value, login page /sign-in, routes /auth/login, /auth/enrol, /auth/callback and /auth/logout

The stand-in presents a form of one field and lists every user of config/auth.toml, which here holds alice and bob. A JSON request shows the step:

$ curl -c jar -b jar -H 'accept: application/json' 'http://127.0.0.1:3000/auth/login?return_to=/account'
{"next":"present","kind":"form","data":{"fields":["user"],"users":["alice","bob"]}}

The sign-in page from 350 posted user and password. The stand-in reads only user, so the page needs one field:

app/routes/sign-in/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ query }: Ctx<"/sign-in">) {
  return { denied: query.error === "denied" };
}
app/routes/sign-in/page.tsx
import type { SignInProps } from "@generated/client";

export default function SignIn({ denied }: SignInProps) {
  return (
    <section>
      <h1>Sign in</h1>
      {denied ? <p>There is no account by that name.</p> : null}
      <form method="post" action="/auth/callback">
        <label>
          Sign in as <input name="user" autoComplete="username" />
        </label>
        <button type="submit">Sign in</button>
      </form>
    </section>
  );
}

Run fsr dev app and open /account. You land on /sign-in?return_to=%2Faccount. Type alice and you are on /account, signed in as alice with her claims. A name that is not in config/auth.toml is refused:

$ curl -i -c jar -b jar -d 'user=mallory' http://127.0.0.1:3000/auth/callback
HTTP/1.1 303 See Other
location: /sign-in?error=denied&return_to=%2Faccount

Everything behind the sign-in can be written now: the middleware, the loaders reading identity, the sign-out form. None of it changes when Google takes over.

Set the secret#

Give the secret a value and start the app:

$ C5_SECRETS__GOOGLE__VALUE=... fsr dev app

The stand-in is gone. /auth/login now sends the browser to Google:

$ curl -i 'http://127.0.0.1:3000/auth/login?return_to=/account'
HTTP/1.1 303 See Other
location: https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=1234.apps.googleusercontent.com&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fauth%2Fcallback&scope=openid+email+profile&state=xiUno07DO3-BwTIBJ0XGO2VOp9LnSxsG7iApERxlmnI&nonce=paoo6L-zv5dSMuDftHmBX58FX6yiX1ufH1JWahjggY4&code_challenge=HPYLKAWp-gdbfKu-g2GiNHRktIlF9_JVMyyFEL15YeE&code_challenge_method=S256

The scopes are openid email profile unless scopes names others. state, nonce and code_challenge are fresh on every sign-in. Google answers at /auth/callback and the visitor lands on /account. A refusal comes back to /sign-in with error=denied, so the page keeps its message. In place of the form it can hold a link to /auth/login?return_to=/account labelled "Sign in with Google".

Register the redirect URI#

Google only sends a visitor back to an address registered with the OAuth client. The redirect URI is <document.origin>/auth/callback. In development without document.origin, the host uses the address it listens on, which is the http://127.0.0.1:3000/auth/callback above. Set the origin the site is reached over:

config/app.toml
[document]
origin = "https://shop.example"

The redirect now carries redirect_uri=https%3A%2F%2Fshop.example%2Fauth%2Fcallback. Register https://shop.example/auth/callback in the Google Cloud console. Register the development address too if you want to sign in with Google on your machine.

Deploy it#

Outside development the stand-in is never used. A deployment that has no value for the secret stops at boot. The message names the configuration directory, then the method and the variable to set:

$ RELEASE_ENV=production fsr serve app
.../config: auth.methods `google`: `secrets.google.value` has no value; set C5_SECRETS__GOOGLE__VALUE

A deployment with the secret but no document.origin stops too, since the host cannot name its redirect URI:

.../config: auth.methods `google`: an oidc method needs `document.origin` to name its redirect URI

Next up: 360. Add light and dark themes.