Skip to content

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.

Single file: config/credentials.yml.enc, decrypted with config/master.key (shared across worktrees by bin/setup-worktree). Edit with:

Terminal window
mise exec -- bin/rails credentials:edit

Heatwave::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.

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.

EnvironmentCustomer callback base
Devhttps://www.warmlyyours.me:3000
Staginghttps://www.warmlyyours.ws
Prodhttps://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).

Status: temporarily deferred (2026-05-03). The Apple Developer Program membership needs renewal. Once renewed, restore by:

  1. Add 'apple' back to Authentication::SUPPORTED_PROVIDERS (app/models/authentication.rb).
  2. Uncomment the config.omniauth :apple, … block in config/initializers/130_devise.rb.
  3. Restore the Apple link_to block in app/views/auth/customer_sessions/_login_form.html.erb (use git history — search for Continue with Apple).
  4. 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 via omniauth-apple’s autoload, and the avatar/email-verified extraction logic in Account#apply_omniauth already 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.”

ItemDevProd
Services ID (apple_client_id)me.warmlyyours.wwwcom.warmlyyours.www
Sign-In Key (apple_key_id)CXG7RN495XY7W42V65L5
Apple Team (apple_team_id)L4XZ84STDDL4XZ84STDD (same)

All under “Identifiers” in the Apple Developer console:

  1. 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.
  2. Services ID (Identifiers → Services IDs). This is the apple_client_id. Edit it → Configure → Sign In with Apple. Add:
    • Domains: www.warmlyyours.com (prod) or www.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
  3. Key (Keys → ”+”). Enable “Sign In with Apple” capability, pick the App ID, save. This downloads a .p8 file once — you cannot re-download. Capture:
    • The Key ID (10 chars, on the key page) → apple_key_id
    • The .p8 file contents (entire PEM with BEGIN/END lines) → apple_private_key
  4. Team ID (Membership tab → Team ID, 10 chars) → apple_team_id.

Apple recommends rotating Sign-In keys every 6 months. The flow:

  1. Generate a new key in the Apple console (Keys → ”+” with Sign In with Apple enabled).
  2. Download the new .p8 (only chance — Apple will not let you re-download).
  3. mise exec -- bin/rails credentials:edit and update apple_key_id + apple_private_key for the relevant environment.
  4. Deploy. Old key is still valid until you delete it on Apple’s console — leave it active for ~24h to drain in-flight sessions.
  5. Delete the old key on Apple’s console.
Terminal window
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.

SymptomLikely cause
redirect_uri_mismatch from AppleDev callback URL missing :3000 in the Services ID’s “Return URLs” field
invalid_clientapple_client_id doesn’t match a Services ID, or that Services ID isn’t configured for Sign In with Apple
id_token_signature_invalidWrong .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

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.”

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.

WhatWhere on LinkedInMaps to credential
AppApps → your app(the container)
Sign In with LinkedIn using OpenID Connect productYour app → Products → “Request access”(no credential — must be approved by LinkedIn before scopes work)
Auth → OAuth 2.0 settingsYour app → Authlinkedin_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 and email_verified fields 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).

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.

SymptomLikely cause
Browser shows a “Bummer, something went wrong” page on the LinkedIn consent screenRedirect URI not in the app’s allowlist (often missing :3000 for dev)
HTTP 403 on /v2/userinfo after a successful authorizeOIDC product not added on the app
HTTP 401 on /v2/userinfoApp 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 responseApp 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).

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.

ProviderCredential keys
Facebookomniauth.facebook_id, omniauth.facebook_secret
Google OAuth2omniauth.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.