Social login setup
How to configure Sign In with Apple and Sign In with LinkedIn (OIDC) on the developer consoles, and where the credentials live in this repo.
Facebook and Google were already wired up before 2026 and are not re-documented here; the credentials section lists their keys for completeness.
Where credentials live in Heatwave
Section titled “Where credentials live in Heatwave”Single file: config/credentials.yml.enc, decrypted with
config/master.key (shared across worktrees by bin/setup-worktree).
Edit with:
mise exec -- bin/rails credentials:editHeatwave::Configuration.fetch(:omniauth, :foo) resolves to the
development: or production: sub-block based on Rails.env. Staging
falls back to development: if no staging: block exists (per
config/configuration.rb).
Current omniauth credential schema:
omniauth: development: facebook_id: <app id> facebook_secret: <app secret> google_oauth2_id: <client id>.apps.googleusercontent.com google_oauth2_secret: <client secret> linkedin_id: <client id> linkedin_secret: <client secret> apple_client_id: me.warmlyyours.www # Services ID for the .me dev domain apple_team_id: L4XZ84STDD # 10-char Apple Team ID apple_key_id: <key id> # 10-char Key ID for the .p8 apple_private_key: | -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY----- production: # same keys, different values for the .com prod domain apple_client_id: com.warmlyyours.www # (different apple_key_id and apple_private_key per env — best practice)Both development and production blocks must be populated. Dev
sign-in flows hit dev callbacks against the dev Services ID / Client ID;
prod hits prod against prod. They are different apps registered with
the providers.
Local dev port — never omit :3000
Section titled “Local dev port — never omit :3000”Heatwave’s dev server binds to port 3000, so WEB_HOSTNAME = "www.warmlyyours.me:3000" and CRM_HOSTNAME = "crm.warmlyyours.me:3000" (defined in
config/initializers/000_hostname_constants.rb). Provider consoles
match redirect URIs exactly, so dev callback URLs registered with
each provider must include :3000. Production has no port. Both
have to be allowlisted in the same provider app.
| Environment | Customer callback base |
|---|---|
| Dev | https://www.warmlyyours.me:3000 |
| Staging | https://www.warmlyyours.ws |
| Prod | https://www.warmlyyours.com |
The full callback path is <base>/accounts/auth/<provider>/callback.
TLD per environment is set in config/initializers/000_hostname_constants.rb.
Both providers’ consoles allow multiple redirect URIs on one app, so a
single LinkedIn / Apple app can serve all three environments. Whether
you use one app or split is a deployment choice — heatwave currently
uses one LinkedIn app across all three (single Client ID with three
allowlisted callbacks) and two Apple Services IDs (one for .me dev,
one for .com prod; staging on .ws would need its own).
Sign In with Apple
Section titled “Sign In with Apple”Status: temporarily deferred (2026-05-03). The Apple Developer Program membership needs renewal. Once renewed, restore by:
- Add
'apple'back toAuthentication::SUPPORTED_PROVIDERS(app/models/authentication.rb).- Uncomment the
config.omniauth :apple, …block inconfig/initializers/130_devise.rb.- Restore the Apple
link_toblock inapp/views/auth/customer_sessions/_login_form.html.erb(use git history — search forContinue with Apple).- Smoke-test against the dev Services ID before deploying.
All other Apple plumbing stays in place: credentials are valid, the
verify_nonce!SameSite-aware monkey-patch is still loaded viaomniauth-apple’s autoload, and the avatar/email-verified extraction logic inAccount#apply_omniauthalready handles Apple-shaped responses.
Console: https://developer.apple.com/account/resources/identifiers (paid Apple Developer Program required, $99/yr).
Our Apple Developer account: Team ID L4XZ84STDD. Admin contact:
cbillen@warmlyyours.com. If you can’t find the team in your Apple
Developer account dropdown, you don’t have access — ask the admin to
invite you under “People.”
What’s currently registered
Section titled “What’s currently registered”| Item | Dev | Prod |
|---|---|---|
Services ID (apple_client_id) | me.warmlyyours.www | com.warmlyyours.www |
Sign-In Key (apple_key_id) | CXG7RN495X | Y7W42V65L5 |
Apple Team (apple_team_id) | L4XZ84STDD | L4XZ84STDD (same) |
Four artifacts to manage
Section titled “Four artifacts to manage”All under “Identifiers” in the Apple Developer console:
- App ID with “Sign In with Apple” capability enabled (Identifiers → App IDs → your bundle ID). Required to make the Services ID valid; not used directly by the web flow.
- Services ID (Identifiers → Services IDs). This is the
apple_client_id. Edit it → Configure → Sign In with Apple. Add:- Domains:
www.warmlyyours.com(prod) orwww.warmlyyours.me(dev) — bare domains, no port, no path. - Return URLs: the full callback URL with port included for dev:
- Prod:
https://www.warmlyyours.com/accounts/auth/apple/callback - Staging:
https://www.warmlyyours.ws/accounts/auth/apple/callback(only when a staging Services ID exists — heatwave currently has no staging Apple Services ID) - Dev:
https://www.warmlyyours.me:3000/accounts/auth/apple/callback
- Prod:
- Domains:
- Key (Keys → ”+”). Enable “Sign In with Apple” capability,
pick the App ID, save. This downloads a
.p8file once — you cannot re-download. Capture:- The Key ID (10 chars, on the key page) →
apple_key_id - The
.p8file contents (entire PEM with BEGIN/END lines) →apple_private_key
- The Key ID (10 chars, on the key page) →
- Team ID (Membership tab → Team ID, 10 chars) →
apple_team_id.
Adding or rotating an Apple key
Section titled “Adding or rotating an Apple key”Apple recommends rotating Sign-In keys every 6 months. The flow:
- Generate a new key in the Apple console (Keys → ”+” with Sign In with Apple enabled).
- Download the new
.p8(only chance — Apple will not let you re-download). mise exec -- bin/rails credentials:editand updateapple_key_id+apple_private_keyfor the relevant environment.- Deploy. Old key is still valid until you delete it on Apple’s console — leave it active for ~24h to drain in-flight sessions.
- Delete the old key on Apple’s console.
Verifying the credentials parse and sign
Section titled “Verifying the credentials parse and sign”mise exec -- bin/rails runner " require 'json/jwt' cfg = Heatwave::Configuration.new(:development) pem = cfg.fetch(:omniauth, :apple_private_key) pkey = OpenSSL::PKey::EC.new(pem) puts 'Private key:', pkey.private? ? 'OK' : 'PUBLIC ONLY' jwt = JSON::JWT.new(iss: cfg.fetch(:omniauth, :apple_team_id), aud: 'https://appleid.apple.com', sub: cfg.fetch(:omniauth, :apple_client_id), iat: Time.now.to_i, exp: (Time.now + 60).to_i) jwt.kid = cfg.fetch(:omniauth, :apple_key_id) puts 'Signed JWT length:', jwt.sign(pkey).to_s.length"If both lines print successfully, our credentials are valid; the only remaining question is whether Apple’s console state matches.
Common Apple failures
Section titled “Common Apple failures”| Symptom | Likely cause |
|---|---|
redirect_uri_mismatch from Apple | Dev callback URL missing :3000 in the Services ID’s “Return URLs” field |
invalid_client | apple_client_id doesn’t match a Services ID, or that Services ID isn’t configured for Sign In with Apple |
id_token_signature_invalid | Wrong .p8 for the apple_key_id we’re claiming, or the key was deleted from Apple’s console |
id_token_claims_invalid (aud) | The aud in Apple’s id_token doesn’t match apple_client_id — the Services ID was renamed or reconfigured |
Sign In with LinkedIn (OpenID Connect)
Section titled “Sign In with LinkedIn (OpenID Connect)”Console: https://www.linkedin.com/developers/apps (free).
Our LinkedIn Developer app: named “WarmlyYours.com” — single
LinkedIn app shared across dev, staging, and prod, with all three
redirect URIs allowlisted. Admin contact:
cbillen@warmlyyours.com. Current Client ID lives in
config/credentials.yml.enc under
omniauth.{development,production}.linkedin_id (read with
mise exec -- bin/rails runner "puts Heatwave::Configuration.fetch(:omniauth, :linkedin_id)").
If you can’t see the app in your LinkedIn Developer apps list, ask
the admin to add you under “Settings” → “App roles.”
What’s currently registered
Section titled “What’s currently registered”The OIDC discovery endpoint
(https://www.linkedin.com/oauth/.well-known/openid-configuration)
confirms LinkedIn supports the modern OIDC userinfo flow we depend on
(scopes_supported: openid profile email,
userinfo_endpoint: https://api.linkedin.com/v2/userinfo). The
authorize URL with our client_id returns the consent page, meaning
the app exists and our redirect URIs are allowlisted. The remaining
verification — that the OIDC product is actually approved on the app —
only surfaces during the /v2/userinfo call after a real sign-in.
Three things to configure
Section titled “Three things to configure”| What | Where on LinkedIn | Maps to credential |
|---|---|---|
| App | Apps → your app | (the container) |
| Sign In with LinkedIn using OpenID Connect product | Your app → Products → “Request access” | (no credential — must be approved by LinkedIn before scopes work) |
| Auth → OAuth 2.0 settings | Your app → Auth | linkedin_id (Client ID), linkedin_secret (Client Secret) |
Critical step. Under Products, you must request and have approved
“Sign In with LinkedIn using OpenID Connect”, not the legacy
“Sign In with LinkedIn”. The legacy product (still listed on apps
created before 2023) only exposes the deprecated /v2/me endpoints
that omniauth-linkedin-oauth2 was built against. The OIDC product
enables /v2/userinfo which is what omniauth-linkedin-openid calls.
If sign-in fails with a 403 from /v2/userinfo, this is almost
always why. The OIDC product is not restricted; LinkedIn typically
approves within minutes.
Authorized redirect URLs to add under Auth → “Authorized redirect URLs for your app” (one LinkedIn app, all three URIs allowlisted):
- Prod:
https://www.warmlyyours.com/accounts/auth/linkedin/callback - Staging:
https://www.warmlyyours.ws/accounts/auth/linkedin/callback - Dev:
https://www.warmlyyours.me:3000/accounts/auth/linkedin/callback
Required scope set: openid profile email. These three appear in
the LinkedIn console under Auth → “OAuth 2.0 scopes” once the OIDC
product is approved on the app — if any are missing from that list,
re-check the Products tab. The omniauth-linkedin-openid gem requests
exactly these scopes by default; no additional config in
config/initializers/130_devise.rb.
Email is optional in LinkedIn’s OIDC response
Section titled “Email is optional in LinkedIn’s OIDC response”Per the official Microsoft/LinkedIn OIDC reference:
The
email_verifiedfields are optional and may not be included in all responses.
This bites when a LinkedIn member completes the OAuth dance but the
userinfo response omits the email field (e.g. unverified primary email
on the LinkedIn side). Account#apply_omniauth falls through to the
existing “missing info” branch in Auth::AuthenticationsController#create
which redirects to register_login_path with the omniauth data so the
user can type their email manually. Not broken, but worth knowing for
support — a “I tried Sign In with LinkedIn and it sent me to a form”
report usually means the member’s LinkedIn primary email isn’t
verified or wasn’t shared.
The email_verified: true claim, when present, is a useful signal
once the planned :confirmable work lands — Heatwave can skip its own
confirmation email for OIDC-bound accounts where the provider already
verified the address (LinkedIn, Apple, Google all return this).
Lead-data ceiling
Section titled “Lead-data ceiling”The OIDC userinfo endpoint returns: sub, name, given_name,
family_name, picture, email, email_verified, locale. Job
title, company, industry, and position-history are not in this scope.
Those require LinkedIn’s “Marketing Developer Platform” enterprise
program, which LinkedIn approves on a case-by-case basis and is
generally restricted to marketing/CRM platforms. Not in scope for
self-serve sign-in.
If lead enrichment from LinkedIn is genuinely valuable, the path that non-MDP apps actually use is third-party enrichment (Clearbit Reveal, Apollo, Cognism, ZoomInfo) — query their API with the post-OAuth email address. That’s a separate integration, not a LinkedIn one.
Common LinkedIn failures
Section titled “Common LinkedIn failures”| Symptom | Likely cause |
|---|---|
| Browser shows a “Bummer, something went wrong” page on the LinkedIn consent screen | Redirect URI not in the app’s allowlist (often missing :3000 for dev) |
HTTP 403 on /v2/userinfo after a successful authorize | OIDC product not added on the app |
HTTP 401 on /v2/userinfo | App is using the legacy non-OIDC scopes — confirm the gem is omniauth-linkedin-openid, not omniauth-linkedin-oauth2 |
Login completes but email is missing from the userinfo response | App didn’t request email scope (the gem requests it by default — check the omniauth config in config/initializers/130_devise.rb) |
Employee social login on WWW — separate identity
Section titled “Employee social login on WWW — separate identity”WWW and CRM are separate identity surfaces by policy. An employee whose
personal social-login email collides with their CRM account login
(typical for warmlyyours.com employees who use their work email
everywhere — e.g. cbillen@warmlyyours.com is both the LinkedIn
identity and the CRM Google Workspace identity) does not get
cross-linked into their CRM account when they sign into the storefront.
Implementation: Auth::AuthenticationsController#create drops any
email-match that points at an is_employee? account before binding
the OAuth identity. The flow then falls through to the registration
branch which mints a unique login (cbillen, cbillen-1, …) since
the employee already owns the email-as-login slot, and creates a
fresh customer-party-bound account.
Pinned by test/controllers/auth/identity_binding_safety_test.rb
(Auth::AuthenticationsController#create drops employee email matches…,
Auth::AuthenticationsController#create mints a unique login when email is taken).
Avatar pulling
Section titled “Avatar pulling”After every successful OAuth sign-in, Account#apply_omniauth extracts
the provider’s profile picture URL (info.image for Facebook/Google,
info.picture_url for LinkedIn OIDC; Apple does not return one) and
queues PartyProfileImageWorker with a picture_url: option that
short-circuits the Clearbit/Gravatar lookup chain. The worker only
fires when the party has no existing profile_image_id, so a returning
OAuth user does not have their custom-uploaded avatar overwritten.
No setup required — works automatically for any OAuth flow that surfaces a picture URL.
Existing Facebook + Google credentials (reference)
Section titled “Existing Facebook + Google credentials (reference)”Already wired up; no setup steps needed unless rotating credentials.
| Provider | Credential keys |
|---|---|
omniauth.facebook_id, omniauth.facebook_secret | |
| Google OAuth2 | omniauth.google_oauth2_id, omniauth.google_oauth2_secret |
Facebook console: https://developers.facebook.com/apps — LinkedIn-style OAuth 2.0 redirect URI allowlisting under “Facebook Login” → “Settings” → “Valid OAuth Redirect URIs.”
Google console: https://console.cloud.google.com/apis/credentials — “Authorized redirect URIs” under the OAuth 2.0 Client ID.
Same dev :3000 rule applies to both.