Skip to content

fix(people): create invited employees with verified emails so SSO can link#3459

Merged
tofikwest merged 2 commits into
mainfrom
fix/invite-email-verified-sso-linking
Jul 20, 2026
Merged

fix(people): create invited employees with verified emails so SSO can link#3459
tofikwest merged 2 commits into
mainfrom
fix/invite-email-verified-sso-linking

Conversation

@tofikwest

@tofikwest tofikwest commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

Invited employees frequently could not sign in to the portal with Google or Microsoft SSO. The OAuth callback aborted with account_not_linked — the dominant Better Auth error in production logs over the last 3 weeks (~70 occurrences, both providers equally).

Root cause

better-auth refuses to link a trusted OAuth provider to an existing local user whose emailVerified is false (the requireLocalEmailVerified gate, default true). Both manual employee-creation paths created users unverified:

  • apps/api/src/people/people-invite.service.ts (invite flow)
  • apps/app/src/lib/db/employee.ts (add-people flow)

So: admin invites employee → user row created with emailVerified: false → employee clicks "Continue with Google/Microsoft" → better-auth finds the existing user, refuses to link → sign-in fails. (Employees who happened to use email OTP first were unaffected, which is why the failure looked intermittent.)

Related: #3454 (CS-760) fixed where the failing user lands after the error; this PR fixes the error itself.

Fix

  1. Create employee user rows with emailVerified: true in both paths (2-line behavioral change + comments).
  2. Backfill migration flips existing unverified org members so already-invited employees are unblocked immediately.

Why this is safe

  • Password auth is disabled. Every sign-in method (email OTP, magic link, trusted OAuth) proves control of the mailbox before any session is issued — the flag grants nothing to anyone who cannot already receive mail at the address.
  • Precedent: the employee-sync paths (Google Workspace, Entra, etc.) already create users with emailVerified: true. This aligns the manual paths with them.
  • Verified consumers: the only other emailVerified read (organization-access.service.ts trycomp.ai auto-approve) uses the session user's email behind HybridAuthGuard + organization:update, so it cannot be influenced by unauthenticated rows.
  • Backfill is scoped to users who are org members, and is inert for the same no-password reason.

Intentionally NOT changed

  • auth.server.ts / accountLinking config: requireLocalEmailVerified: false would also fix this, but the option is @deprecated in better-auth ("removed on next minor; the gate becomes unconditional") — relying on it would silently re-break on a future upgrade.
  • state_mismatch (~10 occurrences/3 weeks) is a separate, minor issue (stale tab / double-click), not addressed here.

Verification

  • cd apps/api && npx jest src/people102/102 pass (incl. new test asserting invited users are created verified).
  • cd apps/app && npx vitest run src/lib/db/employee.test.ts2/2 pass (new-user path creates verified; existing-user path never touches the user row).
  • API + app typecheck: no new errors (remaining errors are pre-existing on main, verified by side-by-side comparison).

Summary by cubic

Fixes SSO failures for invited employees by creating users with verified emails and upgrading legacy users on re-invite so better-auth can link Google/Microsoft accounts, removing account_not_linked errors. Also removes unused app-side employee creation code. Addresses Linear CS-760.

  • Bug Fixes

    • Create users with emailVerified: true in the invite flow.
    • On re-invite (employee or admin), auto-verify legacy unverified users; no-op if already verified.
    • Tests cover verified creation, upgrade on re-invite, and no-op for already verified.
  • Migration

    • Backfill org members with emailVerified: false to true; safe since password auth is disabled and all sign-in methods prove mailbox control.

Written for commit 3305c3b. Summary will update on new commits.

Review in cubic

… link

Employees added via the invite flow (people-invite.service) and the add-people
flow (lib/db/employee) were created with emailVerified: false. better-auth
refuses to link a trusted OAuth provider (Google/Microsoft) sign-in to an
existing unverified local user and aborts the callback with account_not_linked,
so those employees could not sign in to the portal with SSO at all — this was
the dominant OAuth error in production logs, and it hit Google and Microsoft
equally.

Create the user rows with emailVerified: true instead, and backfill existing
org members with a data migration so already-invited employees are unblocked
too.

This is safe because password auth is disabled: every sign-in method (email
OTP, magic link, trusted OAuth) proves control of the mailbox before a session
is issued, so the flag grants nothing to anyone who cannot already receive
mail at the address. The employee-sync paths (Google Workspace etc.) already
create users with emailVerified: true; this aligns the manual paths with them.

Intentionally NOT changed: better-auth's accountLinking config
(requireLocalEmailVerified is deprecated and slated to become unconditional,
so relying on it would re-break on a future upgrade).
@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
app Ready Ready Preview, Comment Jul 20, 2026 9:32pm
comp-framework-editor Ready Ready Preview, Comment Jul 20, 2026 9:32pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
portal Skipped Skipped Jul 20, 2026 9:32pm

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 5 files

Confidence score: 2/5

  • In apps/app/src/lib/db/employee.ts, the new verified-user creation path appears reachable by any active org member without enforcing member:create, so non-admin users could create employee records the code assumes are admin-provided; this is a likely authorization regression—add an explicit permission gate on this route before merging.
  • In apps/app/src/lib/db/employee.ts, emailVerified is being repurposed from an email-verification invariant to an admin-input flag, which can let downstream checks (for example in org access logic) treat users as verified when provider email trust is not actually established; that can weaken access assumptions—separate admin intent from true email verification or tighten the downstream predicate before merging.
  • In apps/api/src/people/people-invite.service.ts, the existing-user invite branch does not apply the same verification update as the new-user insert branch, so already-unverified users may still fail Google/Microsoft account linking after invite; this creates a user-facing onboarding dead end—apply the same verification handling in the existing-user path and add a regression test.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/app/src/lib/db/employee.ts">

<violation number="1" location="apps/app/src/lib/db/employee.ts:175">
P1: This changes `emailVerified` from an email-verification invariant into an admin-input flag, while trusted providers can link without a verified provider email; downstream checks such as `OrganizationAccessService.isEmailVerified` then treat that address as verified. Keeping the row unverified until a mailbox challenge, or tightening trusted-provider/linking and auto-approval rules, would avoid granting verification-dependent behavior from an unverified address.</violation>

<violation number="2" location="apps/app/src/lib/db/employee.ts:183">
P1: Any active organization member can reach this new verified-user creation path without the `member:create` permission, so the comment's assumption that the address is admin-provided is not enforced. Routing employee creation through the API's `member:create` RBAC guard would prevent unauthorized members from provisioning SSO-linkable organization accounts.</violation>
</file>

<file name="apps/api/src/people/people-invite.service.ts">

<violation number="1" location="apps/api/src/people/people-invite.service.ts:179">
P2: Inviting an already-existing unverified user still leaves them unable to link Google/Microsoft SSO. The new flag is applied only to newly inserted users; the existing-user branch should also verify the user when adding them as an employee, or otherwise ensure this membership transition is covered.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread apps/app/src/lib/db/employee.ts Outdated
Comment thread apps/app/src/lib/db/employee.ts Outdated
Comment thread apps/api/src/people/people-invite.service.ts
Review follow-ups:

- Re-inviting a user whose row predates created-verified behavior (and who
  was not a member when the backfill ran, e.g. after their previous org was
  deleted) left them unverified and still unable to link Google/Microsoft
  sign-ins. Both invite paths (employee add + role invitation) now upgrade
  an existing unverified row to verified, with the same justification as
  creation: the address is admin-provided and every sign-in method proves
  mailbox ownership before a session is issued. No-op when already verified.

- createEmployeeAction (and its completeEmployeeCreation helper) had no
  callers anywhere in the app: the live add-people flow goes through the
  API, where POST /v1/people/invite, POST /v1/people and /bulk all enforce
  member:create. Rather than leave an unreferenced server action that
  creates users and members with no permission check of its own, delete it
  and prune lib/db/employee.ts to the one export that is actually used
  (createTrainingVideoEntries).
@vercel
vercel Bot temporarily deployed to Preview – portal July 20, 2026 21:28 Inactive
@tofikwest

Copy link
Copy Markdown
Contributor Author

Addressed the review findings in 3305c3b — disposition of each:

1. Existing-user invite branch left legacy rows unverified (P2 — valid, fixed). Both invite paths (employee add + role invitation) now upgrade an existing unverified row via a shared ensureEmailVerified helper (no-op when already verified). This covers rows that predate created-verified behavior and were missed by the member-scoped backfill (e.g. their previous org was deleted). Tests added for both paths plus the already-verified no-op.

2. employee.ts creation path reachable without member:create (P1 — stale premise, resolved by deletion). createEmployeeAction had zero callers anywhere in the app — the live add-people flow goes through the API, where POST /v1/people/invite, POST /v1/people, and /bulk all enforce member:create. Rather than leave an unreferenced action that creates users/members with no permission check of its own, the action and its completeEmployeeCreation helper are deleted; lib/db/employee.ts now exports only createTrainingVideoEntries (the one function actually used). Net −217 lines.

3. emailVerified semantics with trusted-provider linking (P1 — not a defect introduced here). The OrganizationAccessService.isEmailVerified concern doesn't apply: that check runs on the session user's email behind HybridAuthGuard, so it can't be influenced by rows nobody can authenticate as (password auth is disabled; every sign-in method proves mailbox ownership first). The broader observation about trusted-provider linking semantics is a property of the existing better-auth configuration that applies equally to all previously-verified users (every OTP/magic-link user and all sync-created users, which were already created verified) — it is not widened in a new way by this PR and is better handled as its own auth-layer review if desired.

Verification: npx jest src/people 105/105 pass; API and app typecheck show zero new errors vs main baselines.

@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@tofikwest
tofikwest merged commit cd31daf into main Jul 20, 2026
10 checks passed
@tofikwest
tofikwest deleted the fix/invite-email-verified-sso-linking branch July 20, 2026 21:48
claudfuen pushed a commit that referenced this pull request Jul 20, 2026
## [3.104.2](v3.104.1...v3.104.2) (2026-07-20)

### Bug Fixes

* **auth:** redirect microsoft sso callback errors to portal instead of api docs ([7920bae](7920bae))
* **integrations:** report code scanning off vs missing permission correctly ([220d60f](220d60f))
* **people:** create invited employees with verified emails so SSO can link ([#3459](#3459)) ([cd31daf](cd31daf))
@claudfuen

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.104.2 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants