diff --git a/internal/api/external.go b/internal/api/external.go index 14bdfe087b..7dd25562a9 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -224,10 +224,11 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re return terr } } else { - createdUser = true - if _, user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType, emailOptional); terr != nil { + var decision models.AccountLinkingDecision + if decision, user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType, emailOptional); terr != nil { return terr } + createdUser = decision == models.CreateAccount } if flowState != nil && flowState.IsPKCE() { // PKCE flow: update flow state with user ID and tokens diff --git a/internal/api/external_test.go b/internal/api/external_test.go index 62fdaf9d6f..6a11f17161 100644 --- a/internal/api/external_test.go +++ b/internal/api/external_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "sync/atomic" "testing" "github.com/gofrs/uuid" @@ -323,6 +324,41 @@ func assertValidOAuthState(ts *ExternalTestSuite, state string, expectedProvider ts.Equal(expectedProvider, flowState.ProviderType, "flow state provider should match") } +// after-user-created must fire once when the external identity creates an +// account, and not again when the same user signs in later. +func (ts *ExternalTestSuite) TestExternalCallbackAfterUserCreatedHookOnlyOnSignup() { + code := "authcode" + emails := `[{"email":"github-after-hook@example.com", "primary": true, "verified": true}]` + tokenCount, userCount := 0, 0 + server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails) + defer server.Close() + + var hookCalls int32 + hookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hookCalls, 1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, "{}") + })) + defer hookServer.Close() + + originalHook := ts.Config.Hook.AfterUserCreated + ts.Config.Hook.AfterUserCreated = conf.ExtensibilityPointConfiguration{ + Enabled: true, + URI: hookServer.URL, + } + defer func() { ts.Config.Hook.AfterUserCreated = originalHook }() + + // First sign-in creates the account -> hook fires once. + performAuthorization(ts, "github", code, "") + require.Equal(ts.T(), int32(1), atomic.LoadInt32(&hookCalls), + "after-user-created hook should fire once when the account is created") + + // Second sign-in with the same identity is a returning-user login -> hook must not fire again. + performAuthorization(ts, "github", code, "") + require.Equal(ts.T(), int32(1), atomic.LoadInt32(&hookCalls), + "after-user-created hook must not fire on subsequent external sign-ins") +} + // TestSignupExternalUnsupported tests API /authorize for an unsupported external provider func (ts *ExternalTestSuite) TestSignupExternalUnsupported() { req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=external", nil)