Skip to content

feat: link a social login to the password account at login (dev vehicle) - #1759

Draft
DefeatMan wants to merge 9 commits into
boxlite-ai:mainfrom
DefeatMan:feat/all-in-one-account
Draft

DefeatMan wants to merge 9 commits into
boxlite-ai:mainfrom
DefeatMan:feat/all-in-one-account

Conversation

@DefeatMan

Copy link
Copy Markdown
Collaborator

TL;DR

Dev deploy vehicle for POL-555: the whole login-time account link, so it runs end to end on dev first. Not for review or merge; it lands as Stacked PRs.

Details

Design doc: https://linear.app/polygala/issue/POL-735/design-link-a-social-login-to-the-password-account-at-login

A social login goes through a second sign-in into the password account holding its address, or a sign-up. The callback moves the social user's BoxLite data, then links; the Action makes the password account the token's subject.

Docs: Account linking at login

Verification: nine commits, each audited; API, Postgres-integration, flow and infra suites pass locally.

🤖 Generated with Claude Code

UserController fetched its own client-credentials token and built its
own Management API URLs. The login-time account link (POL-555) calls
the same API from the auth module, and a second copy of the token
exchange would drift from the first.

Move both into an injectable Auth0ManagementService that UserController
now delegates to. The account settings endpoints behave as before; the
controller spec builds the controller with the real service over the
same mocked config and HTTP client.
When the login-time link (POL-555) folds a social identity into the
password account, tokens stop carrying the social subject. Every
organization that subject belonged to would then vanish from the
person's view, because the membership rows, role assignments and API
keys still name the old user id.

LinkedIdentityService.adopt moves them to the primary account in one
transaction, locking the secondary user so two racing callbacks cannot
both copy its memberships. It is idempotent, so the caller can run it
before the tenant link and repeat it on the next login if that link
fails. A primary account BoxLite has not seen yet is created from the
social profile here, without the default organization a first token
would provision. Organizations are not merged; duplicates from before
linking stay and remain reachable.

The integration spec runs against real Postgres when DB_HOST is set.
The login-time link (POL-555) needs one value from the operator: the
HS256 key the Post-Login Action and the API sign their redirects with,
because Auth0's encodeToken and validateToken accept only a shared
secret. Everything else is already known to the API, so asking for it
would only add settings that can disagree.

OIDC_ACCOUNT_LINK_REDIRECT_SECRET turns the link on by being set. At
boot it must hold at least 32 characters (RFC 7518 section 3.2), the
Management API must be enabled, and OIDC_CLIENT_ID must be present,
since the second sign-in reuses the dashboard's public client with
PKCE. The authorize, token and continue endpoints derive from the
issuer the browser uses; a path-based issuer such as Dex or Okta is
refused, because Auth0 always serves them from the domain root.
The Post-Login Action (POL-555) interrupts an unlinked social login and
sends the browser to GET /api/auth/link/start with an HS256 session
token, the only kind Auth0's encodeToken signs. This endpoint verifies
that token and sends the browser through a second /authorize on the
tenant's own login page, so the password account's whole login policy,
MFA included, decides whether the link may happen.

The second sign-in reuses the dashboard's public client with PKCE,
pinned to the database connection with prompt=login so the social
login's session cookie cannot answer it. An address with no password
account opens the tenant's sign-up instead. The state carries Auth0's
transaction and the PKCE verifier through the browser as a JWE under a
key derived from the secret, so the browser can neither read nor alter
it. The endpoint answers 404 while the secret is unset.

The callback that finishes the link lands next.
The tenant returns the browser to GET /api/auth/link/callback once the
second sign-in ends. The callback redeems the code with the PKCE
verifier from its own encrypted state and reads the ID token that came
straight from the token endpoint (OIDC Core 3.1.3.7), checking its
issuer, audience and expiry; the account-link config gains the issuer
it compares against. It links only a database account holding the same
verified address; when the token predates the email Form's
verification, the tenant's current user record decides.

It moves the social user's BoxLite data before it links at the tenant.
The link is the step that cannot be retried: once Auth0 folds the
identity in, later social logins skip this flow, so data left behind
would be stranded. Moving first leaves a failed link retryable, because
moving is idempotent.

Every outcome, failures included, goes back to the Action on /continue
as an HS256 token bound to the transaction's state, so the Action can
finish or refuse the login on the page the person is looking at.
The API turns the login-time account link (POL-555) on when
OIDC_ACCOUNT_LINK_REDIRECT_SECRET is set, but no deploy path forwarded
that value, so no stage could enable it.

Both paths now carry it. SST declares it as a secret, defaulting to
empty so a stage without it keeps the feature off, and passes it to the
API service; the key policy lists it among the application secrets.
mdeploy declares it in the stage's optional API group and forwards it
into the API environment, and its coverage test names it.
The login-time account link (POL-555) needs three things on the tenant
besides the API: the Post-Login Action must know where the API is and
hold the key both sides sign with, and the dashboard's SPA client must
allow the API's callback.

The configurator takes --account-link-api-origin, a bare https origin
or http on localhost, and reads the key from AUTH0_ACCOUNT_LINK_SECRET,
never argv. Apply registers the callback on the SPA client, writes the
origin into the Action and the key into its secrets, and upgrades an
earlier BoxLite-generated Action in place, journaling the code it
replaced; an Action edited outside the tool is still refused. Because
Auth0 never returns a secret's value, apply rewrites the key on every
run, which is how a rotated one arrives, and rollback restores the code
while leaving the key in place.

The Action's account-link branch that reads the origin lands next. The
operator guide gains an "Account linking at login" section covering
the flow, the one setting, configuring a stage and rotating the key.
With an API origin configured, the login policy Action (POL-555) now
interrupts a BoxLite social login whose user is not yet a password
account. It hands the browser to the API's /api/auth/link/start with an
HS256 session token naming the address, the database connection and the
callback (encodeToken puts these claims at the top level and adds the
social user as sub), and on /continue validates the API's answer, which
validateToken accepts only when its state claim matches the login's. A
link makes the password account the token's subject through
setPrimaryUser; any other outcome ends the login with a message.

An address the provider did not verify goes through the existing email
Form first, and is refused when no Form is configured. A social login
with no address, a non-browser exchange that cannot follow a redirect,
and a continuation that is neither the API's answer nor the Form are
refused. The API's own second sign-in runs through the dashboard client
on the database connection, so it arrives as the auth0| user and passes
through untouched. With no origin the branch stays off.
The Action and the API meet only through redirects and tokens Auth0
carries between them, so each side's unit tests pass while a renamed
parameter or a claim one side stops sending breaks the whole login.

This spec hydrates the real login-policy.js the configurator deploys,
runs it against the real AccountLinkController, and stands in for the
tenant with a fake that follows Auth0's documented redirect contract:
encodeToken on the way out, the /authorize and token endpoints in the
middle, and validateToken with the transaction's state on /continue.
It covers a linked password account, a sign-up for a new address, an
unverified GitHub address proven with the email Form, the wrong
account typed on the password page, and a sign-in the person backs
out of.
@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

TL;DR

The PR author must acknowledge the current diff and description before requesting review.

Author review acknowledgment

Awaiting the PR author's acknowledgment.

@DefeatMan: read the current diff and description, then check:

Does the description accurately explain the mechanism shown in the diff?

Use the form best suited to the change. Check drafts too, and repeat this review
after description edits. Post this as a new PR comment:

/reviewed e7ff9e75b67ae39d935c5beb9917dbdd60d2a3a1

Unacknowledged PRs are converted to draft. After this check passes, click Ready for review when you want reviews.
Only a new, unedited comment from the PR author counts. A new commit requires a new acknowledgment.
This records the author's acknowledgment of the commit, not an automated judgment of the description; maintainer approval is separate.

@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant