snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
21
selfservice/strategy/idfirst/.schema/login.schema.json
Normal file
21
selfservice/strategy/idfirst/.schema/login.schema.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/identity_disovery/login.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"csrf_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"method": {
|
||||
"type": "string"
|
||||
},
|
||||
"transient_payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "identifier_first",
|
||||
"attributes": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"autocomplete": "username webauthn",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070004,
|
||||
"text": "ID",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "identifier_first",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "identifier_first",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070009,
|
||||
"text": "Continue",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1,55 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "identifier_first",
|
||||
"attributes": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"value": "",
|
||||
"required": true,
|
||||
"autocomplete": "username webauthn",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070004,
|
||||
"text": "ID",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "input",
|
||||
"group": "identifier_first",
|
||||
"attributes": {
|
||||
"name": "method",
|
||||
"type": "submit",
|
||||
"value": "identifier_first",
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {
|
||||
"label": {
|
||||
"id": 1070009,
|
||||
"text": "Continue",
|
||||
"type": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
11
selfservice/strategy/idfirst/schema.go
Normal file
11
selfservice/strategy/idfirst/schema.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package idfirst
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed .schema/login.schema.json
|
||||
var LoginSchema []byte
|
||||
51
selfservice/strategy/idfirst/strategy.go
Normal file
51
selfservice/strategy/idfirst/strategy.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package idfirst
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
)
|
||||
|
||||
type dependencies interface {
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
nosurfx.CSRFProvider
|
||||
otelx.Provider
|
||||
|
||||
config.Provider
|
||||
|
||||
identity.PrivilegedPoolProvider
|
||||
login.StrategyProvider
|
||||
login.FlowPersistenceProvider
|
||||
}
|
||||
|
||||
type Strategy struct{ d dependencies }
|
||||
|
||||
func NewStrategy(d dependencies) *Strategy { return &Strategy{d: d} }
|
||||
|
||||
func (s *Strategy) ID() identity.CredentialsType {
|
||||
return identity.CredentialsTypeIdentifierFirst
|
||||
}
|
||||
|
||||
func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod {
|
||||
return session.AuthenticationMethod{
|
||||
Method: s.ID(),
|
||||
AAL: identity.NoAuthenticatorAssuranceLevel,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Strategy) NodeGroup() node.UiNodeGroup {
|
||||
return node.IdentifierFirstGroup
|
||||
}
|
||||
196
selfservice/strategy/idfirst/strategy_login.go
Normal file
196
selfservice/strategy/idfirst/strategy_login.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package idfirst
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/decoderx"
|
||||
"github.com/ory/x/sqlcon"
|
||||
)
|
||||
|
||||
var (
|
||||
_ login.AAL1FormHydrator = new(Strategy)
|
||||
_ login.Strategy = new(Strategy)
|
||||
ErrNoCredentialsFound = errors.New("no credentials found")
|
||||
)
|
||||
|
||||
func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, payload UpdateLoginFlowWithIdentifierFirstMethod, err error) error {
|
||||
if f != nil {
|
||||
f.UI.Nodes.SetValueAttribute("identifier", payload.Identifier)
|
||||
if f.Type == flow.TypeBrowser {
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, sess *session.Session) (_ *identity.Identity, err error) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.idfirst.Strategy.Login")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if !s.d.Config().SelfServiceLoginFlowIdentifierFirstEnabled(ctx) {
|
||||
span.SetAttributes(attribute.String("not_responsible_reason", "strategy is not enabled"))
|
||||
return nil, errors.WithStack(flow.ErrStrategyNotResponsible)
|
||||
}
|
||||
|
||||
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1); err != nil {
|
||||
span.SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not sufficient"))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var p UpdateLoginFlowWithIdentifierFirstMethod
|
||||
if err := decoderx.Decode(r, &p,
|
||||
decoderx.HTTPDecoderSetValidatePayloads(true),
|
||||
decoderx.MustHTTPRawJSONSchemaCompiler(LoginSchema),
|
||||
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, err)
|
||||
}
|
||||
f.TransientPayload = p.TransientPayload
|
||||
|
||||
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, err)
|
||||
}
|
||||
|
||||
expand := identity.ExpandCredentials
|
||||
if s.d.Config().SecurityAccountEnumerationMitigate(ctx) {
|
||||
expand = identity.ExpandNothing
|
||||
}
|
||||
// Look up the user by the identifier.
|
||||
identityHint, err := s.d.PrivilegedIdentityPool().FindIdentityByCredentialIdentifier(ctx, p.Identifier,
|
||||
/* We are dealing with user input -> lookup should be case-insensitive */ false,
|
||||
expand,
|
||||
)
|
||||
if errors.Is(err, sqlcon.ErrNoRows()) {
|
||||
// If the user is not found, we still want to potentially show the UI for some method. That's why we don't exit here.
|
||||
// We have to mitigate account enumeration. So we continue without setting the identity hint.
|
||||
//
|
||||
// This will later be handled by `didPopulate`.
|
||||
} else if err != nil {
|
||||
// An error happened during lookup
|
||||
return nil, s.handleLoginError(r, f, p, err)
|
||||
}
|
||||
|
||||
f.UI.ResetMessages()
|
||||
f.UI.Nodes.SetValueAttribute("identifier", p.Identifier)
|
||||
|
||||
// Add identity hint
|
||||
opts := []login.FormHydratorModifier{login.WithIdentityHint(identityHint), login.WithIdentifier(p.Identifier)}
|
||||
|
||||
didPopulate := false
|
||||
for _, ls := range s.d.LoginStrategies(ctx, login.PrepareOrganizations(r, f, sess)...) {
|
||||
populator, ok := ls.(login.AAL1FormHydrator)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := populator.PopulateLoginMethodIdentifierFirstCredentials(r, f, opts...); errors.Is(err, login.ErrBreakLoginPopulate) {
|
||||
didPopulate = true
|
||||
break
|
||||
} else if errors.Is(err, ErrNoCredentialsFound) {
|
||||
// This strategy is not responsible for this flow. We do not set didPopulate to true if that happens.
|
||||
} else if err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, err)
|
||||
} else {
|
||||
didPopulate = true
|
||||
|
||||
// If the method supports FastLogin, we can attempt a fast path.
|
||||
if strategy, ok := ls.(login.FastLoginStrategy); ok {
|
||||
err = strategy.FastLogin1FA(w, r, f, sess)
|
||||
if err == nil {
|
||||
return nil, flow.ErrCompletedByStrategy
|
||||
} else if !errors.Is(err, flow.ErrStrategyNotResponsible) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no strategy populated, it means that the account (very likely) does not exist. We show a user not found error,
|
||||
// but only if account enumeration mitigation is disabled. Otherwise, we proceed to render the rest of the form.
|
||||
if !didPopulate && !s.d.Config().SecurityAccountEnumerationMitigate(ctx) {
|
||||
return nil, s.handleLoginError(r, f, p, errors.WithStack(schema.NewAccountNotFoundError()))
|
||||
}
|
||||
|
||||
// We found credentials - hide the identifier.
|
||||
f.UI.GetNodes().RemoveMatching(node.NewInputField("method", s.ID(), s.NodeGroup(), node.InputAttributeTypeSubmit))
|
||||
|
||||
// We set the identifier to hidden, so it's still available in the form but not visible to the user.
|
||||
for k, n := range f.UI.Nodes {
|
||||
if n.ID() != "identifier" {
|
||||
continue
|
||||
}
|
||||
|
||||
attrs, ok := f.UI.Nodes[k].Attributes.(*node.InputAttributes)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attrs.Autocomplete = node.InputAttributeAutocompleteUsernameWebauthn
|
||||
|
||||
attrs.Type = node.InputAttributeTypeHidden
|
||||
f.UI.Nodes[k].Attributes = attrs
|
||||
}
|
||||
|
||||
f.Active = s.ID()
|
||||
if err = s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
|
||||
return nil, s.handleLoginError(r, f, p, err)
|
||||
}
|
||||
|
||||
if x.IsJSONRequest(r) {
|
||||
s.d.Writer().WriteCode(w, r, http.StatusBadRequest, f)
|
||||
} else {
|
||||
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowLoginUI(ctx)).String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return nil, flow.ErrCompletedByStrategy
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *login.Flow, _ *session.Session) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, sr *login.Flow) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, f *login.Flow) error {
|
||||
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
|
||||
|
||||
ds, err := f.IdentitySchema.URL(r.Context(), s.d.Config())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String(), s.d.Config().SecurityDisallowRefInIdentitySchemas(r.Context()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.UI.SetNode(node.NewInputField("identifier", "", s.NodeGroup(), node.InputAttributeTypeText, node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
a.Autocomplete = node.InputAttributeAutocompleteUsernameWebauthn
|
||||
a.Required = true
|
||||
})).WithMetaLabel(identifierLabel))
|
||||
f.UI.GetNodes().Append(node.NewInputField("method", s.ID(), s.NodeGroup(), node.InputAttributeTypeSubmit).WithMetaLabel(text.NewInfoNodeLabelContinue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(_ *http.Request, f *login.Flow, opts ...login.FormHydratorModifier) error {
|
||||
return ErrNoCredentialsFound
|
||||
}
|
||||
719
selfservice/strategy/idfirst/strategy_login_test.go
Normal file
719
selfservice/strategy/idfirst/strategy_login_test.go
Normal file
@@ -0,0 +1,719 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package idfirst_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/httprouterx"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/selfservice/strategy/idfirst"
|
||||
"github.com/ory/kratos/selfservice/strategy/oidc"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/contextx"
|
||||
"github.com/ory/x/ioutilx"
|
||||
"github.com/ory/x/snapshotx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func TestCompleteLogin(t *testing.T) {
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(map[string]any{
|
||||
// We enable the password method to test the identifier first strategy
|
||||
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
|
||||
config.ViperKeySelfServiceLoginFlowStyle: "identifier_first",
|
||||
}),
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/default.schema.json")),
|
||||
)
|
||||
|
||||
router := httprouterx.NewRouterPublic()
|
||||
publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, httprouterx.NewRouterAdminWithPrefix())
|
||||
|
||||
errTS := testhelpers.NewErrorTestServer(t, reg)
|
||||
uiTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
||||
redirTS := testhelpers.NewRedirSessionEchoTS(t, reg)
|
||||
|
||||
// ensureFieldsExist := func(t *testing.T, body []byte) {
|
||||
// registrationhelpers.CheckFormContent(t, body, "identifier",
|
||||
// "password",
|
||||
// "csrf_token")
|
||||
// }
|
||||
|
||||
apiClient := testhelpers.NewDebugClient(t)
|
||||
|
||||
t.Run("case=should show the error ui because the request payload is malformed", func(t *testing.T) {
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false)
|
||||
|
||||
body, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, true, false, f, apiClient, "14=)=!(%)$/ZP()GHIÖ")
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
||||
assert.Contains(t, body, `Expected JSON sent in request body to be an object but got: Number`)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, false, testhelpers.InitFlowWithContext(t.Context()))
|
||||
|
||||
body, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, false, f, browserClient, "14=)=!(%)$/ZP()GHIÖ")
|
||||
assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts")
|
||||
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
||||
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "invalid URL escape", "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, true, false, false, testhelpers.InitFlowWithContext(t.Context()))
|
||||
|
||||
body, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, true, f, browserClient, "14=)=!(%)$/ZP()GHIÖ")
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
||||
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "invalid URL escape", "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should fail because identifier first can not handle AAL2", func(t *testing.T) {
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false)
|
||||
|
||||
update, err := reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
|
||||
require.NoError(t, err)
|
||||
update.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
||||
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), update))
|
||||
|
||||
req, err := http.NewRequest("POST", f.Ui.Action, bytes.NewBufferString(`{"method":"identifier_first"}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
actual, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, router, req)
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.Equal(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.GetBytes(actual, "ui.messages.0.text").String())
|
||||
})
|
||||
|
||||
t.Run("should return an error because the request does not exist", func(t *testing.T) {
|
||||
check := func(t *testing.T, actual string) {
|
||||
assert.Equal(t, int64(http.StatusNotFound), gjson.Get(actual, "code").Int(), "%s", actual)
|
||||
assert.Equal(t, "Not Found", gjson.Get(actual, "status").String(), "%s", actual)
|
||||
assert.Contains(t, gjson.Get(actual, "message").String(), "Unable to locate the resource", "%s", actual)
|
||||
}
|
||||
|
||||
fakeFlow := &kratos.LoginFlow{
|
||||
Ui: kratos.UiContainer{
|
||||
Action: publicTS.URL + login.RouteSubmitFlow + "?flow=" + x.NewUUID().String(),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, true, false, fakeFlow, apiClient, "{}")
|
||||
assert.Len(t, res.Cookies(), 0)
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
check(t, gjson.Get(actual, "error").Raw)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
actual, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, false, fakeFlow, browserClient, "")
|
||||
assert.Contains(t, res.Request.URL.String(), errTS.URL)
|
||||
check(t, actual)
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, true, fakeFlow, apiClient, "{}")
|
||||
assert.Len(t, res.Cookies(), 0)
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
check(t, gjson.Get(actual, "error").Raw)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should return an error because the request is expired", func(t *testing.T) {
|
||||
lifespan := 100 * time.Millisecond
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceLoginRequestLifespan, lifespan)
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, true)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceLoginRequestLifespan, time.Hour)
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, nil)
|
||||
})
|
||||
|
||||
// Freeze the clock so flow expiry is deterministic and the "expired N minutes ago" reason does not depend on
|
||||
// the request round-trip. Each subtest creates a flow, then advances the clock past its lifespan instead of
|
||||
// sleeping, so the handler and the expected error read the same instant.
|
||||
mock := clock.NewMock(time.Now().UTC())
|
||||
reg.SetClock(mock)
|
||||
t.Cleanup(func() { reg.SetClock(clock.New()) })
|
||||
|
||||
values := url.Values{
|
||||
"csrf_token": {nosurfx.FakeCSRFToken},
|
||||
"identifier": {"identifier"},
|
||||
"method": {"identifier_first"},
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
expiresAt := mock.Now().Add(lifespan)
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false)
|
||||
mock.Set(expiresAt.Add(time.Minute))
|
||||
|
||||
actual, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String())
|
||||
assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(reg.Clock(), expiresAt), json.RawMessage(actual), []string{"use_flow_id", "since", "expired_at"}, "expired", "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
expiresAt := mock.Now().Add(lifespan)
|
||||
f := testhelpers.InitializeLoginFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, false, false, false)
|
||||
mock.Set(expiresAt.Add(time.Minute))
|
||||
|
||||
actual, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, false, f, browserClient, values.Encode())
|
||||
assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/login-ts")
|
||||
assert.NotEqual(t, f.Id, gjson.Get(actual, "id").String(), "%s", actual)
|
||||
assert.Contains(t, gjson.Get(actual, "ui.messages.0.text").String(), "expired", "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=SPA", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
expiresAt := mock.Now().Add(lifespan)
|
||||
f := testhelpers.InitializeLoginFlowViaBrowserCtx(t.Context(), t, browserClient, publicTS, false, true, false, false)
|
||||
mock.Set(expiresAt.Add(time.Minute))
|
||||
|
||||
actual, res := testhelpers.LoginMakeRequestCtx(t.Context(), t, false, true, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
assert.Contains(t, res.Request.URL.String(), publicTS.URL+login.RouteSubmitFlow)
|
||||
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String())
|
||||
assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(reg.Clock(), expiresAt), json.RawMessage(actual), []string{"use_flow_id", "since", "expired_at"}, "expired", "%s", actual)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should have correct CSRF behavior", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, true)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, nil)
|
||||
})
|
||||
|
||||
values := url.Values{
|
||||
"method": {"identifier_first"},
|
||||
"csrf_token": {"invalid_token"},
|
||||
"identifier": {"login-identifier-csrf-browser"},
|
||||
}
|
||||
|
||||
t.Run("case=should fail because of missing CSRF token/type=browser", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
browserClient.Jar.SetCookies(nosurfx.WithFakeCSRFCookie(t, reg, publicTS.URL))
|
||||
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, false, false, false)
|
||||
|
||||
actual, res := testhelpers.LoginMakeRequest(t, false, false, f, browserClient, values.Encode())
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
assertx.EqualAsJSON(t, nosurfx.ErrInvalidCSRFTokenServerTokenMismatch(),
|
||||
json.RawMessage(actual), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("case=should fail because of missing CSRF token/type=spa", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
browserClient.Jar.SetCookies(nosurfx.WithFakeCSRFCookie(t, reg, publicTS.URL))
|
||||
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, publicTS, false, true, false, false)
|
||||
|
||||
actual, res := testhelpers.LoginMakeRequest(t, false, true, f, browserClient, values.Encode())
|
||||
assert.EqualValues(t, http.StatusForbidden, res.StatusCode)
|
||||
assertx.EqualAsJSON(t, nosurfx.ErrInvalidCSRFTokenAJAXTokenMismatch(),
|
||||
json.RawMessage(gjson.Get(actual, "error").Raw), "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("case=should pass even without CSRF token/type=api", func(t *testing.T) {
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false)
|
||||
|
||||
actual, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
|
||||
assert.EqualValues(t, http.StatusBadRequest, res.StatusCode)
|
||||
assert.Contains(t, actual, "1010022")
|
||||
})
|
||||
|
||||
t.Run("case=should fail with correct CSRF error cause/type=api", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
mod func(http.Header)
|
||||
exp string
|
||||
}{
|
||||
{
|
||||
mod: func(h http.Header) {
|
||||
h.Add("Cookie", "name=bar")
|
||||
},
|
||||
exp: "The HTTP Request Header included the \\\"Cookie\\\" key",
|
||||
},
|
||||
{
|
||||
mod: func(h http.Header) {
|
||||
h.Add("Origin", "www.bar.com")
|
||||
},
|
||||
exp: "The HTTP Request Header included the \\\"Origin\\\" key",
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
f := testhelpers.InitializeLoginFlowViaAPICtx(t.Context(), t, apiClient, publicTS, false)
|
||||
|
||||
req := testhelpers.NewPostRequest(t, true, f.Ui.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values)))
|
||||
tc.mod(req.Header)
|
||||
|
||||
res, err := apiClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
actual := string(ioutilx.MustReadAll(res.Body))
|
||||
assert.EqualValues(t, http.StatusBadRequest, res.StatusCode)
|
||||
assert.Contains(t, actual, tc.exp)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expectValidationError := func(t *testing.T, isAPI, refresh, isSPA bool, values func(url.Values)) string {
|
||||
return testhelpers.SubmitLoginForm(t, isAPI, nil, publicTS, values,
|
||||
isSPA, refresh,
|
||||
testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK),
|
||||
testhelpers.ExpectURL(isAPI || isSPA, publicTS.URL+login.RouteSubmitFlow, conf.SelfServiceFlowLoginUI(t.Context()).String()))
|
||||
}
|
||||
|
||||
t.Run("should return an error because the user does not exist", func(t *testing.T) {
|
||||
// In this test we check if the account mitigation behaves correctly by enabling all login strategies EXCEPT
|
||||
// for the passwordless code strategy. That is because this strategy always shows the login button.
|
||||
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), true)
|
||||
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeOIDC.String(), true)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config", &oidc.ConfigurationCollection{Providers: []oidc.Configuration{
|
||||
{
|
||||
ID: "google",
|
||||
Provider: "google",
|
||||
Label: "Google",
|
||||
ClientID: "a",
|
||||
ClientSecret: "b",
|
||||
Mapper: "file://",
|
||||
},
|
||||
}})
|
||||
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeWebAuthn.String(), true)
|
||||
conf.MustSet(t.Context(), config.ViperKeyWebAuthnPasswordless, true)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.display_name", "Ory Corp")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.id", "localhost")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.origin", "http://localhost:4455")
|
||||
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePasskey.String(), true)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".enabled", true)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.display_name", "Ory Corp")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.id", "localhost")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.origins", []string{"http://localhost:4455"})
|
||||
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), "selfservice.methods.password", nil)
|
||||
conf.MustSet(t.Context(), "selfservice.methods.oidc", nil)
|
||||
conf.MustSet(t.Context(), "selfservice.methods.passkey", nil)
|
||||
conf.MustSet(t.Context(), "selfservice.methods.webauthn", nil)
|
||||
conf.MustSet(t.Context(), "selfservice.methods.code", nil)
|
||||
})
|
||||
|
||||
t.Run("account enumeration mitigation enabled", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, true)
|
||||
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, nil)
|
||||
})
|
||||
|
||||
check := func(t *testing.T, body string, isAPI bool) {
|
||||
t.Logf("%s", body)
|
||||
if !isAPI {
|
||||
assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWebAuthn), "we do expect to see a webauthn trigger:\n%s", body)
|
||||
assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPasskey), "we do expect to see a passkey trigger button:\n%s", body)
|
||||
}
|
||||
|
||||
assert.Equal(t, "hidden", gjson.Get(body, "ui.nodes.#(attributes.name==identifier).attributes.type").String(), "identifier is hidden to appear that we found an identity even though we did not")
|
||||
|
||||
assert.NotContains(t, body, text.NewErrorValidationAccountNotFound().Text, "we do not expect to see an account not found error:\n%s", body)
|
||||
|
||||
assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPassword), "we do expect to see a password trigger:\n%s", body)
|
||||
|
||||
// We do expect to see the same social sign in buttons that were on the first page:
|
||||
assert.Contains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWith), "we do expect to see a oidc trigger:\n%s", body)
|
||||
assert.Contains(t, body, "google", "we do expect to see a google trigger:\n%s", body)
|
||||
}
|
||||
|
||||
values := func(v url.Values) {
|
||||
v.Set("identifier", "identifier")
|
||||
v.Set("method", "identifier_first")
|
||||
}
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
check(t, expectValidationError(t, false, false, false, values), false)
|
||||
})
|
||||
|
||||
t.Run("type=SPA", func(t *testing.T) {
|
||||
check(t, expectValidationError(t, false, false, true, values), false)
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
check(t, expectValidationError(t, true, false, false, values), true)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("account enumeration mitigation disabled", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, false)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, nil)
|
||||
})
|
||||
|
||||
check := func(t *testing.T, body string) {
|
||||
t.Logf("aaxx %s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
|
||||
assert.Contains(t, gjson.Get(body, "ui.action").String(), publicTS.URL+login.RouteSubmitFlow, "%s", body)
|
||||
assert.Contains(t, body, text.NewErrorValidationAccountNotFound().Text, "we do expect to see an error that the account does not exist: %s", body)
|
||||
|
||||
assert.Equal(t, "text", gjson.Get(body, "ui.nodes.#(attributes.name==identifier).attributes.type").String(), "identifier is not hidden and we can see the input field as well")
|
||||
assert.Equal(t, "google", gjson.Get(body, "ui.nodes.#(attributes.name==provider).attributes.value").String(), "google oidc button is not hidden")
|
||||
|
||||
assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPasskey), "we do not expect to see a passkey trigger button: %s", body)
|
||||
assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginWebAuthn), "we do not expect to see a webauthn trigger: %s", body)
|
||||
assert.NotContains(t, body, fmt.Sprintf("%d", text.InfoSelfServiceLoginPassword), "we do not expect to see a password trigger: %s", body)
|
||||
}
|
||||
|
||||
values := func(v url.Values) {
|
||||
v.Set("identifier", "identifier")
|
||||
v.Set("method", "identifier_first")
|
||||
}
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
check(t, expectValidationError(t, false, false, false, values))
|
||||
})
|
||||
|
||||
t.Run("type=SPA", func(t *testing.T) {
|
||||
check(t, expectValidationError(t, false, false, true, values))
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
check(t, expectValidationError(t, true, false, false, values))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should pass with real request", func(t *testing.T) {
|
||||
identifier, pwd := x.NewUUID().String(), "password"
|
||||
createIdentity(t.Context(), reg, t, identifier, true, false)
|
||||
|
||||
firstValues := func(v url.Values) {
|
||||
v.Set("identifier", identifier)
|
||||
v.Set("method", "identifier_first")
|
||||
}
|
||||
|
||||
secondValues := func(v url.Values) {
|
||||
v.Set("identifier", identifier)
|
||||
v.Set("password", pwd)
|
||||
v.Set("method", "password")
|
||||
}
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
|
||||
secondStep := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, firstValues,
|
||||
true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
t.Logf("secondStep: %s", secondStep)
|
||||
assert.Contains(t, secondStep, "current-password")
|
||||
assert.Contains(t, secondStep, `"value":"password"`)
|
||||
|
||||
body := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, secondValues,
|
||||
false, false, http.StatusOK, redirTS.URL)
|
||||
|
||||
assert.Equal(t, identifier, gjson.Get(body, "identity.traits.email").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=spa", func(t *testing.T) {
|
||||
hc := testhelpers.NewClientWithCookies(t)
|
||||
|
||||
secondStep := testhelpers.SubmitLoginForm(t, false, hc, publicTS, firstValues,
|
||||
true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
t.Logf("secondStep: %s", secondStep)
|
||||
assert.Contains(t, secondStep, "current-password")
|
||||
assert.Contains(t, secondStep, `"value":"password"`)
|
||||
|
||||
body := testhelpers.SubmitLoginForm(t, false, hc, publicTS, secondValues,
|
||||
true, false, http.StatusOK, publicTS.URL+login.RouteSubmitFlow)
|
||||
|
||||
assert.Equal(t, identifier, gjson.Get(body, "session.identity.traits.email").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session_token").String(), "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session.token").String(), "%s", body)
|
||||
|
||||
// Was the session cookie set?
|
||||
require.NotEmpty(t, hc.Jar.Cookies(urlx.ParseOrPanic(publicTS.URL)), "%+v", hc.Jar)
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
secondStep := testhelpers.SubmitLoginForm(t, true, nil, publicTS, firstValues,
|
||||
false, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
t.Logf("secondStep: %s", secondStep)
|
||||
assert.Contains(t, secondStep, "current-password")
|
||||
assert.Contains(t, secondStep, `"value":"password"`)
|
||||
|
||||
body := testhelpers.SubmitLoginForm(t, true, nil, publicTS, secondValues,
|
||||
false, false, http.StatusOK, publicTS.URL+login.RouteSubmitFlow)
|
||||
|
||||
assert.Equal(t, identifier, gjson.Get(body, "session.identity.traits.email").String(), "%s", body)
|
||||
st := gjson.Get(body, "session_token").String()
|
||||
assert.NotEmpty(t, st, "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("with code method", func(t *testing.T) {
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeCodeAuth.String(), true)
|
||||
conf.MustSet(t.Context(), fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth.String()), true)
|
||||
t.Cleanup(func() {
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeCodeAuth.String(), false)
|
||||
conf.MustSet(t.Context(), fmt.Sprintf("%s.%s.passwordless_enabled", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeCodeAuth.String()), nil)
|
||||
})
|
||||
|
||||
browserClient := testhelpers.NewClientWithCookies(t)
|
||||
|
||||
t.Run("only code method enabled", func(t *testing.T) {
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), false)
|
||||
t.Cleanup(func() {
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), true)
|
||||
})
|
||||
|
||||
email := testhelpers.RandomEmail()
|
||||
createIdentity(t.Context(), reg, t, email, false, true)
|
||||
firstStepValues := func(v url.Values) {
|
||||
v.Set("identifier", email)
|
||||
v.Set("method", "identifier_first")
|
||||
}
|
||||
|
||||
t.Run("account enumeration mitigation off", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, false)
|
||||
|
||||
firstStep := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, firstStepValues,
|
||||
true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
b := []byte(firstStep)
|
||||
|
||||
// Expect email sent (fast login)
|
||||
require.EqualValues(t, flow.StateEmailSent, gjson.GetBytes(b, "state").String(), "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==code)").Array(), 1, "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.messages").Array(), 1, "%s", b)
|
||||
require.EqualValues(t, text.InfoSelfServiceLoginCodeSent, gjson.GetBytes(b, "ui.messages.0.id").Int(), "%s", b)
|
||||
})
|
||||
|
||||
t.Run("account enumeration mitigation on", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, true)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, false)
|
||||
})
|
||||
|
||||
firstStep := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, firstStepValues,
|
||||
true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
b := []byte(firstStep)
|
||||
|
||||
// Expect email sent (fast login)
|
||||
require.EqualValues(t, flow.StateEmailSent, gjson.GetBytes(b, "state").String(), "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==code)").Array(), 1, "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.messages").Array(), 1, "%s", b)
|
||||
require.EqualValues(t, text.InfoSelfServiceLoginCodeSent, gjson.GetBytes(b, "ui.messages.0.id").Int(), "%s", b)
|
||||
})
|
||||
})
|
||||
t.Run("all methods enabled", func(t *testing.T) {
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePassword.String(), true)
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeCodeAuth.String(), true)
|
||||
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeWebAuthn.String(), true)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.display_name", "Ory Corp")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.id", "localhost")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeWebAuthn)+".config.rp.origin", "http://localhost:4455")
|
||||
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypePasskey.String(), true)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.display_name", "Ory Corp")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.id", "localhost")
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config.rp.origins", []string{"http://localhost:4455"})
|
||||
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeOIDC.String(), true)
|
||||
conf.MustSet(t.Context(), config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config", &oidc.ConfigurationCollection{Providers: []oidc.Configuration{
|
||||
{
|
||||
ID: "google",
|
||||
Provider: "google",
|
||||
Label: "Google",
|
||||
ClientID: "a",
|
||||
ClientSecret: "b",
|
||||
Mapper: "file://",
|
||||
},
|
||||
}})
|
||||
|
||||
t.Run("all methods configured", func(t *testing.T) {
|
||||
email := testhelpers.RandomEmail()
|
||||
createIdentityWithAllMethods(t.Context(), reg, t, email)
|
||||
firstStepValues := func(v url.Values) {
|
||||
v.Set("identifier", email)
|
||||
v.Set("method", "identifier_first")
|
||||
}
|
||||
|
||||
firstStep := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, firstStepValues,
|
||||
true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
b := []byte(firstStep)
|
||||
|
||||
// Expect method selection
|
||||
require.EqualValues(t, flow.StateChooseMethod, gjson.GetBytes(b, "state").String(), "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==password)").Array(), 1, "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==code)").Array(), 1, "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==webauthn)").Array(), 1, "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==passkey)").Array(), 1, "%s", b)
|
||||
require.False(t, gjson.GetBytes(b, "ui.messages").Exists())
|
||||
})
|
||||
t.Run("only code method configured", func(t *testing.T) {
|
||||
email := testhelpers.RandomEmail()
|
||||
createIdentity(t.Context(), reg, t, email, false, true)
|
||||
firstStepValues := func(v url.Values) {
|
||||
v.Set("identifier", email)
|
||||
v.Set("method", "identifier_first")
|
||||
}
|
||||
t.Run("account enumeration mitigation off", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, false)
|
||||
|
||||
firstStep := testhelpers.SubmitLoginForm(t, false, browserClient, publicTS, firstStepValues,
|
||||
true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
b := []byte(firstStep)
|
||||
|
||||
// Expect email sent (fast login)
|
||||
require.EqualValues(t, flow.StateEmailSent, gjson.GetBytes(b, "state").String(), "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==code)").Array(), 1, "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.messages").Array(), 1, "%s", b)
|
||||
require.EqualValues(t, text.InfoSelfServiceLoginCodeSent, gjson.GetBytes(b, "ui.messages.0.id").Int(), "%s", b)
|
||||
})
|
||||
t.Run("account enumeration mitigation on", func(t *testing.T) {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, true)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, false)
|
||||
})
|
||||
|
||||
firstStep := testhelpers.SubmitLoginFormCtx(t.Context(), t, false, browserClient, publicTS, firstStepValues,
|
||||
true, false, http.StatusBadRequest, publicTS.URL+login.RouteSubmitFlow)
|
||||
b := []byte(firstStep)
|
||||
|
||||
// Expect method selection
|
||||
require.EqualValues(t, flow.StateChooseMethod, gjson.GetBytes(b, "state").String(), "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==password)").Array(), 1, "%s", b)
|
||||
require.Len(t, gjson.GetBytes(b, "ui.nodes.#(group==code)").Array(), 1, "%s", b)
|
||||
require.False(t, gjson.GetBytes(b, "ui.messages").Exists())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormHydration(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
|
||||
config.ViperKeySelfServiceLoginFlowStyle: "identifier_first",
|
||||
config.ViperKeyDefaultIdentitySchemaID: "default",
|
||||
config.ViperKeyIdentitySchemas: config.Schemas{
|
||||
{ID: "default", URL: "file://stub/default.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "not-default", URL: "file://stub/doesnotexist.schema.json", SelfserviceSelectable: true},
|
||||
},
|
||||
}))
|
||||
|
||||
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsType(node.IdentifierFirstGroup))
|
||||
require.NoError(t, err)
|
||||
fh, ok := s.(login.AAL1FormHydrator)
|
||||
require.True(t, ok)
|
||||
|
||||
toSnapshot := func(t *testing.T, f *login.Flow) {
|
||||
t.Helper()
|
||||
// The CSRF token has a unique value that messes with the snapshot - ignore it.
|
||||
f.UI.Nodes.ResetNodes("csrf_token")
|
||||
snapshotx.SnapshotT(t, f.UI.Nodes)
|
||||
}
|
||||
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) {
|
||||
t.Helper()
|
||||
query := ""
|
||||
|
||||
r := httptest.NewRequest("GET", "/self-service/login/browser"+query, nil)
|
||||
r = r.WithContext(t.Context())
|
||||
f, err := login.NewFlow(reg, r, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
return r, f
|
||||
}
|
||||
|
||||
t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f))
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
|
||||
t.Run("method=PopulateLoginMethodFirstFactorRefresh", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f, nil))
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
|
||||
t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) {
|
||||
t.Run("case=no options", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound)
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
|
||||
t.Run("case=WithIdentifier", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound)
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
|
||||
t.Run("case=WithIdentityHint", func(t *testing.T) {
|
||||
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, true)
|
||||
|
||||
id := identity.NewIdentity("default")
|
||||
r, f := newFlow(ctx, t)
|
||||
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
|
||||
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeySecurityAccountEnumerationMitigate, false)
|
||||
|
||||
t.Run("case=identity has password", func(t *testing.T) {
|
||||
id := identity.NewIdentity("default")
|
||||
|
||||
r, f := newFlow(ctx, t)
|
||||
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
|
||||
t.Run("case=identity does not have a password", func(t *testing.T) {
|
||||
id := identity.NewIdentity("default")
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) {
|
||||
r, f := newFlow(t.Context(), t)
|
||||
require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f))
|
||||
toSnapshot(t, f)
|
||||
})
|
||||
}
|
||||
124
selfservice/strategy/idfirst/strategy_test.go
Normal file
124
selfservice/strategy/idfirst/strategy_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package idfirst_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/sqlxx"
|
||||
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/selfservice/strategy/idfirst"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
)
|
||||
|
||||
func TestCompletedAuthenticationMethod(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
s := idfirst.NewStrategy(reg)
|
||||
ctx := context.Background()
|
||||
|
||||
method := s.CompletedAuthenticationMethod(ctx)
|
||||
assert.Equal(t, s.ID(), method.Method)
|
||||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, method.AAL)
|
||||
}
|
||||
|
||||
func TestNodeGroup(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
s := idfirst.NewStrategy(reg)
|
||||
|
||||
group := s.NodeGroup()
|
||||
assert.Equal(t, node.IdentifierFirstGroup, group)
|
||||
}
|
||||
|
||||
func createIdentity(ctx context.Context, reg *driver.RegistryDefault, t *testing.T, email string, withPassword bool, withCode bool) *identity.Identity {
|
||||
iId := x.NewUUID()
|
||||
id := &identity.Identity{
|
||||
ID: iId,
|
||||
Traits: identity.Traits(fmt.Sprintf(`{ "email": "%s" }`, email)),
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{},
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{
|
||||
Value: email,
|
||||
Verified: true,
|
||||
Status: identity.VerifiableAddressStatusCompleted,
|
||||
},
|
||||
},
|
||||
}
|
||||
if withPassword {
|
||||
p, _ := reg.Hasher(ctx).Generate(context.Background(), []byte("password"))
|
||||
id.Credentials[identity.CredentialsTypePassword] = identity.Credentials{
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Identifiers: []string{email},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
|
||||
}
|
||||
}
|
||||
if withCode {
|
||||
id.Credentials[identity.CredentialsTypeCodeAuth] = identity.Credentials{
|
||||
Type: identity.CredentialsTypeCodeAuth,
|
||||
Identifiers: []string{email}, Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"` + email + `"}]}`),
|
||||
}
|
||||
}
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), id))
|
||||
return id
|
||||
}
|
||||
|
||||
func createIdentityWithAllMethods(ctx context.Context, reg *driver.RegistryDefault, t *testing.T, email string) *identity.Identity {
|
||||
id := x.NewUUID()
|
||||
pwd, _ := reg.Hasher(ctx).Generate(context.Background(), []byte("password"))
|
||||
newIdentity := &identity.Identity{
|
||||
ID: id,
|
||||
Traits: identity.Traits(fmt.Sprintf(`{ "email": "%s" }`, email)),
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Identifiers: []string{email},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(pwd) + `"}`),
|
||||
},
|
||||
identity.CredentialsTypeCodeAuth: {
|
||||
Type: identity.CredentialsTypeCodeAuth,
|
||||
Identifiers: []string{email},
|
||||
Config: sqlxx.JSONRawMessage(`{"addresses":[{"channel":"email","address":"` + email + `"}]}`),
|
||||
},
|
||||
identity.CredentialsTypeOIDC: {
|
||||
Type: identity.CredentialsTypeOIDC,
|
||||
Identifiers: []string{email},
|
||||
Config: sqlxx.JSONRawMessage(`{"some" : "secret"}`),
|
||||
},
|
||||
identity.CredentialsTypeSAML: {
|
||||
Type: identity.CredentialsTypeSAML,
|
||||
Identifiers: []string{email},
|
||||
Config: sqlxx.JSONRawMessage(`{"saml" : "secret"}`),
|
||||
},
|
||||
identity.CredentialsTypeWebAuthn: {
|
||||
Type: identity.CredentialsTypeWebAuthn,
|
||||
Identifiers: []string{email},
|
||||
Config: sqlxx.JSONRawMessage(`{"some" : "secret", "user_handle": "rVIFaWRcTTuQLkXFmQWpgA=="}`),
|
||||
},
|
||||
identity.CredentialsTypePasskey: {
|
||||
Type: identity.CredentialsTypePasskey,
|
||||
Identifiers: []string{email},
|
||||
Config: []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo"},{"id":"YmFyYmFy","display_name":"bar"}]}`),
|
||||
},
|
||||
},
|
||||
VerifiableAddresses: []identity.VerifiableAddress{
|
||||
{
|
||||
Value: email,
|
||||
Verified: true,
|
||||
Status: identity.VerifiableAddressStatusCompleted,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), newIdentity))
|
||||
return newIdentity
|
||||
}
|
||||
33
selfservice/strategy/idfirst/stub/default.schema.json
Normal file
33
selfservice/strategy/idfirst/stub/default.schema.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
},
|
||||
"code": {
|
||||
"identifier": true,
|
||||
"via": "email"
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
},
|
||||
"recovery": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
selfservice/strategy/idfirst/types.go
Normal file
29
selfservice/strategy/idfirst/types.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package idfirst
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Update Login Flow with Multi-Step Method
|
||||
//
|
||||
// swagger:model updateLoginFlowWithIdentifierFirstMethod
|
||||
type UpdateLoginFlowWithIdentifierFirstMethod struct {
|
||||
// Method should be set to "password" when logging in using the identifier and password strategy.
|
||||
//
|
||||
// required: true
|
||||
Method string `json:"method"`
|
||||
|
||||
// Sending the anti-csrf token is only required for browser login flows.
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
|
||||
// Identifier is the email or username of the user trying to log in.
|
||||
//
|
||||
// required: true
|
||||
Identifier string `json:"identifier"`
|
||||
|
||||
// Transient data to pass along to any webhooks
|
||||
//
|
||||
// required: false
|
||||
TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"`
|
||||
}
|
||||
Reference in New Issue
Block a user