CODEX // oaoisme wiki

§ unswayed-backend · Authentication

Social login

updated 2026-06-03

Social login

Social login lets a user sign in with Google or LinkedIn instead of an email + password. The job of the backend is narrow and security-critical: prove the identity is real, then map it to one of our User rows and hand back the same JWT access + rotating refresh tokens that email/password login issues. Everything else (passwords, OTPs) is skipped.

Scope today is Google + LinkedIn. Apple, Facebook and Microsoft are designed for but not built — each is just one more verifier (see Adding a provider).

The two flows

The mobile app does the user-facing OAuth dance; the backend only ever receives a token or a code and verifies it.

Provider What the client sends What the backend does
Google a Firebase ID token (the app signs in with Google through Firebase) verify the token against the Firebase project
LinkedIn an OAuth authorization code exchange the code server-side for an id_token, then verify it

Why the asymmetry? Google has a native Firebase SDK that mints a verifiable ID token on-device. LinkedIn has no such SDK, so the backend must complete the authorization-code exchange — which also keeps the LinkedIn client secret on the server, never in the app. (LinkedIn can be taken one step further and driven entirely by the backend — see Backend-driven LinkedIn redirect flow below.)

Endpoints

  • POST /applicant/social-login, POST /employer/social-login — body is { provider, idToken? , code?, redirectUri? } (the legacy accessToken / access_token aliases are accepted for the Google token). Returns 206 for a brand-new (incomplete) account, 200 for a returning one.
  • POST /applicant/complete-profile, POST /employer/complete-profile — authed, finishes onboarding (below).
  • GET /auth/social/providers — the list of providers that are currently enabled.
  • GET /linkedin/authorize?user_type=…&platform=mobile|web, GET /linkedin/callback — the backend-driven LinkedIn flow (below): the backend redirects to LinkedIn and handles the callback itself, finishing on the mobile app or the web app.

Verifying the identity (the security part)

Each provider has an IdentityVerifier that turns the raw credential into a normalized { provider, providerUserId, email, emailVerified, name }, or throws. Both are verified the same rigorous way with jose: check the signature against the provider's published keys, pin RS256, and require the right audience, issuer, and expiry.

  • Google → the token's audience/issuer-project must equal our Firebase project id (FCM_PROJECT_ID), and firebase.sign_in_provider must be google.com.
  • LinkedIn → after the code exchange, the id_token's audience must equal our LinkedIn client id, issued by https://www.linkedin.com/oauth.

This is the deliberate upgrade over the legacy app, which checked Google's signature but not the audience — meaning a token minted for any other app would have been accepted (an account-takeover hole), and which trusted raw access tokens for the other providers. We never trust a client-supplied identity blob; we verify a signed token. (We use jose rather than the heavy firebase-admin SDK because verifying a Firebase token needs only the project id — no service-account credential.)

Mapping the identity to a user

SocialAuthService resolves the verified identity to a User in three steps:

  1. Known identity? A SocialAccount row (unique(provider, providerUserId)) already linked → that's the user. (A returning login.)
  2. Same email already registered? If the provider says the email is verified and it belongs to a same-type account, link the identity (create a SocialAccount) and log them in. An unverified email, or an email owned by the other user type, is rejected (422) — linking on an unverified email would be an account-takeover vector.
  3. Brand new? Create a User with no password, isVerified from the provider, and isCompleted = false, plus its SocialAccount.

Employer sign-up additionally rejects personal email domains (gmail, yahoo, …) with 400, mirroring the legacy rule. Finally the user must be active (else 403), and we issue our normal access + refresh tokens.

Complete-profile

A brand-new social user has an email but no username, phone, or profile — the provider can't supply those. So social-login returns is_completed: false with a usable token, and the app then calls /{applicant,employer}/complete-profile (authenticated) to fill them in. That one call updates the User (username, phone, isCompleted = true) and creates the single profile row in one transaction. It's rejected with 403 if the profile is already complete, so it can't run twice. (The legacy code accidentally created a second Applicant row here — we don't.)

Backend-driven LinkedIn redirect flow

The code-posting endpoint above assumes the client already obtained the LinkedIn authorization code. But LinkedIn can also be driven entirely by the backend — the frontend never touches OAuth:

  • GET /linkedin/authorize?user_type=applicant|employer&platform=mobile|web → the backend 302-redirects the browser to LinkedIn's consent screen (response_type=code, scope=openid profile email), carrying the chosen user type and platform in the OAuth state.
  • LinkedIn returns the user to GET /linkedin/callback?code&state (the URL registered in the LinkedIn app + LINKEDIN_REDIRECT_URI). The backend exchanges the code through the same SocialAuthService pipeline, issues our tokens, and finishes according to the platform carried in state.

unswayed is both a mobile and a web app, but the flow originally finished only one way: a 302 to the custom-scheme deep link com.unswayed://auth/linkedin?…. A custom scheme only resolves on a device with the app installed — so completing the flow in a desktop browser dead-ends at Safari's "the address is invalid", for both success and failure (you can't tell which). Carrying the surface in state lets the callback finish correctly for each:

platform Callback finishes with
mobile (default) 302 → com.unswayed://auth/linkedin?token&refresh_token&is_completed&user_type (or ?error=) — unchanged
web + LINKEDIN_WEB_REDIRECT_URI set 302 → <web frontend>?token&refresh_token&is_completed&user_type (or ?error=)
web + no web URL configured 200 + a self-contained HTML page showing the result (tokens, or the error)

The HTML fallback means a browser flow is always observable — even before a web frontend exists, you can run /api/linkedin/authorize?platform=web end-to-end and see what the backend actually returned. (It's also how you confirm a backend success vs. failure during QA.)

How state encodes the platform (without breaking the contract)

endpoint_documentation §13 documents the callback state as the plain string applicant/employer. To stay byte-compatible with that and carry the platform, the encoding is conditional (a tiny pure codec, linkedin-state.ts):

  • mobile → the plain applicant/employer string — the documented value, untouched.
  • web → a base64url JSON token { utp, plt } — the same idiom the Google Calendar web callback already uses ({ uid, plt }).

Decoding is tolerant: a plain string ⇒ mobile + that type; a base64url token ⇒ its {utp,plt}; anything missing or un-parseable ⇒ the §13 default (mobile applicant). It never throws.

The service returns a small CallbackTarget{ kind: 'redirect', location } or { kind: 'html', body }. The authorize handler keeps its 302 + envelope; the callback handler now owns its response directly (a non-passthrough @Res()), so it can emit either a 302 or a raw text/html page — bypassing the global JSON-envelope interceptor for the HTML case.

Gotcha: the redirect_uri must be byte-identical in three places — the LinkedIn app's Authorized redirect URLs, LINKEDIN_REDIRECT_URI (used for both the authorize URL and the exchange), and what LinkedIn calls back. That's the LinkedIn↔backend leg; it's separate from LINKEDIN_WEB_REDIRECT_URI/LINKEDIN_APP_REDIRECT_URI, which are where the backend sends the user afterward. Google has no equivalent endpoint — its flow is client-driven.

Per-provider enable / disable

A provider is enabled only when its keys are present:

  • Google ⇐ FCM_PROJECT_ID
  • LinkedIn ⇐ LINKEDIN_CLIENT_ID + LINKEDIN_CLIENT_SECRET

Missing keys ⇒ the provider is disabled: the app still boots, and social-login for that provider (and /linkedin/authorize) returns 501 Not Implemented instead of half-working. GET /auth/social/providers reports the enabled set so the client can hide buttons it can't use. This "a feature self-disables when its keys are absent" rule is the convention other integrations (FCM, Stripe…) will follow too.

How it stays testable (the 90% gate)

The only impure parts are the provider's network + crypto, and they sit behind injectable seams: a key-resolver (Firebase x509 certs / LinkedIn JWKS) and an HTTP port for the LinkedIn exchange. Unit tests drive the real jose verification with crafted tokens signed by a test key, so every claim check (bad audience, wrong issuer, expired, tampered signature, missing subject) is exercised offline. The pure linkedin-state.ts codec is unit-tested for the round-trip, the §13 plain-string back-compat, and garbage→default. The e2e suite overrides the verifiers with controllable fakes and drives the journeys against a real Postgres — new→complete→login, returning login, link-on-verified, unverified-refuse, employer domain rules, cross-type collision, LinkedIn, the disabled→501 path, and the backend-driven /linkedin/authorize/linkedin/callback redirects for mobile (deep link), web (frontend redirect) and web with no URL (the 200 HTML fallback), plus the error paths.

Adding a provider

Apple / Facebook / Microsoft each need only: a value in the SocialProvider enum, a new IdentityVerifier (Facebook now issues a Limited-Login OIDC token; Apple sends the user's name outside the token, on first sign-in only; Microsoft is Entra v2.0), its config + enable rule, and registration in the verifier array. No change to the orchestration, linking, or complete-profile logic.

See also: ADR-0015 (verification), ADR-0017 (the backend-driven LinkedIn flow) and ADR-0018 (web + mobile platform support) in docs/DECISIONS.md, and the Authentication page for the token model this builds on.