mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 17:36:59 +08:00
Treat TOTP and Passkey as alternative enrolled factors across login and sensitive account operations. Gate every primary login transport before issuing a session, require WebAuthn user verification, and consume login challenges atomically with session creation. Reuse the shared verification UI for login, 2FA management, and account deletion. Require scoped, single-use deletion proof; recheck the session inside the deletion transaction and revoke all sessions afterward. Validation: controller/service/model/middleware tests; real SQLite 3.50.4, MySQL 8.4.11, and PostgreSQL 16.15 security regressions; frontend tests, TypeScript, targeted lint, formatting, and production build. Deploy the frontend and all backend nodes together. No schema changes.
821 lines
38 KiB
Go
821 lines
38 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/oauth"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
|
"github.com/fxamacker/cbor/v2"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/pquerna/otp/totp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func newSecurityLoginPasskey(t *testing.T, userID int) *ecdsa.PrivateKey {
|
|
t.Helper()
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
credentialID := sha256.Sum256(elliptic.Marshal(key.Curve, key.X, key.Y))
|
|
publicKey, err := cbor.Marshal(map[int]any{1: 2, 3: -7, -1: 1, -2: key.X.FillBytes(make([]byte, 32)), -3: key.Y.FillBytes(make([]byte, 32))})
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{
|
|
UserID: userID, CredentialID: base64.StdEncoding.EncodeToString(credentialID[:]), PublicKey: base64.StdEncoding.EncodeToString(publicKey),
|
|
UserPresent: true, UserVerified: true,
|
|
}).Error)
|
|
return key
|
|
}
|
|
|
|
func beginSecurityLoginPasskey(t *testing.T, parentToken string) (string, string) {
|
|
t.Helper()
|
|
body, err := common.Marshal(map[string]string{"flow_token": parentToken})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/begin", string(body), "", service.AuthIdentity{}, LoginPasskeyBegin)
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Data struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
UserVerification string `json:"userVerification"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification)
|
|
return result.Data.FlowToken, result.Data.Options.PublicKey.Challenge
|
|
}
|
|
|
|
func TestSecurityLoginCodeCompletesOnce(t *testing.T) {
|
|
for _, test := range []struct {
|
|
path string
|
|
backup bool
|
|
}{{"/api/user/login/verify", false}, {"/api/user/login/2fa", false}, {"/api/user/login/verify", true}} {
|
|
t.Run(fmt.Sprintf("%s/backup=%t", test.path, test.backup), func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
|
require.NoError(t, model.DB.Create(factor).Error)
|
|
challenge, err := service.StartLoginVerification(user, "password")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, challenge)
|
|
code, err := totp.GenerateCode(factor.Secret, time.Now())
|
|
require.NoError(t, err)
|
|
if test.backup {
|
|
code = "ABCD-1234"
|
|
hash, err := common.HashBackupCode(code)
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
|
|
}
|
|
body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code})
|
|
require.NoError(t, err)
|
|
router := gin.New()
|
|
router.POST("/api/user/login/verify", VerifyLogin)
|
|
router.POST("/api/user/login/2fa", Verify2FALogin)
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, httptest.NewRequest("POST", test.path, strings.NewReader(string(body))))
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.Equal(t, attempt == 0, result.Success, response.Body.String())
|
|
if attempt == 0 {
|
|
var bundle service.AuthBundle
|
|
require.NoError(t, common.Unmarshal(result.Data, &bundle))
|
|
assert.NotEmpty(t, bundle.AccessToken)
|
|
assert.Equal(t, "password", bundle.Session.LoginMethod)
|
|
assert.NotEmpty(t, response.Header().Values("Set-Cookie"))
|
|
} else {
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
}
|
|
}
|
|
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 2, count)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityLoginRejectsChangedOrExpiredAuthorization(t *testing.T) {
|
|
for _, change := range []string{"expired", "disabled user", "auth version", "factor removed", "factor locked", "legacy flow", "wrong method", "other purpose"} {
|
|
t.Run(change, func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
|
require.NoError(t, model.DB.Create(factor).Error)
|
|
challenge, err := service.StartLoginVerification(user, "password")
|
|
require.NoError(t, err)
|
|
method := "2fa"
|
|
switch change {
|
|
case "expired":
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("expires_at", time.Now().Add(-time.Minute)).Error)
|
|
case "disabled user":
|
|
require.NoError(t, model.DB.Model(user).Update("status", common.UserStatusDisabled).Error)
|
|
case "auth version":
|
|
require.NoError(t, model.DB.Model(user).Update("auth_version", user.AuthVersion+1).Error)
|
|
case "factor removed":
|
|
require.NoError(t, model.DB.Delete(factor).Error)
|
|
case "factor locked":
|
|
require.NoError(t, model.DB.Model(factor).Update("locked_until", time.Now().Add(time.Minute)).Error)
|
|
case "legacy flow":
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeTwoFALogin).Error)
|
|
case "wrong method":
|
|
method = "password"
|
|
case "other purpose":
|
|
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeLoginVerification).Update("purpose", model.AuthFlowPurposeSecurityProof).Error)
|
|
}
|
|
code, err := totp.GenerateCode(factor.Secret, time.Now())
|
|
require.NoError(t, err)
|
|
body, err := common.Marshal(map[string]string{"flow_token": challenge.FlowToken, "code": code, "method": method})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/login/verify", string(body), "", service.AuthIdentity{}, VerifyLogin)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.False(t, result.Success, change)
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 1, count)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityLoginPasskeyDoesNotRequireAdditionalTwoFA(t *testing.T) {
|
|
for _, direct := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("direct=%t", direct), func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
|
|
for _, verified := range []bool{false, true} {
|
|
var flowToken, challenge, parentToken string
|
|
if direct {
|
|
response := securityEnrollmentRequest("POST", "/api/user/passkey/login/begin", "", "", service.AuthIdentity{}, PasskeyLoginBegin)
|
|
var result struct {
|
|
Data struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
UserVerification string `json:"userVerification"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.Equal(t, "required", result.Data.Options.PublicKey.UserVerification)
|
|
flowToken, challenge = result.Data.FlowToken, result.Data.Options.PublicKey.Challenge
|
|
} else {
|
|
pending, err := service.StartLoginVerification(user, "oauth:github")
|
|
require.NoError(t, err)
|
|
parentToken = pending.FlowToken
|
|
flowToken, challenge = beginSecurityLoginPasskey(t, parentToken)
|
|
}
|
|
var assertion map[string]any
|
|
require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 0, verified), &assertion))
|
|
if direct {
|
|
response, ok := assertion["response"].(map[string]any)
|
|
require.True(t, ok)
|
|
response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(user.Id)))
|
|
}
|
|
payload := map[string]any{"flow_token": flowToken, "credential": assertion}
|
|
handler, path := PasskeyLoginFinish, "/api/user/passkey/login/finish"
|
|
if !direct {
|
|
payload["flow_token"], payload["passkey_flow_token"] = parentToken, flowToken
|
|
handler, path = LoginPasskeyFinish, "/api/user/login/passkey/finish"
|
|
}
|
|
body, err := common.Marshal(payload)
|
|
require.NoError(t, err)
|
|
router := gin.New()
|
|
router.POST(path, handler)
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, httptest.NewRequest("POST", path, strings.NewReader(string(body))))
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.Equal(t, verified, result.Success, response.Body.String())
|
|
if verified {
|
|
var bundle service.AuthBundle
|
|
require.NoError(t, common.Unmarshal(result.Data, &bundle))
|
|
assert.NotEmpty(t, bundle.AccessToken)
|
|
} else {
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityLoginPasskeyConcurrentCompletionCreatesOneSession(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
pending, err := service.StartLoginVerification(user, "password")
|
|
require.NoError(t, err)
|
|
requests := make([]string, 2)
|
|
for index := range requests {
|
|
token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken)
|
|
body, err := common.Marshal(map[string]any{
|
|
"flow_token": pending.FlowToken, "passkey_flow_token": token,
|
|
"credential": securityPasskeyResponse(t, key, challenge, false, 0),
|
|
})
|
|
require.NoError(t, err)
|
|
requests[index] = string(body)
|
|
}
|
|
start := make(chan struct{})
|
|
responses := make(chan *httptest.ResponseRecorder, 2)
|
|
var workers sync.WaitGroup
|
|
for _, body := range requests {
|
|
workers.Add(1)
|
|
go func() {
|
|
defer workers.Done()
|
|
<-start
|
|
responses <- securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", body, "", service.AuthIdentity{}, LoginPasskeyFinish)
|
|
}()
|
|
}
|
|
close(start)
|
|
workers.Wait()
|
|
close(responses)
|
|
successes := 0
|
|
for response := range responses {
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
if result.Success {
|
|
successes++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, successes)
|
|
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 2, count)
|
|
}
|
|
|
|
func TestSecurityLoginSessionFailureRollsBackChallengeConsumption(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
pending, err := service.StartLoginVerification(user, "password")
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("login_session_failure", func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "user_sessions" {
|
|
tx.AddError(errors.New("private session creation failure"))
|
|
}
|
|
}))
|
|
t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("login_session_failure") })
|
|
for _, fail := range []bool{true, false} {
|
|
token, challenge := beginSecurityLoginPasskey(t, pending.FlowToken)
|
|
body, err := common.Marshal(map[string]any{"flow_token": pending.FlowToken, "passkey_flow_token": token, "credential": securityPasskeyResponse(t, key, challenge, false, 0)})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.Equal(t, !fail, result.Success, response.Body.String())
|
|
if fail {
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
assert.NotContains(t, response.Body.String(), "private")
|
|
_, err = model.GetAuthFlow(pending.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeLoginVerification})
|
|
require.NoError(t, err, "a failed session transaction must leave the parent challenge usable")
|
|
require.NoError(t, model.DB.Callback().Create().Remove("login_session_failure"))
|
|
}
|
|
}
|
|
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 2, count)
|
|
}
|
|
|
|
func TestSecurityLoginFactorStateDoesNotAddPasswordLoginQueries(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
newSecurityLoginPasskey(t, user.Id)
|
|
previousPasswordLogin := common.PasswordLoginEnabled
|
|
common.PasswordLoginEnabled = true
|
|
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin })
|
|
queries := 0
|
|
require.NoError(t, model.DB.Callback().Query().After("gorm:query").Register("login_query_count", func(tx *gorm.DB) {
|
|
if !tx.DryRun {
|
|
queries++
|
|
}
|
|
}))
|
|
t.Cleanup(func() { _ = model.DB.Callback().Query().Remove("login_query_count") })
|
|
state, err := model.GetUserVerificationState(user.Id)
|
|
require.NoError(t, err)
|
|
assert.True(t, state.HasPassword)
|
|
assert.True(t, state.HasPasskey)
|
|
assert.False(t, state.HasTwoFA)
|
|
assert.Equal(t, 1, queries, "factor availability must be one database round trip")
|
|
queries = 0
|
|
response := securityEnrollmentRequest("POST", "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login)
|
|
assert.Contains(t, response.Body.String(), `"require_verification":true`)
|
|
assert.Equal(t, 2, queries, "only the existing credential lookup and the replacement factor-state lookup run before the challenge")
|
|
}
|
|
|
|
type boundLoginOAuthProvider struct {
|
|
authFlowTestOAuthProvider
|
|
userID int
|
|
}
|
|
|
|
func (*boundLoginOAuthProvider) IsUserIDTaken(string) bool { return true }
|
|
func (*boundLoginOAuthProvider) ProviderUserIDColumn() string { return "github_id" }
|
|
func (provider *boundLoginOAuthProvider) FillUserByProviderID(user *model.User, _ string) error {
|
|
return model.DB.First(user, provider.userID).Error
|
|
}
|
|
|
|
func TestSecurityLoginAllPrimaryTransportsRequireAdditionalVerification(t *testing.T) {
|
|
for _, transport := range []string{"oauth", "custom oauth", "wechat", "telegram"} {
|
|
t.Run(transport, func(t *testing.T) {
|
|
var user *model.User
|
|
var telegram *telegramOAuthFixture
|
|
if transport == "telegram" {
|
|
telegram = setupTelegramOAuthTest(t)
|
|
user = telegram.user
|
|
} else {
|
|
user, _ = setupSecurityEnrollmentTest(t)
|
|
}
|
|
newSecurityLoginPasskey(t, user.Id)
|
|
var response *httptest.ResponseRecorder
|
|
switch transport {
|
|
case "telegram":
|
|
require.NoError(t, model.DB.Model(user).Update("telegram_id", "42").Error)
|
|
state, code := telegram.authorization(t, "login", service.AuthIdentity{}, "", telegramIdentityClaims(42))
|
|
response = telegramOAuthCallback(state, code, service.AuthIdentity{})
|
|
case "wechat":
|
|
require.NoError(t, model.DB.Model(user).Update("wechat_id", "bound-wechat").Error)
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "wechat-code", r.URL.Query().Get("code"))
|
|
_, _ = w.Write([]byte(`{"success":true,"data":"bound-wechat"}`))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
previousEnabled, previousAddress := common.WeChatAuthEnabled, common.WeChatServerAddress
|
|
common.WeChatAuthEnabled, common.WeChatServerAddress = true, upstream.URL
|
|
t.Cleanup(func() { common.WeChatAuthEnabled, common.WeChatServerAddress = previousEnabled, previousAddress })
|
|
response = securityEnrollmentRequest("GET", "/api/oauth/wechat?code=wechat-code", "", "", service.AuthIdentity{}, WeChatAuth)
|
|
default:
|
|
const slug = "unified-login-test"
|
|
if transport == "custom oauth" {
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path == "/token" {
|
|
_, _ = w.Write([]byte(`{"access_token":"provider-token","token_type":"Bearer"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"sub":"bound-custom","name":"User"}`))
|
|
}))
|
|
t.Cleanup(upstream.Close)
|
|
oauth.RegisterCustom(slug, oauth.NewGenericOAuthProvider(&model.CustomOAuthProvider{
|
|
Id: 42, Slug: slug, Name: "Custom login", Enabled: true, ClientId: "client", ClientSecret: "secret", UserIdField: "sub",
|
|
TokenEndpoint: upstream.URL + "/token", UserInfoEndpoint: upstream.URL + "/userinfo",
|
|
}))
|
|
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 42, ProviderUserId: "bound-custom"}).Error)
|
|
} else {
|
|
oauth.Register(slug, &boundLoginOAuthProvider{userID: user.Id})
|
|
}
|
|
t.Cleanup(func() { oauth.Unregister(slug) })
|
|
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{Purpose: model.AuthFlowPurposeOAuth, Provider: slug, Intent: model.AuthFlowIntentLogin, Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute)})
|
|
require.NoError(t, err)
|
|
router := gin.New()
|
|
router.GET("/api/oauth/:provider", HandleOAuth)
|
|
response = httptest.NewRecorder()
|
|
router.ServeHTTP(response, httptest.NewRequest("GET", "/api/oauth/"+slug+"?state="+token+"&code=provider-code", nil))
|
|
}
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Data service.LoginChallenge `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
assert.True(t, result.Data.RequireVerification)
|
|
assert.Equal(t, []service.VerificationMethodOption{{Method: "passkey", Available: true}}, result.Data.Methods)
|
|
assert.NotEmpty(t, result.Data.FlowToken)
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
assert.EqualValues(t, 1, count)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityLoginPasskeyCannotCompleteAnotherChallenge(t *testing.T) {
|
|
for _, otherUser := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("other-user=%t", otherUser), func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
first, err := service.StartLoginVerification(user, "password")
|
|
require.NoError(t, err)
|
|
passkeyToken, challenge := beginSecurityLoginPasskey(t, first.FlowToken)
|
|
if otherUser {
|
|
user = &model.User{Username: "other-login", Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AffCode: "other-login", AuthVersion: 1}
|
|
require.NoError(t, model.DB.Create(user).Error)
|
|
newSecurityLoginPasskey(t, user.Id)
|
|
}
|
|
second, err := service.StartLoginVerification(user, "password")
|
|
require.NoError(t, err)
|
|
body, err := common.Marshal(map[string]any{"flow_token": second.FlowToken, "passkey_flow_token": passkeyToken, "credential": securityPasskeyResponse(t, key, challenge, false, 0)})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/login/passkey/finish", string(body), "", service.AuthIdentity{}, LoginPasskeyFinish)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.False(t, result.Success)
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
var count int64
|
|
require.NoError(t, model.DB.Model(&model.UserSession{}).Count(&count).Error)
|
|
assert.EqualValues(t, 1, count)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityLoginPasskeyCanManageTwoFAWithScopedProofs(t *testing.T) {
|
|
for _, scope := range []string{service.VerificationScopeTwoFADisable, service.VerificationScopeTwoFABackupCodes} {
|
|
t.Run(scope, func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
key := newSecurityLoginPasskey(t, user.Id)
|
|
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
|
|
hash, err := common.HashBackupCode("ABCD-1234")
|
|
require.NoError(t, err)
|
|
require.NoError(t, model.DB.Create(&model.TwoFABackupCode{UserId: user.Id, CodeHash: hash}).Error)
|
|
if scope == service.VerificationScopeTwoFABackupCodes {
|
|
_, err := service.VerifySecurityInput(identity, service.VerificationInput{Scope: scope, Method: "2fa", Code: "ABCD-1234"})
|
|
assert.ErrorIs(t, err, service.ErrVerificationFailed)
|
|
remaining, err := model.GetUnusedBackupCodeCount(user.Id)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, remaining, "backup codes cannot authorize generating replacement backup codes")
|
|
}
|
|
beginBody, err := common.Marshal(map[string]string{"scope": scope})
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest("POST", "/api/user/passkey/verify/begin", string(beginBody), "", identity, PasskeyVerifyBegin)
|
|
var started struct {
|
|
Success bool `json:"success"`
|
|
Data struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
UserVerification string `json:"userVerification"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &started))
|
|
require.True(t, started.Success, response.Body.String())
|
|
assert.Equal(t, "required", started.Data.Options.PublicKey.UserVerification)
|
|
finish, err := common.Marshal(passkeyFinishRequest{FlowToken: started.Data.FlowToken, Credential: securityPasskeyResponse(t, key, started.Data.Options.PublicKey.Challenge, false, 0)})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/verify/finish", string(finish), "", identity, PasskeyVerifyFinish)
|
|
var result securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
var proof service.SecurityProof
|
|
require.NoError(t, common.Unmarshal(result.Data, &proof))
|
|
handler, path := Disable2FA, "/api/user/2fa/disable"
|
|
if scope == service.VerificationScopeTwoFABackupCodes {
|
|
handler, path = RegenerateBackupCodes, "/api/user/2fa/backup_codes"
|
|
response = securityEnrollmentRequest("POST", "/api/user/2fa/disable", `{}`, proof.ProofToken, identity, Disable2FA)
|
|
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_SCOPE_MISMATCH")
|
|
}
|
|
response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
stored, err := model.GetUserById(user.Id, false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, identity.UserAuthVersion+1, stored.AuthVersion)
|
|
_, err = model.GetPasskeyByUserID(user.Id)
|
|
require.NoError(t, err, "managing 2FA must retain the alternative Passkey factor")
|
|
factor, err := model.GetTwoFAByUserId(user.Id)
|
|
require.NoError(t, err)
|
|
if scope == service.VerificationScopeTwoFADisable {
|
|
assert.Nil(t, factor)
|
|
} else {
|
|
require.NotNil(t, factor)
|
|
assert.True(t, factor.IsEnabled)
|
|
assert.Contains(t, string(result.Data), "backup_codes")
|
|
}
|
|
response = securityEnrollmentRequest("POST", path, `{}`, proof.ProofToken, identity, handler)
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
assert.False(t, result.Success, "the proof cannot authorize a second mutation")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityLoginRegisteredPasskeyRequiresUserVerification(t *testing.T) {
|
|
for _, verified := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("verified=%t", verified), func(t *testing.T) {
|
|
user, identity := setupSecurityEnrollmentTest(t)
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, service.VerificationMethodPassword)
|
|
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin)
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Data struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
AuthenticatorSelection struct {
|
|
UserVerification string `json:"userVerification"`
|
|
} `json:"authenticatorSelection"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
assert.Equal(t, "required", result.Data.Options.PublicKey.AuthenticatorSelection.UserVerification)
|
|
body, err := common.Marshal(passkeyFinishRequest{FlowToken: result.Data.FlowToken, Credential: securityPasskeyResponse(t, key, result.Data.Options.PublicKey.Challenge, true, 0, verified)})
|
|
require.NoError(t, err)
|
|
response = securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
|
|
var finished securityEnrollmentResponse
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &finished))
|
|
assert.Equal(t, verified, finished.Success, response.Body.String())
|
|
_, err = model.GetPasskeyByUserID(user.Id)
|
|
if verified {
|
|
require.NoError(t, err)
|
|
} else {
|
|
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSecurityLoginRequiresConfiguredFactors(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
twoFA, passkey bool
|
|
locked, disabled bool
|
|
methods []service.VerificationMethodOption
|
|
unavailable bool
|
|
}{
|
|
{name: "password without additional factors"},
|
|
{name: "passkey requires verification", passkey: true, methods: []service.VerificationMethodOption{{Method: "passkey", Available: true}}},
|
|
{name: "twofa requires verification", twoFA: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}}},
|
|
{name: "both factors are alternatives", twoFA: true, passkey: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: true}}},
|
|
{name: "locked twofa permits passkey", twoFA: true, passkey: true, locked: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: false, Reason: service.ErrVerificationLocked.Error()}, {Method: "passkey", Available: true}}},
|
|
{name: "disabled passkey permits twofa", twoFA: true, passkey: true, disabled: true, methods: []service.VerificationMethodOption{{Method: "2fa", Available: true}, {Method: "passkey", Available: false, Reason: "Passkey authentication is disabled."}}},
|
|
{name: "only passkey disabled blocks password", passkey: true, disabled: true, unavailable: true},
|
|
{name: "both factors unavailable block password", passkey: true, twoFA: true, disabled: true, locked: true, unavailable: true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
user, _ := setupSecurityEnrollmentTest(t)
|
|
previousPasswordLogin := common.PasswordLoginEnabled
|
|
common.PasswordLoginEnabled = true
|
|
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordLogin })
|
|
if test.passkey {
|
|
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "login-key", PublicKey: "public-key"}).Error)
|
|
}
|
|
if test.twoFA {
|
|
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
|
|
if test.locked {
|
|
until := time.Now().Add(time.Minute)
|
|
factor.LockedUntil = &until
|
|
}
|
|
require.NoError(t, model.DB.Create(factor).Error)
|
|
}
|
|
system_setting.GetPasskeySettings().Enabled = !test.disabled
|
|
before, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
response := securityEnrollmentRequest(http.MethodPost, "/api/user/login", `{"username":"enrollment-user","password":"enrollment-password"}`, "", service.AuthIdentity{}, Login)
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Data struct {
|
|
RequireVerification bool `json:"require_verification"`
|
|
FlowToken string `json:"flow_token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
AccessToken string `json:"access_token"`
|
|
Methods []service.VerificationMethodOption `json:"methods"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
after, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
|
require.NoError(t, err)
|
|
if test.unavailable {
|
|
assert.False(t, result.Success)
|
|
assert.Equal(t, before, after)
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
return
|
|
}
|
|
require.True(t, result.Success, response.Body.String())
|
|
if len(test.methods) == 0 {
|
|
assert.False(t, result.Data.RequireVerification)
|
|
assert.NotEmpty(t, result.Data.AccessToken)
|
|
assert.Equal(t, before+1, after)
|
|
return
|
|
}
|
|
assert.True(t, result.Data.RequireVerification)
|
|
assert.NotEmpty(t, result.Data.FlowToken)
|
|
assert.Greater(t, result.Data.ExpiresAt, time.Now().Unix())
|
|
assert.LessOrEqual(t, result.Data.ExpiresAt, time.Now().Add(5*time.Minute).Unix())
|
|
assert.Equal(t, test.methods, result.Data.Methods)
|
|
assert.Empty(t, result.Data.AccessToken)
|
|
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
|
assert.Equal(t, before, after, "a pending challenge must not create a session")
|
|
})
|
|
}
|
|
}
|
|
|
|
type authFlowTestOAuthProvider struct {
|
|
exchangeErr error
|
|
userInfoErr error
|
|
exchangeCalls int
|
|
userInfoCalls int
|
|
}
|
|
|
|
func (*authFlowTestOAuthProvider) GetName() string { return "Auth Flow Test" }
|
|
func (*authFlowTestOAuthProvider) IsEnabled() bool { return true }
|
|
func (provider *authFlowTestOAuthProvider) ExchangeToken(context.Context, string, *gin.Context) (*oauth.OAuthToken, error) {
|
|
provider.exchangeCalls++
|
|
if provider.exchangeErr != nil {
|
|
return nil, provider.exchangeErr
|
|
}
|
|
return &oauth.OAuthToken{}, nil
|
|
}
|
|
func (provider *authFlowTestOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
|
|
provider.userInfoCalls++
|
|
if provider.userInfoErr != nil {
|
|
return nil, provider.userInfoErr
|
|
}
|
|
return &oauth.OAuthUser{ProviderUserID: "external-user"}, nil
|
|
}
|
|
func (*authFlowTestOAuthProvider) IsUserIDTaken(string) bool { return false }
|
|
func (*authFlowTestOAuthProvider) FillUserByProviderID(*model.User, string) error { return nil }
|
|
func (*authFlowTestOAuthProvider) SetProviderUserID(*model.User, string) {}
|
|
func (*authFlowTestOAuthProvider) GetProviderPrefix() string { return "flow_" }
|
|
func (*authFlowTestOAuthProvider) ProviderUserIDColumn() string { return "" }
|
|
|
|
func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider {
|
|
t.Helper()
|
|
previousDB, previousLogDB := model.DB, model.LOG_DB
|
|
previousRedis := common.RedisEnabled
|
|
common.RedisEnabled = false
|
|
previousType := common.MainDatabaseType()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.AutoMigrate(&model.AuthFlow{}, &model.User{}, &model.UserSession{}, &model.AuditLog{}))
|
|
model.DB, model.LOG_DB = db, db
|
|
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
|
|
provider := &authFlowTestOAuthProvider{}
|
|
oauth.Register("auth-flow-test", provider)
|
|
t.Cleanup(func() {
|
|
oauth.Unregister("auth-flow-test")
|
|
model.DB, model.LOG_DB = previousDB, previousLogDB
|
|
common.RedisEnabled = previousRedis
|
|
common.SetMainDatabaseType(previousType)
|
|
})
|
|
return provider
|
|
}
|
|
|
|
func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) {
|
|
setupAuthFlowControllerTest(t)
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"login","aff":"invite-code"}`))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
GenerateOAuthCode(c)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
var response struct {
|
|
Success bool `json:"success"`
|
|
Data struct {
|
|
FlowToken string `json:"flow_token"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
|
require.True(t, response.Success)
|
|
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
|
|
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
|
|
})
|
|
require.NoError(t, err)
|
|
var payload oauthFlowPayload
|
|
require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
|
|
assert.Equal(t, "invite-code", payload.AffiliateCode)
|
|
assert.Zero(t, flow.UserId)
|
|
assert.Empty(t, flow.SessionId)
|
|
}
|
|
|
|
func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
oauth.Register("auth-flow-test", &authFlowTestOAuthProvider{})
|
|
t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
|
|
response := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Data struct {
|
|
FlowToken string `json:"flow_token"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
|
require.True(t, result.Success, response.Body.String())
|
|
flow, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind, UserId: identity.UserID, SessionId: identity.SessionID})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, identity.UserID, flow.UserId)
|
|
assert.Equal(t, identity.SessionID, flow.SessionId)
|
|
}
|
|
|
|
func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) {
|
|
provider := setupAuthFlowControllerTest(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
exchangeErr error
|
|
userInfoErr error
|
|
}{
|
|
{name: "exchange failure", exchangeErr: errors.New("exchange failed")},
|
|
{name: "user info failure", userInfoErr: errors.New("user info failed")},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
provider.exchangeErr = test.exchangeErr
|
|
provider.userInfoErr = test.userInfoErr
|
|
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
|
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
|
|
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
router := gin.New()
|
|
router.GET("/api/oauth/:provider", HandleOAuth)
|
|
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+token+"&code=test", nil)
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
|
|
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{
|
|
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Nil(t, flow.ConsumedAt)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testing.T) {
|
|
provider := setupAuthFlowControllerTest(t)
|
|
|
|
provider.exchangeErr = nil
|
|
provider.userInfoErr = nil
|
|
successToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
|
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
|
|
Payload: `{invalid`, ExpiresAt: time.Now().Add(time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
router := gin.New()
|
|
router.GET("/api/oauth/:provider", HandleOAuth)
|
|
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+successToken+"&code=test", nil)
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
_, err = model.GetAuthFlow(successToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
|
|
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
|
|
assert.Equal(t, 1, provider.exchangeCalls)
|
|
assert.Equal(t, 1, provider.userInfoCalls)
|
|
|
|
providerErrorToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
|
|
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
|
|
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
|
|
})
|
|
require.NoError(t, err)
|
|
request = httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+providerErrorToken+"&error=access_denied", nil)
|
|
response = httptest.NewRecorder()
|
|
router.ServeHTTP(response, request)
|
|
_, err = model.GetAuthFlow(providerErrorToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
|
|
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
|
|
assert.Equal(t, 1, provider.exchangeCalls)
|
|
assert.Equal(t, 1, provider.userInfoCalls)
|
|
}
|
|
|
|
func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
|
|
_, identity := setupSecurityEnrollmentTest(t)
|
|
provider := &authFlowTestOAuthProvider{}
|
|
oauth.Register("auth-flow-test", provider)
|
|
t.Cleanup(func() { oauth.Unregister("auth-flow-test") })
|
|
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"auth-flow-test"}`)}, service.VerificationMethodPassword)
|
|
started := securityEnrollmentRequest(http.MethodPost, "/api/oauth/state", `{"provider":"auth-flow-test","intent":"bind"}`, proof, identity, GenerateOAuthCode)
|
|
var result struct {
|
|
Data struct {
|
|
FlowToken string `json:"flow_token"`
|
|
} `json:"data"`
|
|
}
|
|
require.NoError(t, common.Unmarshal(started.Body.Bytes(), &result))
|
|
require.NotEmpty(t, result.Data.FlowToken)
|
|
response := securityEnrollmentRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+result.Data.FlowToken+"&error=access_denied&error_description=cancelled", "", "", identity, func(c *gin.Context) {
|
|
c.Params = gin.Params{{Key: "provider", Value: "auth-flow-test"}}
|
|
HandleOAuth(c)
|
|
})
|
|
assert.Equal(t, http.StatusOK, response.Code)
|
|
_, err := model.GetAuthFlow(result.Data.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
|
|
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
|
|
assert.Zero(t, provider.exchangeCalls)
|
|
assert.Zero(t, provider.userInfoCalls)
|
|
}
|