fix(auth): Sign in with LinkedIn (OpenID Connect) and X for existing users

Merged
EV
15d ago
+653-19

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 own email_verified flag.
  • X flags the address returned by account/verify_credentials (include_email) as verified. X documents that this endpoint returns null when the address is not verified. An empty value is never verified.
  • Two small, pure, shared helpers in @gauzy/auth now set the verified flag. 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:

  1. LinkedIn retired the r_liteprofile / r_emailaddress scopes (and the /v2/me and /v2/emailAddress APIs that passport-linkedin-oauth2@2 reads) for OpenID Connect. Apps that only have the new product cannot get past the authorization step.
  2. AuthService.validateOAuthLoginEmail skips every address without verified: true. The LinkedIn and X strategies passed profile.emails through 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): email is the member's primary address and email_verified is 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 field confirmed_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 when email_verified === true. A missing claim, false or 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 and parseLinkedinUserInfo, which builds the Passport profile. A response that is not JSON, or has no sub, fails the sign-in.
  • src/lib/linkedin/linkedin.strategy.ts:
    • Scopes are now openid profile email.
    • userProfile is overridden to call UserInfo with the token in the Authorization: Bearer header (never in the URL).
    • validate returns the profile's flagged addresses.
    • The strategy name, authorization and token URLs (unchanged at LinkedIn), config parsing, the 'disabled' defaults, the callback URL and FEATURE_LINKEDIN_LOGIN are unchanged.
  • src/lib/twitter/twitter.strategy.ts:
    • validate flags the confirmed address, with the X documentation cited in a comment.
    • includeEmail is kept.
    • The console.log of the whole profile (e-mail and raw JSON) on every sign-in is removed.
  • src/lib/facebook/facebook.strategy.ts uses flagProviderConfirmedEmails (same result for every value Graph returns).
  • README.md gets a short "Built-in social sign-in" section.

Root

  • .env.sample notes 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.ts covers verified, unverified, a missing email_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 sub all fail;
    • end-to-end userProfile then validate, through the Passport verify callback, for email_verified true, false, missing and "true", and for a missing address.
  • twitter/twitter.strategy.spec.ts:
    • includeEmail is set, and the profile URL is not overridden;
    • passport-twitter requests verify_credentials with include_email=true;
    • a confirmed address is flagged verified;
    • email: null gives 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.email and /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/auth that set the verified flag 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.log of 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.