Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
819 lines
38 KiB
Go
819 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 := range 2 {
|
|
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.Go(func() {
|
|
<-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)
|
|
}
|