fix(auth): harden oauth identity upgrade paths
This commit is contained in:
@@ -335,6 +335,75 @@ func TestSettingHandler_UpdateSettings_PersistsExplicitFalseOIDCCompatibilityFla
|
||||
require.Equal(t, false, data["oidc_connect_validate_id_token"])
|
||||
}
|
||||
|
||||
func TestSettingHandler_UpdateSettings_DoesNotSolidifyImplicitOIDCSecurityDefaultsOnLegacyUpgrade(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &settingHandlerRepoStub{
|
||||
values: map[string]string{
|
||||
service.SettingKeyPromoCodeEnabled: "true",
|
||||
service.SettingKeyOIDCConnectEnabled: "true",
|
||||
service.SettingKeyOIDCConnectProviderName: "OIDC",
|
||||
service.SettingKeyOIDCConnectClientID: "oidc-client",
|
||||
service.SettingKeyOIDCConnectClientSecret: "oidc-secret",
|
||||
service.SettingKeyOIDCConnectIssuerURL: "https://issuer.example.com",
|
||||
service.SettingKeyOIDCConnectAuthorizeURL: "https://issuer.example.com/auth",
|
||||
service.SettingKeyOIDCConnectTokenURL: "https://issuer.example.com/token",
|
||||
service.SettingKeyOIDCConnectUserInfoURL: "https://issuer.example.com/userinfo",
|
||||
service.SettingKeyOIDCConnectJWKSURL: "https://issuer.example.com/jwks",
|
||||
service.SettingKeyOIDCConnectScopes: "openid email profile",
|
||||
service.SettingKeyOIDCConnectRedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback",
|
||||
service.SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
|
||||
service.SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
|
||||
service.SettingKeyOIDCConnectAllowedSigningAlgs: "RS256",
|
||||
service.SettingKeyOIDCConnectClockSkewSeconds: "120",
|
||||
service.SettingKeyOIDCConnectRequireEmailVerified: "false",
|
||||
service.SettingKeyOIDCConnectUserInfoEmailPath: "",
|
||||
service.SettingKeyOIDCConnectUserInfoIDPath: "",
|
||||
service.SettingKeyOIDCConnectUserInfoUsernamePath: "",
|
||||
},
|
||||
}
|
||||
svc := service.NewSettingService(repo, &config.Config{
|
||||
Default: config.DefaultConfig{UserConcurrency: 5},
|
||||
OIDC: config.OIDCConnectConfig{
|
||||
Enabled: true,
|
||||
ProviderName: "OIDC",
|
||||
ClientID: "oidc-client",
|
||||
ClientSecret: "oidc-secret",
|
||||
IssuerURL: "https://issuer.example.com",
|
||||
AuthorizeURL: "https://issuer.example.com/auth",
|
||||
TokenURL: "https://issuer.example.com/token",
|
||||
UserInfoURL: "https://issuer.example.com/userinfo",
|
||||
JWKSURL: "https://issuer.example.com/jwks",
|
||||
Scopes: "openid email profile",
|
||||
RedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback",
|
||||
FrontendRedirectURL: "/auth/oidc/callback",
|
||||
TokenAuthMethod: "client_secret_post",
|
||||
UsePKCE: true,
|
||||
ValidateIDToken: true,
|
||||
AllowedSigningAlgs: "RS256",
|
||||
ClockSkewSeconds: 120,
|
||||
},
|
||||
})
|
||||
handler := NewSettingHandler(svc, nil, nil, nil, nil, nil)
|
||||
|
||||
body := map[string]any{
|
||||
"promo_code_enabled": true,
|
||||
"oidc_connect_enabled": true,
|
||||
}
|
||||
rawBody, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.UpdateSettings(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeyOIDCConnectUsePKCE])
|
||||
require.Equal(t, "false", repo.values[service.SettingKeyOIDCConnectValidateIDToken])
|
||||
}
|
||||
|
||||
func TestSettingHandler_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &settingHandlerRepoStub{
|
||||
|
||||
@@ -355,15 +355,20 @@ func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email stri
|
||||
}
|
||||
|
||||
userEntity, err := client.User.Query().
|
||||
Where(dbuser.EmailEqualFold(email)).
|
||||
Only(ctx)
|
||||
Where(userNormalizedEmailPredicate(email)).
|
||||
Order(dbent.Asc(dbuser.FieldID)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
|
||||
}
|
||||
return userEntity, nil
|
||||
switch len(userEntity) {
|
||||
case 0:
|
||||
return nil, nil
|
||||
case 1:
|
||||
return userEntity[0], nil
|
||||
default:
|
||||
return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
|
||||
@@ -411,9 +416,15 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession(
|
||||
completionResponse["choice_reason"] = "force_email_on_signup"
|
||||
}
|
||||
|
||||
var targetUserID *int64
|
||||
if compatEmailUser != nil && compatEmailUser.ID > 0 {
|
||||
targetUserID = &compatEmailUser.ID
|
||||
}
|
||||
|
||||
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||
Intent: oauthIntentLogin,
|
||||
Identity: identity,
|
||||
TargetUserID: targetUserID,
|
||||
ResolvedEmail: resolvedChoiceEmail,
|
||||
RedirectTo: redirectTo,
|
||||
BrowserSessionKey: browserSessionKey,
|
||||
@@ -490,9 +501,13 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
client := h.entClient()
|
||||
if client == nil {
|
||||
response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
|
||||
return
|
||||
}
|
||||
if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
|
||||
respondPendingOAuthBindingApplyError(c, err)
|
||||
return
|
||||
}
|
||||
decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
|
||||
@@ -503,17 +518,16 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), h.authService, h.userService, session, decision, &user.ID); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
|
||||
return
|
||||
}
|
||||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||||
if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
|
||||
respondPendingOAuthBindingApplyError(c, err)
|
||||
return
|
||||
}
|
||||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
|
||||
|
||||
@@ -508,7 +508,7 @@ func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *test
|
||||
|
||||
ctx := context.Background()
|
||||
existingUser, err := client.User.Create().
|
||||
SetEmail("legacy@example.com").
|
||||
SetEmail(" Legacy@Example.com ").
|
||||
SetUsername("legacy-user").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
@@ -539,16 +539,17 @@ func TestLinuxDoOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *test
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||
require.Nil(t, session.TargetUserID)
|
||||
require.Equal(t, existingUser.Email, session.ResolvedEmail)
|
||||
require.NotNil(t, session.TargetUserID)
|
||||
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
||||
require.Equal(t, strings.TrimSpace(existingUser.Email), session.ResolvedEmail)
|
||||
require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
|
||||
|
||||
completion, ok := session.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "/dashboard", completion["redirect"])
|
||||
require.Equal(t, oauthPendingChoiceStep, completion["step"])
|
||||
require.Equal(t, existingUser.Email, completion["email"])
|
||||
require.Equal(t, existingUser.Email, completion["existing_account_email"])
|
||||
require.Equal(t, strings.TrimSpace(existingUser.Email), completion["email"])
|
||||
require.Equal(t, strings.TrimSpace(existingUser.Email), completion["existing_account_email"])
|
||||
require.Equal(t, true, completion["existing_account_bindable"])
|
||||
require.Equal(t, "compat_email_match", completion["choice_reason"])
|
||||
_, hasAccessToken := completion["access_token"]
|
||||
@@ -943,6 +944,68 @@ func TestCompleteLinuxDoOAuthRegistrationBindsIdentityWithoutAdoptionFlags(t *te
|
||||
require.False(t, decision.AdoptAvatar)
|
||||
}
|
||||
|
||||
func TestCompleteLinuxDoOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUserCreation(t *testing.T) {
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
existingOwner, err := client.User.Create().
|
||||
SetEmail("owner@example.com").
|
||||
SetUsername("owner-user").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.AuthIdentity.Create().
|
||||
SetUserID(existingOwner.ID).
|
||||
SetProviderType("linuxdo").
|
||||
SetProviderKey("linuxdo").
|
||||
SetProviderSubject("linuxdo-conflict-subject").
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("linuxdo-complete-conflict-session").
|
||||
SetIntent("login").
|
||||
SetProviderType("linuxdo").
|
||||
SetProviderKey("linuxdo").
|
||||
SetProviderSubject("linuxdo-conflict-subject").
|
||||
SetResolvedEmail("linuxdo-conflict-subject@linuxdo-connect.invalid").
|
||||
SetBrowserSessionKey("linuxdo-conflict-browser").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "linuxdo_user",
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/linuxdo/complete-registration", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("linuxdo-conflict-browser")})
|
||||
c.Request = req
|
||||
|
||||
handler.CompleteLinuxDoOAuthRegistration(c)
|
||||
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
payload := decodeJSONBody(t, recorder)
|
||||
require.Equal(t, "AUTH_IDENTITY_OWNERSHIP_CONFLICT", payload["reason"])
|
||||
|
||||
userCount, err := client.User.Query().
|
||||
Where(dbuser.EmailEQ("linuxdo-conflict-subject@linuxdo-connect.invalid")).
|
||||
Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, userCount)
|
||||
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, storedSession.ConsumedAt)
|
||||
}
|
||||
|
||||
func newLinuxDoOAuthTestHandler(t *testing.T, invitationEnabled bool, oauthCfg config.LinuxDoConnectConfig) *AuthHandler {
|
||||
t.Helper()
|
||||
handler, _ := newLinuxDoOAuthHandlerAndClient(t, invitationEnabled, oauthCfg)
|
||||
|
||||
@@ -519,7 +519,7 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
|
||||
|
||||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email); err == nil && existingUser != nil {
|
||||
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
|
||||
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, existingUser, email)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -704,6 +704,38 @@ func findUserByNormalizedEmail(ctx context.Context, client *dbent.Client, email
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func ensurePendingOAuthRegistrationIdentityAvailable(ctx context.Context, client *dbent.Client, session *dbent.PendingAuthSession) error {
|
||||
if client == nil || session == nil {
|
||||
return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
|
||||
}
|
||||
|
||||
identity, err := client.AuthIdentity.Query().
|
||||
Where(
|
||||
authidentity.ProviderTypeEQ(strings.TrimSpace(session.ProviderType)),
|
||||
authidentity.ProviderKeyEQ(strings.TrimSpace(session.ProviderKey)),
|
||||
authidentity.ProviderSubjectEQ(strings.TrimSpace(session.ProviderSubject)),
|
||||
).
|
||||
Only(ctx)
|
||||
if err != nil {
|
||||
if dbent.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if identity == nil || identity.UserID <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
activeOwner, err := findActiveUserByID(ctx, client, identity.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if activeOwner != nil {
|
||||
return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func oauthIdentityIssuer(session *dbent.PendingAuthSession) *string {
|
||||
if session == nil {
|
||||
return nil
|
||||
@@ -1206,6 +1238,38 @@ func consumePendingOAuthBrowserSessionTx(
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyPendingOAuthAdoptionAndConsumeSession(
|
||||
ctx context.Context,
|
||||
client *dbent.Client,
|
||||
authService *service.AuthService,
|
||||
userService *service.UserService,
|
||||
session *dbent.PendingAuthSession,
|
||||
decision *dbent.IdentityAdoptionDecision,
|
||||
userID int64,
|
||||
) error {
|
||||
if client == nil {
|
||||
return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||||
}
|
||||
if session == nil || userID <= 0 {
|
||||
return infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")
|
||||
}
|
||||
|
||||
tx, err := client.Tx(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
txCtx := dbent.NewTxContext(ctx, tx)
|
||||
if err := applyPendingOAuthAdoption(txCtx, client, authService, userService, session, decision, &userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := consumePendingOAuthBrowserSessionTx(txCtx, tx, session); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func applyPendingOAuthAdoption(
|
||||
ctx context.Context,
|
||||
client *dbent.Client,
|
||||
@@ -1448,16 +1512,21 @@ func (h *AuthHandler) transitionPendingOAuthAccountToChoiceState(
|
||||
c *gin.Context,
|
||||
client *dbent.Client,
|
||||
session *dbent.PendingAuthSession,
|
||||
targetUser *dbent.User,
|
||||
email string,
|
||||
) (*dbent.PendingAuthSession, error) {
|
||||
completionResponse := pendingOAuthChoiceCompletionResponse(session, email)
|
||||
var targetUserID *int64
|
||||
if targetUser != nil && targetUser.ID > 0 {
|
||||
targetUserID = &targetUser.ID
|
||||
}
|
||||
session, err := updatePendingOAuthSessionProgress(
|
||||
c.Request.Context(),
|
||||
client,
|
||||
session,
|
||||
strings.TrimSpace(session.Intent),
|
||||
email,
|
||||
nil,
|
||||
targetUserID,
|
||||
completionResponse,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -1601,7 +1670,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
|
||||
}
|
||||
}
|
||||
if existingUser != nil {
|
||||
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
|
||||
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, existingUser, email)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -1624,7 +1693,12 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrEmailExists) {
|
||||
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, email)
|
||||
existingUser, lookupErr := findUserByNormalizedEmail(c.Request.Context(), client, email)
|
||||
if lookupErr != nil {
|
||||
response.ErrorFrom(c, lookupErr)
|
||||
return
|
||||
}
|
||||
session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, existingUser, email)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@@ -1045,7 +1045,7 @@ func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *
|
||||
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.User.Create().
|
||||
existingUser, err := client.User.Create().
|
||||
SetEmail("owner@example.com").
|
||||
SetUsername("owner-user").
|
||||
SetPasswordHash("hash").
|
||||
@@ -1099,7 +1099,8 @@ func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, oauthIntentLogin, storedSession.Intent)
|
||||
require.Nil(t, storedSession.TargetUserID)
|
||||
require.NotNil(t, storedSession.TargetUserID)
|
||||
require.Equal(t, existingUser.ID, *storedSession.TargetUserID)
|
||||
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
|
||||
require.Nil(t, storedSession.ConsumedAt)
|
||||
|
||||
@@ -1118,7 +1119,7 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te
|
||||
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.User.Create().
|
||||
existingUser, err := client.User.Create().
|
||||
SetEmail(" Owner@Example.com ").
|
||||
SetUsername("owner-user").
|
||||
SetPasswordHash("hash").
|
||||
@@ -1164,7 +1165,8 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te
|
||||
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, storedSession.TargetUserID)
|
||||
require.NotNil(t, storedSession.TargetUserID)
|
||||
require.Equal(t, existingUser.ID, *storedSession.TargetUserID)
|
||||
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
|
||||
}
|
||||
|
||||
@@ -1172,7 +1174,7 @@ func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing
|
||||
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := client.User.Create().
|
||||
existingUser, err := client.User.Create().
|
||||
SetEmail("owner@example.com").
|
||||
SetUsername("owner-user").
|
||||
SetPasswordHash("hash").
|
||||
@@ -1220,7 +1222,8 @@ func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, oauthIntentLogin, storedSession.Intent)
|
||||
require.Nil(t, storedSession.TargetUserID)
|
||||
require.NotNil(t, storedSession.TargetUserID)
|
||||
require.Equal(t, existingUser.ID, *storedSession.TargetUserID)
|
||||
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
|
||||
}
|
||||
|
||||
|
||||
@@ -563,10 +563,15 @@ func (h *AuthHandler) createOIDCOAuthChoicePendingSession(
|
||||
if compatEmailUser != nil {
|
||||
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
|
||||
}
|
||||
var targetUserID *int64
|
||||
if compatEmailUser != nil && compatEmailUser.ID > 0 {
|
||||
targetUserID = &compatEmailUser.ID
|
||||
}
|
||||
|
||||
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||||
Intent: oauthIntentLogin,
|
||||
Identity: identity,
|
||||
TargetUserID: targetUserID,
|
||||
ResolvedEmail: resolvedChoiceEmail,
|
||||
RedirectTo: redirectTo,
|
||||
BrowserSessionKey: browserSessionKey,
|
||||
@@ -643,9 +648,13 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
client := h.entClient()
|
||||
if client == nil {
|
||||
response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
|
||||
return
|
||||
}
|
||||
if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
|
||||
respondPendingOAuthBindingApplyError(c, err)
|
||||
return
|
||||
}
|
||||
decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
|
||||
@@ -656,17 +665,16 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoption(c.Request.Context(), h.entClient(), h.authService, h.userService, session, decision, &user.ID); err != nil {
|
||||
response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_ADOPTION_APPLY_FAILED", "failed to apply oauth profile adoption").WithCause(err))
|
||||
return
|
||||
}
|
||||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||||
if _, err := pendingSvc.ConsumeBrowserSession(c.Request.Context(), sessionToken, browserSessionKey); err != nil {
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
|
||||
respondPendingOAuthBindingApplyError(c, err)
|
||||
return
|
||||
}
|
||||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||||
|
||||
|
||||
@@ -438,7 +438,8 @@ func TestOIDCOAuthCallbackCreatesBindPendingSessionForCompatEmailUser(t *testing
|
||||
Only(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, oauthIntentLogin, session.Intent)
|
||||
require.Nil(t, session.TargetUserID)
|
||||
require.NotNil(t, session.TargetUserID)
|
||||
require.Equal(t, existingUser.ID, *session.TargetUserID)
|
||||
require.Equal(t, existingUser.Email, session.ResolvedEmail)
|
||||
require.Equal(t, "legacy@example.com", session.UpstreamIdentityClaims["compat_email"])
|
||||
|
||||
@@ -862,6 +863,69 @@ func TestCompleteOIDCOAuthRegistrationBindsIdentityWithoutAdoptionFlags(t *testi
|
||||
require.False(t, decision.AdoptAvatar)
|
||||
}
|
||||
|
||||
func TestCompleteOIDCOAuthRegistrationRejectsIdentityOwnershipConflictBeforeUserCreation(t *testing.T) {
|
||||
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
existingOwner, err := client.User.Create().
|
||||
SetEmail("owner@example.com").
|
||||
SetUsername("owner-user").
|
||||
SetPasswordHash("hash").
|
||||
SetRole(service.RoleUser).
|
||||
SetStatus(service.StatusActive).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.AuthIdentity.Create().
|
||||
SetUserID(existingOwner.ID).
|
||||
SetProviderType("oidc").
|
||||
SetProviderKey("https://issuer.example.com").
|
||||
SetProviderSubject("oidc-conflict-subject").
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := client.PendingAuthSession.Create().
|
||||
SetSessionToken("oidc-complete-conflict-session").
|
||||
SetIntent("login").
|
||||
SetProviderType("oidc").
|
||||
SetProviderKey("https://issuer.example.com").
|
||||
SetProviderSubject("oidc-conflict-subject").
|
||||
SetResolvedEmail("f6f5f1f16f9248ccb11e0d633963b290@oidc-connect.invalid").
|
||||
SetBrowserSessionKey("oidc-conflict-browser").
|
||||
SetUpstreamIdentityClaims(map[string]any{
|
||||
"username": "oidc_user",
|
||||
"issuer": "https://issuer.example.com",
|
||||
}).
|
||||
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
||||
Save(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := bytes.NewBufferString(`{"invitation_code":"invite-1"}`)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
||||
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-conflict-browser")})
|
||||
c.Request = req
|
||||
|
||||
handler.CompleteOIDCOAuthRegistration(c)
|
||||
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
payload := decodeJSONBody(t, recorder)
|
||||
require.Equal(t, "AUTH_IDENTITY_OWNERSHIP_CONFLICT", payload["reason"])
|
||||
|
||||
userCount, err := client.User.Query().
|
||||
Where(dbuser.EmailEQ("f6f5f1f16f9248ccb11e0d633963b290@oidc-connect.invalid")).
|
||||
Count(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, userCount)
|
||||
|
||||
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, storedSession.ConsumedAt)
|
||||
}
|
||||
|
||||
type oidcProviderFixture struct {
|
||||
Subject string
|
||||
PreferredUsername string
|
||||
|
||||
Reference in New Issue
Block a user