Logotipo Hedhog

Get Hedhog updates in your inbox

New releases, fresh recipes, and breaking changes — no spam.

OAuth / SSO

Hedhog's auth library supports social and enterprise login via OAuth 2.0. Once a provider is configured, users can sign in, register, or link an existing account to a third-party identity with one click.

This guide assumes you have a running Hedhog instance and admin access. If you just cloned the project, the default URLs are:

  • Admin (frontend): http://localhost:3200
  • API (backend): http://localhost:3100

These map directly to the url setting (Settings → Configurations → General → System URL), which Hedhog uses to build every OAuth redirect URL. Update it to your real domain before configuring any provider in production — every URL below is derived from it.

Status: ✅ Fully implemented — Google, GitHub, Microsoft, Microsoft Entra ID, Facebook, Apple, and LinkedIn all work end-to-end, including the login/register/connect account-linking flows. Native apps (Electron, Android, iOS) also sign in through the same hub, via a system-browser redirect.

OAuth login, register, and connect flows redirecting through the admin callback pages to the Hedhog API

Quick Setup

  1. Create an Integration Profile (type OAuth / SSO, pick a provider) at Settings → Integration Profiles
  2. Get client_id/client_secret from the provider's console and paste them into the profile
  3. Register the single callback URL for that provider in that console — see the table below (GitHub and Apple use a fixed backend URL instead)
  4. Go to Settings → Configurations → OAuth, select the profile on that provider's tab, and flip Enable
  5. Click "Sign in with <Provider>" on the login page to confirm it redirects and logs you in

How It Works

OAuth in Hedhog has three distinct flows, not one:

FlowTriggered fromPurpose
login"Sign in with Google/GitHub/…" buttonAuthenticates an existing user, or falls through to register/connect automatically
register"Sign up with…" buttonCreates a brand-new account from the provider's profile
connectLogged-in user's Account → Linked accountsAttaches a provider identity to the current account without logging out

Unlike many OAuth setups, Hedhog does not register a separate redirect URI per flow. Every provider gets exactly one callback URL, and the flow — plus the app that initiated it — travels inside a signed state parameter instead. The round trip:

  1. The browser opens {API_URL}/oauth/{provider}/login (or /register, /connect), optionally with a ?redirectApp=<key> query param when a non-admin app (e.g. training) is the one initiating the flow
  2. The Hedhog API signs a state of the form hhweb.<app>.<flow>.<sig> (HMAC-signed, app defaults to the sentinel self when no redirectApp is given) and redirects the browser to the provider's authorization screen, requesting a single, flow-less redirect_uri of {ADMIN_URL}/callback/{provider}
  3. The user approves access on the provider's site
  4. The provider redirects the browser back to {ADMIN_URL}/callback/{provider}?code=...&state=...
  5. The admin's /callback/[provider] dispatcher page reads and parses the state, resolves the initiating app's origin from the app-urls setting (exposed via GET /setting/initial), and forwards the browser to ${origin}/callback/{provider}/{flow} — which is the admin itself when the app is self or unknown
  6. That flow page calls {API_URL}/oauth/{provider}/callback/{flow}?code=... to finish the exchange
  7. The API exchanges the code for an access token, fetches the profile, and issues a Hedhog JWT session (or creates/links the account, for register/connect)

Origin binding: when the resolved app isn't self, the API also requires the callback request's Origin (or Referer, as a fallback) header to match that app's registered URL in app-urls — otherwise it rejects with 400 OAuth callback origin is invalid. This is what stops a forged state from redirecting tokens to an arbitrary origin.

GitHub and Apple are both exceptions to the single-callback rule, for different reasons: neither lets Hedhog register the flow-carrying {ADMIN_URL}/callback/{provider} URL, so both always redirect to a fixed backend URL, {API_URL}/oauth/{provider}/callback, and the API itself 302-redirects the browser onward to {ADMIN_URL}/callback/{provider}/{flow} to continue the same flow as the other providers. Apple additionally requires response_mode=form_post, so its backend endpoint is a POST, not a GET — the API converts that POST into the same GET-based bounce internally.

You do not write any of this OAuth exchange code yourself — @hed-hog/core's OAuthService and the per-provider classes under libraries/core/src/oauth/providers/ handle it.

The Redirect URIs You Actually Need to Register

This is the part new developers usually get wrong: it looks like a lot of moving pieces (login/register/connect), but almost every provider only needs one redirect URI registered, because the flow travels in the state, not in the path.

ProviderRedirect URI to register
Google, Facebook, Microsoft, Entra ID, LinkedIn{ADMIN_URL}/callback/<provider>
GitHub, Apple{API_URL}/oauth/<provider>/callback (fixed URL — the flow travels in state)

Replace <provider> with google, facebook, microsoft, microsoft-entra-id, or linkedin. With the default local setup that's, e.g. for Google:

http://localhost:3200/callback/google

If you forget to register this URI (or register it with a mismatched scheme/trailing slash), every flow — not just register/connect — will fail with a provider-side "redirect_uri_mismatch" error, since login, register, and connect all share the same URI now.

Most providers also ask for app-level metadata that has nothing to do with the callback — typically a Homepage URL, a Privacy Policy URL, and a Terms of Service URL — before they'll let you publish the app outside testing/development mode. These don't need to point anywhere functional during development; you can use your marketing site or even the admin URL itself, but production apps (especially Google's "External" consent screen and Facebook apps in Live mode) will refuse verification without all three.

Setting Up the Integration Profile (every provider)

OAuth credentials are not stored as flat settings — they live in a generic Integration Profile, the same mechanism used for email, storage, AI, and payment providers:

  1. Go to Settings → Integration Profiles (/core/integration/profiles) → New
  2. Choose Type: OAuth / SSO and Provider: Google, GitHub, Microsoft, Facebook, Microsoft Entra ID, Apple, or LinkedIn
  3. Fill in client_id and client_secret from the provider console — except Entra ID, which also needs tenant_id, and Apple, which needs team_id, key_id, and a private_key instead of a static client_secret (see the Apple section below)
  4. Save — this creates a row in integration_profile with your credentials in its config JSON

Then, for each provider you want to activate, go to Settings → Configurations → OAuth and, on that provider's tab:

FieldWhat it does
<Provider> OAuth ProfileEntity picker — select the Integration Profile you just created
Enable <Provider>Switch — flips the provider on/off without deleting the profile
OAuth ScopesCheckboxes — permissions requested during consent

A provider with no profile selected, or with the switch off, returns 400 OAuth provider X is not enabled if a client tries to use it — that's expected, not a bug, while you're mid-setup.

There is also a global Default Role setting on the OAuth → General tab (oauth-role-assignment): roles automatically granted to users who register via any OAuth provider. Leave it empty to fall back to the system default role.


Google

Console: console.cloud.google.com

  1. Create (or select) a project, then go to APIs & Services → OAuth consent screen. Fill in the app name, support email, and the three URLs mentioned above (Homepage, Privacy Policy, Terms of Service) — required to move the app out of "Testing" status
  2. Go to APIs & Services → Credentials → Create Credentials → OAuth client ID, type Web application
  3. Under Authorized redirect URIs, add the single callback URL from the table above
  4. Copy the Client ID and Client secret into the Integration Profile's client_id / client_secret

Default requested scopes are email and profile; Hedhog also calls the People API for gender/birthday if granted, but this is optional and not required for login to work.

While the OAuth consent screen is in Testing mode, only the test users you explicitly add can log in — if a real user gets "Access blocked: this app's request is invalid," that's almost always the cause.


GitHub

Console: github.com → Settings → Developer settings → OAuth Apps

  1. Click New OAuth App
  2. Set Homepage URL to your admin or marketing URL
  3. Set Authorization callback URL to {API_URL}/oauth/github/callback — this is the only URL GitHub lets you register per app, which is why Hedhog routes login, register, and connect through it using the state parameter
  4. Copy the Client ID, then click Generate a new client secret and copy it immediately (GitHub only shows it once)

Default scopes are read:user and user:email. If a GitHub account has no public email and email-sharing is restricted, Hedhog falls back to calling /user/emails to find the verified primary address — make sure the user has at least one verified email or registration will fail.


Microsoft (Personal accounts)

Console: portal.azure.com → App registrations

  1. New registration → set Supported account types to Accounts in any organizational directory and personal Microsoft accounts
  2. Under Authentication → Add a platform → Web, add the single redirect URI from the table above
  3. Under Certificates & secrets → New client secret, copy the Value immediately — Azure hides it after you leave the page
  4. Copy the Application (client) ID from the Overview page

Default scopes are openid, profile, email, User.Read, and offline_access (the last one is what lets Hedhog request a refresh token).


Microsoft Entra ID (Work / school accounts)

Console: entra.microsoft.comIdentity → Applications → App registrations (the dedicated Microsoft Entra admin center — a more direct path than the general Azure Portal for tenant/app configuration)

Same App Registration flow as Microsoft above, with two differences:

  1. Set Supported account types to Accounts in this organizational directory only (single tenant) — this is what actually restricts login to your org
  2. Copy the Directory (tenant) ID from the Overview page into the Integration Profile's tenant_id field — without it, the profile is rejected with "Microsoft Entra ID OAuth profile is missing tenant_id"

The redirect URI uses the microsoft-entra-id provider slug: {ADMIN_URL}/callback/microsoft-entra-id.


Facebook

Console: developers.facebook.com

  1. Create App → type Consumer → add the Facebook Login product
  2. Under App Settings → Basic, fill in App Domains, Privacy Policy URL, and Terms of Service URL — Facebook will not allow the app to leave Development mode without all three
  3. Under Facebook Login → Settings → Valid OAuth Redirect URIs, add the single callback URL from the table above
  4. Copy the App ID and App secret from Basic settings

Default scope is email only. While the app is in Development mode, only users with a role on the app (Admin/Developer/Tester) can log in — add test users under Roles before testing with a regular account.


Apple (Sign in with Apple)

Console: developer.apple.com/account

Apple's setup is the most involved of all seven providers, and it doesn't use a static client secret:

  1. Under Certificates, Identifiers & Profiles → Identifiers, create an App ID (if you don't have one) and enable the Sign in with Apple capability on it
  2. Create a Services ID — this is what acts as the OAuth client_id. Enable Sign in with Apple on it and configure it, adding {API_URL}/oauth/apple/callback as the only registered Return URL (Apple only accepts a single fixed redirect, same constraint as GitHub)
  3. Under Keys, create a new key with Sign in with Apple enabled, and download the .p8 private key file immediately — Apple only lets you download it once
  4. Note your Team ID (top-right of the developer account) and the Key ID of the key you just created
  5. Paste team_id, key_id, and the contents of the .p8 file into the Integration Profile's team_id / key_id / private_key fields (client_id is the Services ID identifier)

Unlike every other provider, Hedhog does not store a static client_secret for Apple — it mints a fresh ES256-signed JWT from team_id/key_id/private_key on every single token exchange, valid for 5 minutes. There's nothing to rotate or expire on your end.

Apple also requires response_mode=form_post, so the redirect back is a browser POST, not a GET — Hedhog's /oauth/apple/callback endpoint handles that and converts it into the same flow as the other providers. Default scopes are name and email, but Apple only sends the user's name on the very first authorization ever — if it's missing on a later login, Hedhog falls back to the local part of the email address.


LinkedIn

Console: linkedin.com/developers/apps

  1. Create app, associate it with a LinkedIn Company Page (required by LinkedIn even for testing)
  2. Under Products, request "Sign In with LinkedIn using OpenID Connect"
  3. Under Auth → OAuth 2.0 settings → Authorized redirect URLs, add the single callback URL from the table above
  4. Copy the Client ID and Client Secret from the Auth tab

Default scopes are openid, profile, and email. Profile data comes from a single OIDC /v2/userinfo call — LinkedIn's older two-call /v2/me + /v2/emailAddress dance isn't used.


OAuth from Native Apps (Android, iOS, Electron)

Native apps (the Electron desktop app, and Android/iOS via Expo/React Native) don't talk to OAuth providers directly — they reuse the exact same web hub described above, by opening a system browser (not an embedded WebView) and catching the final redirect through a custom URI scheme:

  1. The app opens the system browser at {ADMIN_URL}/auth/desktop-oauth?provider=<provider>&scheme=<custom-scheme>
  2. That admin page redirects into the normal web flow, {API_URL}/oauth/<provider>/login — no redirectApp is passed, so the state's app resolves to self and the hub (admin) handles the callback locally
  3. The user authenticates with the provider as usual and lands back on the admin app, now logged in
  4. The admin shows an /auth/desktop screen with an Authorize button; clicking it calls POST /auth/desktop-token for a fresh token pair and navigates to <scheme>://auth-callback?accessToken=...&refreshToken=...
  5. The native app intercepts that custom-scheme URL and extracts the tokens:
    • Electron registers itself as the scheme's handler with app.setAsDefaultProtocolClient('hedhog'), then reads the URL from the open-url (macOS) or second-instance (Windows/Linux) event
    • Android/iOS (Expo/React Native) uses expo-web-browser's openAuthSessionAsync(authUrl, '<scheme>://'), which opens an ephemeral auth browser session and resolves directly with the final redirect URL — parsed with expo-linking, no separate deep-link listener needed

Every native app needs its own registered custom scheme — configured via electron-builder's protocols option for Electron, or the scheme field in app.json for Expo apps (e.g. hedhog://, hedhogoperations://, hedhoginbox://). There are no universal links / Android App Links involved — just the custom scheme.


Verify It Worked

  • Click "Sign in with <Provider>" — you should land on the provider's consent screen, then bounce back through /callback/<provider> and end up logged in
  • Try the same flow with an account that has never signed up before — it should auto-register instead of failing
  • If you support account linking, log in with a different method, go to Account → Linked accounts, and connect the provider — a new user_account row should appear instead of an error

Troubleshooting

SymptomLikely cause
Provider error redirect_uri_mismatchThe single callback URI wasn't registered, or doesn't exactly match (trailing slash, http vs https)
OAuth provider X is not enabledThe provider's switch is off, or no Integration Profile is selected, in Settings → Configurations → OAuth
OAuth callback origin is invalidA non-admin app (e.g. training) initiated the flow via redirectApp, but its origin isn't registered in the app-urls setting, or the request's Origin/Referer doesn't match it
Works for login but not for new sign-upsThe provider itself is blocking new accounts (e.g. Testing/Development mode restrictions) — the redirect URI is shared across all three flows now, so it's rarely a missing-URI issue
Microsoft Entra ID OAuth profile is missing tenant_idtenant_id wasn't filled in on the Integration Profile
Entra ID error AADSTS54005The authorization code was already redeemed (replay) — have the user restart the login
Apple error invalid_clientteam_id, key_id, or private_key on the Integration Profile don't match the Services ID/key in Apple's developer console
User stuck on provider's consent screen foreverApp is still in Testing/Development mode and the user isn't on the test-users list