2996 lines
105 KiB
Go
2996 lines
105 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
|
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
|
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
|
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
|
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"entgo.io/ent/dialect"
|
|
entsql "entgo.io/ent/dialect/sql"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func TestApplySuggestedProfileToCompletionResponse(t *testing.T) {
|
|
payload := map[string]any{
|
|
"access_token": "token",
|
|
}
|
|
upstream := map[string]any{
|
|
"suggested_display_name": "Alice",
|
|
"suggested_avatar_url": "https://cdn.example/avatar.png",
|
|
}
|
|
|
|
applySuggestedProfileToCompletionResponse(payload, upstream)
|
|
|
|
require.Equal(t, "Alice", payload["suggested_display_name"])
|
|
require.Equal(t, "https://cdn.example/avatar.png", payload["suggested_avatar_url"])
|
|
require.Equal(t, true, payload["adoption_required"])
|
|
}
|
|
|
|
func TestApplySuggestedProfileToCompletionResponseKeepsExistingPayloadValues(t *testing.T) {
|
|
payload := map[string]any{
|
|
"suggested_display_name": "Existing",
|
|
"adoption_required": false,
|
|
}
|
|
upstream := map[string]any{
|
|
"suggested_display_name": "Alice",
|
|
"suggested_avatar_url": "https://cdn.example/avatar.png",
|
|
}
|
|
|
|
applySuggestedProfileToCompletionResponse(payload, upstream)
|
|
|
|
require.Equal(t, "Existing", payload["suggested_display_name"])
|
|
require.Equal(t, "https://cdn.example/avatar.png", payload["suggested_avatar_url"])
|
|
require.Equal(t, true, payload["adoption_required"])
|
|
}
|
|
|
|
func TestSetOAuthPendingSessionCookieUsesProviderCompletionPathPrefix(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
ginCtx.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/callback", nil)
|
|
|
|
setOAuthPendingSessionCookie(ginCtx, "pending-session-token", false)
|
|
|
|
cookie := findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName)
|
|
require.NotNil(t, cookie)
|
|
require.Equal(t, "/api/v1/auth/oauth", cookie.Path)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionPreviewThenFinalizeAppliesAdoptionDecision(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("linuxdo-123@linuxdo-connect.invalid").
|
|
SetUsername("legacy-name").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("pending-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "linuxdo_user",
|
|
"suggested_display_name": "Alice Example",
|
|
"suggested_avatar_url": "https://cdn.example/alice.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
"redirect": "/dashboard",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
previewRecorder := httptest.NewRecorder()
|
|
previewCtx, _ := gin.CreateTestContext(previewRecorder)
|
|
previewReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
|
previewReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
previewReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-session-key")})
|
|
previewCtx.Request = previewReq
|
|
|
|
handler.ExchangePendingOAuthCompletion(previewCtx)
|
|
|
|
require.Equal(t, http.StatusOK, previewRecorder.Code)
|
|
previewData := decodeJSONResponseData(t, previewRecorder)
|
|
require.Equal(t, "Alice Example", previewData["suggested_display_name"])
|
|
require.Equal(t, "https://cdn.example/alice.png", previewData["suggested_avatar_url"])
|
|
require.Equal(t, true, previewData["adoption_required"])
|
|
|
|
storedUser, err := client.User.Get(ctx, userEntity.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "legacy-name", storedUser.Username)
|
|
|
|
previewSession, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, previewSession.ConsumedAt)
|
|
|
|
body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`)
|
|
finalizeRecorder := httptest.NewRecorder()
|
|
finalizeCtx, _ := gin.CreateTestContext(finalizeRecorder)
|
|
finalizeReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
|
finalizeReq.Header.Set("Content-Type", "application/json")
|
|
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-session-key")})
|
|
finalizeCtx.Request = finalizeReq
|
|
|
|
handler.ExchangePendingOAuthCompletion(finalizeCtx)
|
|
|
|
require.Equal(t, http.StatusOK, finalizeRecorder.Code)
|
|
|
|
storedUser, err = client.User.Get(ctx, userEntity.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Alice Example", storedUser.Username)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userEntity.ID, identity.UserID)
|
|
require.Equal(t, "Alice Example", identity.Metadata["display_name"])
|
|
require.Equal(t, "https://cdn.example/alice.png", identity.Metadata["avatar_url"])
|
|
|
|
avatar := loadUserAvatarRecord(t, client, userEntity.ID)
|
|
require.NotNil(t, avatar)
|
|
require.Equal(t, "remote_url", avatar.StorageProvider)
|
|
require.Equal(t, "https://cdn.example/alice.png", avatar.URL)
|
|
|
|
decision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decision.IdentityID)
|
|
require.Equal(t, identity.ID, *decision.IdentityID)
|
|
require.True(t, decision.AdoptDisplayName)
|
|
require.True(t, decision.AdoptAvatar)
|
|
|
|
consumed, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionSkipsInvalidAvatarAdoptionWithoutBlockingCompletion(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("invalid-avatar@example.com").
|
|
SetUsername("legacy-name").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("pending-invalid-avatar-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("invalid-avatar-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("browser-invalid-avatar-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "linuxdo_user",
|
|
"suggested_display_name": "Alice Example",
|
|
"suggested_avatar_url": "/avatars/alice.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
"redirect": "/dashboard",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", 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("browser-invalid-avatar-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("invalid-avatar-123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Alice Example", identity.Metadata["display_name"])
|
|
_, hasAdoptedAvatar := identity.Metadata["avatar_url"]
|
|
require.False(t, hasAdoptedAvatar)
|
|
|
|
avatar := loadUserAvatarRecord(t, client, userEntity.ID)
|
|
require.Nil(t, avatar)
|
|
|
|
consumed, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionBindCurrentUserPreviewThenFinalizeBindsIdentityWithoutAdoption(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("bind-target@example.com").
|
|
SetUsername("legacy-name").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("bind-pending-session-token").
|
|
SetIntent("bind_current_user").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("bind-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("bind-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "linuxdo_user",
|
|
"suggested_display_name": "Bound Example",
|
|
"suggested_avatar_url": "https://cdn.example/bound.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
"redirect": "/settings/profile",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
previewRecorder := httptest.NewRecorder()
|
|
previewCtx, _ := gin.CreateTestContext(previewRecorder)
|
|
previewReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
|
previewReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
previewReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("bind-browser-session-key")})
|
|
previewCtx.Request = previewReq
|
|
|
|
handler.ExchangePendingOAuthCompletion(previewCtx)
|
|
|
|
require.Equal(t, http.StatusOK, previewRecorder.Code)
|
|
previewData := decodeJSONResponseData(t, previewRecorder)
|
|
require.Equal(t, "Bound Example", previewData["suggested_display_name"])
|
|
require.Equal(t, "https://cdn.example/bound.png", previewData["suggested_avatar_url"])
|
|
require.Equal(t, true, previewData["adoption_required"])
|
|
|
|
identityCount, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("bind-123"),
|
|
).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, identityCount)
|
|
|
|
previewSession, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, previewSession.ConsumedAt)
|
|
|
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
|
finalizeRecorder := httptest.NewRecorder()
|
|
finalizeCtx, _ := gin.CreateTestContext(finalizeRecorder)
|
|
finalizeReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
|
|
finalizeReq.Header.Set("Content-Type", "application/json")
|
|
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
finalizeReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("bind-browser-session-key")})
|
|
finalizeCtx.Request = finalizeReq
|
|
|
|
handler.ExchangePendingOAuthCompletion(finalizeCtx)
|
|
|
|
require.Equal(t, http.StatusOK, finalizeRecorder.Code)
|
|
|
|
storedUser, err := client.User.Get(ctx, userEntity.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "legacy-name", storedUser.Username)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("bind-123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userEntity.ID, identity.UserID)
|
|
require.Equal(t, "Bound Example", identity.Metadata["suggested_display_name"])
|
|
require.Equal(t, "https://cdn.example/bound.png", identity.Metadata["suggested_avatar_url"])
|
|
_, hasDisplayName := identity.Metadata["display_name"]
|
|
require.False(t, hasDisplayName)
|
|
_, hasAvatarURL := identity.Metadata["avatar_url"]
|
|
require.False(t, hasAvatarURL)
|
|
|
|
decision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decision.IdentityID)
|
|
require.Equal(t, identity.ID, *decision.IdentityID)
|
|
require.False(t, decision.AdoptDisplayName)
|
|
require.False(t, decision.AdoptAvatar)
|
|
|
|
consumed, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionBindCurrentUserOwnershipConflict(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
targetUser, err := client.User.Create().
|
|
SetEmail("bind-conflict-target@example.com").
|
|
SetUsername("target-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
ownerUser, err := client.User.Create().
|
|
SetEmail("bind-conflict-owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
existingIdentity, err := client.AuthIdentity.Create().
|
|
SetUserID(ownerUser.ID).
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("conflict-123").
|
|
SetMetadata(map[string]any{"username": "owner-user"}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("bind-conflict-session-token").
|
|
SetIntent("bind_current_user").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("conflict-123").
|
|
SetTargetUserID(targetUser.ID).
|
|
SetResolvedEmail(targetUser.Email).
|
|
SetBrowserSessionKey("bind-conflict-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Conflict Example",
|
|
"suggested_avatar_url": "https://cdn.example/conflict.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", 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("bind-conflict-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
|
payload := decodeJSONBody(t, recorder)
|
|
require.Equal(t, "PENDING_AUTH_ADOPTION_APPLY_FAILED", payload["reason"])
|
|
|
|
identity, err := client.AuthIdentity.Get(ctx, existingIdentity.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, ownerUser.ID, identity.UserID)
|
|
|
|
decision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, decision.IdentityID)
|
|
require.False(t, decision.AdoptDisplayName)
|
|
require.False(t, decision.AdoptAvatar)
|
|
|
|
storedSession, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionLoginFalseFalseBindsIdentityWithoutAdoption(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("login-false@example.com").
|
|
SetUsername("legacy-name").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("login-false-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("login-false-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("login-false-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Login Example",
|
|
"suggested_avatar_url": "https://cdn.example/login.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", 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("login-false-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("login-false-123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userEntity.ID, identity.UserID)
|
|
|
|
decision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decision.IdentityID)
|
|
require.Equal(t, identity.ID, *decision.IdentityID)
|
|
require.False(t, decision.AdoptDisplayName)
|
|
require.False(t, decision.AdoptAvatar)
|
|
|
|
storedSession, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionLoginReassignsExistingDecisionIdentityReference(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("login-reassign@example.com").
|
|
SetUsername("legacy-name").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
existingIdentity, err := client.AuthIdentity.Create().
|
|
SetUserID(userEntity.ID).
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("login-reassign-123").
|
|
SetMetadata(map[string]any{}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
previousSession, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("login-reassign-previous-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("login-reassign-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("login-reassign-previous-browser-session-key").
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "previous-access-token",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
previousDecision, err := client.IdentityAdoptionDecision.Create().
|
|
SetPendingAuthSessionID(previousSession.ID).
|
|
SetIdentityID(existingIdentity.ID).
|
|
SetAdoptDisplayName(true).
|
|
SetAdoptAvatar(true).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("login-reassign-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("login-reassign-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("login-reassign-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Login Reassign",
|
|
"suggested_avatar_url": "https://cdn.example/login-reassign.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.IdentityAdoptionDecision.Create().
|
|
SetPendingAuthSessionID(session.ID).
|
|
SetAdoptDisplayName(false).
|
|
SetAdoptAvatar(false).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", 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("login-reassign-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
reloadedPrevious, err := client.IdentityAdoptionDecision.Get(ctx, previousDecision.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, reloadedPrevious.IdentityID)
|
|
|
|
currentDecision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, currentDecision.IdentityID)
|
|
require.Equal(t, existingIdentity.ID, *currentDecision.IdentityID)
|
|
|
|
storedSession, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionLoginWithoutDecisionStillBindsIdentity(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("login-nodecision@example.com").
|
|
SetUsername("legacy-name").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("login-nodecision-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("login-nodecision-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("login-nodecision-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "login-nodecision-user",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("login-nodecision-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("login-nodecision-123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userEntity.ID, identity.UserID)
|
|
|
|
storedSession, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionExistingLoginWithSuggestedProfileSkipsAdoptionPrompt(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("existing-login@example.com").
|
|
SetUsername("existing-login-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.AuthIdentity.Create().
|
|
SetUserID(userEntity.ID).
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("existing-login-123").
|
|
SetMetadata(map[string]any{
|
|
"username": "existing-login-user",
|
|
}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("existing-login-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("existing-login-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("existing-login-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Existing Login Example",
|
|
"suggested_avatar_url": "https://cdn.example/existing-login.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "legacy-access-token",
|
|
"refresh_token": "legacy-refresh-token",
|
|
"expires_in": float64(3600),
|
|
"token_type": "Bearer",
|
|
"redirect": "/dashboard",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("existing-login-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
payload := decodeJSONResponseData(t, recorder)
|
|
require.NotEmpty(t, payload["access_token"])
|
|
require.NotEmpty(t, payload["refresh_token"])
|
|
require.NotEqual(t, "legacy-access-token", payload["access_token"])
|
|
require.NotEqual(t, "legacy-refresh-token", payload["refresh_token"])
|
|
require.Equal(t, "/dashboard", payload["redirect"])
|
|
require.Equal(t, "Existing Login Example", payload["suggested_display_name"])
|
|
require.Equal(t, "https://cdn.example/existing-login.png", payload["suggested_avatar_url"])
|
|
require.NotContains(t, payload, "adoption_required")
|
|
|
|
accessToken, ok := payload["access_token"].(string)
|
|
require.True(t, ok)
|
|
claims, err := handler.authService.ValidateToken(accessToken)
|
|
require.NoError(t, err)
|
|
reloadedUser, err := handler.userService.GetByID(ctx, userEntity.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, reloadedUser.TokenVersion, claims.TokenVersion)
|
|
|
|
decisionCount, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, decisionCount)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.ConsumedAt)
|
|
|
|
completion, ok := storedSession.LocalFlowState[oauthCompletionResponseKey].(map[string]any)
|
|
require.True(t, ok)
|
|
require.NotContains(t, completion, "access_token")
|
|
require.NotContains(t, completion, "refresh_token")
|
|
require.NotContains(t, completion, "expires_in")
|
|
require.NotContains(t, completion, "token_type")
|
|
require.Equal(t, "/dashboard", completion["redirect"])
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionBlocksBackendModeBeforeReturningTokenPayload(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
settingValues: map[string]string{
|
|
service.SettingKeyBackendModeEnabled: "true",
|
|
},
|
|
})
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("blocked@example.com").
|
|
SetUsername("blocked-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("blocked-backend-mode-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("blocked-subject-123").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("blocked-backend-mode-browser-session-key").
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"access_token": "access-token",
|
|
"refresh_token": "refresh-token",
|
|
"expires_in": float64(3600),
|
|
"token_type": "Bearer",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("blocked-backend-mode-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusForbidden, recorder.Code)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionRejectsDisabledTargetUser(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
userEntity, err := client.User.Create().
|
|
SetEmail("disabled-linked@example.com").
|
|
SetUsername("disabled-linked-user").
|
|
SetPasswordHash("hash").
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusDisabled).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("disabled-linked-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("disabled-linked-subject").
|
|
SetTargetUserID(userEntity.ID).
|
|
SetResolvedEmail(userEntity.Email).
|
|
SetBrowserSessionKey("disabled-linked-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Disabled Linked User",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"redirect": "/dashboard",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("disabled-linked-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusForbidden, recorder.Code)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestNormalizePendingOAuthCompletionResponseScrubsLegacyTokenPayload(t *testing.T) {
|
|
payload := normalizePendingOAuthCompletionResponse(map[string]any{
|
|
"access_token": "legacy-access-token",
|
|
"refresh_token": "legacy-refresh-token",
|
|
"expires_in": float64(3600),
|
|
"token_type": "Bearer",
|
|
"redirect": "/dashboard",
|
|
})
|
|
|
|
require.NotContains(t, payload, "access_token")
|
|
require.NotContains(t, payload, "refresh_token")
|
|
require.NotContains(t, payload, "expires_in")
|
|
require.NotContains(t, payload, "token_type")
|
|
require.Equal(t, "/dashboard", payload["redirect"])
|
|
}
|
|
|
|
func TestExchangePendingOAuthCompletionInvitationRequiredFalseFalsePersistsDecisionWithoutBinding(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, true)
|
|
ctx := context.Background()
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("invitation-required-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("linuxdo").
|
|
SetProviderKey("linuxdo").
|
|
SetProviderSubject("invitation-123").
|
|
SetBrowserSessionKey("invitation-required-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Invite Example",
|
|
"suggested_avatar_url": "https://cdn.example/invite.png",
|
|
}).
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"error": "invitation_required",
|
|
},
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", 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("invitation-required-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.ExchangePendingOAuthCompletion(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
data := decodeJSONResponseData(t, recorder)
|
|
require.Equal(t, "invitation_required", data["error"])
|
|
require.Equal(t, true, data["adoption_required"])
|
|
|
|
identityCount, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("linuxdo"),
|
|
authidentity.ProviderKeyEQ("linuxdo"),
|
|
authidentity.ProviderSubjectEQ("invitation-123"),
|
|
).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, identityCount)
|
|
|
|
decision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, decision.IdentityID)
|
|
require.False(t, decision.AdoptDisplayName)
|
|
require.False(t, decision.AdoptAvatar)
|
|
|
|
storedSession, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestCreateOIDCOAuthAccountCreatesUserBindsIdentityAndConsumesSession(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "fresh@example.com", "246810")
|
|
ctx := context.Background()
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("create-account-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-create-123").
|
|
SetBrowserSessionKey("create-account-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
"suggested_display_name": "Fresh OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/fresh.png",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", 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("create-account-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.CreateOIDCOAuthAccount(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
require.NotEmpty(t, payload["access_token"])
|
|
require.NotEmpty(t, payload["refresh_token"])
|
|
require.Equal(t, "Bearer", payload["token_type"])
|
|
|
|
createdUser, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, service.StatusActive, createdUser.Status)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-create-123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, createdUser.ID, identity.UserID)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
|
|
ctx := context.Background()
|
|
|
|
existingUser, 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)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("existing-email-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-existing-123").
|
|
SetBrowserSessionKey("existing-email-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
"suggested_display_name": "Existing OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/existing.png",
|
|
}).
|
|
SetRedirectTo("/dashboard").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com","verify_code":"135790","password":"secret-123"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", 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("existing-email-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.CreateOIDCOAuthAccount(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
require.Equal(t, "pending_session", payload["auth_result"])
|
|
require.Equal(t, oauthIntentLogin, payload["intent"])
|
|
require.Equal(t, "oidc", payload["provider"])
|
|
require.Equal(t, "/dashboard", payload["redirect"])
|
|
require.Equal(t, true, payload["adoption_required"])
|
|
require.Equal(t, oauthPendingChoiceStep, payload["step"])
|
|
require.Equal(t, "owner@example.com", payload["email"])
|
|
require.Equal(t, "Existing OIDC User", payload["suggested_display_name"])
|
|
require.Equal(t, "https://cdn.example/existing.png", payload["suggested_avatar_url"])
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, oauthIntentLogin, storedSession.Intent)
|
|
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)
|
|
|
|
identityCount, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-existing-123"),
|
|
).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, identityCount)
|
|
}
|
|
|
|
func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
|
|
ctx := context.Background()
|
|
|
|
existingUser, 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)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("existing-email-normalized-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-existing-normalized-123").
|
|
SetBrowserSessionKey("existing-email-normalized-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
"suggested_display_name": "Existing OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/existing.png",
|
|
}).
|
|
SetRedirectTo("/dashboard").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com","verify_code":"135790","password":"secret-123"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", 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("existing-email-normalized-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.CreateOIDCOAuthAccount(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
require.Equal(t, oauthIntentLogin, payload["intent"])
|
|
require.Equal(t, oauthPendingChoiceStep, payload["step"])
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.TargetUserID)
|
|
require.Equal(t, existingUser.ID, *storedSession.TargetUserID)
|
|
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
|
|
}
|
|
|
|
func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
|
|
ctx := context.Background()
|
|
|
|
existingUser, 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)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("existing-email-send-code-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-existing-send-code-123").
|
|
SetBrowserSessionKey("existing-email-send-code-browser-session-key").
|
|
SetLocalFlowState(map[string]any{
|
|
oauthCompletionResponseKey: map[string]any{
|
|
"step": "email_required",
|
|
},
|
|
}).
|
|
SetRedirectTo("/dashboard").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/send-verify-code", 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("existing-email-send-code-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.SendPendingOAuthVerifyCode(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
require.Equal(t, "pending_session", payload["auth_result"])
|
|
require.Equal(t, oauthPendingChoiceStep, payload["step"])
|
|
require.Equal(t, "owner@example.com", payload["email"])
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, oauthIntentLogin, storedSession.Intent)
|
|
require.NotNil(t, storedSession.TargetUserID)
|
|
require.Equal(t, existingUser.ID, *storedSession.TargetUserID)
|
|
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
|
|
}
|
|
|
|
func TestCreateOIDCOAuthAccountBlocksBackendModeBeforeCreatingUser(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
emailVerifyEnabled: true,
|
|
emailCache: &oauthPendingFlowEmailCacheStub{
|
|
verificationCodes: map[string]*service.VerificationCodeData{
|
|
"fresh@example.com": {
|
|
Code: "246810",
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
|
|
},
|
|
},
|
|
},
|
|
settingValues: map[string]string{
|
|
service.SettingKeyBackendModeEnabled: "true",
|
|
},
|
|
})
|
|
ctx := context.Background()
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("create-account-backend-mode-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-create-backend-mode-123").
|
|
SetBrowserSessionKey("create-account-backend-mode-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", 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("create-account-backend-mode-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.CreateOIDCOAuthAccount(ginCtx)
|
|
|
|
require.Equal(t, http.StatusForbidden, recorder.Code)
|
|
|
|
userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).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 TestLogoutClearsPendingOAuthAndBindCookies(t *testing.T) {
|
|
handler, _ := newOAuthPendingFlowTestHandler(t, false)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", bytes.NewBufferString(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue("pending-session-token")})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("pending-browser-key")})
|
|
req.AddCookie(&http.Cookie{Name: oauthBindAccessTokenCookieName, Value: "bind-token"})
|
|
ginCtx.Request = req
|
|
|
|
handler.Logout(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
require.Equal(t, -1, findCookie(recorder.Result().Cookies(), oauthPendingSessionCookieName).MaxAge)
|
|
require.Equal(t, -1, findCookie(recorder.Result().Cookies(), oauthPendingBrowserCookieName).MaxAge)
|
|
require.Equal(t, -1, findCookie(recorder.Result().Cookies(), oauthBindAccessTokenCookieName).MaxAge)
|
|
}
|
|
|
|
func TestCreateOIDCOAuthAccountRollsBackCreatedUserWhenBindingFails(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, true, "fresh@example.com", "246810")
|
|
ctx := context.Background()
|
|
|
|
conflictOwner, 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(conflictOwner.ID).
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-conflict-123").
|
|
SetMetadata(map[string]any{
|
|
"username": "owner-user",
|
|
}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
invitation, err := client.RedeemCode.Create().
|
|
SetCode("INVITE123").
|
|
SetType(service.RedeemTypeInvitation).
|
|
SetStatus(service.StatusUnused).
|
|
SetValue(0).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("create-account-conflict-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-conflict-123").
|
|
SetBrowserSessionKey("create-account-conflict-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123","invitation_code":"INVITE123"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", 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("create-account-conflict-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.CreateOIDCOAuthAccount(ginCtx)
|
|
|
|
require.Equal(t, http.StatusConflict, recorder.Code)
|
|
|
|
userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, userCount)
|
|
|
|
storedInvitation, err := client.RedeemCode.Get(ctx, invitation.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, service.StatusUnused, storedInvitation.Status)
|
|
require.Nil(t, storedInvitation.UsedBy)
|
|
require.Nil(t, storedInvitation.UsedAt)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestCreateOIDCOAuthAccountRollsBackPostBindFailureBeforeIdentityCanCommit(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
emailVerifyEnabled: true,
|
|
emailCache: &oauthPendingFlowEmailCacheStub{
|
|
verificationCodes: map[string]*service.VerificationCodeData{
|
|
"fresh@example.com": {
|
|
Code: "246810",
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
|
|
},
|
|
},
|
|
},
|
|
userRepoOptions: oauthPendingFlowUserRepoOptions{
|
|
rejectDeleteWhileAuthIdentityExists: true,
|
|
},
|
|
})
|
|
ctx := context.Background()
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("create-account-finalize-failure-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-finalize-failure-123").
|
|
SetBrowserSessionKey("create-account-finalize-failure-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
pendingOAuthCreateAccountPreCommitHook = func(context.Context, *dbent.PendingAuthSession) error {
|
|
return errors.New("forced post-bind failure")
|
|
}
|
|
t.Cleanup(func() {
|
|
pendingOAuthCreateAccountPreCommitHook = nil
|
|
})
|
|
|
|
body := bytes.NewBufferString(`{"email":"fresh@example.com","verify_code":"246810","password":"secret-123"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", 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("create-account-finalize-failure-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.CreateOIDCOAuthAccount(ginCtx)
|
|
|
|
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
|
|
|
userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, userCount)
|
|
|
|
identityCount, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-finalize-failure-123"),
|
|
).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, identityCount)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestBindOIDCOAuthLoginBindsExistingUserAndConsumesSession(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
passwordHash, err := handler.authService.HashPassword("secret-123")
|
|
require.NoError(t, err)
|
|
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash(passwordHash).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("bind-login-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-123").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("bind-login-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
"suggested_display_name": "Bound OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/bound.png",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/bind-login", 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("bind-login-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.BindOIDCOAuthLogin(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
require.NotEmpty(t, payload["access_token"])
|
|
require.NotEmpty(t, payload["refresh_token"])
|
|
require.Equal(t, "Bearer", payload["token_type"])
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-bind-123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, existingUser.ID, identity.UserID)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestBindOIDCOAuthLoginBlocksBackendModeBeforeTokenIssue(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
settingValues: map[string]string{
|
|
service.SettingKeyBackendModeEnabled: "true",
|
|
},
|
|
})
|
|
ctx := context.Background()
|
|
|
|
passwordHash, err := handler.authService.HashPassword("secret-123")
|
|
require.NoError(t, err)
|
|
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash(passwordHash).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("bind-login-backend-mode-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-backend-mode-123").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("bind-login-backend-mode-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com","password":"secret-123"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/bind-login", 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("bind-login-backend-mode-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.BindOIDCOAuthLogin(ginCtx)
|
|
|
|
require.Equal(t, http.StatusForbidden, recorder.Code)
|
|
|
|
identityCount, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-bind-backend-mode-123"),
|
|
).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, identityCount)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestBindOIDCOAuthLoginRejectsInvalidPasswordWithoutConsumingSession(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
passwordHash, err := handler.authService.HashPassword("secret-123")
|
|
require.NoError(t, err)
|
|
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash(passwordHash).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("bind-login-invalid-password-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-invalid-123").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("bind-login-invalid-password-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
"suggested_display_name": "Bound OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/bound.png",
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com","password":"wrong-password"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/bind-login", 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("bind-login-invalid-password-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.BindOIDCOAuthLogin(ginCtx)
|
|
|
|
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
|
payload := decodeJSONBody(t, recorder)
|
|
require.Equal(t, "INVALID_CREDENTIALS", payload["reason"])
|
|
|
|
identityCount, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-bind-invalid-123"),
|
|
).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, identityCount)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestBindOIDCOAuthLoginReclaimsIdentityOwnedBySoftDeletedUser(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
oldOwnerHash, err := handler.authService.HashPassword("old-secret")
|
|
require.NoError(t, err)
|
|
oldOwner, err := client.User.Create().
|
|
SetEmail("old-owner@example.com").
|
|
SetUsername("old-owner").
|
|
SetPasswordHash(oldOwnerHash).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
identity, err := client.AuthIdentity.Create().
|
|
SetUserID(oldOwner.ID).
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-soft-deleted-123").
|
|
SetMetadata(map[string]any{"username": "old-owner"}).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.User.Delete().Where(dbuser.IDEQ(oldOwner.ID)).Exec(ctx)
|
|
require.NoError(t, err)
|
|
|
|
newOwnerHash, err := handler.authService.HashPassword("secret-123")
|
|
require.NoError(t, err)
|
|
newOwner, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash(newOwnerHash).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("bind-login-soft-deleted-owner-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-soft-deleted-123").
|
|
SetTargetUserID(newOwner.ID).
|
|
SetResolvedEmail(newOwner.Email).
|
|
SetBrowserSessionKey("bind-login-soft-deleted-owner-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
"suggested_display_name": "Recovered OIDC User",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/bind-login", 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("bind-login-soft-deleted-owner-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.BindOIDCOAuthLogin(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
|
|
identity, err = client.AuthIdentity.Get(ctx, identity.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, newOwner.ID, identity.UserID)
|
|
}
|
|
|
|
func TestBindOIDCOAuthLoginAppliesFirstBindGrantOnce(t *testing.T) {
|
|
defaultSubAssigner := &oauthPendingFlowDefaultSubAssignerStub{}
|
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
settingValues: map[string]string{
|
|
service.SettingKeyAuthSourceDefaultOIDCBalance: "12.5",
|
|
service.SettingKeyAuthSourceDefaultOIDCConcurrency: "3",
|
|
service.SettingKeyAuthSourceDefaultOIDCSubscriptions: `[{"group_id":101,"validity_days":30}]`,
|
|
service.SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "true",
|
|
},
|
|
defaultSubAssigner: defaultSubAssigner,
|
|
})
|
|
ctx := context.Background()
|
|
|
|
passwordHash, err := handler.authService.HashPassword("secret-123")
|
|
require.NoError(t, err)
|
|
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash(passwordHash).
|
|
SetBalance(5).
|
|
SetConcurrency(2).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
firstSession, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("first-bind-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-first-123").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("first-bind-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Bound OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/bound.png",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
firstBody := bytes.NewBufferString(`{"email":"owner@example.com","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
|
|
firstRecorder := httptest.NewRecorder()
|
|
firstGinCtx, _ := gin.CreateTestContext(firstRecorder)
|
|
firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/bind-login", firstBody)
|
|
firstReq.Header.Set("Content-Type", "application/json")
|
|
firstReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(firstSession.SessionToken)})
|
|
firstReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("first-bind-browser-session-key")})
|
|
firstGinCtx.Request = firstReq
|
|
|
|
handler.BindOIDCOAuthLogin(firstGinCtx)
|
|
|
|
require.Equal(t, http.StatusOK, firstRecorder.Code)
|
|
|
|
storedUser, err := client.User.Get(ctx, existingUser.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 17.5, storedUser.Balance)
|
|
require.Equal(t, 5, storedUser.Concurrency)
|
|
require.Zero(t, storedUser.TotalRecharged)
|
|
require.Len(t, defaultSubAssigner.calls, 1)
|
|
require.Equal(t, int64(existingUser.ID), defaultSubAssigner.calls[0].UserID)
|
|
require.Equal(t, int64(101), defaultSubAssigner.calls[0].GroupID)
|
|
require.Equal(t, 30, defaultSubAssigner.calls[0].ValidityDays)
|
|
require.Equal(t, 1, countProviderGrantRecords(t, client, existingUser.ID, "oidc", "first_bind"))
|
|
|
|
secondSession, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("second-bind-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-second-456").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("second-bind-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Second OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/second.png",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
secondBody := bytes.NewBufferString(`{"email":"owner@example.com","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
|
|
secondRecorder := httptest.NewRecorder()
|
|
secondGinCtx, _ := gin.CreateTestContext(secondRecorder)
|
|
secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/bind-login", secondBody)
|
|
secondReq.Header.Set("Content-Type", "application/json")
|
|
secondReq.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(secondSession.SessionToken)})
|
|
secondReq.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("second-bind-browser-session-key")})
|
|
secondGinCtx.Request = secondReq
|
|
|
|
handler.BindOIDCOAuthLogin(secondGinCtx)
|
|
|
|
require.Equal(t, http.StatusOK, secondRecorder.Code)
|
|
|
|
storedUser, err = client.User.Get(ctx, existingUser.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 17.5, storedUser.Balance)
|
|
require.Equal(t, 5, storedUser.Concurrency)
|
|
require.Zero(t, storedUser.TotalRecharged)
|
|
require.Len(t, defaultSubAssigner.calls, 1)
|
|
require.Equal(t, 1, countProviderGrantRecords(t, client, existingUser.ID, "oidc", "first_bind"))
|
|
}
|
|
|
|
func TestResolvePendingOAuthTargetUserIDNormalizesLegacySpacingAndCase(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
_ = handler
|
|
ctx := context.Background()
|
|
|
|
existingUser, 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)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("resolve-target-session-token").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-target-123").
|
|
SetResolvedEmail("owner@example.com").
|
|
SetBrowserSessionKey("resolve-target-browser-session-key").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
resolvedUserID, err := resolvePendingOAuthTargetUserID(ctx, client, session)
|
|
require.NoError(t, err)
|
|
require.Equal(t, existingUser.ID, resolvedUserID)
|
|
}
|
|
|
|
func TestBindOIDCOAuthLoginReturns2FAChallengeWhenUserHasTotp(t *testing.T) {
|
|
totpCache := &oauthPendingFlowTotpCacheStub{}
|
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
settingValues: map[string]string{
|
|
service.SettingKeyTotpEnabled: "true",
|
|
},
|
|
totpCache: totpCache,
|
|
totpEncryptor: oauthPendingFlowTotpEncryptorStub{},
|
|
})
|
|
ctx := context.Background()
|
|
|
|
passwordHash, err := handler.authService.HashPassword("secret-123")
|
|
require.NoError(t, err)
|
|
totpEnabledAt := time.Now().UTC().Add(-time.Hour)
|
|
secret := "JBSWY3DPEHPK3PXP"
|
|
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash(passwordHash).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
SetTotpEnabled(true).
|
|
SetTotpSecretEncrypted(secret).
|
|
SetTotpEnabledAt(totpEnabledAt).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("bind-login-2fa-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-bind-2fa-123").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("bind-login-2fa-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Bound OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/bound.png",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"email":"owner@example.com","password":"secret-123","adopt_display_name":false,"adopt_avatar":false}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/bind-login", 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("bind-login-2fa-browser-session-key")})
|
|
ginCtx.Request = req
|
|
|
|
handler.BindOIDCOAuthLogin(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
data := decodeJSONResponseData(t, recorder)
|
|
require.Equal(t, true, data["requires_2fa"])
|
|
require.Equal(t, "o***r@example.com", data["user_email_masked"])
|
|
tempToken, ok := data["temp_token"].(string)
|
|
require.True(t, ok)
|
|
require.NotEmpty(t, tempToken)
|
|
|
|
loginSession, err := totpCache.GetLoginSession(ctx, tempToken)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, loginSession)
|
|
require.NotNil(t, loginSession.PendingOAuthBind)
|
|
require.Equal(t, session.SessionToken, loginSession.PendingOAuthBind.PendingSessionToken)
|
|
require.Equal(t, session.BrowserSessionKey, loginSession.PendingOAuthBind.BrowserSessionKey)
|
|
|
|
identityCount, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-bind-2fa-123"),
|
|
).
|
|
Count(ctx)
|
|
require.NoError(t, err)
|
|
require.Zero(t, identityCount)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.Nil(t, storedSession.ConsumedAt)
|
|
}
|
|
|
|
func TestLogin2FACompletesPendingOAuthBindAndConsumesSession(t *testing.T) {
|
|
totpCache := &oauthPendingFlowTotpCacheStub{}
|
|
defaultSubAssigner := &oauthPendingFlowDefaultSubAssignerStub{}
|
|
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
settingValues: map[string]string{
|
|
service.SettingKeyTotpEnabled: "true",
|
|
service.SettingKeyAuthSourceDefaultOIDCBalance: "8",
|
|
service.SettingKeyAuthSourceDefaultOIDCConcurrency: "2",
|
|
service.SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "true",
|
|
},
|
|
defaultSubAssigner: defaultSubAssigner,
|
|
totpCache: totpCache,
|
|
totpEncryptor: oauthPendingFlowTotpEncryptorStub{},
|
|
})
|
|
ctx := context.Background()
|
|
|
|
passwordHash, err := handler.authService.HashPassword("secret-123")
|
|
require.NoError(t, err)
|
|
totpEnabledAt := time.Now().UTC().Add(-time.Hour)
|
|
secret := "JBSWY3DPEHPK3PXP"
|
|
|
|
existingUser, err := client.User.Create().
|
|
SetEmail("owner@example.com").
|
|
SetUsername("owner-user").
|
|
SetPasswordHash(passwordHash).
|
|
SetBalance(1.5).
|
|
SetConcurrency(4).
|
|
SetRole(service.RoleUser).
|
|
SetStatus(service.StatusActive).
|
|
SetTotpEnabled(true).
|
|
SetTotpSecretEncrypted(secret).
|
|
SetTotpEnabledAt(totpEnabledAt).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("login-2fa-pending-session-token").
|
|
SetIntent("adopt_existing_user_by_email").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example").
|
|
SetProviderSubject("oidc-login-2fa-123").
|
|
SetTargetUserID(existingUser.ID).
|
|
SetResolvedEmail(existingUser.Email).
|
|
SetBrowserSessionKey("login-2fa-browser-session-key").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"suggested_display_name": "Bound OIDC User",
|
|
"suggested_avatar_url": "https://cdn.example/bound.png",
|
|
}).
|
|
SetRedirectTo("/profile").
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.IdentityAdoptionDecision.Create().
|
|
SetPendingAuthSessionID(session.ID).
|
|
SetAdoptDisplayName(false).
|
|
SetAdoptAvatar(false).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
tempToken, err := handler.totpService.CreatePendingOAuthBindLoginSession(
|
|
ctx,
|
|
existingUser.ID,
|
|
existingUser.Email,
|
|
session.SessionToken,
|
|
session.BrowserSessionKey,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
code, err := totp.GenerateCode(secret, time.Now().UTC())
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"temp_token":"` + tempToken + `","totp_code":"` + code + `"}`)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/2fa", 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(session.BrowserSessionKey)})
|
|
ginCtx.Request = req
|
|
|
|
handler.Login2FA(ginCtx)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
payload := decodeJSONResponseData(t, recorder)
|
|
require.NotEmpty(t, payload["access_token"])
|
|
require.NotEmpty(t, payload["refresh_token"])
|
|
accessToken, ok := payload["access_token"].(string)
|
|
require.True(t, ok)
|
|
claims, err := handler.authService.ValidateToken(accessToken)
|
|
require.NoError(t, err)
|
|
reloadedUser, err := handler.userService.GetByID(ctx, existingUser.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, reloadedUser.TokenVersion, claims.TokenVersion)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example"),
|
|
authidentity.ProviderSubjectEQ("oidc-login-2fa-123"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, existingUser.ID, identity.UserID)
|
|
|
|
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, storedSession.ConsumedAt)
|
|
|
|
loginSession, err := totpCache.GetLoginSession(ctx, tempToken)
|
|
require.NoError(t, err)
|
|
require.Nil(t, loginSession)
|
|
|
|
storedUser, err := client.User.Get(ctx, existingUser.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 9.5, storedUser.Balance)
|
|
require.Equal(t, 6, storedUser.Concurrency)
|
|
require.Equal(t, 1, countProviderGrantRecords(t, client, existingUser.ID, "oidc", "first_bind"))
|
|
require.Empty(t, defaultSubAssigner.calls)
|
|
}
|
|
|
|
func newOAuthPendingFlowTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandler, *dbent.Client) {
|
|
t.Helper()
|
|
|
|
return newOAuthPendingFlowTestHandlerWithOptions(t, invitationEnabled, false, nil)
|
|
}
|
|
|
|
func newOAuthPendingFlowTestHandlerWithEmailVerification(
|
|
t *testing.T,
|
|
invitationEnabled bool,
|
|
email string,
|
|
code string,
|
|
) (*AuthHandler, *dbent.Client) {
|
|
t.Helper()
|
|
|
|
cache := &oauthPendingFlowEmailCacheStub{
|
|
verificationCodes: map[string]*service.VerificationCodeData{
|
|
email: {
|
|
Code: code,
|
|
Attempts: 0,
|
|
CreatedAt: time.Now().UTC(),
|
|
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
|
|
},
|
|
},
|
|
}
|
|
return newOAuthPendingFlowTestHandlerWithOptions(t, invitationEnabled, true, cache)
|
|
}
|
|
|
|
func newOAuthPendingFlowTestHandlerWithOptions(
|
|
t *testing.T,
|
|
invitationEnabled bool,
|
|
emailVerifyEnabled bool,
|
|
emailCache service.EmailCache,
|
|
) (*AuthHandler, *dbent.Client) {
|
|
return newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
|
|
invitationEnabled: invitationEnabled,
|
|
emailVerifyEnabled: emailVerifyEnabled,
|
|
emailCache: emailCache,
|
|
})
|
|
}
|
|
|
|
type oauthPendingFlowTestHandlerOptions struct {
|
|
invitationEnabled bool
|
|
emailVerifyEnabled bool
|
|
emailCache service.EmailCache
|
|
settingValues map[string]string
|
|
defaultSubAssigner service.DefaultSubscriptionAssigner
|
|
totpCache service.TotpCache
|
|
totpEncryptor service.SecretEncryptor
|
|
userRepoOptions oauthPendingFlowUserRepoOptions
|
|
}
|
|
|
|
func newOAuthPendingFlowTestHandlerWithDependencies(
|
|
t *testing.T,
|
|
options oauthPendingFlowTestHandlerOptions,
|
|
) (*AuthHandler, *dbent.Client) {
|
|
t.Helper()
|
|
|
|
db, err := sql.Open("sqlite", "file:auth_oauth_pending_flow_handler?mode=memory&cache=shared")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
_, err = db.Exec("PRAGMA foreign_keys = ON")
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS user_provider_default_grants (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
provider_type TEXT NOT NULL,
|
|
grant_reason TEXT NOT NULL DEFAULT 'first_bind',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, provider_type, grant_reason)
|
|
)`)
|
|
require.NoError(t, err)
|
|
_, err = db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS user_avatars (
|
|
user_id INTEGER PRIMARY KEY,
|
|
storage_provider TEXT NOT NULL,
|
|
storage_key TEXT NOT NULL DEFAULT '',
|
|
url TEXT NOT NULL,
|
|
content_type TEXT NOT NULL DEFAULT '',
|
|
byte_size INTEGER NOT NULL DEFAULT 0,
|
|
sha256 TEXT NOT NULL DEFAULT '',
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`)
|
|
require.NoError(t, err)
|
|
|
|
drv := entsql.OpenDB(dialect.SQLite, db)
|
|
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
|
|
|
cfg := &config.Config{
|
|
JWT: config.JWTConfig{
|
|
Secret: "test-secret",
|
|
ExpireHour: 1,
|
|
AccessTokenExpireMinutes: 60,
|
|
RefreshTokenExpireDays: 7,
|
|
},
|
|
Default: config.DefaultConfig{
|
|
UserBalance: 0,
|
|
UserConcurrency: 1,
|
|
},
|
|
}
|
|
settingValues := map[string]string{
|
|
service.SettingKeyRegistrationEnabled: "true",
|
|
service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
|
|
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
|
|
}
|
|
for key, value := range options.settingValues {
|
|
settingValues[key] = value
|
|
}
|
|
settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{values: settingValues}, cfg)
|
|
userRepo := &oauthPendingFlowUserRepo{
|
|
client: client,
|
|
options: options.userRepoOptions,
|
|
}
|
|
redeemRepo := &oauthPendingFlowRedeemCodeRepo{client: client}
|
|
var emailService *service.EmailService
|
|
if options.emailCache != nil {
|
|
emailService = service.NewEmailService(&oauthPendingFlowSettingRepoStub{
|
|
values: map[string]string{
|
|
service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
|
|
},
|
|
}, options.emailCache)
|
|
}
|
|
authSvc := service.NewAuthService(
|
|
client,
|
|
userRepo,
|
|
redeemRepo,
|
|
&oauthPendingFlowRefreshTokenCacheStub{},
|
|
cfg,
|
|
settingSvc,
|
|
emailService,
|
|
nil,
|
|
nil,
|
|
nil,
|
|
options.defaultSubAssigner,
|
|
)
|
|
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
|
var totpSvc *service.TotpService
|
|
if options.totpCache != nil || options.totpEncryptor != nil {
|
|
totpCache := options.totpCache
|
|
if totpCache == nil {
|
|
totpCache = &oauthPendingFlowTotpCacheStub{}
|
|
}
|
|
totpEncryptor := options.totpEncryptor
|
|
if totpEncryptor == nil {
|
|
totpEncryptor = oauthPendingFlowTotpEncryptorStub{}
|
|
}
|
|
totpSvc = service.NewTotpService(userRepo, totpEncryptor, totpCache, settingSvc, nil, nil)
|
|
}
|
|
|
|
return &AuthHandler{
|
|
authService: authSvc,
|
|
userService: userSvc,
|
|
settingSvc: settingSvc,
|
|
totpService: totpSvc,
|
|
}, client
|
|
}
|
|
|
|
func boolSettingValue(v bool) string {
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
func boolPtr(v bool) *bool {
|
|
return &v
|
|
}
|
|
|
|
type oauthPendingFlowSettingRepoStub struct {
|
|
values map[string]string
|
|
}
|
|
|
|
func (s *oauthPendingFlowSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
|
|
return nil, service.ErrSettingNotFound
|
|
}
|
|
|
|
func (s *oauthPendingFlowSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
|
|
value, ok := s.values[key]
|
|
if !ok {
|
|
return "", service.ErrSettingNotFound
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowSettingRepoStub) Set(context.Context, string, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
|
result := make(map[string]string, len(keys))
|
|
for _, key := range keys {
|
|
if value, ok := s.values[key]; ok {
|
|
result[key] = value
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
|
|
result := make(map[string]string, len(s.values))
|
|
for key, value := range s.values {
|
|
result[key] = value
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowSettingRepoStub) Delete(context.Context, string) error {
|
|
return nil
|
|
}
|
|
|
|
type oauthPendingFlowRefreshTokenCacheStub struct{}
|
|
|
|
type oauthPendingFlowEmailCacheStub struct {
|
|
verificationCodes map[string]*service.VerificationCodeData
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) GetVerificationCode(_ context.Context, email string) (*service.VerificationCodeData, error) {
|
|
if s == nil || s.verificationCodes == nil {
|
|
return nil, nil
|
|
}
|
|
return s.verificationCodes[email], nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) SetVerificationCode(_ context.Context, email string, data *service.VerificationCodeData, _ time.Duration) error {
|
|
if s.verificationCodes == nil {
|
|
s.verificationCodes = map[string]*service.VerificationCodeData{}
|
|
}
|
|
s.verificationCodes[email] = data
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) DeleteVerificationCode(_ context.Context, email string) error {
|
|
delete(s.verificationCodes, email)
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) GetNotifyVerifyCode(context.Context, string) (*service.VerificationCodeData, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) SetNotifyVerifyCode(context.Context, string, *service.VerificationCodeData, time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) DeleteNotifyVerifyCode(context.Context, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) GetPasswordResetToken(context.Context, string) (*service.PasswordResetTokenData, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) SetPasswordResetToken(context.Context, string, *service.PasswordResetTokenData, time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) DeletePasswordResetToken(context.Context, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) IsPasswordResetEmailInCooldown(context.Context, string) bool {
|
|
return false
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) SetPasswordResetEmailCooldown(context.Context, string, time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) IncrNotifyCodeUserRate(context.Context, int64, time.Duration) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowEmailCacheStub) GetNotifyCodeUserRate(context.Context, int64) (int64, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) StoreRefreshToken(context.Context, string, *service.RefreshTokenData, time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) GetRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
|
|
return nil, service.ErrRefreshTokenNotFound
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) DeleteUserRefreshTokens(context.Context, int64) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) DeleteTokenFamily(context.Context, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) GetUserTokenHashes(context.Context, int64) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) GetFamilyTokenHashes(context.Context, string) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowRefreshTokenCacheStub) IsTokenInFamily(context.Context, string, string) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
type oauthPendingFlowRedeemCodeRepo struct {
|
|
client *dbent.Client
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) Create(context.Context, *service.RedeemCode) error {
|
|
panic("unexpected Create call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) CreateBatch(context.Context, []service.RedeemCode) error {
|
|
panic("unexpected CreateBatch call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) GetByID(context.Context, int64) (*service.RedeemCode, error) {
|
|
panic("unexpected GetByID call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) GetByCode(ctx context.Context, code string) (*service.RedeemCode, error) {
|
|
entity, err := r.client.RedeemCode.Query().Where(redeemcode.CodeEQ(code)).Only(ctx)
|
|
if err != nil {
|
|
if dbent.IsNotFound(err) {
|
|
return nil, service.ErrRedeemCodeNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
notes := ""
|
|
if entity.Notes != nil {
|
|
notes = *entity.Notes
|
|
}
|
|
return &service.RedeemCode{
|
|
ID: entity.ID,
|
|
Code: entity.Code,
|
|
Type: entity.Type,
|
|
Value: entity.Value,
|
|
Status: entity.Status,
|
|
UsedBy: entity.UsedBy,
|
|
UsedAt: entity.UsedAt,
|
|
Notes: notes,
|
|
CreatedAt: entity.CreatedAt,
|
|
GroupID: entity.GroupID,
|
|
ValidityDays: entity.ValidityDays,
|
|
}, nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) Update(ctx context.Context, code *service.RedeemCode) error {
|
|
if code == nil {
|
|
return nil
|
|
}
|
|
update := r.client.RedeemCode.UpdateOneID(code.ID).
|
|
SetCode(code.Code).
|
|
SetType(code.Type).
|
|
SetValue(code.Value).
|
|
SetStatus(code.Status).
|
|
SetNotes(code.Notes).
|
|
SetValidityDays(code.ValidityDays)
|
|
if code.UsedBy != nil {
|
|
update = update.SetUsedBy(*code.UsedBy)
|
|
} else {
|
|
update = update.ClearUsedBy()
|
|
}
|
|
if code.UsedAt != nil {
|
|
update = update.SetUsedAt(*code.UsedAt)
|
|
} else {
|
|
update = update.ClearUsedAt()
|
|
}
|
|
if code.GroupID != nil {
|
|
update = update.SetGroupID(*code.GroupID)
|
|
} else {
|
|
update = update.ClearGroupID()
|
|
}
|
|
_, err := update.Save(ctx)
|
|
return err
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) Delete(context.Context, int64) error {
|
|
panic("unexpected Delete call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) Use(ctx context.Context, id, userID int64) error {
|
|
affected, err := r.client.RedeemCode.Update().
|
|
Where(redeemcode.IDEQ(id), redeemcode.StatusEQ(service.StatusUnused)).
|
|
SetStatus(service.StatusUsed).
|
|
SetUsedBy(userID).
|
|
SetUsedAt(time.Now().UTC()).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return service.ErrRedeemCodeUsed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) List(context.Context, pagination.PaginationParams) ([]service.RedeemCode, *pagination.PaginationResult, error) {
|
|
panic("unexpected List call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
|
|
panic("unexpected ListWithFilters call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) ListByUser(context.Context, int64, int) ([]service.RedeemCode, error) {
|
|
panic("unexpected ListByUser call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) ListByUserPaginated(context.Context, int64, pagination.PaginationParams, string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
|
|
panic("unexpected ListByUserPaginated call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowRedeemCodeRepo) SumPositiveBalanceByUser(context.Context, int64) (float64, error) {
|
|
panic("unexpected SumPositiveBalanceByUser call")
|
|
}
|
|
|
|
func decodeJSONResponseData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
|
|
var envelope struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
|
|
return envelope.Data
|
|
}
|
|
|
|
func decodeJSONBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
|
return payload
|
|
}
|
|
|
|
type oauthPendingFlowAvatarRecord struct {
|
|
StorageProvider string
|
|
URL string
|
|
}
|
|
|
|
func loadUserAvatarRecord(t *testing.T, client *dbent.Client, userID int64) *oauthPendingFlowAvatarRecord {
|
|
t.Helper()
|
|
|
|
var rows entsql.Rows
|
|
err := client.Driver().Query(
|
|
context.Background(),
|
|
`SELECT storage_provider, url FROM user_avatars WHERE user_id = ?`,
|
|
[]any{userID},
|
|
&rows,
|
|
)
|
|
require.NoError(t, err)
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
if !rows.Next() {
|
|
require.NoError(t, rows.Err())
|
|
return nil
|
|
}
|
|
|
|
var record oauthPendingFlowAvatarRecord
|
|
require.NoError(t, rows.Scan(&record.StorageProvider, &record.URL))
|
|
require.NoError(t, rows.Err())
|
|
return &record
|
|
}
|
|
|
|
func countProviderGrantRecords(
|
|
t *testing.T,
|
|
client *dbent.Client,
|
|
userID int64,
|
|
providerType string,
|
|
grantReason string,
|
|
) int {
|
|
t.Helper()
|
|
|
|
var rows entsql.Rows
|
|
err := client.Driver().Query(
|
|
context.Background(),
|
|
`SELECT COUNT(*) FROM user_provider_default_grants WHERE user_id = ? AND provider_type = ? AND grant_reason = ?`,
|
|
[]any{userID, providerType, grantReason},
|
|
&rows,
|
|
)
|
|
require.NoError(t, err)
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
require.True(t, rows.Next())
|
|
var count int
|
|
require.NoError(t, rows.Scan(&count))
|
|
require.False(t, rows.Next())
|
|
return count
|
|
}
|
|
|
|
type oauthPendingFlowUserRepo struct {
|
|
client *dbent.Client
|
|
options oauthPendingFlowUserRepoOptions
|
|
}
|
|
|
|
type oauthPendingFlowUserRepoOptions struct {
|
|
rejectDeleteWhileAuthIdentityExists bool
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) Create(ctx context.Context, user *service.User) error {
|
|
entity, err := r.client.User.Create().
|
|
SetEmail(user.Email).
|
|
SetUsername(user.Username).
|
|
SetNotes(user.Notes).
|
|
SetPasswordHash(user.PasswordHash).
|
|
SetRole(user.Role).
|
|
SetBalance(user.Balance).
|
|
SetConcurrency(user.Concurrency).
|
|
SetStatus(user.Status).
|
|
SetNillableTotpSecretEncrypted(user.TotpSecretEncrypted).
|
|
SetTotpEnabled(user.TotpEnabled).
|
|
SetNillableTotpEnabledAt(user.TotpEnabledAt).
|
|
SetTotalRecharged(user.TotalRecharged).
|
|
SetSignupSource(user.SignupSource).
|
|
SetNillableLastLoginAt(user.LastLoginAt).
|
|
SetNillableLastActiveAt(user.LastActiveAt).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user.ID = entity.ID
|
|
user.CreatedAt = entity.CreatedAt
|
|
user.UpdatedAt = entity.UpdatedAt
|
|
return nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) {
|
|
entity, err := r.client.User.Get(ctx, id)
|
|
if err != nil {
|
|
if dbent.IsNotFound(err) {
|
|
return nil, service.ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return oauthPendingFlowServiceUser(entity), nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) GetByEmail(ctx context.Context, email string) (*service.User, error) {
|
|
entity, err := r.client.User.Query().Where(dbuser.EmailEQ(email)).Only(ctx)
|
|
if err != nil {
|
|
if dbent.IsNotFound(err) {
|
|
return nil, service.ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return oauthPendingFlowServiceUser(entity), nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) GetFirstAdmin(context.Context) (*service.User, error) {
|
|
panic("unexpected GetFirstAdmin call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User) error {
|
|
entity, err := r.client.User.UpdateOneID(user.ID).
|
|
SetEmail(user.Email).
|
|
SetUsername(user.Username).
|
|
SetNotes(user.Notes).
|
|
SetPasswordHash(user.PasswordHash).
|
|
SetRole(user.Role).
|
|
SetBalance(user.Balance).
|
|
SetConcurrency(user.Concurrency).
|
|
SetStatus(user.Status).
|
|
SetNillableTotpSecretEncrypted(user.TotpSecretEncrypted).
|
|
SetTotpEnabled(user.TotpEnabled).
|
|
SetNillableTotpEnabledAt(user.TotpEnabledAt).
|
|
SetTotalRecharged(user.TotalRecharged).
|
|
SetSignupSource(user.SignupSource).
|
|
SetNillableLastLoginAt(user.LastLoginAt).
|
|
SetNillableLastActiveAt(user.LastActiveAt).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user.UpdatedAt = entity.UpdatedAt
|
|
return nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
|
|
return r.client.User.UpdateOneID(userID).SetLastActiveAt(activeAt).Exec(ctx)
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) Delete(ctx context.Context, id int64) error {
|
|
if r.options.rejectDeleteWhileAuthIdentityExists {
|
|
count, err := r.client.AuthIdentity.Query().Where(authidentity.UserIDEQ(id)).Count(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
return errors.New("cannot delete user while auth identities still exist")
|
|
}
|
|
}
|
|
return r.client.User.DeleteOneID(id).Exec(ctx)
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*service.UserAvatar, error) {
|
|
driver := r.client.Driver()
|
|
if tx := dbent.TxFromContext(ctx); tx != nil {
|
|
driver = tx.Client().Driver()
|
|
}
|
|
|
|
var rows entsql.Rows
|
|
if err := driver.Query(
|
|
ctx,
|
|
`SELECT storage_provider, storage_key, url, content_type, byte_size, sha256 FROM user_avatars WHERE user_id = ?`,
|
|
[]any{userID},
|
|
&rows,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
if !rows.Next() {
|
|
return nil, rows.Err()
|
|
}
|
|
|
|
var avatar service.UserAvatar
|
|
if err := rows.Scan(
|
|
&avatar.StorageProvider,
|
|
&avatar.StorageKey,
|
|
&avatar.URL,
|
|
&avatar.ContentType,
|
|
&avatar.ByteSize,
|
|
&avatar.SHA256,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &avatar, nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input service.UpsertUserAvatarInput) (*service.UserAvatar, error) {
|
|
driver := r.client.Driver()
|
|
if tx := dbent.TxFromContext(ctx); tx != nil {
|
|
driver = tx.Client().Driver()
|
|
}
|
|
|
|
var result entsql.Result
|
|
if err := driver.Exec(
|
|
ctx,
|
|
`INSERT INTO user_avatars (user_id, storage_provider, storage_key, url, content_type, byte_size, sha256, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
storage_provider = excluded.storage_provider,
|
|
storage_key = excluded.storage_key,
|
|
url = excluded.url,
|
|
content_type = excluded.content_type,
|
|
byte_size = excluded.byte_size,
|
|
sha256 = excluded.sha256,
|
|
updated_at = CURRENT_TIMESTAMP`,
|
|
[]any{
|
|
userID,
|
|
input.StorageProvider,
|
|
input.StorageKey,
|
|
input.URL,
|
|
input.ContentType,
|
|
input.ByteSize,
|
|
input.SHA256,
|
|
},
|
|
&result,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &service.UserAvatar{
|
|
StorageProvider: input.StorageProvider,
|
|
StorageKey: input.StorageKey,
|
|
URL: input.URL,
|
|
ContentType: input.ContentType,
|
|
ByteSize: input.ByteSize,
|
|
SHA256: input.SHA256,
|
|
}, nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
|
|
driver := r.client.Driver()
|
|
if tx := dbent.TxFromContext(ctx); tx != nil {
|
|
driver = tx.Client().Driver()
|
|
}
|
|
|
|
var result entsql.Result
|
|
return driver.Exec(ctx, `DELETE FROM user_avatars WHERE user_id = ?`, []any{userID}, &result)
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) List(context.Context, pagination.PaginationParams) ([]service.User, *pagination.PaginationResult, error) {
|
|
panic("unexpected List call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) ListWithFilters(context.Context, pagination.PaginationParams, service.UserListFilters) ([]service.User, *pagination.PaginationResult, error) {
|
|
panic("unexpected ListWithFilters call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) UpdateBalance(context.Context, int64, float64) error {
|
|
panic("unexpected UpdateBalance call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64) error {
|
|
panic("unexpected DeductBalance call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int) error {
|
|
panic("unexpected UpdateConcurrency call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
|
|
return map[int64]*time.Time{}, nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserID(context.Context, int64) (*time.Time, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
|
|
count, err := r.client.User.Query().Where(dbuser.EmailEQ(email)).Count(ctx)
|
|
return count > 0, err
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
|
panic("unexpected RemoveGroupFromAllowedGroups call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error {
|
|
panic("unexpected AddGroupToAllowedGroups call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
|
|
panic("unexpected RemoveGroupFromUserAllowedGroups call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]service.UserAuthIdentityRecord, error) {
|
|
identities, err := r.client.AuthIdentity.Query().
|
|
Where(authidentity.UserIDEQ(userID)).
|
|
All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records := make([]service.UserAuthIdentityRecord, 0, len(identities))
|
|
for _, identity := range identities {
|
|
if identity == nil {
|
|
continue
|
|
}
|
|
records = append(records, service.UserAuthIdentityRecord{
|
|
ProviderType: identity.ProviderType,
|
|
ProviderKey: identity.ProviderKey,
|
|
ProviderSubject: identity.ProviderSubject,
|
|
VerifiedAt: identity.VerifiedAt,
|
|
Issuer: identity.Issuer,
|
|
Metadata: identity.Metadata,
|
|
CreatedAt: identity.CreatedAt,
|
|
UpdatedAt: identity.UpdatedAt,
|
|
})
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) UnbindUserAuthProvider(context.Context, int64, string) error {
|
|
panic("unexpected UnbindUserAuthProvider call")
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
|
|
update := r.client.User.UpdateOneID(userID)
|
|
if encryptedSecret == nil {
|
|
update = update.ClearTotpSecretEncrypted()
|
|
} else {
|
|
update = update.SetTotpSecretEncrypted(*encryptedSecret)
|
|
}
|
|
return update.Exec(ctx)
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) EnableTotp(ctx context.Context, userID int64) error {
|
|
return r.client.User.UpdateOneID(userID).
|
|
SetTotpEnabled(true).
|
|
SetTotpEnabledAt(time.Now().UTC()).
|
|
Exec(ctx)
|
|
}
|
|
|
|
func (r *oauthPendingFlowUserRepo) DisableTotp(ctx context.Context, userID int64) error {
|
|
return r.client.User.UpdateOneID(userID).
|
|
SetTotpEnabled(false).
|
|
ClearTotpSecretEncrypted().
|
|
ClearTotpEnabledAt().
|
|
Exec(ctx)
|
|
}
|
|
|
|
func oauthPendingFlowServiceUser(entity *dbent.User) *service.User {
|
|
if entity == nil {
|
|
return nil
|
|
}
|
|
return &service.User{
|
|
ID: entity.ID,
|
|
Email: entity.Email,
|
|
Username: entity.Username,
|
|
Notes: entity.Notes,
|
|
PasswordHash: entity.PasswordHash,
|
|
Role: entity.Role,
|
|
Balance: entity.Balance,
|
|
Concurrency: entity.Concurrency,
|
|
Status: entity.Status,
|
|
SignupSource: entity.SignupSource,
|
|
LastLoginAt: entity.LastLoginAt,
|
|
LastActiveAt: entity.LastActiveAt,
|
|
TotpSecretEncrypted: entity.TotpSecretEncrypted,
|
|
TotpEnabled: entity.TotpEnabled,
|
|
TotpEnabledAt: entity.TotpEnabledAt,
|
|
TotalRecharged: entity.TotalRecharged,
|
|
CreatedAt: entity.CreatedAt,
|
|
UpdatedAt: entity.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
type oauthPendingFlowDefaultSubAssignerStub struct {
|
|
calls []service.AssignSubscriptionInput
|
|
}
|
|
|
|
func (s *oauthPendingFlowDefaultSubAssignerStub) AssignOrExtendSubscription(
|
|
_ context.Context,
|
|
input *service.AssignSubscriptionInput,
|
|
) (*service.UserSubscription, bool, error) {
|
|
if input != nil {
|
|
s.calls = append(s.calls, *input)
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
type oauthPendingFlowTotpCacheStub struct {
|
|
setupSessions map[int64]*service.TotpSetupSession
|
|
loginSessions map[string]*service.TotpLoginSession
|
|
verifyAttempts map[int64]int
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) GetSetupSession(_ context.Context, userID int64) (*service.TotpSetupSession, error) {
|
|
if s == nil || s.setupSessions == nil {
|
|
return nil, nil
|
|
}
|
|
return s.setupSessions[userID], nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) SetSetupSession(_ context.Context, userID int64, session *service.TotpSetupSession, _ time.Duration) error {
|
|
if s.setupSessions == nil {
|
|
s.setupSessions = map[int64]*service.TotpSetupSession{}
|
|
}
|
|
s.setupSessions[userID] = session
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) DeleteSetupSession(_ context.Context, userID int64) error {
|
|
delete(s.setupSessions, userID)
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) GetLoginSession(_ context.Context, tempToken string) (*service.TotpLoginSession, error) {
|
|
if s == nil || s.loginSessions == nil {
|
|
return nil, nil
|
|
}
|
|
return s.loginSessions[tempToken], nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) SetLoginSession(_ context.Context, tempToken string, session *service.TotpLoginSession, _ time.Duration) error {
|
|
if s.loginSessions == nil {
|
|
s.loginSessions = map[string]*service.TotpLoginSession{}
|
|
}
|
|
s.loginSessions[tempToken] = session
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) DeleteLoginSession(_ context.Context, tempToken string) error {
|
|
delete(s.loginSessions, tempToken)
|
|
return nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) IncrementVerifyAttempts(_ context.Context, userID int64) (int, error) {
|
|
if s.verifyAttempts == nil {
|
|
s.verifyAttempts = map[int64]int{}
|
|
}
|
|
s.verifyAttempts[userID]++
|
|
return s.verifyAttempts[userID], nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) GetVerifyAttempts(_ context.Context, userID int64) (int, error) {
|
|
if s == nil || s.verifyAttempts == nil {
|
|
return 0, nil
|
|
}
|
|
return s.verifyAttempts[userID], nil
|
|
}
|
|
|
|
func (s *oauthPendingFlowTotpCacheStub) ClearVerifyAttempts(_ context.Context, userID int64) error {
|
|
delete(s.verifyAttempts, userID)
|
|
return nil
|
|
}
|
|
|
|
type oauthPendingFlowTotpEncryptorStub struct{}
|
|
|
|
func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, error) {
|
|
return plaintext, nil
|
|
}
|
|
|
|
func (oauthPendingFlowTotpEncryptorStub) Decrypt(ciphertext string) (string, error) {
|
|
return ciphertext, nil
|
|
}
|