§ unswayed-backend · Authentication
Social login
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 |
|---|---|---|
| a Firebase ID token (the app signs in with Google through Firebase) | verify the token against the Firebase project | |
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 legacyaccessToken/access_tokenaliases 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), andfirebase.sign_in_providermust begoogle.com. - LinkedIn → after the code exchange, the
id_token's audience must equal our LinkedIn client id, issued byhttps://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:
- Known identity? A
SocialAccountrow (unique(provider, providerUserId)) already linked → that's the user. (A returning login.) - 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. - Brand new? Create a
Userwith no password,isVerifiedfrom the provider, andisCompleted = false, plus itsSocialAccount.
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 OAuthstate.- 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 sameSocialAuthServicepipeline, issues our tokens, and finishes according to the platform carried instate.
Why platform matters — the "invalid link" trap
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/employerstring — 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_urimust 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 fromLINKEDIN_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.