VYPR
Critical severity9.8GHSA Advisory· Published May 8, 2026· Updated May 13, 2026

CVE-2026-41574

CVE-2026-41574

Description

Nhost is an open source Firebase alternative with GraphQL. Prior to version 0.49.1, Nhost automatically links an incoming OAuth identity to an existing Nhost account when the email addresses match. This is only safe when the email has been verified by the OAuth provider. Nhost's controller trusts a profile.EmailVerified boolean that is set by each provider adapter. The vulnerability is that several provider adapters do not correctly populate this field they either silently drop a verified field the provider API actually returns (Discord), or they fall back to accepting unconfirmed emails and marking them as verified (Bitbucket). Two Microsoft providers (AzureAD, EntraID) derive the email from non-ownership-proving fields like the user principal name, then mark it verified. The result is that an attacker can present an email they don't own to Nhost, have the OAuth identity merged into the victim's account, and receive a full authenticated session. This issue has been patched in version 0.49.1.

Affected products

2
  • Range: < 0.0.0-20260417112436-ec8dab3f2cf4
  • cpe:2.3:a:nhost:nhost\/auth:*:*:*:*:*:*:*:*
    Range: <0.49.1

Patches

1
ec8dab3f2cf4

fix(auth): strict use of email verified with oauth2 providers (#4162)

https://github.com/nhost/nhostDavid BarrosoApr 17, 2026via ghsa
31 files changed · +1014 91
  • services/auth/go/controller/link_id_token_test.go+90 2 modified
    @@ -22,12 +22,18 @@ import (
     func testToken(t *testing.T, nonce string) string {
     	t.Helper()
     
    +	return testTokenWithEmailVerified(t, nonce, true)
    +}
    +
    +func testTokenWithEmailVerified(t *testing.T, nonce string, emailVerified bool) string {
    +	t.Helper()
    +
     	claims := jwt.MapClaims{
     		"iss":            "fake.issuer",
     		"aud":            "myapp.local",
     		"sub":            "106964149809169421082",
     		"email":          "jane@myapp.local",
    -		"email_verified": true,
    +		"email_verified": emailVerified,
     		"name":           "Jane",
     		"picture":        "https://myapp.local/jane.jpg",
     		"iat":            time.Now().Unix(),
    @@ -97,7 +103,7 @@ func TestLinkIdToken(t *testing.T) { //nolint:maintidx
     		{
     			name:   "success",
     			config: getConfig,
    -			db: func(ctrl *gomock.Controller) controller.DBClient {
    +			db: func(ctrl *gomock.Controller) controller.DBClient { //nolint:dupl
     				mock := mock.NewMockDBClient(ctrl)
     
     				mock.EXPECT().GetUser( //nolint:dupl
    @@ -172,6 +178,88 @@ func TestLinkIdToken(t *testing.T) { //nolint:maintidx
     			jwtTokenFn:       jwtTokenFn,
     		},
     
    +		{
    +			// Locks in that /link/idtoken ignores the OAuth provider's email
    +			// verification status: the Nhost account is identified by the
    +			// authenticated JWT, not by the OAuth email, so an unverified
    +			// (or unknown) email from the provider must not block linking.
    +			name:   "success - unverified provider email still links",
    +			config: getConfig,
    +			db: func(ctrl *gomock.Controller) controller.DBClient { //nolint:dupl
    +				mock := mock.NewMockDBClient(ctrl)
    +
    +				mock.EXPECT().GetUser( //nolint:dupl
    +					gomock.Any(),
    +					userID,
    +				).Return(sql.AuthUser{
    +					ID: userID,
    +					CreatedAt: pgtype.Timestamptz{ //nolint:exhaustruct
    +						Time: time.Now(),
    +					},
    +					UpdatedAt:   pgtype.Timestamptz{}, //nolint:exhaustruct
    +					LastSeen:    pgtype.Timestamptz{}, //nolint:exhaustruct
    +					Disabled:    false,
    +					DisplayName: "John",
    +					AvatarUrl:   "",
    +					Locale:      "en",
    +					Email:       sql.Text("fake@gmail.com"),
    +					PhoneNumber: pgtype.Text{}, //nolint:exhaustruct
    +					PasswordHash: sql.Text(
    +						"$2a$10$pyv7eu9ioQcFnLSz7u/enex22P3ORdh6z6116Vj5a3vSjo0oxFa1u",
    +					),
    +					EmailVerified:            true,
    +					PhoneNumberVerified:      false,
    +					NewEmail:                 pgtype.Text{},        //nolint:exhaustruct
    +					OtpMethodLastUsed:        pgtype.Text{},        //nolint:exhaustruct
    +					OtpHash:                  pgtype.Text{},        //nolint:exhaustruct
    +					OtpHashExpiresAt:         pgtype.Timestamptz{}, //nolint:exhaustruct
    +					DefaultRole:              "user",
    +					IsAnonymous:              false,
    +					TotpSecret:               pgtype.Text{},        //nolint:exhaustruct
    +					ActiveMfaType:            pgtype.Text{},        //nolint:exhaustruct
    +					Ticket:                   pgtype.Text{},        //nolint:exhaustruct
    +					TicketExpiresAt:          pgtype.Timestamptz{}, //nolint:exhaustruct
    +					Metadata:                 []byte{},
    +					WebauthnCurrentChallenge: pgtype.Text{}, //nolint:exhaustruct
    +				}, nil)
    +
    +				mock.EXPECT().InsertUserProvider(
    +					gomock.Any(),
    +					sql.InsertUserProviderParams{
    +						UserID:         userID,
    +						ProviderID:     "fake",
    +						ProviderUserID: "106964149809169421082",
    +					},
    +				).Return(
    +					sql.AuthUserProvider{
    +						ID:             userID,
    +						CreatedAt:      pgtype.Timestamptz{}, //nolint:exhaustruct
    +						UpdatedAt:      pgtype.Timestamptz{}, //nolint:exhaustruct
    +						UserID:         userID,
    +						AccessToken:    "unset",
    +						RefreshToken:   pgtype.Text{}, //nolint:exhaustruct
    +						ProviderID:     "fake",
    +						ProviderUserID: "106964149809169421082",
    +					}, nil,
    +				)
    +
    +				return mock
    +			},
    +			getControllerOpts: []getControllerOptsFunc{
    +				withIDTokenValidatorProviders(getTestIDTokenValidatorProviders()),
    +			},
    +			request: api.LinkIdTokenRequestObject{
    +				Body: &api.LinkIdTokenRequest{
    +					IdToken:  testTokenWithEmailVerified(t, nonce, false),
    +					Nonce:    new(nonce),
    +					Provider: "fake",
    +				},
    +			},
    +			expectedResponse: api.LinkIdToken200JSONResponse("OK"),
    +			expectedJWT:      nil,
    +			jwtTokenFn:       jwtTokenFn,
    +		},
    +
     		{
     			name:   "user disabled",
     			config: getConfig,
    
  • services/auth/go/controller/sign_in_id_token.go+59 24 modified
    @@ -92,7 +92,7 @@ func (ctrl *Controller) providerSignInFlow(
     
     	if userFound {
     		return ctrl.providerFlowSignIn(
    -			ctx, user, providerFound, provider, profile.ProviderUserID, logger,
    +			ctx, user, providerFound, provider, profile, logger,
     		)
     	}
     
    @@ -149,7 +149,7 @@ func (ctrl *Controller) providerFlowSignUp(
     		ctx,
     		profile.Email,
     		options,
    -		profile.Email != "" && !profile.EmailVerified,
    +		profile.Email != "" && !profile.EmailVerified.IsVerified(),
     		ctrl.providerFlowSignupWithSession(ctx, profile, provider, options),
     		ctrl.providerFlowSignupWithoutSession(ctx, profile, provider, options),
     		"",
    @@ -161,7 +161,7 @@ func (ctrl *Controller) providerFlowSignUp(
     
     	if session != nil {
     		session.User.AvatarUrl = profile.Picture
    -		session.User.EmailVerified = profile.EmailVerified
    +		session.User.EmailVerified = profile.EmailVerified.IsVerified()
     	}
     
     	return session, nil
    @@ -198,7 +198,7 @@ func (ctrl *Controller) providerFlowSignupWithSession(
     				Email:                 email,
     				Ticket:                pgtype.Text{}, //nolint:exhaustruct
     				TicketExpiresAt:       sql.TimestampTz(time.Now()),
    -				EmailVerified:         profile.EmailVerified,
    +				EmailVerified:         profile.EmailVerified.IsVerified(),
     				Locale:                deptr(options.Locale),
     				DefaultRole:           deptr(options.DefaultRole),
     				Metadata:              metadata,
    @@ -248,7 +248,7 @@ func (ctrl *Controller) providerFlowSignupWithoutSession(
     			Email:           email,
     			Ticket:          ticket,
     			TicketExpiresAt: ticketExpiresAt,
    -			EmailVerified:   profile.EmailVerified,
    +			EmailVerified:   profile.EmailVerified.IsVerified(),
     			Locale:          deptr(options.Locale),
     			DefaultRole:     deptr(options.DefaultRole),
     			Metadata:        metadata,
    @@ -264,22 +264,53 @@ func (ctrl *Controller) providerFlowSignupWithoutSession(
     	}
     }
     
    +// ensureProviderLinkAllowed rejects an attempt to link a new OAuth provider
    +// identity to an existing Nhost account when the provider has not explicitly
    +// attested that the caller owns the email address. EmailVerificationStatus
    +// values of Unknown (no signal) and Unverified are both rejected; only the
    +// explicit Verified status allows linking.
    +//
    +// Without this guard, an attacker could claim an unverified email on the
    +// OAuth provider (e.g. by changing their email to the victim's address and
    +// skipping confirmation) and take over the matching Nhost account.
    +func (ctrl *Controller) ensureProviderLinkAllowed(
    +	ctx context.Context,
    +	profile oidc.Profile,
    +	provider string,
    +	logger *slog.Logger,
    +) *APIError {
    +	if profile.EmailVerified.IsVerified() {
    +		return nil
    +	}
    +
    +	logger.WarnContext(ctx,
    +		"refusing to link provider to existing account: email not verified by provider",
    +		slog.String("provider", provider),
    +	)
    +
    +	return ErrUnverifiedUser
    +}
    +
     func (ctrl *Controller) providerFlowSignIn(
     	ctx context.Context,
     	user sql.AuthUser,
     	providerFound bool,
     	provider string,
    -	providerUserID string,
    +	profile oidc.Profile,
     	logger *slog.Logger,
     ) (*api.Session, *APIError) {
     	logger.InfoContext(ctx, "user found, signing in")
     
     	if !providerFound {
    +		if apiErr := ctrl.ensureProviderLinkAllowed(ctx, profile, provider, logger); apiErr != nil {
    +			return nil, apiErr
    +		}
    +
     		if _, apiErr := ctrl.wf.InsertUserProvider(
     			ctx,
     			user.ID,
     			provider,
    -			providerUserID,
    +			profile.ProviderUserID,
     			logger,
     		); apiErr != nil {
     			return nil, apiErr
    @@ -313,25 +344,29 @@ func (ctrl *Controller) providerResolveUser(
     		return uuid.Nil, apiError
     	}
     
    -	if userFound {
    -		logger.InfoContext(ctx, "user found, resolving for PKCE")
    -
    -		if !providerFound {
    -			if _, apiErr := ctrl.wf.InsertUserProvider(
    -				ctx,
    -				user.ID,
    -				provider,
    -				profile.ProviderUserID,
    -				logger,
    -			); apiErr != nil {
    -				return uuid.Nil, apiErr
    -			}
    +	if !userFound {
    +		return ctrl.providerSignUpResolveOnly(ctx, provider, profile, options, logger)
    +	}
    +
    +	logger.InfoContext(ctx, "user found, resolving for PKCE")
    +
    +	if !providerFound {
    +		if apiErr := ctrl.ensureProviderLinkAllowed(ctx, profile, provider, logger); apiErr != nil {
    +			return uuid.Nil, apiErr
     		}
     
    -		return user.ID, nil
    +		if _, apiErr := ctrl.wf.InsertUserProvider(
    +			ctx,
    +			user.ID,
    +			provider,
    +			profile.ProviderUserID,
    +			logger,
    +		); apiErr != nil {
    +			return uuid.Nil, apiErr
    +		}
     	}
     
    -	return ctrl.providerSignUpResolveOnly(ctx, provider, profile, options, logger)
    +	return user.ID, nil
     }
     
     // providerSignUpResolveOnly creates a new user from an OAuth provider profile
    @@ -351,7 +386,7 @@ func (ctrl *Controller) providerSignUpResolveOnly( //nolint:cyclop,funlen
     		return uuid.Nil, apiError
     	}
     
    -	sendConfirmationEmail := profile.Email != "" && !profile.EmailVerified
    +	sendConfirmationEmail := profile.Email != "" && !profile.EmailVerified.IsVerified()
     
     	// If email verification is needed or new users are disabled,
     	// use the existing sign-up-without-session flow and signal that
    @@ -400,7 +435,7 @@ func (ctrl *Controller) providerSignUpResolveOnly( //nolint:cyclop,funlen
     			Email:           email,
     			Ticket:          pgtype.Text{}, //nolint:exhaustruct
     			TicketExpiresAt: sql.TimestampTz(time.Now()),
    -			EmailVerified:   profile.EmailVerified,
    +			EmailVerified:   profile.EmailVerified.IsVerified(),
     			Locale:          deptr(options.Locale),
     			DefaultRole:     deptr(options.DefaultRole),
     			Metadata:        metadata,
    
  • services/auth/go/controller/sign_in_id_token_test.go+53 0 modified
    @@ -785,6 +785,59 @@ func TestSignInIdToken(t *testing.T) { //nolint:maintidx
     			expectedJWT: nil,
     			jwtTokenFn:  nil,
     		},
    +
    +		{
    +			name:   "signin - existing account - email not verified - refuses to link",
    +			config: getConfig,
    +			db: func(ctrl *gomock.Controller) controller.DBClient {
    +				mock := mock.NewMockDBClient(ctrl)
    +
    +				mock.EXPECT().GetUserByProviderID(
    +					gomock.Any(),
    +					sql.GetUserByProviderIDParams{
    +						ProviderID:     "fake",
    +						ProviderUserID: "106964149809169421082",
    +					},
    +				).Return(sql.AuthUser{}, pgx.ErrNoRows) //nolint:exhaustruct
    +
    +				mock.EXPECT().GetUserByEmail(
    +					gomock.Any(),
    +					sql.Text("jane@myapp.local"),
    +				).Return(
    +					//nolint:exhaustruct
    +					sql.AuthUser{
    +						ID: userID,
    +						CreatedAt: pgtype.Timestamptz{
    +							Time: time.Now(),
    +						},
    +						Disabled:      false,
    +						DisplayName:   "Jane",
    +						Email:         sql.Text("jane@myapp.local"),
    +						EmailVerified: true,
    +						DefaultRole:   "user",
    +					}, nil)
    +
    +				return mock
    +			},
    +			getControllerOpts: []getControllerOptsFunc{
    +				withIDTokenValidatorProviders(getTestIDTokenValidatorProviders()),
    +			},
    +			request: api.SignInIdTokenRequestObject{
    +				Body: &api.SignInIdTokenRequest{
    +					IdToken:  testTokenWithEmailVerified(t, nonce, false),
    +					Nonce:    new(nonce),
    +					Options:  nil,
    +					Provider: "fake",
    +				},
    +			},
    +			expectedResponse: controller.ErrorResponse{
    +				Error:   "unverified-user",
    +				Message: "User is not verified.",
    +				Status:  401,
    +			},
    +			expectedJWT: nil,
    +			jwtTokenFn:  nil,
    +		},
     	}
     
     	for _, tc := range cases {
    
  • services/auth/go/controller/sign_in_provider_callback_get_test.go+197 0 modified
    @@ -601,6 +601,56 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
     			getControllerOpts: nil,
     		},
     
    +		{
    +			name:   "signin - simple - email found - email not verified - refuses to link",
    +			config: getConfig,
    +			db: func(ctrl *gomock.Controller) controller.DBClient {
    +				mock := mock.NewMockDBClient(ctrl)
    +
    +				mock.EXPECT().GetUserByProviderID(
    +					gomock.Any(),
    +					sql.GetUserByProviderIDParams{
    +						ProviderID:     "fake",
    +						ProviderUserID: "1234567890",
    +					},
    +				).Return(sql.AuthUser{}, pgx.ErrNoRows) //nolint:exhaustruct
    +
    +				mock.EXPECT().GetUserByEmail(
    +					gomock.Any(),
    +					sql.Text("user1@fake.com"),
    +				).Return(
    +					//nolint:exhaustruct
    +					sql.AuthUser{
    +						ID: userID,
    +						CreatedAt: pgtype.Timestamptz{
    +							Time: time.Now(),
    +						},
    +						Disabled:      false,
    +						DisplayName:   "Jane",
    +						Email:         sql.Text("jane@myapp.local"),
    +						EmailVerified: true,
    +						DefaultRole:   "user",
    +					}, nil)
    +
    +				return mock
    +			},
    +			request: api.SignInProviderCallbackGetRequestObject{
    +				Params: api.SignInProviderCallbackGetParams{ //nolint:exhaustruct
    +					Code:  new("valid-code-unverified-email"),
    +					State: getState(t, jwtGetter, nil, api.SignUpOptions{}), //nolint:exhaustruct
    +				},
    +				Provider: "fake",
    +			},
    +			expectedResponse: controller.ErrorRedirectResponse{
    +				Headers: struct{ Location string }{
    +					Location: `^http://localhost:3000\?error=unverified-user&errorDescription=User\+is\+not\+verified.&state=some-random-state$`, //nolint:lll
    +				},
    +			},
    +			expectedJWT:       nil,
    +			jwtTokenFn:        nil,
    +			getControllerOpts: nil,
    +		},
    +
     		{
     			name:   "signin - user disabled",
     			config: getConfig,
    @@ -861,6 +911,100 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
     			getControllerOpts: nil,
     		},
     
    +		{
    +			// Locks in that the OAuth callback's connect branch ignores the
    +			// provider's email verification status: the Nhost account is
    +			// identified by the connect JWT embedded in the state, not by the
    +			// OAuth email, so an unverified (or unknown) email from the
    +			// provider must not block linking.
    +			name:   "connect - success - unverified provider email still links",
    +			config: getConfig,
    +			db: func(ctrl *gomock.Controller) controller.DBClient {
    +				mock := mock.NewMockDBClient(ctrl)
    +
    +				//nolint:exhaustruct
    +				mock.EXPECT().GetUser( //nolint:dupl
    +					gomock.Any(),
    +					userIDConnect,
    +				).Return(sql.AuthUser{
    +					ID: userID,
    +					CreatedAt: pgtype.Timestamptz{
    +						Time: time.Now(),
    +					},
    +					UpdatedAt:   pgtype.Timestamptz{},
    +					LastSeen:    pgtype.Timestamptz{},
    +					Disabled:    false,
    +					DisplayName: "John",
    +					AvatarUrl:   "",
    +					Locale:      "en",
    +					Email:       sql.Text("fake@gmail.com"),
    +					PhoneNumber: pgtype.Text{},
    +					PasswordHash: sql.Text(
    +						"$2a$10$pyv7eu9ioQcFnLSz7u/enex22P3ORdh6z6116Vj5a3vSjo0oxFa1u",
    +					),
    +					EmailVerified:            true,
    +					PhoneNumberVerified:      false,
    +					NewEmail:                 pgtype.Text{},
    +					OtpMethodLastUsed:        pgtype.Text{},
    +					OtpHash:                  pgtype.Text{},
    +					OtpHashExpiresAt:         pgtype.Timestamptz{},
    +					DefaultRole:              "user",
    +					IsAnonymous:              false,
    +					TotpSecret:               pgtype.Text{},
    +					ActiveMfaType:            pgtype.Text{},
    +					Ticket:                   pgtype.Text{},
    +					TicketExpiresAt:          sql.TimestampTz(time.Now()),
    +					Metadata:                 []byte{},
    +					WebauthnCurrentChallenge: pgtype.Text{},
    +				}, nil)
    +
    +				mock.EXPECT().InsertUserProvider(
    +					gomock.Any(),
    +					sql.InsertUserProviderParams{
    +						UserID:         userIDConnect,
    +						ProviderID:     "fake",
    +						ProviderUserID: "1234567890",
    +					},
    +				).Return(
    +					//nolint:exhaustruct
    +					sql.AuthUserProvider{
    +						ID:             userIDConnect,
    +						CreatedAt:      pgtype.Timestamptz{},
    +						UpdatedAt:      pgtype.Timestamptz{},
    +						UserID:         userID,
    +						AccessToken:    "unset",
    +						RefreshToken:   pgtype.Text{},
    +						ProviderID:     "fake",
    +						ProviderUserID: "1234567890",
    +					}, nil,
    +				)
    +
    +				mock.EXPECT().UpdateProviderSession(
    +					gomock.Any(),
    +					gomock.Any(),
    +				).Return(nil)
    +
    +				return mock
    +			},
    +			request: api.SignInProviderCallbackGetRequestObject{
    +				Params: api.SignInProviderCallbackGetParams{ //nolint:exhaustruct
    +					Code: new("valid-code-unverified-email"),
    +					State: getState(t, jwtGetter, &jwtToken, api.SignUpOptions{ //nolint:exhaustruct
    +						RedirectTo: new("http://localhost:3000/connect-success"),
    +					}),
    +				},
    +				Provider: "fake",
    +			},
    +			expectedResponse: api.SignInProviderCallbackGet302Response{
    +				Headers: api.SignInProviderCallbackGet302ResponseHeaders{
    +					Location: `^http://localhost:3000/connect-success\?state=some-random-state$`,
    +				},
    +			},
    +			expectedJWT:       nil,
    +			jwtTokenFn:        nil,
    +			getControllerOpts: nil,
    +		},
    +
     		{
     			name:   "connect - user disabled",
     			config: getConfig,
    @@ -1500,6 +1644,59 @@ func TestSignInProviderCallback(t *testing.T) { //nolint:maintidx
     			getControllerOpts: nil,
     		},
     
    +		{
    +			name:   "pkce - signin - email found - email not verified - refuses to link",
    +			config: getConfig,
    +			db: func(ctrl *gomock.Controller) controller.DBClient {
    +				mock := mock.NewMockDBClient(ctrl)
    +
    +				mock.EXPECT().GetUserByProviderID(
    +					gomock.Any(),
    +					sql.GetUserByProviderIDParams{
    +						ProviderID:     "fake",
    +						ProviderUserID: "1234567890",
    +					},
    +				).Return(sql.AuthUser{}, pgx.ErrNoRows) //nolint:exhaustruct
    +
    +				mock.EXPECT().GetUserByEmail(
    +					gomock.Any(),
    +					sql.Text("user1@fake.com"),
    +				).Return(
    +					//nolint:exhaustruct
    +					sql.AuthUser{
    +						ID: userID,
    +						CreatedAt: pgtype.Timestamptz{
    +							Time: time.Now(),
    +						},
    +						Disabled:      false,
    +						DisplayName:   "Jane",
    +						Email:         sql.Text("jane@myapp.local"),
    +						EmailVerified: true,
    +						DefaultRole:   "user",
    +					}, nil)
    +
    +				return mock
    +			},
    +			request: api.SignInProviderCallbackGetRequestObject{
    +				Params: api.SignInProviderCallbackGetParams{ //nolint:exhaustruct
    +					Code: new("valid-code-unverified-email"),
    +					State: getStateWithPKCE(
    +						t, jwtGetter, nil, api.SignUpOptions{}, //nolint:exhaustruct
    +						ptr("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"),
    +					),
    +				},
    +				Provider: "fake",
    +			},
    +			expectedResponse: controller.ErrorRedirectResponse{
    +				Headers: struct{ Location string }{
    +					Location: `^http://localhost:3000\?error=unverified-user&errorDescription=User\+is\+not\+verified.&state=some-random-state$`, //nolint:lll
    +				},
    +			},
    +			expectedJWT:       nil,
    +			jwtTokenFn:        nil,
    +			getControllerOpts: nil,
    +		},
    +
     		{
     			name: "pkce - signup - disable new users",
     			config: func() *controller.Config {
    
  • services/auth/go/oauth2/helpers.go+1 1 modified
    @@ -39,7 +39,7 @@ func computeAtHash(accessToken string) string {
     	return base64.RawURLEncoding.EncodeToString(h[:sha256.Size/2])
     }
     
    -func deptr[T any](x *T) T { //nolint:ireturn
    +func deptr[T any](x *T) T { //nolint:ireturn,nolintlint
     	if x == nil {
     		return *new(T)
     	}
    
  • services/auth/go/oidc/google.go+7 1 modified
    @@ -49,11 +49,17 @@ func getProfile(token *jwt.Token) (Profile, error) {
     		return Profile{}, fmt.Errorf("failed to get email claim from token: %w", err)
     	}
     
    +	emailVerifiedStatus := EmailVerificationStatusUnknown
    +
     	emailVerified, err := GetClaim[bool](token, "email_verified")
     	if err != nil && !errors.Is(err, ErrClaimNotFound) {
     		return Profile{}, fmt.Errorf("failed to get email_verified claim from token: %w", err)
     	}
     
    +	if err == nil {
    +		emailVerifiedStatus = EmailVerificationFromBool(emailVerified)
    +	}
    +
     	name, err := GetClaim[string](token, "name")
     	if err != nil && !errors.Is(err, ErrClaimNotFound) {
     		return Profile{}, fmt.Errorf("failed to get name claim from token: %w", err)
    @@ -67,7 +73,7 @@ func getProfile(token *jwt.Token) (Profile, error) {
     	return Profile{
     		ProviderUserID: sub,
     		Email:          email,
    -		EmailVerified:  emailVerified,
    +		EmailVerified:  emailVerifiedStatus,
     		Name:           name,
     		Picture:        picture,
     	}, nil
    
  • services/auth/go/oidc/idtoken.go+37 1 modified
    @@ -182,10 +182,46 @@ func validateNonce(token *jwt.Token, nonce string) error {
     	return nil
     }
     
    +// EmailVerificationStatus expresses whether the upstream OAuth/OIDC provider
    +// has attested that the user owns the email address returned on the profile.
    +//
    +// The zero value is EmailVerificationStatusUnknown, which means the adapter
    +// had no explicit signal from the provider. Unknown is treated as unverified
    +// for security-sensitive decisions (account linking, session issuance); use
    +// IsVerified to branch on it.
    +//
    +// Adapters MUST NOT infer verification from the presence of an email. Only
    +// set Verified when the provider exposes an explicit signal (an
    +// email_verified claim, a confirmed_at timestamp, or equivalent).
    +type EmailVerificationStatus int
    +
    +const (
    +	EmailVerificationStatusUnknown EmailVerificationStatus = iota
    +	EmailVerificationStatusVerified
    +	EmailVerificationStatusUnverified
    +)
    +
    +// IsVerified returns true only when the provider explicitly attested that the
    +// email is verified. Unknown and Unverified both return false.
    +func (s EmailVerificationStatus) IsVerified() bool {
    +	return s == EmailVerificationStatusVerified
    +}
    +
    +// EmailVerificationFromBool maps a provider-supplied boolean to the matching
    +// verification status. Use it when the adapter has an explicit signal and
    +// just needs to translate its shape.
    +func EmailVerificationFromBool(verified bool) EmailVerificationStatus {
    +	if verified {
    +		return EmailVerificationStatusVerified
    +	}
    +
    +	return EmailVerificationStatusUnverified
    +}
    +
     type Profile struct {
     	ProviderUserID string
     	Email          string
    -	EmailVerified  bool
    +	EmailVerified  EmailVerificationStatus
     	Name           string
     	Picture        string
     }
    
  • services/auth/go/providers/apple.go+1 1 modified
    @@ -154,7 +154,7 @@ func (a *Apple) GetProfile(
     	return oidc.Profile{
     		ProviderUserID: sub,
     		Email:          email,
    -		EmailVerified:  emailVerified,
    +		EmailVerified:  oidc.EmailVerificationFromBool(emailVerified),
     		Name:           displayName,
     		Picture:        "",
     	}, nil
    
  • services/auth/go/providers/azuread.go+15 16 modified
    @@ -41,11 +41,9 @@ func NewAzureadProvider(
     }
     
     type azureUser struct {
    -	OID    string `json:"oid"`
    -	Email  string `json:"email"`
    -	Name   string `json:"name"`
    -	UPN    string `json:"upn"`
    -	Prefer string `json:"preferred_username"`
    +	OID   string `json:"oid"`
    +	Email string `json:"email"`
    +	Name  string `json:"name"`
     }
     
     func (a *AzureAD) GetProfile(
    @@ -64,19 +62,20 @@ func (a *AzureAD) GetProfile(
     		return oidc.Profile{}, fmt.Errorf("AzureAD API error: %w", err)
     	}
     
    -	email := userProfile.Email
    -	if email == "" {
    -		email = userProfile.Prefer
    -	}
    -
    -	if email == "" {
    -		email = userProfile.UPN
    -	}
    -
    +	// Only the `email` claim represents a real external email Azure AD has
    +	// associated with the account. `preferred_username` and `upn` are internal
    +	// directory identifiers that can be set to arbitrary values (e.g. a custom
    +	// UPN like `ceo@target-company.com`) and do not prove email ownership.
    +	//
    +	// The legacy v1 `/openid/userinfo` endpoint does not expose an
    +	// `email_verified` claim, so there is no explicit signal to report and
    +	// the status is Unknown. For stricter verification, use the Entra ID
    +	// provider, which returns `email_verified` via Microsoft Graph's OIDC
    +	// userinfo endpoint.
     	return oidc.Profile{
     		ProviderUserID: userProfile.OID,
    -		Email:          email,
    -		EmailVerified:  email != "",
    +		Email:          userProfile.Email,
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     		Name:           userProfile.Name,
     		Picture:        "",
     	}, nil
    
  • services/auth/go/providers/azuread_internal_test.go+51 0 added
    @@ -0,0 +1,51 @@
    +package providers
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +)
    +
    +// TestAzureADProfileIgnoresUPNAndPreferredUsername verifies that the Azure AD
    +// user profile struct no longer deserializes the `upn` or `preferred_username`
    +// claims. These fields are internal directory identifiers and cannot be used
    +// to prove ownership of an external email, so they must never be treated as
    +// an account-linking email.
    +func TestAzureADProfileIgnoresUPNAndPreferredUsername(t *testing.T) {
    +	t.Parallel()
    +
    +	body := `{
    +		"oid": "00000000-0000-0000-66f3-3332eca7ea81",
    +		"name": "Jane",
    +		"email": "",
    +		"upn": "attacker@tenant.onmicrosoft.com",
    +		"preferred_username": "ceo@target-company.com"
    +	}`
    +
    +	var profile azureUser
    +	if err := json.Unmarshal([]byte(body), &profile); err != nil {
    +		t.Fatalf("unmarshal failed: %v", err)
    +	}
    +
    +	if profile.Email != "" {
    +		t.Errorf("email should be empty when the claim is empty, got %q", profile.Email)
    +	}
    +}
    +
    +func TestAzureADProfileUsesEmailOnly(t *testing.T) {
    +	t.Parallel()
    +
    +	body := `{
    +		"oid": "00000000-0000-0000-66f3-3332eca7ea81",
    +		"name": "Jane",
    +		"email": "jane@contoso.com"
    +	}`
    +
    +	var profile azureUser
    +	if err := json.Unmarshal([]byte(body), &profile); err != nil {
    +		t.Fatalf("unmarshal failed: %v", err)
    +	}
    +
    +	if profile.Email != "jane@contoso.com" {
    +		t.Errorf("email: got %q, want %q", profile.Email, "jane@contoso.com")
    +	}
    +}
    
  • services/auth/go/providers/bitbucket.go+10 14 modified
    @@ -99,34 +99,30 @@ func (b *Bitbucket) GetProfile(
     		return oidc.Profile{}, fmt.Errorf("Bitbucket email decode error: %w", err)
     	}
     
    -	// Pick the first verified email
    -	var (
    -		primaryEmail  string
    -		fallbackEmail string
    -	)
    +	// Pick the first confirmed email. Bitbucket lets users add any email to
    +	// their account without proof of ownership until they click the
    +	// confirmation link, so unconfirmed addresses must never be reported as
    +	// verified.
    +	var primaryEmail string
     
     	for _, e := range emailResp.Values {
     		if e.IsConfirmed {
     			primaryEmail = e.Email
     			break
    -		} else if fallbackEmail == "" {
    -			fallbackEmail = e.Email
     		}
     	}
     
     	if primaryEmail == "" {
    -		if fallbackEmail == "" {
    -			return oidc.Profile{}, ErrNoConfirmedBitbucketEmail
    -		}
    -
    -		primaryEmail = fallbackEmail
    +		return oidc.Profile{}, ErrNoConfirmedBitbucketEmail
     	}
     
    -	// Step 3: Return profile
    +	// Step 3: Return profile. We only reach this point after selecting a
    +	// confirmed email, so the status reflects Bitbucket's explicit
    +	// `is_confirmed` signal.
     	return oidc.Profile{
     		ProviderUserID: user.UUID,
     		Email:          primaryEmail,
    -		EmailVerified:  primaryEmail != "",
    +		EmailVerified:  oidc.EmailVerificationStatusVerified,
     		Name:           user.DisplayName,
     		Picture:        user.Links.Avatar.Href,
     	}, nil
    
  • services/auth/go/providers/bitbucket_internal_test.go+161 0 added
    @@ -0,0 +1,161 @@
    +package providers
    +
    +import (
    +	"context"
    +	"encoding/json"
    +	"errors"
    +	"fmt"
    +	"net/http"
    +	"net/http/httptest"
    +	"strings"
    +	"testing"
    +)
    +
    +// newBitbucketTestServer serves the two endpoints used by Bitbucket.GetProfile.
    +// The caller supplies the body returned for /user/emails.
    +func newBitbucketTestServer(t *testing.T, emailsBody string) *httptest.Server {
    +	t.Helper()
    +
    +	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +		switch {
    +		case strings.HasSuffix(r.URL.Path, "/user/emails"):
    +			w.Header().Set("Content-Type", "application/json")
    +			_, _ = w.Write([]byte(emailsBody))
    +		case strings.HasSuffix(r.URL.Path, "/user"):
    +			w.Header().Set("Content-Type", "application/json")
    +			_, _ = w.Write([]byte(`{
    +				"uuid": "{00000000-0000-0000-0000-000000000000}",
    +				"display_name": "Jane",
    +				"links": {"avatar": {"href": "https://example.com/a.png"}}
    +			}`))
    +		default:
    +			http.NotFound(w, r)
    +		}
    +	}))
    +}
    +
    +// bitbucketGetProfileWithBase is a test-only variant of Bitbucket.GetProfile
    +// that uses baseURL instead of the hard-coded Bitbucket API host, so we can
    +// exercise the email-selection logic against an httptest server.
    +//
    +// This mirrors the production code exactly; it intentionally only differs in
    +// the two URLs it targets.
    +func bitbucketGetProfileWithBase(
    +	ctx context.Context,
    +	baseURL, accessToken string,
    +) (string, bool, error) {
    +	var user bitbucketAPIUser
    +	if err := fetchOAuthProfile(ctx, baseURL+"/user", accessToken, &user); err != nil {
    +		return "", false, err
    +	}
    +
    +	req, err := http.NewRequestWithContext(
    +		ctx,
    +		http.MethodGet,
    +		baseURL+"/user/emails",
    +		nil,
    +	)
    +	if err != nil {
    +		return "", false, fmt.Errorf("new request: %w", err)
    +	}
    +
    +	req.Header.Set("Authorization", "Bearer "+accessToken)
    +
    +	resp, err := http.DefaultClient.Do(req)
    +	if err != nil {
    +		return "", false, fmt.Errorf("do request: %w", err)
    +	}
    +	defer resp.Body.Close()
    +
    +	var emailResp bitbucketEmailsResponse
    +	if err := json.NewDecoder(resp.Body).Decode(&emailResp); err != nil {
    +		return "", false, fmt.Errorf("decode response: %w", err)
    +	}
    +
    +	var primaryEmail string
    +
    +	for _, e := range emailResp.Values {
    +		if e.IsConfirmed {
    +			primaryEmail = e.Email
    +			break
    +		}
    +	}
    +
    +	if primaryEmail == "" {
    +		return "", false, ErrNoConfirmedBitbucketEmail
    +	}
    +
    +	return primaryEmail, true, nil
    +}
    +
    +func TestBitbucketRejectsUnconfirmedEmails(t *testing.T) {
    +	t.Parallel()
    +
    +	tests := []struct {
    +		name          string
    +		emailsBody    string
    +		expectedEmail string
    +		expectedErr   error
    +	}{
    +		{
    +			name: "picks first confirmed email",
    +			emailsBody: `{
    +				"values": [
    +					{"email": "unconfirmed@target.io", "is_confirmed": false},
    +					{"email": "jane@bitbucket.io", "is_confirmed": true}
    +				]
    +			}`,
    +			expectedEmail: "jane@bitbucket.io",
    +			expectedErr:   nil,
    +		},
    +		{
    +			name: "rejects when only unconfirmed email present (attacker case)",
    +			emailsBody: `{
    +				"values": [
    +					{"email": "victim@target.io", "is_confirmed": false}
    +				]
    +			}`,
    +			expectedEmail: "",
    +			expectedErr:   ErrNoConfirmedBitbucketEmail,
    +		},
    +		{
    +			name:          "rejects empty email list",
    +			emailsBody:    `{"values": []}`,
    +			expectedEmail: "",
    +			expectedErr:   ErrNoConfirmedBitbucketEmail,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			srv := newBitbucketTestServer(t, tc.emailsBody)
    +			defer srv.Close()
    +
    +			email, verified, err := bitbucketGetProfileWithBase(
    +				t.Context(), srv.URL, "fake-token",
    +			)
    +
    +			if tc.expectedErr != nil {
    +				if !errors.Is(err, tc.expectedErr) {
    +					t.Fatalf("error: got %v, want %v", err, tc.expectedErr)
    +				}
    +
    +				return
    +			}
    +
    +			if err != nil {
    +				t.Fatalf("unexpected error: %v", err)
    +			}
    +
    +			if email != tc.expectedEmail {
    +				t.Errorf("email: got %q, want %q", email, tc.expectedEmail)
    +			}
    +
    +			if !verified {
    +				t.Errorf("verified: got false, want true")
    +			}
    +		})
    +	}
    +}
    
  • services/auth/go/providers/discord.go+2 1 modified
    @@ -38,6 +38,7 @@ type discordUserProfile struct {
     	Username      string `json:"username"`
     	Discriminator string `json:"discriminator"`
     	Email         string `json:"email"`
    +	Verified      bool   `json:"verified"`
     	Locale        string `json:"locale"`
     	Avatar        string `json:"avatar"`
     }
    @@ -62,7 +63,7 @@ func (d *Discord) GetProfile(
     		ProviderUserID: userProfile.ID,
     		Name:           fmt.Sprintf("%s#%s", userProfile.Username, userProfile.Discriminator),
     		Email:          userProfile.Email,
    -		EmailVerified:  userProfile.Email != "",
    +		EmailVerified:  oidc.EmailVerificationFromBool(userProfile.Verified),
     		Picture: fmt.Sprintf(
     			"https://cdn.discordapp.com/avatars/%s/%s.png",
     			userProfile.ID,
    
  • services/auth/go/providers/discord_internal_test.go+50 0 added
    @@ -0,0 +1,50 @@
    +package providers
    +
    +import "testing"
    +
    +func TestDiscordProfileDecoding(t *testing.T) {
    +	t.Parallel()
    +
    +	runProfileDecodingCases(
    +		t,
    +		[]profileDecodingCase{
    +			{
    +				name: "verified account",
    +				body: `{
    +					"id": "80351110224678912",
    +					"username": "Nelly",
    +					"discriminator": "1337",
    +					"email": "nelly@discord.com",
    +					"verified": true,
    +					"avatar": "8342729096ea3675442027381ff50dfe"
    +				}`,
    +				expectedEmail:    "nelly@discord.com",
    +				expectedVerified: true,
    +			},
    +			{
    +				name: "unverified account must not be marked verified",
    +				body: `{
    +					"id": "80351110224678912",
    +					"username": "Nelly",
    +					"discriminator": "1337",
    +					"email": "victim@target.io",
    +					"verified": false
    +				}`,
    +				expectedEmail:    "victim@target.io",
    +				expectedVerified: false,
    +			},
    +			{
    +				name: "missing verified field defaults to false (safe)",
    +				body: `{
    +					"id": "80351110224678912",
    +					"username": "Nelly",
    +					"email": "victim@target.io"
    +				}`,
    +				expectedEmail:    "victim@target.io",
    +				expectedVerified: false,
    +			},
    +		},
    +		func(p discordUserProfile) string { return p.Email },
    +		func(p discordUserProfile) bool { return p.Verified },
    +	)
    +}
    
  • services/auth/go/providers/entraid.go+6 5 modified
    @@ -39,10 +39,11 @@ func NewEntraIDProvider(
     }
     
     type entraidUser struct {
    -	Sub        string `json:"sub"`
    -	GivenName  string `json:"givenname"`
    -	FamilyName string `json:"familyname"`
    -	Email      string `json:"email"`
    +	Sub           string `json:"sub"`
    +	GivenName     string `json:"givenname"`
    +	FamilyName    string `json:"familyname"`
    +	Email         string `json:"email"`
    +	EmailVerified bool   `json:"email_verified"`
     }
     
     func (a *EntraID) GetProfile(
    @@ -64,7 +65,7 @@ func (a *EntraID) GetProfile(
     	return oidc.Profile{
     		ProviderUserID: userProfile.Sub,
     		Email:          userProfile.Email,
    -		EmailVerified:  userProfile.Email != "",
    +		EmailVerified:  oidc.EmailVerificationFromBool(userProfile.EmailVerified),
     		Name:           userProfile.GivenName + " " + userProfile.FamilyName,
     		Picture:        "",
     	}, nil
    
  • services/auth/go/providers/entraid_internal_test.go+46 0 added
    @@ -0,0 +1,46 @@
    +package providers
    +
    +import "testing"
    +
    +func TestEntraIDProfileDecoding(t *testing.T) {
    +	t.Parallel()
    +
    +	runProfileDecodingCases(
    +		t,
    +		[]profileDecodingCase{
    +			{
    +				name: "verified email",
    +				body: `{
    +					"sub": "00000000-0000-0000-66f3-3332eca7ea81",
    +					"givenname": "Jane",
    +					"familyname": "Doe",
    +					"email": "jane@contoso.com",
    +					"email_verified": true
    +				}`,
    +				expectedEmail:    "jane@contoso.com",
    +				expectedVerified: true,
    +			},
    +			{
    +				name: "email present but not verified must be rejected",
    +				body: `{
    +					"sub": "00000000-0000-0000-66f3-3332eca7ea81",
    +					"email": "victim@target.io",
    +					"email_verified": false
    +				}`,
    +				expectedEmail:    "victim@target.io",
    +				expectedVerified: false,
    +			},
    +			{
    +				name: "missing email_verified defaults to false (safe)",
    +				body: `{
    +					"sub": "00000000-0000-0000-66f3-3332eca7ea81",
    +					"email": "victim@target.io"
    +				}`,
    +				expectedEmail:    "victim@target.io",
    +				expectedVerified: false,
    +			},
    +		},
    +		func(p entraidUser) string { return p.Email },
    +		func(p entraidUser) bool { return p.EmailVerified },
    +	)
    +}
    
  • services/auth/go/providers/facebook.go+3 1 modified
    @@ -60,11 +60,13 @@ func (t *Facebook) GetProfile(
     		return oidc.Profile{}, fmt.Errorf("Facebook API error: %w", err)
     	}
     
    +	// Facebook's Graph API does not return an explicit verification flag, so
    +	// we have no signal to trust.
     	return oidc.Profile{
     		ProviderUserID: userProfile.ID,
     		Name:           userProfile.Name,
     		Email:          userProfile.Email,
    -		EmailVerified:  userProfile.Email != "",
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     		Picture:        userProfile.Picture.Data.URL,
     	}, nil
     }
    
  • services/auth/go/providers/fake.go+17 2 modified
    @@ -56,6 +56,13 @@ func (f *FakeProvider) Exchange(
     			TokenType:    "Bearer",
     			ExpiresIn:    9000, //nolint:mnd
     		}, nil
    +	case "valid-code-unverified-email":
    +		return &oauth2.Token{ //nolint:exhaustruct
    +			AccessToken:  "valid-accesstoken-unverified-email",
    +			RefreshToken: "valid-refreshtoken-unverified-email",
    +			TokenType:    "Bearer",
    +			ExpiresIn:    9000, //nolint:mnd
    +		}, nil
     	default:
     		return nil, errors.New("invalid code") //nolint:err113
     	}
    @@ -72,18 +79,26 @@ func (f *FakeProvider) GetProfile(
     		return oidc.Profile{
     			ProviderUserID: "1234567890",
     			Email:          "user1@fake.com",
    -			EmailVerified:  true,
    +			EmailVerified:  oidc.EmailVerificationStatusVerified,
     			Name:           "User One",
     			Picture:        "https://fake.com/images/profile/user1.jpg",
     		}, nil
     	case "valid-accesstoken-empty-email":
     		return oidc.Profile{
     			ProviderUserID: "9876543210",
     			Email:          "",
    -			EmailVerified:  false,
    +			EmailVerified:  oidc.EmailVerificationStatusUnverified,
     			Name:           "User No Email",
     			Picture:        "https://fake.com/images/profile/user2.jpg",
     		}, nil
    +	case "valid-accesstoken-unverified-email":
    +		return oidc.Profile{
    +			ProviderUserID: "1234567890",
    +			Email:          "user1@fake.com",
    +			EmailVerified:  oidc.EmailVerificationStatusUnverified,
    +			Name:           "User One",
    +			Picture:        "https://fake.com/images/profile/user1.jpg",
    +		}, nil
     	default:
     		return oidc.Profile{}, errors.New("invalid access token") //nolint:err113
     	}
    
  • services/auth/go/providers/github.go+1 1 modified
    @@ -118,7 +118,7 @@ func (g *Github) GetProfile(
     	return oidc.Profile{
     		ProviderUserID: strconv.Itoa(user.ID),
     		Email:          selected.Email,
    -		EmailVerified:  selected.Verified,
    +		EmailVerified:  oidc.EmailVerificationFromBool(selected.Verified),
     		Name:           user.Name,
     		Picture:        user.AvatarURL,
     	}, nil
    
  • services/auth/go/providers/gitlab.go+14 7 modified
    @@ -35,11 +35,12 @@ func NewGitlabProvider(
     }
     
     type gitlabUserProfile struct {
    -	ID        int    `json:"id"`
    -	Username  string `json:"username"`
    -	Name      string `json:"name"`
    -	Email     string `json:"email"`
    -	AvatarURL string `json:"avatar_url"`
    +	ID          int    `json:"id"`
    +	Username    string `json:"username"`
    +	Name        string `json:"name"`
    +	Email       string `json:"email"`
    +	ConfirmedAt string `json:"confirmed_at"`
    +	AvatarURL   string `json:"avatar_url"`
     }
     
     func (g *Gitlab) GetProfile(
    @@ -58,12 +59,18 @@ func (g *Gitlab) GetProfile(
     		return oidc.Profile{}, fmt.Errorf("GitLab API error: %w", err)
     	}
     
    +	// GitLab's /user endpoint returns the account's primary email and a
    +	// `confirmed_at` timestamp that is non-empty only after the user has
    +	// verified the address. Require both to mark the email as verified so a
    +	// pending-confirmation account cannot be linked to an existing Nhost user.
     	return oidc.Profile{
     		ProviderUserID: strconv.Itoa(userProfile.ID),
     		Name:           userProfile.Name,
     		Email:          userProfile.Email,
    -		EmailVerified:  userProfile.Email != "",
    -		Picture:        userProfile.AvatarURL,
    +		EmailVerified: oidc.EmailVerificationFromBool(
    +			userProfile.Email != "" && userProfile.ConfirmedAt != "",
    +		),
    +		Picture: userProfile.AvatarURL,
     	}, nil
     }
     
    
  • services/auth/go/providers/gitlab_internal_test.go+71 0 added
    @@ -0,0 +1,71 @@
    +package providers
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +)
    +
    +func TestGitlabProfileDecoding(t *testing.T) {
    +	t.Parallel()
    +
    +	tests := []struct {
    +		name         string
    +		body         string
    +		wantEmail    string
    +		wantVerified bool
    +	}{
    +		{
    +			name: "confirmed email",
    +			body: `{
    +				"id": 42,
    +				"name": "Jane",
    +				"email": "jane@gitlab.com",
    +				"confirmed_at": "2024-01-15T10:00:00Z"
    +			}`,
    +			wantEmail:    "jane@gitlab.com",
    +			wantVerified: true,
    +		},
    +		{
    +			name: "unconfirmed email must not be marked verified",
    +			body: `{
    +				"id": 42,
    +				"name": "Jane",
    +				"email": "victim@target.io",
    +				"confirmed_at": null
    +			}`,
    +			wantEmail:    "victim@target.io",
    +			wantVerified: false,
    +		},
    +		{
    +			name: "missing confirmed_at is treated as unverified",
    +			body: `{
    +				"id": 42,
    +				"name": "Jane",
    +				"email": "victim@target.io"
    +			}`,
    +			wantEmail:    "victim@target.io",
    +			wantVerified: false,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			var profile gitlabUserProfile
    +			if err := json.Unmarshal([]byte(tc.body), &profile); err != nil {
    +				t.Fatalf("unmarshal failed: %v", err)
    +			}
    +
    +			verified := profile.Email != "" && profile.ConfirmedAt != ""
    +
    +			if profile.Email != tc.wantEmail {
    +				t.Errorf("email: got %q, want %q", profile.Email, tc.wantEmail)
    +			}
    +
    +			if verified != tc.wantVerified {
    +				t.Errorf("verified: got %v, want %v", verified, tc.wantVerified)
    +			}
    +		})
    +	}
    +}
    
  • services/auth/go/providers/google.go+1 1 modified
    @@ -58,7 +58,7 @@ func (g *Google) GetProfile(
     	return oidc.Profile{
     		ProviderUserID: user.ID,
     		Email:          user.Email,
    -		EmailVerified:  user.VerifiedEmail,
    +		EmailVerified:  oidc.EmailVerificationFromBool(user.VerifiedEmail),
     		Name:           user.Name,
     		Picture:        user.Picture,
     	}, nil
    
  • services/auth/go/providers/linkedin.go+7 6 modified
    @@ -34,11 +34,12 @@ func NewLinkedInProvider(
     }
     
     type linkedInUserInfoProfile struct {
    -	ID         string `json:"sub"`
    -	Email      string `json:"email"`
    -	GivenName  string `json:"given_name"`
    -	FamilyName string `json:"family_name"`
    -	Picture    string `json:"picture"`
    +	ID            string `json:"sub"`
    +	Email         string `json:"email"`
    +	EmailVerified bool   `json:"email_verified"`
    +	GivenName     string `json:"given_name"`
    +	FamilyName    string `json:"family_name"`
    +	Picture       string `json:"picture"`
     }
     
     func (l *LinkedIn) GetProfile(
    @@ -75,7 +76,7 @@ func (l *LinkedIn) GetProfile(
     	return oidc.Profile{
     		ProviderUserID: userProfile.ID,
     		Email:          userProfile.Email,
    -		EmailVerified:  userProfile.Email != "",
    +		EmailVerified:  oidc.EmailVerificationFromBool(userProfile.EmailVerified),
     		Name:           name,
     		Picture:        userProfile.Picture,
     	}, nil
    
  • services/auth/go/providers/linkedin_internal_test.go+46 0 added
    @@ -0,0 +1,46 @@
    +package providers
    +
    +import "testing"
    +
    +func TestLinkedInProfileDecoding(t *testing.T) {
    +	t.Parallel()
    +
    +	runProfileDecodingCases(
    +		t,
    +		[]profileDecodingCase{
    +			{
    +				name: "verified email",
    +				body: `{
    +					"sub": "123",
    +					"email": "jane@example.com",
    +					"email_verified": true,
    +					"given_name": "Jane",
    +					"family_name": "Doe"
    +				}`,
    +				expectedEmail:    "jane@example.com",
    +				expectedVerified: true,
    +			},
    +			{
    +				name: "unverified email must not be marked verified",
    +				body: `{
    +					"sub": "123",
    +					"email": "victim@target.io",
    +					"email_verified": false
    +				}`,
    +				expectedEmail:    "victim@target.io",
    +				expectedVerified: false,
    +			},
    +			{
    +				name: "missing email_verified defaults to false (safe)",
    +				body: `{
    +					"sub": "123",
    +					"email": "victim@target.io"
    +				}`,
    +				expectedEmail:    "victim@target.io",
    +				expectedVerified: false,
    +			},
    +		},
    +		func(p linkedInUserInfoProfile) string { return p.Email },
    +		func(p linkedInUserInfoProfile) bool { return p.EmailVerified },
    +	)
    +}
    
  • services/auth/go/providers/profile_decoding_internal_test.go+48 0 added
    @@ -0,0 +1,48 @@
    +package providers
    +
    +import (
    +	"encoding/json"
    +	"testing"
    +)
    +
    +// profileDecodingCase describes a JSON → provider-struct decoding assertion
    +// used by the per-provider verified-email decoding tests. Shared to avoid
    +// line-for-line duplication across adapters.
    +type profileDecodingCase struct {
    +	name             string
    +	body             string
    +	expectedEmail    string
    +	expectedVerified bool
    +}
    +
    +// runProfileDecodingCases unmarshals each case's body into a freshly-constructed
    +// T and asserts the expected email/verified extracted via the supplied
    +// accessors. Designed for provider adapters where the raw JSON struct carries
    +// both an email field and an explicit verification flag.
    +func runProfileDecodingCases[T any](
    +	t *testing.T,
    +	tests []profileDecodingCase,
    +	emailOf func(T) string,
    +	verifiedOf func(T) bool,
    +) {
    +	t.Helper()
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			t.Parallel()
    +
    +			var profile T
    +			if err := json.Unmarshal([]byte(tc.body), &profile); err != nil {
    +				t.Fatalf("unmarshal failed: %v", err)
    +			}
    +
    +			if got := emailOf(profile); got != tc.expectedEmail {
    +				t.Errorf("email: got %q, want %q", got, tc.expectedEmail)
    +			}
    +
    +			if got := verifiedOf(profile); got != tc.expectedVerified {
    +				t.Errorf("verified: got %v, want %v", got, tc.expectedVerified)
    +			}
    +		})
    +	}
    +}
    
  • services/auth/go/providers/spotify.go+3 1 modified
    @@ -76,11 +76,13 @@ func (s *Spotify) GetProfile(
     		avatarURL = userProfile.Images[0].URL
     	}
     
    +	// Spotify's /v1/me endpoint does not return an explicit verification
    +	// flag, so there is no signal to report.
     	return oidc.Profile{
     		ProviderUserID: userProfile.ID,
     		Name:           userProfile.DisplayName,
     		Email:          userProfile.Email,
    -		EmailVerified:  userProfile.Email != "",
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     		Picture:        avatarURL,
     	}, nil
     }
    
  • services/auth/go/providers/strava.go+2 2 modified
    @@ -67,12 +67,12 @@ func (s *Strava) GetProfile(
     		return oidc.Profile{}, fmt.Errorf("Strava API error: %w", err)
     	}
     
    -	// Email intentionally left out, and not marked verified
    +	// Strava does not expose email at all, so there is nothing to verify.
     	return oidc.Profile{
     		ProviderUserID: strconv.Itoa(athlete.ID),
     		Name:           athlete.Firstname + " " + athlete.Lastname,
     		Picture:        athlete.ProfileMedium,
     		Email:          "",
    -		EmailVerified:  false,
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     	}, nil
     }
    
  • services/auth/go/providers/twitch.go+3 1 modified
    @@ -74,11 +74,13 @@ func (t *Twitch) GetProfile(
     
     	userProfile := response.Data[0]
     
    +	// Twitch's /helix/users endpoint does not return an explicit
    +	// verification flag, so there is no signal to report.
     	return oidc.Profile{
     		ProviderUserID: userProfile.ID,
     		Name:           userProfile.DisplayName,
     		Email:          userProfile.Email,
    -		EmailVerified:  userProfile.Email != "",
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     		Picture:        userProfile.ProfileImageURL,
     	}, nil
     }
    
  • services/auth/go/providers/twitter.go+3 1 modified
    @@ -60,10 +60,12 @@ func (t *Twitter) GetProfile(
     		return oidc.Profile{}, fmt.Errorf("Twitter API error: %w", err)
     	}
     
    +	// Twitter's verify_credentials endpoint does not return an explicit
    +	// verification flag, so there is no signal to report.
     	return oidc.Profile{
     		ProviderUserID: user.ID,
     		Email:          user.Email,
    -		EmailVerified:  user.Email != "",
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     		Name:           user.Name,
     		Picture:        user.ProfileImageURL,
     	}, nil
    
  • services/auth/go/providers/windowslive.go+3 1 modified
    @@ -73,11 +73,13 @@ func (w *WindowsLive) GetProfile(
     		email = profile.Emails.Account
     	}
     
    +	// Microsoft's Live v5.0/me endpoint does not return an explicit
    +	// verification flag, so there is no signal to report.
     	return oidc.Profile{
     		ProviderUserID: profile.ID,
     		Name:           profile.Name + " " + profile.LastName,
     		Email:          email,
    -		EmailVerified:  email != "",
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     		Picture:        profile.ProfileImageURL,
     	}, nil
     }
    
  • services/auth/go/providers/workos.go+6 1 modified
    @@ -115,11 +115,16 @@ func (w *WorkOS) GetProfile(
     		return oidc.Profile{}, fmt.Errorf("WorkOS API Error: %w", err)
     	}
     
    +	// WorkOS's /sso/profile endpoint does not surface an explicit
    +	// verification flag. The profile is produced by the enterprise identity
    +	// provider configured by the customer (SAML, OIDC, Google Workspace,
    +	// etc.) and carries no verification signal to this layer, so the status
    +	// is Unknown.
     	return oidc.Profile{
     		ProviderUserID: userProfile.ID,
     		Email:          userProfile.Email,
     		Name:           userProfile.FirstName + " " + userProfile.LastName,
     		Picture:        "",
    -		EmailVerified:  userProfile.Email != "",
    +		EmailVerified:  oidc.EmailVerificationStatusUnknown,
     	}, nil
     }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.