Conversation
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.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
TL;DRThe PR author must acknowledge the current diff and description before requesting review. Author review acknowledgmentAwaiting 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 Unacknowledged PRs are converted to draft. After this check passes, click Ready for review when you want reviews. |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
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