A small teaching demo of three-legged OAuth 2.0 + OpenID Connect (OIDC)
"Sign in with Google", built as a single-file Roda
web app (app.rb).
It is deliberately a one-process monolith that plays both halves of the flow — the client that talks to Google and the resource server that trusts the result — so the whole OIDC workflow is visible in one file.
Plain OAuth 2.0 answers "can this app act on the user's behalf?" and hands back
an opaque access token that you then spend on a userinfo endpoint to learn who the
user is. OpenID Connect adds an identity layer: the token response also carries
a signed id_token (a JWT) whose claims already say who the user is.
The heart of the demo is verify_google_id_token in app.rb, which establishes
identity by cryptography rather than by trusting a callback:
- Read the token header (unverified) to find which key (
kid) signed it. - Fetch Google's public keys (JWKS).
- Verify the RS256 signature with the matching key.
- Validate the
iss/aud/expclaims manually —audmust equal our own client id, which is what stops a token minted for another app from being replayed against us.
- Ruby — the version pinned in
.ruby-version(install with e.g.rbenv install $(cat .ruby-version)). - Bundler (
gem install bundler). - A Google account to create OAuth credentials.
bundleIn the Google Cloud Console → APIs & Services → Credentials:
-
Create an OAuth 2.0 Client ID of type Web application.
-
Add this Authorized redirect URI, exactly (byte-for-byte):
http://localhost:4567/google_callbackThis must match
REDIRECT_URIinapp.rb. -
Note the generated Client ID and Client secret.
Copy the example file and fill in your credentials:
cp config/secrets.example.yml config/secrets.ymlThen edit config/secrets.yml:
development:
GOOGLE_CLIENT_ID: <your Google OAuth client id>
GOOGLE_CLIENT_SECRET: <your Google OAuth client secret>
SESSION_SECRET: <a long random string>config/secrets.yml is gitignored — it is never committed. Generate a fresh
SESSION_SECRET with bundle exec ruby -rsecurerandom -e 'puts SecureRandom.base64(64)'.
rake run(equivalently: bundle exec puma -p 4567)
Then open http://localhost:4567 and follow the secret to life link to start
the sign-in flow. The server console prints the raw id_token JWT and the verified
claims as you go — these are intentional teaching aids.
| Route | Purpose |
|---|---|
GET / |
Landing page linking to the gated content. |
GET /secret |
Gated content; redirects to /login unless signed in. |
GET /login |
Step 1: generate anti-CSRF state, link to Google's auth URL. |
GET /google_callback |
Step 2: verify state, exchange code for tokens, verify id_token. |
GET /logout |
Clear the session identity. |
bundle exec rubocop