fix(auth): Sign in with LinkedIn (OpenID Connect) and X for existing users
Summary
Sign in with LinkedIn and Sign in with X (Twitter) work again for existing users, and only through an address the provider itself vouches for.
- LinkedIn now uses "Sign In with LinkedIn using OpenID Connect" (scopes
openid profile email) and reads the member from the OpenID Connect UserInfo endpoint. The address keeps LinkedIn's ownemail_verifiedflag. - X flags the address returned by
account/verify_credentials(include_email) as verified. X documents that this endpoint returnsnullwhen the address is not verified. An empty value is never verified. - Two small, pure, shared helpers in
@gauzy/authnow set theverifiedflag. Facebook uses one of them instead of its inline mapping, with the same result.
Why
Fixes #9203. On develop both sign-ins always land on the register page, for two reasons in the code:
- LinkedIn retired the
r_liteprofile/r_emailaddressscopes (and the/v2/meand/v2/emailAddressAPIs thatpassport-linkedin-oauth2@2reads) for OpenID Connect. Apps that only have the new product cannot get past the authorization step. AuthService.validateOAuthLoginEmailskips every address withoutverified: true. The LinkedIn and X strategies passedprofile.emailsthrough with no flag, so they could never match an existing account. Facebook, GitHub and Microsoft were fixed the same way earlier (#10665).
The provider contracts this relies on:
- LinkedIn UserInfo (
GET https://api.linkedin.com/v2/userinfo,Authorization: Bearer):emailis the member's primary address andemail_verifiedis the "indicator that Member's primary email has been verified". Both are optional. See https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2 - X
GET account/verify_credentials,include_email: "If the user does not have an email address on their account, or if the email address is not verified, null will be returned." X API v2 names the same fieldconfirmed_email("The confirmed email of the authenticated user", https://docs.x.com/x-api/fundamentals/data-dictionary). The v1.1 reference page now sits behind X's paywall; I checked the text against its archived copy from 2024-01-04.
What changed
packages/auth
src/lib/social-auth-emails.ts(new) has two helpers:emailsFromOidcClaims(claims)flags an address verified only whenemail_verified === true. A missing claim,falseor the string"true"all count as unverified. Claims without an address give no entry.flagProviderConfirmedEmails(emails)is for providers that return an address only once it is confirmed. A missing, blank or non-string value is never verified.
src/lib/linkedin/linkedin-userinfo.ts(new) holds the OpenID Connect scopes, the UserInfo URL andparseLinkedinUserInfo, which builds the Passport profile. A response that is not JSON, or has nosub, fails the sign-in.src/lib/linkedin/linkedin.strategy.ts:- Scopes are now
openid profile email. userProfileis overridden to call UserInfo with the token in theAuthorization: Bearerheader (never in the URL).validatereturns the profile's flagged addresses.- The strategy name, authorization and token URLs (unchanged at LinkedIn), config parsing, the
'disabled'defaults, the callback URL andFEATURE_LINKEDIN_LOGINare unchanged.
- Scopes are now
src/lib/twitter/twitter.strategy.ts:validateflags the confirmed address, with the X documentation cited in a comment.includeEmailis kept.- The
console.logof the whole profile (e-mail and raw JSON) on every sign-in is removed.
src/lib/facebook/facebook.strategy.tsusesflagProviderConfirmedEmails(same result for every value Graph returns).README.mdgets a short "Built-in social sign-in" section.
Root
.env.samplenotes that the LinkedIn app needs the "Sign In with LinkedIn using OpenID Connect" product (callback URL unchanged), and that the X app needs "Request email address from users".
Unchanged: AuthService.validateOAuthLoginEmail and its verified-address check, SOCIAL_SIGNIN_REQUIRE_VERIFIED_ACCOUNT (the matched account must also have confirmed the address itself; default on), and the token sign-in route, which still rejects X tokens. No change outside packages/auth apart from .env.sample.
Flags / settings and their defaults
No new flag. The existing FEATURE_LINKEDIN_LOGIN / FEATURE_TWITTER_LOGIN feature flags and the LINKEDIN_* / TWITTER_* settings keep their names and defaults.
Tests
Node 26.10, from the worktree root:
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/auth/jest.config.ts --rootDir packages/auth
Test Suites: 24 passed, 24 total
Tests: 200 passed, 200 total
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/core/jest.config.ts --rootDir packages/core auth.service.social-signin-verified verify-oauth-tokens
Test Suites: 2 passed, 2 total
Tests: 50 passed, 50 total (unchanged code; regression check of the verified-address gate)
npx tsc -p packages/auth/tsconfig.lib.json --noEmit -> no errors
npx tsc -p packages/auth/tsconfig.spec.json --noEmit -> no errors
npx eslint <touched files> -> 0 errors (3 existing `any` warnings, down from 5)
npx prettier --check <touched files> -> clean
npx cspell -c .cspell.json <touched files> -> 0 issues
New specs:
social-auth-emails.spec.tscovers verified, unverified, a missingemail_verified, the string"true", and missing, blank or non-string addresses.linkedin/linkedin.strategy.spec.ts:- scopes, defaults and configured values;
- UserInfo is called with a bearer header and no token in the URL, checked through node-oauth's real
get; - profile mapping;
- an HTTP error, a response that is not JSON, and a response with no
suball fail; - end-to-end
userProfilethenvalidate, through the Passport verify callback, foremail_verifiedtrue, false, missing and"true", and for a missing address.
twitter/twitter.strategy.spec.ts:includeEmailis set, and the profile URL is not overridden;- passport-twitter requests
verify_credentialswithinclude_email=true; - a confirmed address is flagged verified;
email: nullgives no address;- empty values are never verified.
Manual verification still needed
I could not run an end-to-end sign-in here, because it needs real LinkedIn and X developer apps (owner credentials):
- LinkedIn: add the "Sign In with LinkedIn using OpenID Connect" product to the app, then sign in with a member whose primary address matches a confirmed Gauzy account. The expected result is
#/sign-in/success. - X: turn on "Request email address from users" (Privacy Policy and Terms URLs are required for that), then do the same check. Tokens issued before the permission was enabled must be re-issued, which happens on the next sign-in.
Risk and rollback
- Risk is low and limited to the two sign-ins that do not work today.
- A LinkedIn app that still has only the retired legacy product will now stop at LinkedIn's authorization page. Before, it reached the callback and was sent to the register page with nothing filled in, so no working path is lost.
- X sign-in now matches an existing account through an X-confirmed address. That is the same trust model Facebook already uses, and it still needs the Gauzy account to have confirmed the address (default).
- Rollback: revert this PR. There is no migration and no stored data.
Open questions
- X also offers OAuth 2.0 with
users.emailand/2/users/me?user.fields=confirmed_email. Moving the X strategy to OAuth 2.0 would need a new Passport strategy (a dependency), so it is left out here. Is it worth a follow-up? - LinkedIn could also be served by the generic OpenID Connect client in
packages/auth/src/lib/oidc, which validates the ID token. That would mean moving it to a plugin, as done for Keycloak and Zitadel, which is out of scope for this fix.
Closes #9203
š¤ Generated with Claude Code
Summary by cubic
Makes Sign in with LinkedIn and Sign in with X match existing accounts again, instead of always landing on the register page. This fixes #9203.
LinkedIn now uses "Sign In with LinkedIn using OpenID Connect" (scopes openid profile email) and reads the member from the UserInfo endpoint, keeping LinkedIn's own email_verified flag. X flags the address from account/verify_credentials as verified, which X returns only for confirmed addresses. Both were skipped by the verified-address check in AuthService.validateOAuthLoginEmail because the strategies passed addresses with no flag.
- Adds two shared helpers in
@gauzy/auththat set theverifiedflag from the provider's own guarantee; Facebook uses one of them with the same result. - Requires the LinkedIn app to have the OpenID Connect product and the X app the "Request email address from users" permission; callback URLs are unchanged.
- Removes the
console.logof the full X profile on every sign-in.
Sign-in still requires the matched account to have confirmed the address itself (SOCIAL_SIGNIN_REQUIRE_VERIFIED_ACCOUNT, default on). No new flags or settings, no migration.