Skip to content

fix(api): create email identity when setting password on OAuth-only user#2509

Open
mastermanas805 wants to merge 1 commit intosupabase:masterfrom
mastermanas805:fix/updateuser-creates-email-identity-on-password-set
Open

fix(api): create email identity when setting password on OAuth-only user#2509
mastermanas805 wants to merge 1 commit intosupabase:masterfrom
mastermanas805:fix/updateuser-creates-email-identity-on-password-set

Conversation

@mastermanas805
Copy link
Copy Markdown

What

Fixes #2085.

When a user whose only existing identity is from an external OAuth provider (custom OIDC, Apple, GitHub, etc.) calls PUT /user with a password field, UserUpdate writes the new password hash to the users table but does not create a corresponding email identity row in auth.identities. After the call, the user's password works, but app_metadata.providers still lists only the external provider, /user/identities doesn't show an email identity, and downstream code that gates on identity rows treats the user as not having email/password.

The recovery and email-change flows in verify.go already do this correctly via createNewIdentity(tx, user, "email", ...) plus UpdateAppMetaDataProviders. UserUpdate was the missing copy.

How to reproduce on master (for reviewers)

This needs a user whose only identity is non-email. Easiest way is the custom OIDC flow against a local IdP (Zitadel works), but any OAuth provider will do. Steps:

# 1. Bring up auth via the dev compose
cd auth
cp example.docker.env .env.docker
# Edit .env.docker: set GOTRUE_JWT_SECRET to a 32+ char value.
make dev
# In another shell:
make migrate_dev

Now create an OAuth-only user. The fastest path is the unit-style reproducer below, but anyone with a working OIDC provider can drive this through /authorize?provider=... and complete the OAuth dance.

# Generate a service_role JWT (signed with the same GOTRUE_JWT_SECRET).
JWT_SECRET="..."
NOW=$(date +%s); EXP=$((NOW + 3600))
JWT=$( ... )  # standard HS256 JWT with role=service_role, iat, exp

# Create a user that has an email but only an external identity, mimicking
# what an OAuth provider sign-in would produce.
USER_ID=$(curl -s -X POST http://localhost:9999/admin/users \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{"email":"oauth-only@example.com","email_confirm":true}' \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")

# Add an external identity for that user (simulating Apple / Google / custom OIDC).
curl -s -X POST http://localhost:9999/admin/users/$USER_ID/identities \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d "{\"provider\":\"google\",\"provider_id\":\"ext-sub-123\",\"identity_data\":{\"sub\":\"ext-sub-123\",\"email\":\"oauth-only@example.com\",\"email_verified\":true}}" || true

# Sign that user in (e.g. via a session-mint admin path or by driving the
# OAuth flow). For evidence in this PR I drove the full custom-OIDC
# roundtrip against a local Zitadel instance and captured the access
# token from the redirect URL fragment.
ACCESS_TOKEN=...

# Inspect identities. Expected: only the external provider is present.
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:9999/user \
  | python3 -c "import json,sys;d=json.load(sys.stdin);print([i['provider'] for i in d.get('identities',[])])"
# => ['google']     (or whatever external provider was used)

# Set a password.
curl -s -X PUT http://localhost:9999/user \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"password":"NewPassword12345!"}'

# Re-inspect identities. On master: still only the external provider.
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:9999/user \
  | python3 -c "import json,sys;d=json.load(sys.stdin);print([i['provider'] for i in d.get('identities',[])])"
# => ['google']     (BUG: email identity was not created)

DB inspection on master:

SELECT user_id, provider FROM auth.identities WHERE user_id = '<USER_ID>';
-- Returns one row, provider='google'. No email identity.

Live reproduction I ran

Drove the full custom-OIDC flow against a local Zitadel instance configured as a custom OIDC provider in auth, captured the access token, ran the above PUT, and confirmed:

  • BEFORE: identities = ['custom:zitadel']
  • After PUT /user with password (HTTP 200)
  • AFTER on master: identities = ['custom:zitadel'] ← BUG
  • AFTER with this fix: identities = ['custom:zitadel', 'email'] ← FIXED

The DB row added by the fix has provider='email', provider_id=<user.ID>, identity_data containing sub, email, and email_verified mirroring the user record.

Root cause

internal/api/user.go::UserUpdate. After user.UpdatePassword(tx, sessionID) succeeds, the function continues with audit log + notification but never creates an email identity. Compare with the recovery and email-change flows in internal/api/verify.go which call createNewIdentity(tx, user, "email", structs.Map(provider.Claims{...})) on the same condition.

Fix

In the password-update branch of the transaction in UserUpdate, after the audit log and password-changed notification, look up models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"). If the user has an email and no email identity exists, create one via a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{...})) and call user.UpdateAppMetaDataProviders(tx) so app_metadata.providers reflects the new provider. Idempotent for callers that already have an email identity.

How to verify the fix manually (for reviewers)

Apply this PR's diff, hot-reload auth (docker compose -f docker-compose-dev.yml restart auth or just save the file under CompileDaemon), and re-run the PUT step from the reproduction. The /user response now lists the email identity. The DB:

SELECT user_id, provider FROM auth.identities WHERE user_id = '<USER_ID>';
-- Now two rows: the external provider AND email.

Automated tests

internal/api/user_test.go adds TestUserUpdatePasswordCreatesEmailIdentity:

  • Creates a user with an email but only an external (Google-shaped) identity
  • Asserts no email identity exists
  • Drives PUT /user with {"password": "..."}
  • Asserts an email identity is created and points at the user
  • Drives a second PUT /user with {"password": "...", "current_password": "..."} and asserts the email identity is not duplicated

Run:

go test ./internal/api/ -run TestUser/TestUserUpdatePasswordCreatesEmailIdentity -v -count=1

Fails on master (require.NoError on the post-PUT identity lookup), passes after this fix.

Verification I ran locally

  • go test ./internal/api/ -run TestUser -v -count=1 — green (existing TestUserUpdatePassword* cases unchanged + new case passes)
  • go test ./... -count=1 -p 1 -race — full module suite green
  • gofmt -l internal/api/user.go internal/api/user_test.go — empty
  • go vet ./... — empty
  • Live before/after Docker roundtrip per the steps above

Notes for review

  • Idempotent for users who already have an email identity: the lookup returns one and the create branch is skipped.
  • No password change without an email is required: the create branch is gated on user.GetEmail() != "".
  • EmailVerified on the new identity reflects user.IsConfirmed() so an unverified email stays unverified.
  • Mirrors the existing pattern in internal/api/verify.go::recoverVerify and emailChangeVerify so a reader will recognize the shape.

UserUpdate handles a password change by writing the new hash to the
users table but never creates a corresponding email identity row, even
though the user now has email/password as a valid sign-in method. For
users whose only existing identity is from an external OAuth provider
(custom OIDC, Apple, GitHub, etc.), this leaves auth.identities and
the providers list in app_metadata out of sync with what /user reports
and what the user can actually do.

Mirror the pattern already used by recoverVerify / emailChangeVerify in
verify.go: after UpdatePassword succeeds, look up an email identity for
the user, and if there isn't one and the user has an email address,
create it via createNewIdentity and refresh app_metadata.providers.
The existing operation is idempotent for callers that already have an
email identity.

Adds a regression test that creates a user with a single external
identity, asserts no email identity exists initially, calls PUT /user
with a password, and asserts an email identity is now present and is
not duplicated on a second password change.

Fixes supabase#2085

Signed-off-by: Manas Srivastava <mastermanas805@gmail.com>
@mastermanas805 mastermanas805 marked this pull request as ready for review April 27, 2026 01:54
@mastermanas805 mastermanas805 requested a review from a team as a code owner April 27, 2026 01:54
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.

Setting password via updateUser does not add email provider to user identities

1 participant