mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 00:57:33 +00:00
feat: session checks with intent (#6031)
* feat: session checks with intent * feat: session checks with intent * fix: integration tests for intent session * fix: integration tests for intent session * fix merge * fix: integration tests for intent session --------- Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SessionsProjectionTable = "projections.sessions1"
|
||||
SessionsProjectionTable = "projections.sessions2"
|
||||
|
||||
SessionColumnID = "id"
|
||||
SessionColumnCreationDate = "creation_date"
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
SessionColumnUserID = "user_id"
|
||||
SessionColumnUserCheckedAt = "user_checked_at"
|
||||
SessionColumnPasswordCheckedAt = "password_checked_at"
|
||||
SessionColumnIntentCheckedAt = "intent_checked_at"
|
||||
SessionColumnPasskeyCheckedAt = "passkey_checked_at"
|
||||
SessionColumnMetadata = "metadata"
|
||||
SessionColumnTokenID = "token_id"
|
||||
@@ -53,6 +54,7 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi
|
||||
crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()),
|
||||
crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
|
||||
crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
|
||||
crdb.NewColumn(SessionColumnIntentCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
|
||||
crdb.NewColumn(SessionColumnPasskeyCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
|
||||
crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()),
|
||||
crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()),
|
||||
@@ -81,6 +83,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
|
||||
Event: session.PasswordCheckedType,
|
||||
Reduce: p.reducePasswordChecked,
|
||||
},
|
||||
{
|
||||
Event: session.IntentCheckedType,
|
||||
Reduce: p.reduceIntentChecked,
|
||||
},
|
||||
{
|
||||
Event: session.PasskeyCheckedType,
|
||||
Reduce: p.reducePasskeyChecked,
|
||||
@@ -181,6 +187,26 @@ func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*hand
|
||||
), nil
|
||||
}
|
||||
|
||||
func (p *sessionProjection) reduceIntentChecked(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*session.IntentCheckedEvent)
|
||||
if !ok {
|
||||
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SDgr2", "reduce.wrong.event.type %s", session.IntentCheckedType)
|
||||
}
|
||||
|
||||
return crdb.NewUpdateStatement(
|
||||
e,
|
||||
[]handler.Column{
|
||||
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
|
||||
handler.NewCol(SessionColumnSequence, e.Sequence()),
|
||||
handler.NewCol(SessionColumnIntentCheckedAt, e.CheckedAt),
|
||||
},
|
||||
[]handler.Condition{
|
||||
handler.NewCond(SessionColumnID, e.Aggregate().ID),
|
||||
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
|
||||
},
|
||||
), nil
|
||||
}
|
||||
|
||||
func (p *sessionProjection) reducePasskeyChecked(event eventstore.Event) (*handler.Statement, error) {
|
||||
e, ok := event.(*session.PasskeyCheckedEvent)
|
||||
if !ok {
|
||||
|
@@ -41,7 +41,7 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "INSERT INTO projections.sessions1 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
expectedStmt: "INSERT INTO projections.sessions2 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
expectedArgs: []interface{}{
|
||||
"agg-id",
|
||||
"instance-id",
|
||||
@@ -77,7 +77,7 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
|
||||
expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
|
||||
expectedArgs: []interface{}{
|
||||
anyArg{},
|
||||
anyArg{},
|
||||
@@ -110,7 +110,39 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
|
||||
expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
|
||||
expectedArgs: []interface{}{
|
||||
anyArg{},
|
||||
anyArg{},
|
||||
time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC),
|
||||
"agg-id",
|
||||
"instance-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance reduceIntentChecked",
|
||||
args: args{
|
||||
event: getEvent(testEvent(
|
||||
session.AddedType,
|
||||
session.AggregateType,
|
||||
[]byte(`{
|
||||
"checkedAt": "2023-05-04T00:00:00Z"
|
||||
}`),
|
||||
), session.IntentCheckedEventMapper),
|
||||
},
|
||||
reduce: (&sessionProjection{}).reduceIntentChecked,
|
||||
want: wantReduce{
|
||||
aggregateType: eventstore.AggregateType("session"),
|
||||
sequence: 15,
|
||||
previousSequence: 10,
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, intent_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
|
||||
expectedArgs: []interface{}{
|
||||
anyArg{},
|
||||
anyArg{},
|
||||
@@ -142,7 +174,7 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
|
||||
expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
|
||||
expectedArgs: []interface{}{
|
||||
anyArg{},
|
||||
anyArg{},
|
||||
@@ -176,7 +208,7 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
|
||||
expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
|
||||
expectedArgs: []interface{}{
|
||||
anyArg{},
|
||||
anyArg{},
|
||||
@@ -208,7 +240,7 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "DELETE FROM projections.sessions1 WHERE (id = $1) AND (instance_id = $2)",
|
||||
expectedStmt: "DELETE FROM projections.sessions2 WHERE (id = $1) AND (instance_id = $2)",
|
||||
expectedArgs: []interface{}{
|
||||
"agg-id",
|
||||
"instance-id",
|
||||
@@ -235,7 +267,7 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "DELETE FROM projections.sessions1 WHERE (instance_id = $1)",
|
||||
expectedStmt: "DELETE FROM projections.sessions2 WHERE (instance_id = $1)",
|
||||
expectedArgs: []interface{}{
|
||||
"agg-id",
|
||||
},
|
||||
@@ -266,7 +298,7 @@ func TestSessionProjection_reduces(t *testing.T) {
|
||||
executer: &testExecuter{
|
||||
executions: []execution{
|
||||
{
|
||||
expectedStmt: "UPDATE projections.sessions1 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)",
|
||||
expectedStmt: "UPDATE projections.sessions2 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)",
|
||||
expectedArgs: []interface{}{
|
||||
nil,
|
||||
"agg-id",
|
||||
|
@@ -32,6 +32,7 @@ type Session struct {
|
||||
Creator string
|
||||
UserFactor SessionUserFactor
|
||||
PasswordFactor SessionPasswordFactor
|
||||
IntentFactor SessionIntentFactor
|
||||
PasskeyFactor SessionPasskeyFactor
|
||||
Metadata map[string][]byte
|
||||
}
|
||||
@@ -47,6 +48,10 @@ type SessionPasswordFactor struct {
|
||||
PasswordCheckedAt time.Time
|
||||
}
|
||||
|
||||
type SessionIntentFactor struct {
|
||||
IntentCheckedAt time.Time
|
||||
}
|
||||
|
||||
type SessionPasskeyFactor struct {
|
||||
PasskeyCheckedAt time.Time
|
||||
}
|
||||
@@ -113,6 +118,10 @@ var (
|
||||
name: projection.SessionColumnPasswordCheckedAt,
|
||||
table: sessionsTable,
|
||||
}
|
||||
SessionColumnIntentCheckedAt = Column{
|
||||
name: projection.SessionColumnIntentCheckedAt,
|
||||
table: sessionsTable,
|
||||
}
|
||||
SessionColumnPasskeyCheckedAt = Column{
|
||||
name: projection.SessionColumnPasskeyCheckedAt,
|
||||
table: sessionsTable,
|
||||
@@ -207,6 +216,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
|
||||
LoginNameNameCol.identifier(),
|
||||
HumanDisplayNameCol.identifier(),
|
||||
SessionColumnPasswordCheckedAt.identifier(),
|
||||
SessionColumnIntentCheckedAt.identifier(),
|
||||
SessionColumnPasskeyCheckedAt.identifier(),
|
||||
SessionColumnMetadata.identifier(),
|
||||
SessionColumnToken.identifier(),
|
||||
@@ -222,6 +232,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
|
||||
loginName sql.NullString
|
||||
displayName sql.NullString
|
||||
passwordCheckedAt sql.NullTime
|
||||
intentCheckedAt sql.NullTime
|
||||
passkeyCheckedAt sql.NullTime
|
||||
metadata database.Map[[]byte]
|
||||
token sql.NullString
|
||||
@@ -240,6 +251,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
|
||||
&loginName,
|
||||
&displayName,
|
||||
&passwordCheckedAt,
|
||||
&intentCheckedAt,
|
||||
&passkeyCheckedAt,
|
||||
&metadata,
|
||||
&token,
|
||||
@@ -257,6 +269,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
|
||||
session.UserFactor.LoginName = loginName.String
|
||||
session.UserFactor.DisplayName = displayName.String
|
||||
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
|
||||
session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time
|
||||
session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time
|
||||
session.Metadata = metadata
|
||||
|
||||
@@ -278,6 +291,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
|
||||
LoginNameNameCol.identifier(),
|
||||
HumanDisplayNameCol.identifier(),
|
||||
SessionColumnPasswordCheckedAt.identifier(),
|
||||
SessionColumnIntentCheckedAt.identifier(),
|
||||
SessionColumnPasskeyCheckedAt.identifier(),
|
||||
SessionColumnMetadata.identifier(),
|
||||
countColumn.identifier(),
|
||||
@@ -296,6 +310,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
|
||||
loginName sql.NullString
|
||||
displayName sql.NullString
|
||||
passwordCheckedAt sql.NullTime
|
||||
intentCheckedAt sql.NullTime
|
||||
passkeyCheckedAt sql.NullTime
|
||||
metadata database.Map[[]byte]
|
||||
)
|
||||
@@ -313,6 +328,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
|
||||
&loginName,
|
||||
&displayName,
|
||||
&passwordCheckedAt,
|
||||
&intentCheckedAt,
|
||||
&passkeyCheckedAt,
|
||||
&metadata,
|
||||
&sessions.Count,
|
||||
@@ -326,6 +342,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
|
||||
session.UserFactor.LoginName = loginName.String
|
||||
session.UserFactor.DisplayName = displayName.String
|
||||
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
|
||||
session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time
|
||||
session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time
|
||||
session.Metadata = metadata
|
||||
|
||||
|
@@ -17,43 +17,45 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` +
|
||||
` projections.sessions1.creation_date,` +
|
||||
` projections.sessions1.change_date,` +
|
||||
` projections.sessions1.sequence,` +
|
||||
` projections.sessions1.state,` +
|
||||
` projections.sessions1.resource_owner,` +
|
||||
` projections.sessions1.creator,` +
|
||||
` projections.sessions1.user_id,` +
|
||||
` projections.sessions1.user_checked_at,` +
|
||||
expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions2.id,` +
|
||||
` projections.sessions2.creation_date,` +
|
||||
` projections.sessions2.change_date,` +
|
||||
` projections.sessions2.sequence,` +
|
||||
` projections.sessions2.state,` +
|
||||
` projections.sessions2.resource_owner,` +
|
||||
` projections.sessions2.creator,` +
|
||||
` projections.sessions2.user_id,` +
|
||||
` projections.sessions2.user_checked_at,` +
|
||||
` projections.login_names2.login_name,` +
|
||||
` projections.users8_humans.display_name,` +
|
||||
` projections.sessions1.password_checked_at,` +
|
||||
` projections.sessions1.passkey_checked_at,` +
|
||||
` projections.sessions1.metadata,` +
|
||||
` projections.sessions1.token_id` +
|
||||
` FROM projections.sessions1` +
|
||||
` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` +
|
||||
` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` +
|
||||
` projections.sessions2.password_checked_at,` +
|
||||
` projections.sessions2.intent_checked_at,` +
|
||||
` projections.sessions2.passkey_checked_at,` +
|
||||
` projections.sessions2.metadata,` +
|
||||
` projections.sessions2.token_id` +
|
||||
` FROM projections.sessions2` +
|
||||
` LEFT JOIN projections.login_names2 ON projections.sessions2.user_id = projections.login_names2.user_id AND projections.sessions2.instance_id = projections.login_names2.instance_id` +
|
||||
` LEFT JOIN projections.users8_humans ON projections.sessions2.user_id = projections.users8_humans.user_id AND projections.sessions2.instance_id = projections.users8_humans.instance_id` +
|
||||
` AS OF SYSTEM TIME '-1 ms'`)
|
||||
expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` +
|
||||
` projections.sessions1.creation_date,` +
|
||||
` projections.sessions1.change_date,` +
|
||||
` projections.sessions1.sequence,` +
|
||||
` projections.sessions1.state,` +
|
||||
` projections.sessions1.resource_owner,` +
|
||||
` projections.sessions1.creator,` +
|
||||
` projections.sessions1.user_id,` +
|
||||
` projections.sessions1.user_checked_at,` +
|
||||
expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions2.id,` +
|
||||
` projections.sessions2.creation_date,` +
|
||||
` projections.sessions2.change_date,` +
|
||||
` projections.sessions2.sequence,` +
|
||||
` projections.sessions2.state,` +
|
||||
` projections.sessions2.resource_owner,` +
|
||||
` projections.sessions2.creator,` +
|
||||
` projections.sessions2.user_id,` +
|
||||
` projections.sessions2.user_checked_at,` +
|
||||
` projections.login_names2.login_name,` +
|
||||
` projections.users8_humans.display_name,` +
|
||||
` projections.sessions1.password_checked_at,` +
|
||||
` projections.sessions1.passkey_checked_at,` +
|
||||
` projections.sessions1.metadata,` +
|
||||
` projections.sessions2.password_checked_at,` +
|
||||
` projections.sessions2.intent_checked_at,` +
|
||||
` projections.sessions2.passkey_checked_at,` +
|
||||
` projections.sessions2.metadata,` +
|
||||
` COUNT(*) OVER ()` +
|
||||
` FROM projections.sessions1` +
|
||||
` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` +
|
||||
` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` +
|
||||
` FROM projections.sessions2` +
|
||||
` LEFT JOIN projections.login_names2 ON projections.sessions2.user_id = projections.login_names2.user_id AND projections.sessions2.instance_id = projections.login_names2.instance_id` +
|
||||
` LEFT JOIN projections.users8_humans ON projections.sessions2.user_id = projections.users8_humans.user_id AND projections.sessions2.instance_id = projections.users8_humans.instance_id` +
|
||||
` AS OF SYSTEM TIME '-1 ms'`)
|
||||
|
||||
sessionCols = []string{
|
||||
@@ -69,6 +71,7 @@ var (
|
||||
"login_name",
|
||||
"display_name",
|
||||
"password_checked_at",
|
||||
"intent_checked_at",
|
||||
"passkey_checked_at",
|
||||
"metadata",
|
||||
"token",
|
||||
@@ -87,6 +90,7 @@ var (
|
||||
"login_name",
|
||||
"display_name",
|
||||
"password_checked_at",
|
||||
"intent_checked_at",
|
||||
"passkey_checked_at",
|
||||
"metadata",
|
||||
"count",
|
||||
@@ -138,6 +142,7 @@ func Test_SessionsPrepare(t *testing.T) {
|
||||
"display-name",
|
||||
testNow,
|
||||
testNow,
|
||||
testNow,
|
||||
[]byte(`{"key": "dmFsdWU="}`),
|
||||
},
|
||||
},
|
||||
@@ -165,6 +170,9 @@ func Test_SessionsPrepare(t *testing.T) {
|
||||
PasswordFactor: SessionPasswordFactor{
|
||||
PasswordCheckedAt: testNow,
|
||||
},
|
||||
IntentFactor: SessionIntentFactor{
|
||||
IntentCheckedAt: testNow,
|
||||
},
|
||||
PasskeyFactor: SessionPasskeyFactor{
|
||||
PasskeyCheckedAt: testNow,
|
||||
},
|
||||
@@ -197,6 +205,7 @@ func Test_SessionsPrepare(t *testing.T) {
|
||||
"display-name",
|
||||
testNow,
|
||||
testNow,
|
||||
testNow,
|
||||
[]byte(`{"key": "dmFsdWU="}`),
|
||||
},
|
||||
{
|
||||
@@ -213,6 +222,7 @@ func Test_SessionsPrepare(t *testing.T) {
|
||||
"display-name2",
|
||||
testNow,
|
||||
testNow,
|
||||
testNow,
|
||||
[]byte(`{"key": "dmFsdWU="}`),
|
||||
},
|
||||
},
|
||||
@@ -240,6 +250,9 @@ func Test_SessionsPrepare(t *testing.T) {
|
||||
PasswordFactor: SessionPasswordFactor{
|
||||
PasswordCheckedAt: testNow,
|
||||
},
|
||||
IntentFactor: SessionIntentFactor{
|
||||
IntentCheckedAt: testNow,
|
||||
},
|
||||
PasskeyFactor: SessionPasskeyFactor{
|
||||
PasskeyCheckedAt: testNow,
|
||||
},
|
||||
@@ -264,6 +277,9 @@ func Test_SessionsPrepare(t *testing.T) {
|
||||
PasswordFactor: SessionPasswordFactor{
|
||||
PasswordCheckedAt: testNow,
|
||||
},
|
||||
IntentFactor: SessionIntentFactor{
|
||||
IntentCheckedAt: testNow,
|
||||
},
|
||||
PasskeyFactor: SessionPasskeyFactor{
|
||||
PasskeyCheckedAt: testNow,
|
||||
},
|
||||
@@ -349,6 +365,7 @@ func Test_SessionPrepare(t *testing.T) {
|
||||
"display-name",
|
||||
testNow,
|
||||
testNow,
|
||||
testNow,
|
||||
[]byte(`{"key": "dmFsdWU="}`),
|
||||
"tokenID",
|
||||
},
|
||||
@@ -371,6 +388,9 @@ func Test_SessionPrepare(t *testing.T) {
|
||||
PasswordFactor: SessionPasswordFactor{
|
||||
PasswordCheckedAt: testNow,
|
||||
},
|
||||
IntentFactor: SessionIntentFactor{
|
||||
IntentCheckedAt: testNow,
|
||||
},
|
||||
PasskeyFactor: SessionPasskeyFactor{
|
||||
PasskeyCheckedAt: testNow,
|
||||
},
|
||||
|
Reference in New Issue
Block a user