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:
18
selfservice/flow/login/aal.go
Normal file
18
selfservice/flow/login/aal.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
)
|
||||
|
||||
func CheckAAL(f *Flow, expected identity.AuthenticatorAssuranceLevel) error {
|
||||
if f.RequestedAAL != expected {
|
||||
return errors.WithStack(flow.ErrStrategyNotResponsible)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
selfservice/flow/login/aal_test.go
Normal file
20
selfservice/flow/login/aal_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
)
|
||||
|
||||
func TestCheckAAL(t *testing.T) {
|
||||
f := &login.Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
|
||||
assert.NoError(t, login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1))
|
||||
assert.ErrorIs(t, login.CheckAAL(f, identity.AuthenticatorAssuranceLevel2), flow.ErrStrategyNotResponsible)
|
||||
}
|
||||
208
selfservice/flow/login/error.go
Normal file
208
selfservice/flow/login/error.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/sessiontokenexchange"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x/events"
|
||||
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/selfservice/errorx"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
var ErrHookAbortFlow = errors.New("aborted login hook execution")
|
||||
|
||||
func ErrAlreadyLoggedIn() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithID(text.ErrIDAlreadyLoggedIn).WithError("you are already logged in").WithReason("A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?")
|
||||
}
|
||||
|
||||
func ErrAddressNotVerified() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithID(text.ErrIDAddressNotVerified).WithError("your email or phone address is not yet verified").WithReason("Your account's email or phone address are not verified yet. Please check your email or phone inbox or re-request verification.")
|
||||
}
|
||||
|
||||
// ErrSessionHasAALAlready is returned when one attempts to upgrade the AAL of an active session which already has that AAL.
|
||||
func ErrSessionHasAALAlready() *herodot.DefaultError {
|
||||
return herodot.ErrUnauthorized().WithID(text.ErrIDSessionHasAALAlready).WithError("session has the requested authenticator assurance level already").WithReason("The session has the requested AAL already.")
|
||||
}
|
||||
|
||||
// ErrSessionRequiredForHigherAAL is returned when someone requests AAL2 or AAL3 even though no active session exists yet.
|
||||
func ErrSessionRequiredForHigherAAL() *herodot.DefaultError {
|
||||
return herodot.ErrUnauthorized().WithID(text.ErrIDSessionRequiredForHigherAAL).WithError("aal2 and aal3 can only be requested if a session exists already").WithReason("You can not requested a higher AAL (AAL2/AAL3) without an active session.")
|
||||
}
|
||||
|
||||
type (
|
||||
errorHandlerDependencies interface {
|
||||
clock.Provider
|
||||
errorx.ManagementProvider
|
||||
httpx.WriterProvider
|
||||
logrusx.Provider
|
||||
otelx.Provider
|
||||
config.Provider
|
||||
sessiontokenexchange.PersistenceProvider
|
||||
hydra.Provider
|
||||
|
||||
FlowPersistenceProvider
|
||||
HandlerProvider
|
||||
}
|
||||
|
||||
ErrorHandlerProvider interface{ LoginFlowErrorHandler() *ErrorHandler }
|
||||
|
||||
ErrorHandler struct {
|
||||
d errorHandlerDependencies
|
||||
}
|
||||
)
|
||||
|
||||
func NewFlowErrorHandler(d errorHandlerDependencies) *ErrorHandler {
|
||||
return &ErrorHandler{d: d}
|
||||
}
|
||||
|
||||
func (s *ErrorHandler) PrepareReplacementForExpiredFlow(w http.ResponseWriter, r *http.Request, f *Flow, err error) (*flow.ExpiredError, error) {
|
||||
errExpired := new(flow.ExpiredError)
|
||||
if !errors.As(err, &errExpired) {
|
||||
return nil, nil
|
||||
}
|
||||
// create new flow because the old one is not valid
|
||||
newFlow, err := s.d.LoginHandler().FromOldFlow(w, r, *f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newFlow.UI.Messages.Add(text.NewErrorValidationLoginFlowExpired(s.d.Clock(), errExpired.ExpiredAt))
|
||||
if err := s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), newFlow); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return errExpired.WithFlow(newFlow), nil
|
||||
}
|
||||
|
||||
func (s *ErrorHandler) WriteFlowError(w http.ResponseWriter, r *http.Request, f *Flow, ct identity.CredentialsType, group node.UiNodeGroup, err error) {
|
||||
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.ErrorHandler.WriteFlowError",
|
||||
trace.WithAttributes(attribute.String("error", err.Error())))
|
||||
r = r.WithContext(ctx)
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
logger := s.d.Logger().
|
||||
WithError(err).
|
||||
WithRequest(r).
|
||||
WithField("login_flow", f.ToLoggerField())
|
||||
|
||||
logger.Info("Encountered self-service login error.")
|
||||
|
||||
if f == nil {
|
||||
trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginFailed(r.Context(), uuid.Nil, "", "", "", false, err))
|
||||
s.forward(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
span.SetAttributes(attribute.String("flow_id", f.ID.String()))
|
||||
trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginFailed(r.Context(), f.ID, string(f.Type), ct.String(), string(f.RequestedAAL), f.Refresh, err))
|
||||
|
||||
if expired, inner := s.PrepareReplacementForExpiredFlow(w, r, f, err); inner != nil {
|
||||
s.WriteFlowError(w, r, f, ct, group, inner)
|
||||
return
|
||||
} else if expired != nil {
|
||||
if f.Type == flow.TypeAPI || x.IsJSONRequest(r) {
|
||||
s.d.Writer().WriteError(w, r, expired)
|
||||
} else {
|
||||
http.Redirect(w, r, expired.GetFlow().AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())).String(), http.StatusSeeOther)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
f.UI.ResetMessages()
|
||||
if err := f.UI.ParseError(group, err); err != nil {
|
||||
s.forward(w, r, f, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := sortNodes(r.Context(), f.UI.Nodes); err != nil {
|
||||
s.forward(w, r, f, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil {
|
||||
s.forward(w, r, f, err)
|
||||
return
|
||||
}
|
||||
|
||||
if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) {
|
||||
http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())).String(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
codes, hasCode, _ := s.d.SessionTokenExchangePersister().CodeForFlow(r.Context(), f.ID)
|
||||
if f.Type == flow.TypeAPI && hasCode && group == node.OpenIDConnectGroup {
|
||||
returnTo, err := url.Parse(f.ReturnTo)
|
||||
if err != nil {
|
||||
s.forward(w, r, f, errors.WithStack(err))
|
||||
return
|
||||
}
|
||||
q := returnTo.Query()
|
||||
q.Set("code", codes.ReturnToCode)
|
||||
returnTo.RawQuery = q.Encode()
|
||||
http.Redirect(w, r, returnTo.String(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
updatedFlow, innerErr := s.d.LoginFlowPersister().GetLoginFlow(r.Context(), f.ID)
|
||||
if innerErr != nil {
|
||||
s.forward(w, r, updatedFlow, innerErr)
|
||||
return
|
||||
}
|
||||
|
||||
// If the flow has an OAuth2LoginChallenge, we try to fetch the login request from Hydra because we return the flow object as JSON.
|
||||
if updatedFlow.OAuth2LoginChallenge != "" {
|
||||
hlr, err := s.d.Hydra().GetLoginRequest(ctx, string(updatedFlow.OAuth2LoginChallenge))
|
||||
if err != nil {
|
||||
// We don't redirect back to the third party on errors because Hydra doesn't
|
||||
// give us the 3rd party return_uri when it redirects to the login UI.
|
||||
s.forward(w, r, updatedFlow, err)
|
||||
return
|
||||
}
|
||||
updatedFlow.HydraLoginRequest = hlr
|
||||
}
|
||||
|
||||
s.d.Writer().WriteCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), updatedFlow)
|
||||
}
|
||||
|
||||
func (s *ErrorHandler) forward(w http.ResponseWriter, r *http.Request, rr *Flow, err error) {
|
||||
if rr == nil {
|
||||
if x.IsJSONRequest(r) {
|
||||
s.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if rr.Type == flow.TypeAPI {
|
||||
s.d.Writer().WriteErrorCode(w, r, x.RecoverStatusCode(err, http.StatusBadRequest), err)
|
||||
} else {
|
||||
s.d.SelfServiceErrorManager().Forward(r.Context(), w, r, err)
|
||||
}
|
||||
}
|
||||
320
selfservice/flow/login/error_test.go
Normal file
320
selfservice/flow/login/error_test.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
|
||||
"github.com/gobuffalo/httptest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/sqlxx"
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
type opts struct {
|
||||
loginChallenge string
|
||||
}
|
||||
|
||||
func withLoginChallenge(challenge string) func(*opts) {
|
||||
return func(o *opts) {
|
||||
o.loginChallenge = challenge
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
reg.SetHydra(hydra.NewFake())
|
||||
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/password.schema.json")
|
||||
|
||||
router := http.NewServeMux()
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
||||
testhelpers.NewErrorTestServer(t, reg)
|
||||
|
||||
h := reg.LoginFlowErrorHandler()
|
||||
sdk := testhelpers.NewSDKClient(public)
|
||||
|
||||
var loginFlow *login.Flow
|
||||
var flowError error
|
||||
var group node.UiNodeGroup
|
||||
router.HandleFunc("GET /error", func(w http.ResponseWriter, r *http.Request) {
|
||||
h.WriteFlowError(w, r, loginFlow, "", group, flowError)
|
||||
})
|
||||
|
||||
reset := func() {
|
||||
loginFlow = nil
|
||||
flowError = nil
|
||||
group = ""
|
||||
}
|
||||
|
||||
newFlow := func(t *testing.T, ttl time.Duration, ft flow.Type, options ...func(*opts)) *login.Flow {
|
||||
req := &http.Request{URL: urlx.ParseOrPanic("/")}
|
||||
f, err := login.NewFlow(reg, req, ft)
|
||||
require.NoError(t, err)
|
||||
f.ExpiresAt = f.IssuedAt.Add(ttl)
|
||||
|
||||
optsD := &opts{}
|
||||
for _, o := range options {
|
||||
o(optsD)
|
||||
}
|
||||
if optsD.loginChallenge != "" {
|
||||
f.OAuth2LoginChallenge = sqlxx.NullString(optsD.loginChallenge)
|
||||
}
|
||||
|
||||
for _, s := range reg.LoginStrategies(context.Background()) {
|
||||
switch s := s.(type) {
|
||||
case login.UnifiedFormHydrator:
|
||||
require.NoError(t, s.PopulateLoginMethod(req, identity.AuthenticatorAssuranceLevel1, f))
|
||||
case login.AAL1FormHydrator:
|
||||
require.NoError(t, s.PopulateLoginMethodFirstFactor(req, f))
|
||||
}
|
||||
}
|
||||
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), f))
|
||||
return f
|
||||
}
|
||||
|
||||
expectErrorUI := func(t *testing.T) (map[string]interface{}, *http.Response) {
|
||||
res, err := ts.Client().Get(ts.URL + "/error")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
require.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
|
||||
|
||||
sse, _, err := sdk.FrontendAPI.GetFlowError(context.Background()).Id(res.Request.URL.Query().Get("id")).Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
return sse.Error, nil
|
||||
}
|
||||
|
||||
anHourAgo := time.Now().Add(-time.Hour)
|
||||
|
||||
t.Run("case=error with nil flow defaults to error ui redirect", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
flowError = herodot.ErrInternalServerError().WithReason("system error")
|
||||
group = node.PasswordGroup
|
||||
|
||||
sse, _ := expectErrorUI(t)
|
||||
assertx.EqualAsJSON(t, flowError, sse)
|
||||
})
|
||||
|
||||
t.Run("case=relative error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
reg.Config().MustSet(ctx, config.ViperKeySelfServiceErrorUI, "/login-ts")
|
||||
flowError = herodot.ErrInternalServerError().WithReason("system error")
|
||||
group = node.PasswordGroup
|
||||
assert.Regexp(
|
||||
t,
|
||||
"^/login-ts.*$",
|
||||
testhelpers.GetSelfServiceRedirectLocation(t, ts.URL+"/error"),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("case=error with nil flow detects application/json", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
flowError = herodot.ErrInternalServerError().WithReason("system error")
|
||||
group = node.PasswordGroup
|
||||
|
||||
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.Contains(t, res.Header.Get("Content-Type"), "application/json")
|
||||
assert.NotContains(t, res.Request.URL.String(), conf.SelfServiceFlowErrorURL(ctx).String()+"?id=")
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(body), "system error")
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
n string
|
||||
t flow.Type
|
||||
}{
|
||||
{"api", flow.TypeAPI},
|
||||
{"spa", flow.TypeBrowser},
|
||||
} {
|
||||
t.Run("flow="+tc.n, func(t *testing.T) {
|
||||
t.Run("case=expired error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
loginFlow = newFlow(t, time.Minute, tc.t)
|
||||
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
|
||||
group = node.PasswordGroup
|
||||
|
||||
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusGone, res.StatusCode, "%+v\n\t%s", res.Request, body)
|
||||
|
||||
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.GetBytes(body, "use_flow_id").String())
|
||||
// "since" and "error.reason" both derive from time.Since(at), recomputed when the expected error is
|
||||
// built here — after the response was generated. Across a rounding boundary the two timings disagree,
|
||||
// so exclude both from the exact comparison and verify the reason template separately.
|
||||
assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(clock.New(), anHourAgo), json.RawMessage(body), []string{"since", "error.reason", "redirect_browser_to", "use_flow_id"})
|
||||
assert.Regexp(t, `^The self-service flow expired \d+\.\d{2} minutes ago, initialize a new one\.$`, gjson.GetBytes(body, "error.reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=validation error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
loginFlow = newFlow(t, time.Minute, tc.t)
|
||||
flowError = schema.NewInvalidCredentialsError()
|
||||
group = node.PasswordGroup
|
||||
|
||||
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(gjson.GetBytes(body, "ui.messages.0.id").Int()), "%s", body)
|
||||
assert.Equal(t, loginFlow.ID.String(), gjson.GetBytes(body, "id").String())
|
||||
})
|
||||
|
||||
t.Run("case=generic error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
loginFlow = newFlow(t, time.Minute, tc.t)
|
||||
flowError = herodot.ErrInternalServerError().WithReason("system error")
|
||||
group = node.PasswordGroup
|
||||
|
||||
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, x.MustEncodeJSON(t, flowError), gjson.GetBytes(body, "error").Raw)
|
||||
})
|
||||
|
||||
t.Run("case=refuses to parse oauth2 login challenge when Hydra is not configured", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
loginFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeInvalidLoginChallenge))
|
||||
group = node.PasswordGroup
|
||||
flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error")
|
||||
|
||||
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body)
|
||||
require.Equal(t, "Unable to get OAuth 2.0 Login Challenge.", gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=fetches hydra login request", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra")
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, nil)
|
||||
})
|
||||
|
||||
loginFlow = newFlow(t, time.Minute, flow.TypeBrowser, withLoginChallenge(hydra.FakeValidLoginChallenge))
|
||||
group = node.PasswordGroup
|
||||
flowError = herodot.ErrBadRequest().WithReason("missing field 'password' fake error")
|
||||
|
||||
res, err := ts.Client().Do(testhelpers.NewHTTPGetJSONRequest(t, ts.URL+"/error"))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode, "%+v\n\t%s", res.Request, body)
|
||||
require.NotEmpty(t, gjson.GetBytes(body, "oauth2_login_request").Value(), "%s", body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("flow=browser", func(t *testing.T) {
|
||||
expectLoginUI := func(t *testing.T) (*login.Flow, *http.Response) {
|
||||
res, err := testhelpers.NewTestClient(t).Get(ts.URL + "/error")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
assert.Contains(t, res.Request.URL.String(), conf.SelfServiceFlowLoginUI(ctx).String()+"?flow=")
|
||||
|
||||
lf, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(res.Request.URL.Query().Get("flow")))
|
||||
require.NoError(t, err)
|
||||
return lf, res
|
||||
}
|
||||
|
||||
t.Run("case=expired error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
loginFlow = &login.Flow{Type: flow.TypeBrowser}
|
||||
flowError = flow.NewFlowExpiredError(clock.New(), anHourAgo)
|
||||
group = node.PasswordGroup
|
||||
|
||||
lf, _ := expectLoginUI(t)
|
||||
require.Len(t, lf.UI.Messages, 1)
|
||||
assert.Equal(t, int(text.ErrorValidationLoginFlowExpired), int(lf.UI.Messages[0].ID))
|
||||
})
|
||||
|
||||
t.Run("case=validation error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
loginFlow = newFlow(t, time.Minute, flow.TypeBrowser)
|
||||
flowError = schema.NewInvalidCredentialsError()
|
||||
group = node.PasswordGroup
|
||||
|
||||
lf, _ := expectLoginUI(t)
|
||||
require.NotEmpty(t, lf.UI.Nodes, x.MustEncodeJSON(t, lf))
|
||||
require.Len(t, lf.UI.Messages, 1, x.MustEncodeJSON(t, lf))
|
||||
assert.Equal(t, int(text.ErrorValidationInvalidCredentials), int(lf.UI.Messages[0].ID), x.MustEncodeJSON(t, lf))
|
||||
})
|
||||
|
||||
t.Run("case=generic error", func(t *testing.T) {
|
||||
t.Cleanup(reset)
|
||||
|
||||
loginFlow = newFlow(t, time.Minute, flow.TypeBrowser)
|
||||
flowError = herodot.ErrInternalServerError().WithReason("system error")
|
||||
group = node.PasswordGroup
|
||||
|
||||
sse, _ := expectErrorUI(t)
|
||||
assertx.EqualAsJSON(t, flowError, sse)
|
||||
})
|
||||
})
|
||||
}
|
||||
14
selfservice/flow/login/export_test.go
Normal file
14
selfservice/flow/login/export_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/kratos/session"
|
||||
)
|
||||
|
||||
func CheckAALForTest(ctx context.Context, e *HookExecutor, s *session.Session, flow *Flow) error {
|
||||
return e.checkAAL(ctx, s, flow)
|
||||
}
|
||||
54
selfservice/flow/login/extension_identifier_label.go
Normal file
54
selfservice/flow/login/extension_identifier_label.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/x/jsonschemax"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
)
|
||||
|
||||
func GetIdentifierLabelFromSchema(ctx context.Context, schemaURL string, disallowRefs bool) (*text.Message, error) {
|
||||
runner, err := schema.NewExtensionRunner(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := schema.NewCompilerWithURL(ctx, schemaURL, disallowRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.ExtractAnnotations = true
|
||||
runner.Register(c)
|
||||
|
||||
paths, err := jsonschemax.ListPaths(ctx, schemaURL, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
labels := []jsonschemax.Path{}
|
||||
for _, path := range paths {
|
||||
if ext := path.CustomProperties[schema.ExtensionName]; ext != nil {
|
||||
config, ok := ext.(*schema.ExtensionConfig)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if config.Credentials.Password.Identifier ||
|
||||
config.Credentials.WebAuthn.Identifier ||
|
||||
config.Credentials.Passkey.DisplayName ||
|
||||
config.Credentials.TOTP.AccountName ||
|
||||
config.Credentials.Code.Identifier {
|
||||
labels = append(labels, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metaLabel := text.NewInfoNodeLabelID()
|
||||
if len(labels) == 1 && labels[0].Title != "" {
|
||||
metaLabel = text.NewInfoNodeLabelGenerated(labels[0].Title, labels[0].Name)
|
||||
}
|
||||
return metaLabel, nil
|
||||
}
|
||||
158
selfservice/flow/login/extension_identifier_label_test.go
Normal file
158
selfservice/flow/login/extension_identifier_label_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/sjson"
|
||||
|
||||
"github.com/ory/kratos/schema"
|
||||
)
|
||||
|
||||
func constructSchema(t *testing.T, ecModifier, ucModifier func(*schema.ExtensionConfig)) string {
|
||||
var emailConfig, usernameConfig schema.ExtensionConfig
|
||||
|
||||
if ecModifier != nil {
|
||||
ecModifier(&emailConfig)
|
||||
}
|
||||
if ucModifier != nil {
|
||||
ucModifier(&usernameConfig)
|
||||
}
|
||||
|
||||
ec, err := json.Marshal(&emailConfig)
|
||||
require.NoError(t, err)
|
||||
uc, err := json.Marshal(&usernameConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
ec, err = sjson.DeleteBytes(ec, "verification")
|
||||
require.NoError(t, err)
|
||||
ec, err = sjson.DeleteBytes(ec, "recovery")
|
||||
require.NoError(t, err)
|
||||
ec, err = sjson.DeleteBytes(ec, "credentials.code.via")
|
||||
require.NoError(t, err)
|
||||
ec, err = sjson.DeleteBytes(ec, "organizations.matcher")
|
||||
require.NoError(t, err)
|
||||
|
||||
uc, err = sjson.DeleteBytes(uc, "verification")
|
||||
require.NoError(t, err)
|
||||
uc, err = sjson.DeleteBytes(uc, "recovery")
|
||||
require.NoError(t, err)
|
||||
uc, err = sjson.DeleteBytes(uc, "credentials.code.via")
|
||||
require.NoError(t, err)
|
||||
uc, err = sjson.DeleteBytes(uc, "organizations.matcher")
|
||||
require.NoError(t, err)
|
||||
|
||||
return "base64://" + base64.StdEncoding.EncodeToString(fmt.Appendf(nil, `
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/presets/kratos/identity.email.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"title": "Email",
|
||||
"type": "string",
|
||||
"ory.sh/kratos": %s
|
||||
},
|
||||
"username": {
|
||||
"title": "Username",
|
||||
"type": "string",
|
||||
"ory.sh/kratos": %s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, ec, uc))
|
||||
}
|
||||
|
||||
func TestGetIdentifierLabelFromSchema(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
emailConfig, usernameConfig func(*schema.ExtensionConfig)
|
||||
expected *text.Message
|
||||
}{
|
||||
{
|
||||
name: "email for password",
|
||||
emailConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.Password.Identifier = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
|
||||
},
|
||||
{
|
||||
name: "email for webauthn",
|
||||
emailConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.WebAuthn.Identifier = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
|
||||
},
|
||||
{
|
||||
name: "email for totp",
|
||||
emailConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.TOTP.AccountName = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
|
||||
},
|
||||
{
|
||||
name: "email for code",
|
||||
emailConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.Code.Identifier = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
|
||||
},
|
||||
{
|
||||
name: "email for passkey",
|
||||
emailConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.Passkey.DisplayName = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
|
||||
},
|
||||
{
|
||||
name: "email for all",
|
||||
emailConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.Password.Identifier = true
|
||||
c.Credentials.WebAuthn.Identifier = true
|
||||
c.Credentials.TOTP.AccountName = true
|
||||
c.Credentials.Code.Identifier = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelGenerated("Email", "traits.email"),
|
||||
},
|
||||
{
|
||||
name: "username works as well",
|
||||
usernameConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.Password.Identifier = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelGenerated("Username", "traits.username"),
|
||||
},
|
||||
{
|
||||
name: "multiple identifiers",
|
||||
emailConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.Password.Identifier = true
|
||||
},
|
||||
usernameConfig: func(c *schema.ExtensionConfig) {
|
||||
c.Credentials.Password.Identifier = true
|
||||
},
|
||||
expected: text.NewInfoNodeLabelID(),
|
||||
},
|
||||
{
|
||||
name: "no identifiers",
|
||||
expected: text.NewInfoNodeLabelID(),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
label, err := GetIdentifierLabelFromSchema(t.Context(), constructSchema(t, tc.emailConfig, tc.usernameConfig), true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, label)
|
||||
})
|
||||
}
|
||||
}
|
||||
349
selfservice/flow/login/flow.go
Normal file
349
selfservice/flow/login/flow.go
Normal file
@@ -0,0 +1,349 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
hydraclientgo "github.com/ory/hydra-client-go/v2"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/kratos/x/redir"
|
||||
"github.com/ory/pop/v6"
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/sqlxx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
// Login Flow
|
||||
//
|
||||
// This object represents a login flow. A login flow is initiated at the "Initiate Login API / Browser Flow"
|
||||
// endpoint by a client.
|
||||
//
|
||||
// Once a login flow is completed successfully, a session cookie or session token will be issued.
|
||||
//
|
||||
// swagger:model loginFlow
|
||||
type Flow struct {
|
||||
// ID represents the flow's unique ID. When performing the login flow, this
|
||||
// represents the id in the login UI's query parameter: http://<selfservice.flows.login.ui_url>/?flow=<flow_id>
|
||||
//
|
||||
// required: true
|
||||
ID uuid.UUID `json:"id" faker:"-" db:"id" rw:"r"`
|
||||
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
|
||||
OrganizationID uuid.NullUUID `json:"organization_id,omitempty" faker:"-" db:"organization_id"`
|
||||
|
||||
// Ory OAuth 2.0 Login Challenge.
|
||||
//
|
||||
// This value is set using the `login_challenge` query parameter of the registration and login endpoints.
|
||||
// If set will cooperate with Ory OAuth2 and OpenID to act as an OAuth2 server / OpenID Provider.
|
||||
OAuth2LoginChallenge sqlxx.NullString `json:"oauth2_login_challenge,omitempty" faker:"-" db:"oauth2_login_challenge_data"`
|
||||
|
||||
// HydraLoginRequest is an optional field whose presence indicates that Kratos
|
||||
// is being used as an identity provider in a Hydra OAuth2 flow. Kratos
|
||||
// populates this field by retrieving its value from Hydra and it is used by
|
||||
// the login and consent UIs.
|
||||
HydraLoginRequest *hydraclientgo.OAuth2LoginRequest `json:"oauth2_login_request,omitempty" faker:"-" db:"-"`
|
||||
|
||||
// Type represents the flow's type which can be either "api" or "browser", depending on the flow interaction.
|
||||
//
|
||||
// required: true
|
||||
Type flow.Type `json:"type" db:"type" faker:"flow_type"`
|
||||
|
||||
// ExpiresAt is the time (UTC) when the flow expires. If the user still wishes to log in,
|
||||
// a new flow has to be initiated.
|
||||
//
|
||||
// required: true
|
||||
ExpiresAt time.Time `json:"expires_at" faker:"time_type" db:"expires_at"`
|
||||
|
||||
// IssuedAt is the time (UTC) when the flow started.
|
||||
//
|
||||
// required: true
|
||||
IssuedAt time.Time `json:"issued_at" faker:"time_type" db:"issued_at"`
|
||||
|
||||
// InternalContext stores internal context used by internals - for example MFA keys.
|
||||
InternalContext sqlxx.JSONRawMessage `db:"internal_context" json:"-" faker:"-"`
|
||||
|
||||
// RequestURL is the initial URL that was requested from Ory Kratos. It can be used
|
||||
// to forward information contained in the URL's path or query for example.
|
||||
//
|
||||
// required: true
|
||||
RequestURL string `json:"request_url" db:"request_url"`
|
||||
|
||||
// ReturnTo contains the requested return_to URL.
|
||||
ReturnTo string `json:"return_to,omitempty" db:"-"`
|
||||
|
||||
// The active login method
|
||||
//
|
||||
// If set contains the login method used. If the flow is new, it is unset.
|
||||
Active identity.CredentialsType `json:"active,omitempty" db:"active_method"`
|
||||
|
||||
// UI contains data which must be shown in the user interface.
|
||||
//
|
||||
// required: true
|
||||
UI *container.Container `json:"ui" db:"ui"`
|
||||
|
||||
// CreatedAt is a helper struct field for gobuffalo.pop.
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
|
||||
// UpdatedAt is a helper struct field for gobuffalo.pop.
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
|
||||
// CSRFToken contains the anti-csrf token associated with this flow. Only set for browser flows.
|
||||
CSRFToken string `json:"-" db:"csrf_token"`
|
||||
|
||||
// Refresh stores whether this login flow should enforce re-authentication.
|
||||
Refresh bool `json:"refresh" db:"forced"`
|
||||
|
||||
// RequestedAAL stores if the flow was requested to update the authenticator assurance level.
|
||||
//
|
||||
// This value can be one of "aal1", "aal2", "aal3".
|
||||
RequestedAAL identity.AuthenticatorAssuranceLevel `json:"requested_aal" faker:"len=4" db:"requested_aal"`
|
||||
|
||||
// SessionTokenExchangeCode holds the secret code that the client can use to retrieve a session token after the login flow has been completed.
|
||||
// This is only set if the client has requested a session token exchange code, and if the flow is of type "api",
|
||||
// and only on creating the login flow.
|
||||
SessionTokenExchangeCode string `json:"session_token_exchange_code,omitempty" faker:"-" db:"-"`
|
||||
|
||||
// State represents the state of this request:
|
||||
//
|
||||
// - choose_method: ask the user to choose a method to sign in with
|
||||
// - sent_email: the email has been sent to the user
|
||||
// - passed_challenge: the request was successful and the login challenge was passed.
|
||||
//
|
||||
// required: true
|
||||
State State `json:"state" faker:"-" db:"state"`
|
||||
|
||||
// Only used internally
|
||||
IDToken string `json:"-" db:"-"`
|
||||
|
||||
// Only used internally
|
||||
RawIDTokenNonce string `json:"-" db:"-"`
|
||||
|
||||
// TransientPayload is used to pass data from the login to hooks and email templates
|
||||
//
|
||||
// required: false
|
||||
TransientPayload json.RawMessage `json:"transient_payload,omitempty" faker:"-" db:"-"`
|
||||
|
||||
// Contains a list of actions, that could follow this flow
|
||||
//
|
||||
// It can, for example, contain a reference to the verification flow, created as part of the user's
|
||||
// registration.
|
||||
ContinueWithItems []flow.ContinueWith `json:"-" db:"-" faker:"-" `
|
||||
|
||||
// ReturnToVerification contains the redirect URL for the verification flow.
|
||||
ReturnToVerification string `json:"-" db:"-"`
|
||||
|
||||
isAccountLinkingFlow bool `db:"-"`
|
||||
|
||||
// IdentitySchema optionally holds the ID of the identity schema that is used
|
||||
// for this flow. This value can be set by the user when creating the flow and
|
||||
// should be retained when the flow is saved or converted to another flow.
|
||||
IdentitySchema flow.IdentitySchema `json:"identity_schema,omitempty" faker:"-" db:"identity_schema_id"`
|
||||
|
||||
// TestFlow marks this flow as an admin-created dry-run OIDC test. Test
|
||||
// flows short-circuit the OIDC callback: no identity is persisted and no
|
||||
// session is issued. The captured debug data is returned via the derived
|
||||
// TestContext field below.
|
||||
//
|
||||
// Nullable so that existing rows created before the column was added read
|
||||
// as a SQL NULL (effectively false); see IsTest.
|
||||
TestFlow sql.NullBool `json:"-" faker:"-" db:"test_flow"`
|
||||
|
||||
// TestContext is the derived API view of test-mode data. It is populated
|
||||
// from InternalContext["test"] during marshal and is nil for regular
|
||||
// login flows. See test_context.go.
|
||||
//
|
||||
// required: false
|
||||
// readOnly: true
|
||||
TestContext *TestContext `json:"test_context,omitempty" faker:"-" db:"-"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ flow.Flow = (*Flow)(nil)
|
||||
_ flow.FlowWithContinueWith = (*Flow)(nil)
|
||||
)
|
||||
|
||||
// flowDependencies are the dependencies NewFlow needs to construct a login
|
||||
// flow: the configuration (for the lifespan and return-to validation), the
|
||||
// clock, and the CSRF token generator.
|
||||
type flowDependencies interface {
|
||||
config.Provider
|
||||
clock.Provider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
}
|
||||
|
||||
func NewFlow(reg flowDependencies, r *http.Request, flowType flow.Type) (*Flow, error) {
|
||||
conf := reg.Config()
|
||||
now := reg.Clock().Now().UTC()
|
||||
exp := conf.SelfServiceFlowLoginRequestLifespan(r.Context())
|
||||
id := x.NewUUID()
|
||||
requestURL := x.RequestURL(r).String()
|
||||
|
||||
// Pre-validate the return to URL which is contained in the HTTP request.
|
||||
_, err := redir.SecureRedirectTo(r,
|
||||
conf.SelfServiceBrowserDefaultReturnTo(r.Context()),
|
||||
redir.SecureRedirectUseSourceURL(requestURL),
|
||||
redir.SecureRedirectAllowURLs(conf.SelfServiceBrowserAllowedReturnToDomains(r.Context())),
|
||||
redir.SecureRedirectAllowSelfServiceURLs(conf.SelfPublicURL(r.Context())),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hydraLoginChallenge, err := hydra.GetLoginChallengeID(conf, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refresh, _ := strconv.ParseBool(r.URL.Query().Get("refresh"))
|
||||
|
||||
f := &Flow{
|
||||
ID: id,
|
||||
OAuth2LoginChallenge: hydraLoginChallenge,
|
||||
ExpiresAt: now.Add(exp),
|
||||
IssuedAt: now,
|
||||
UI: &container.Container{
|
||||
Method: "POST",
|
||||
Action: flow.AppendFlowTo(urlx.AppendPaths(conf.SelfPublicURL(r.Context()), RouteSubmitFlow), id).String(),
|
||||
},
|
||||
RequestURL: requestURL,
|
||||
CSRFToken: reg.GenerateCSRFToken(r),
|
||||
Type: flowType,
|
||||
Refresh: refresh,
|
||||
RequestedAAL: identity.AuthenticatorAssuranceLevel(strings.ToLower(cmp.Or(
|
||||
r.URL.Query().Get("aal"),
|
||||
string(identity.AuthenticatorAssuranceLevel1)))),
|
||||
InternalContext: []byte("{}"),
|
||||
State: flow.StateChooseMethod,
|
||||
}
|
||||
if err := flow.SetRequestBaseURL(f, x.BaseURLStringFromContext(r.Context())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *Flow) GetType() flow.Type { return f.Type }
|
||||
func (f *Flow) GetRequestURL() string { return f.RequestURL }
|
||||
func (f *Flow) GetID() uuid.UUID { return f.ID }
|
||||
func (f *Flow) GetInternalContext() sqlxx.JSONRawMessage { return f.InternalContext }
|
||||
func (f *Flow) SetInternalContext(bytes sqlxx.JSONRawMessage) { f.InternalContext = bytes }
|
||||
func (f *Flow) GetUI() *container.Container { return f.UI }
|
||||
func (f *Flow) GetState() flow.State { return f.State }
|
||||
func (Flow) GetFlowName() flow.FlowName { return flow.LoginFlow }
|
||||
func (Flow) TableName() string { return "selfservice_login_flows" }
|
||||
func (f *Flow) ContinueWith() []flow.ContinueWith { return f.ContinueWithItems }
|
||||
func (f *Flow) SetReturnToVerification(to string) { f.ReturnToVerification = to }
|
||||
func (f *Flow) GetOAuth2LoginChallenge() sqlxx.NullString { return f.OAuth2LoginChallenge }
|
||||
func (f *Flow) GetHydraLoginRequest() *hydraclientgo.OAuth2LoginRequest { return f.HydraLoginRequest }
|
||||
func (f *Flow) AppendTo(src *url.URL) *url.URL { return flow.AppendFlowTo(src, f.ID) }
|
||||
func (f *Flow) SetState(state flow.State) { f.State = state }
|
||||
func (f *Flow) GetTransientPayload() json.RawMessage { return f.TransientPayload }
|
||||
|
||||
// IsRefresh returns true if the login flow was triggered to re-authenticate the user.
|
||||
// This is the case if the refresh query parameter is set to true.
|
||||
func (f *Flow) IsRefresh() bool { return f.Refresh }
|
||||
|
||||
func (f *Flow) Valid(c clock.Clock) error {
|
||||
if f.ExpiresAt.Before(c.Now()) {
|
||||
return errors.WithStack(flow.NewFlowExpiredError(c, f.ExpiresAt))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flow) EnsureInternalContext() {
|
||||
if !gjson.ParseBytes(f.InternalContext).IsObject() {
|
||||
f.InternalContext = []byte("{}")
|
||||
}
|
||||
}
|
||||
|
||||
func (f Flow) MarshalJSON() ([]byte, error) {
|
||||
type local Flow
|
||||
f.SetReturnTo()
|
||||
// Populate the derived TestContext field for test-mode flows so the
|
||||
// admin UI sees provider_id and (once captured) debug_payload.
|
||||
if err := (&f).LoadTestContext(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(local(f))
|
||||
}
|
||||
|
||||
// UnmarshalJSON drops any incoming test_context. The field is derived from
|
||||
// InternalContext and must only be set via LoadTestContext, otherwise a caller
|
||||
// submitting a Flow blob could forge a debug payload.
|
||||
func (f *Flow) UnmarshalJSON(data []byte) error {
|
||||
type local Flow
|
||||
if err := json.Unmarshal(data, (*local)(f)); err != nil {
|
||||
return err
|
||||
}
|
||||
f.TestContext = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flow) SetReturnTo() {
|
||||
// Return to is already set, do not overwrite it.
|
||||
if len(f.ReturnTo) > 0 {
|
||||
return
|
||||
}
|
||||
if u, err := url.Parse(f.RequestURL); err == nil {
|
||||
f.ReturnTo = u.Query().Get("return_to")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) AfterFind(*pop.Connection) error {
|
||||
f.SetReturnTo()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flow) AfterSave(*pop.Connection) error {
|
||||
f.SetReturnTo()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flow) SecureRedirectToOpts(ctx context.Context, cfg config.Provider) (opts []redir.SecureRedirectOption) {
|
||||
return []redir.SecureRedirectOption{
|
||||
redir.SecureRedirectReturnTo(f.ReturnTo),
|
||||
redir.SecureRedirectUseSourceURL(f.RequestURL),
|
||||
redir.SecureRedirectAllowURLs(cfg.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
|
||||
redir.SecureRedirectAllowSelfServiceURLs(cfg.Config().SelfPublicURL(ctx)),
|
||||
redir.SecureRedirectOverrideDefaultReturnTo(cfg.Config().SelfServiceFlowLoginReturnTo(ctx, f.Active.String())),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) AddContinueWith(c flow.ContinueWith) {
|
||||
f.ContinueWithItems = append(f.ContinueWithItems, c)
|
||||
}
|
||||
|
||||
func (f *Flow) ToLoggerField() map[string]any {
|
||||
if f == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": f.ID.String(),
|
||||
"return_to": f.ReturnTo,
|
||||
"request_url": f.RequestURL,
|
||||
"active": f.Active,
|
||||
"type": f.Type,
|
||||
"nid": f.NID,
|
||||
"state": f.State,
|
||||
"refresh": f.Refresh,
|
||||
"requested_aal": f.RequestedAAL,
|
||||
}
|
||||
}
|
||||
283
selfservice/flow/login/flow_test.go
Normal file
283
selfservice/flow/login/flow_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/jsonx"
|
||||
"github.com/ory/x/sqlxx"
|
||||
|
||||
"github.com/ory/kratos/pkg"
|
||||
|
||||
"github.com/go-faker/faker/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestFakeFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
var r login.Flow
|
||||
require.NoError(t, faker.FakeData(&r))
|
||||
|
||||
assert.Equal(t, uuid.Nil, r.ID)
|
||||
assert.NotEmpty(t, r.IssuedAt)
|
||||
assert.NotEmpty(t, r.ExpiresAt)
|
||||
assert.NotEmpty(t, r.RequestURL)
|
||||
assert.NotEmpty(t, r.Active)
|
||||
assert.NotNil(t, r.UI)
|
||||
}
|
||||
|
||||
func TestNewFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
t.Run("captures request base URL from context at init", func(t *testing.T) {
|
||||
// A proxy-aware middleware (cloud CaptureOriginalBaseURLMiddleware)
|
||||
// stashes the validated customer-facing base URL on the request
|
||||
// context at flow init. NewFlow must persist it on the flow so the
|
||||
// OIDC/SAML provider submit can read it back.
|
||||
req := (&http.Request{URL: &url.URL{Path: "/"}, Host: "slug.projects.oryapis.com"}).
|
||||
WithContext(x.WithBaseURL(ctx, urlx.ParseOrPanic("http://localhost:4000")))
|
||||
r, err := login.NewFlow(reg, req, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "http://localhost:4000", flow.GetRequestBaseURL(r))
|
||||
|
||||
// No captured base URL → nothing persisted (plain oryapis traffic).
|
||||
r, err = login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/"}, Host: "slug.projects.oryapis.com"}, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, flow.GetRequestBaseURL(r))
|
||||
})
|
||||
|
||||
t.Run("type=aal", func(t *testing.T) {
|
||||
r, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "aal=aal2&refresh=true"}, Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, r.Refresh)
|
||||
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, r.RequestedAAL)
|
||||
|
||||
r, err = login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "refresh=true"}, Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, r.Refresh)
|
||||
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, r.RequestedAAL)
|
||||
})
|
||||
|
||||
t.Run("type=refresh", func(t *testing.T) {
|
||||
t.Run("case=refresh accepts any truthy value", func(t *testing.T) {
|
||||
parameters := []string{"true", "True", "1"}
|
||||
|
||||
for _, refresh := range parameters {
|
||||
r, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: fmt.Sprintf("refresh=%v", refresh)}, Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, r.Refresh)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=refresh silently ignores invalid values", func(t *testing.T) {
|
||||
r, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "refresh=foo"}, Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, r.Refresh)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("type=return_to", func(t *testing.T) {
|
||||
_, err := login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=https://not-allowed/foobar"}, Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = login.NewFlow(reg, &http.Request{URL: &url.URL{Path: "/", RawQuery: "return_to=" + urlx.AppendPaths(conf.SelfPublicURL(ctx), "/self-service/login/browser").String()}, Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
t.Run("case=regular flow creation without a session", func(t *testing.T) {
|
||||
r, err := login.NewFlow(reg, &http.Request{
|
||||
URL: urlx.ParseOrPanic("/"),
|
||||
Host: "ory.sh", TLS: &tls.ConnectionState{},
|
||||
}, flow.TypeBrowser)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
|
||||
assert.Equal(t, flow.TypeBrowser, r.Type)
|
||||
assert.False(t, r.Refresh)
|
||||
assert.Equal(t, "https://ory.sh/", r.RequestURL)
|
||||
})
|
||||
|
||||
t.Run("case=regular flow creation", func(t *testing.T) {
|
||||
r, err := login.NewFlow(reg, &http.Request{
|
||||
URL: urlx.ParseOrPanic("https://ory.sh/"),
|
||||
Host: "ory.sh",
|
||||
}, flow.TypeBrowser)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://ory.sh/", r.RequestURL)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
t.Run("case=flow with refresh", func(t *testing.T) {
|
||||
r, err := login.NewFlow(reg, &http.Request{
|
||||
URL: urlx.ParseOrPanic("/?refresh=true"),
|
||||
Host: "ory.sh",
|
||||
}, flow.TypeAPI)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
|
||||
assert.Equal(t, flow.TypeAPI, r.Type)
|
||||
assert.True(t, r.Refresh)
|
||||
assert.Equal(t, "http://ory.sh/?refresh=true", r.RequestURL)
|
||||
})
|
||||
|
||||
t.Run("case=flow without refresh", func(t *testing.T) {
|
||||
r, err := login.NewFlow(reg, &http.Request{
|
||||
URL: urlx.ParseOrPanic("/"),
|
||||
Host: "ory.sh",
|
||||
}, flow.TypeAPI)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, r.ExpiresAt.After(r.IssuedAt))
|
||||
assert.Equal(t, flow.TypeAPI, r.Type)
|
||||
assert.False(t, r.Refresh)
|
||||
assert.Equal(t, "http://ory.sh/", r.RequestURL)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should parse login_challenge when Hydra is configured", func(t *testing.T) {
|
||||
_, err := login.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("https://ory.sh/?login_challenge=badee1"), Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.Error(t, err)
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://hydra")
|
||||
|
||||
r, err := login.NewFlow(reg, &http.Request{URL: urlx.ParseOrPanic("https://ory.sh/?login_challenge=8aadcb8fc1334186a84c4da9813356d9"), Host: "ory.sh"}, flow.TypeBrowser)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "8aadcb8fc1334186a84c4da9813356d9", string(r.OAuth2LoginChallenge))
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := &login.Flow{ID: x.NewUUID()}
|
||||
assert.Equal(t, r.ID, r.GetID())
|
||||
|
||||
t.Run("case=expired", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
r *login.Flow
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
r: &login.Flow{ExpiresAt: time.Now().Add(time.Hour), IssuedAt: time.Now().Add(-time.Minute)},
|
||||
valid: true,
|
||||
},
|
||||
{r: &login.Flow{ExpiresAt: time.Now().Add(-time.Hour), IssuedAt: time.Now().Add(-time.Minute)}},
|
||||
} {
|
||||
if tc.valid {
|
||||
require.NoError(t, tc.r.Valid(clock.New()))
|
||||
} else {
|
||||
require.Error(t, tc.r.Valid(clock.New()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetType(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ft := range []flow.Type{
|
||||
flow.TypeAPI,
|
||||
flow.TypeBrowser,
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%s", ft), func(t *testing.T) {
|
||||
r := &login.Flow{Type: ft}
|
||||
assert.Equal(t, ft, r.GetType())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequestURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
expectedURL := "http://foo/bar/baz"
|
||||
f := &login.Flow{RequestURL: expectedURL}
|
||||
assert.Equal(t, expectedURL, f.GetRequestURL())
|
||||
}
|
||||
|
||||
func TestFlowEncodeJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.EqualValues(t, "", gjson.Get(jsonx.TestMarshalJSONString(t, &login.Flow{RequestURL: "https://foo.bar?foo=bar"}), "return_to").String())
|
||||
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, &login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
|
||||
assert.EqualValues(t, "/bar", gjson.Get(jsonx.TestMarshalJSONString(t, login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}), "return_to").String())
|
||||
}
|
||||
|
||||
func TestFlowTestContextNotUnmarshalable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// test_context is a derived field populated from internal_context during
|
||||
// MarshalJSON. It must not be settable via incoming JSON, otherwise an
|
||||
// attacker who can submit a Flow blob could forge a debug payload.
|
||||
raw := []byte(`{
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"test_context": {
|
||||
"provider_id": "forged",
|
||||
"debug_payload": {"id_token_claims": {"sub": "forged"}}
|
||||
}
|
||||
}`)
|
||||
|
||||
var f login.Flow
|
||||
require.NoError(t, json.Unmarshal(raw, &f))
|
||||
assert.Nil(t, f.TestContext, "test_context must not be populated from JSON input")
|
||||
}
|
||||
|
||||
func TestFlowDontOverrideReturnTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &login.Flow{ReturnTo: "/foo"}
|
||||
f.SetReturnTo()
|
||||
assert.Equal(t, "/foo", f.ReturnTo)
|
||||
|
||||
f = &login.Flow{RequestURL: "https://foo.bar?return_to=/bar"}
|
||||
f.SetReturnTo()
|
||||
assert.Equal(t, "/bar", f.ReturnTo)
|
||||
}
|
||||
|
||||
func TestDuplicateCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("case=returns previous data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := new(login.Flow)
|
||||
dc := flow.DuplicateCredentialsData{
|
||||
CredentialsType: "foo",
|
||||
CredentialsConfig: sqlxx.JSONRawMessage(`{"bar":"baz"}`),
|
||||
DuplicateIdentifier: "bar",
|
||||
}
|
||||
|
||||
require.NoError(t, flow.SetDuplicateCredentials(f, dc))
|
||||
actual, err := flow.DuplicateCredentials(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dc, *actual)
|
||||
})
|
||||
|
||||
t.Run("case=returns nil data", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := new(login.Flow)
|
||||
actual, err := flow.DuplicateCredentials(f)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, actual)
|
||||
})
|
||||
}
|
||||
999
selfservice/flow/login/handler.go
Normal file
999
selfservice/flow/login/handler.go
Normal file
@@ -0,0 +1,999 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
hydraclientgo "github.com/ory/hydra-client-go/v2"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/selfservice/errorx"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/sessiontokenexchange"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/events"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/kratos/x/redir"
|
||||
"github.com/ory/nosurf"
|
||||
"github.com/ory/x/clock"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
"github.com/ory/x/otelx/semconv"
|
||||
"github.com/ory/x/sqlxx"
|
||||
"github.com/ory/x/stringsx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
const (
|
||||
RouteInitBrowserFlow = "/self-service/login/browser"
|
||||
RouteInitAPIFlow = "/self-service/login/api"
|
||||
|
||||
RouteGetFlow = "/self-service/login/flows"
|
||||
|
||||
RouteSubmitFlow = "/self-service/login"
|
||||
)
|
||||
|
||||
type (
|
||||
dependencies interface {
|
||||
clock.Provider
|
||||
HookExecutorProvider
|
||||
FlowPersistenceProvider
|
||||
errorx.ManagementProvider
|
||||
hydra.Provider
|
||||
StrategyProvider
|
||||
session.HandlerProvider
|
||||
session.ManagementProvider
|
||||
httpx.WriterProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
nosurfx.CSRFProvider
|
||||
otelx.Provider
|
||||
config.Provider
|
||||
ErrorHandlerProvider
|
||||
sessiontokenexchange.PersistenceProvider
|
||||
logrusx.Provider
|
||||
TestStrategyProvider
|
||||
}
|
||||
HandlerProvider interface {
|
||||
LoginHandler() *Handler
|
||||
}
|
||||
Handler struct{ d dependencies }
|
||||
)
|
||||
|
||||
func NewHandler(d dependencies) *Handler { return &Handler{d: d} }
|
||||
|
||||
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
|
||||
h.d.CSRFHandler().IgnorePath(RouteInitAPIFlow)
|
||||
h.d.CSRFHandler().IgnorePath(RouteSubmitFlow)
|
||||
// The test-login-flow delete endpoint is authorized by the HMAC cookie
|
||||
// set on the OIDC callback, not the global nosurf middleware.
|
||||
h.d.CSRFHandler().IgnorePath(RouteDeleteTestFlow)
|
||||
|
||||
public.GET(RouteInitBrowserFlow, h.createBrowserLoginFlow)
|
||||
public.GET(RouteInitAPIFlow, h.createNativeLoginFlow)
|
||||
public.GET(RouteGetFlow, h.getLoginFlow)
|
||||
|
||||
public.POST(RouteSubmitFlow, h.updateLoginFlow)
|
||||
public.GET(RouteSubmitFlow, h.updateLoginFlow)
|
||||
|
||||
public.DELETE(RouteDeleteTestFlow, h.deleteTestLoginFlow)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
|
||||
admin.GET(RouteInitBrowserFlow, redir.RedirectToPublicRoute(h.d))
|
||||
admin.GET(RouteInitAPIFlow, redir.RedirectToPublicRoute(h.d))
|
||||
admin.GET(RouteGetFlow, redir.RedirectToPublicRoute(h.d))
|
||||
|
||||
admin.POST(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
|
||||
admin.GET(RouteSubmitFlow, redir.RedirectToPublicRoute(h.d))
|
||||
|
||||
admin.POST(RouteAdminCreateTestFlow, h.adminCreateTestLoginFlow)
|
||||
}
|
||||
|
||||
type FlowOption func(f *Flow)
|
||||
|
||||
func WithFlowReturnTo(returnTo string) FlowOption {
|
||||
return func(f *Flow) {
|
||||
f.ReturnTo = returnTo
|
||||
}
|
||||
}
|
||||
|
||||
func WithOrganizationID(organizationID uuid.NullUUID) FlowOption {
|
||||
return func(f *Flow) {
|
||||
f.OrganizationID = organizationID
|
||||
}
|
||||
}
|
||||
|
||||
func WithInternalContext(internalContext []byte) FlowOption {
|
||||
return func(f *Flow) {
|
||||
f.InternalContext = internalContext
|
||||
}
|
||||
}
|
||||
|
||||
func WithFormErrorMessage(messages []text.Message) FlowOption {
|
||||
return func(f *Flow) {
|
||||
for i := range messages {
|
||||
f.UI.Messages.Add(&messages[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithIsAccountLinking() FlowOption {
|
||||
return func(f *Flow) {
|
||||
f.isAccountLinkingFlow = true
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoginChallenge(loginChallenge string) FlowOption {
|
||||
return func(f *Flow) {
|
||||
f.OAuth2LoginChallenge = sqlxx.NullString(loginChallenge)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) NewLoginFlow(w http.ResponseWriter, r *http.Request, ft flow.Type, opts ...FlowOption) (*Flow, *session.Session, error) {
|
||||
conf := h.d.Config()
|
||||
f, err := NewFlow(h.d, r, ft)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(f)
|
||||
}
|
||||
|
||||
if f.RequestedAAL == "" {
|
||||
f.RequestedAAL = identity.AuthenticatorAssuranceLevel1
|
||||
}
|
||||
|
||||
switch cs := stringsx.SwitchExact(string(f.RequestedAAL)); {
|
||||
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel1)):
|
||||
f.RequestedAAL = identity.AuthenticatorAssuranceLevel1
|
||||
identitySchema := ""
|
||||
if requestedSchema := r.URL.Query().Get("identity_schema"); requestedSchema != "" {
|
||||
identitySchema, err = conf.SelfServiceFlowIdentitySchema(r.Context(), requestedSchema)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
f.IdentitySchema = flow.IdentitySchema(identitySchema)
|
||||
case cs.AddCase(string(identity.AuthenticatorAssuranceLevel2)):
|
||||
f.RequestedAAL = identity.AuthenticatorAssuranceLevel2
|
||||
default:
|
||||
return nil, nil, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse AuthenticationMethod Assurance Level (AAL): %s", cs.ToUnknownCaseErr()))
|
||||
}
|
||||
|
||||
// We assume an error means the user has no session.
|
||||
// The full expansion is required: the strategies' form hydrators and the organization
|
||||
// filter read the session's identity.
|
||||
sess, err := h.d.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
|
||||
if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
|
||||
// No session exists yet
|
||||
returnSessionTokenExchangeCode, _ := strconv.ParseBool(r.URL.Query().Get("return_session_token_exchange_code"))
|
||||
|
||||
if ft == flow.TypeAPI && returnSessionTokenExchangeCode {
|
||||
e, err := h.d.SessionTokenExchangePersister().CreateSessionTokenExchanger(r.Context(), f.ID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithStack(err)
|
||||
}
|
||||
f.SessionTokenExchangeCode = e.InitCode
|
||||
}
|
||||
|
||||
// We can not request an AAL > 1 because we must first verify the first factor.
|
||||
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
|
||||
return nil, nil, errors.WithStack(ErrSessionRequiredForHigherAAL())
|
||||
}
|
||||
|
||||
// We are setting refresh to false if no session exists.
|
||||
f.Refresh = false
|
||||
|
||||
goto preLoginHook
|
||||
} else if err != nil {
|
||||
// Some other error happened - return that one.
|
||||
return nil, nil, err
|
||||
} else {
|
||||
// A session exists already
|
||||
if f.Refresh {
|
||||
// We are refreshing so let's continue
|
||||
goto preLoginHook
|
||||
}
|
||||
|
||||
// We are not refreshing - so are we requesting MFA?
|
||||
|
||||
// If level is 1 we are not requesting AAL -> we are logged in already.
|
||||
if f.RequestedAAL == identity.AuthenticatorAssuranceLevel1 {
|
||||
return nil, sess, errors.WithStack(ErrAlreadyLoggedIn())
|
||||
}
|
||||
|
||||
// We are requesting an assurance level which the session already has. So we are not upgrading the session
|
||||
// in which case we want to return an error.
|
||||
if f.RequestedAAL <= sess.AuthenticatorAssuranceLevel {
|
||||
return nil, sess, errors.WithStack(ErrAlreadyLoggedIn())
|
||||
}
|
||||
|
||||
// Looks like we are requesting an AAL which is higher than what the session has.
|
||||
goto preLoginHook
|
||||
}
|
||||
|
||||
preLoginHook:
|
||||
loginStrategies := h.d.LoginStrategies(r.Context(), PrepareOrganizations(r, f, sess)...)
|
||||
for _, s := range loginStrategies {
|
||||
var populateErr error
|
||||
|
||||
switch f.RequestedAAL {
|
||||
case identity.AuthenticatorAssuranceLevel1:
|
||||
switch s := s.(type) {
|
||||
case AAL1FormHydrator:
|
||||
switch {
|
||||
case f.IsRefresh() && sess != nil:
|
||||
// Refreshing takes precedence over identifier_first auth which can not be a refresh flow.
|
||||
// Therefor this comes first.
|
||||
populateErr = s.PopulateLoginMethodFirstFactorRefresh(r, f, sess)
|
||||
case h.d.Config().SelfServiceLoginFlowIdentifierFirstEnabled(r.Context()) && !f.isAccountLinkingFlow:
|
||||
populateErr = s.PopulateLoginMethodIdentifierFirstIdentification(r, f)
|
||||
default:
|
||||
populateErr = s.PopulateLoginMethodFirstFactor(r, f)
|
||||
}
|
||||
case AAL2FormHydrator:
|
||||
continue
|
||||
default:
|
||||
populateErr = errors.WithStack(
|
||||
x.PseudoPanic.WithReason("A login strategy was expected to implement interface AAL1FormHydrator but did not."),
|
||||
)
|
||||
}
|
||||
case identity.AuthenticatorAssuranceLevel2:
|
||||
switch s := s.(type) {
|
||||
case AAL2FormHydrator:
|
||||
switch {
|
||||
case f.IsRefresh():
|
||||
// Refresh takes precedence.
|
||||
populateErr = s.PopulateLoginMethodSecondFactorRefresh(r, f)
|
||||
default:
|
||||
populateErr = s.PopulateLoginMethodSecondFactor(r, f)
|
||||
}
|
||||
case AAL1FormHydrator:
|
||||
continue
|
||||
default:
|
||||
populateErr = errors.WithStack(
|
||||
x.PseudoPanic.WithReason("A login strategy was expected to implement interface AAL2FormHydrator but did not."),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if populateErr != nil {
|
||||
return nil, nil, populateErr
|
||||
}
|
||||
}
|
||||
|
||||
if f.Refresh {
|
||||
f.UI.Messages.Set(text.NewInfoLoginReAuth())
|
||||
}
|
||||
|
||||
if sess != nil && f.RequestedAAL > sess.AuthenticatorAssuranceLevel && f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
|
||||
f.UI.Messages.Add(text.NewInfoLoginMFA())
|
||||
}
|
||||
|
||||
if err := sortNodes(r.Context(), f.UI.Nodes); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if f.Type == flow.TypeBrowser {
|
||||
f.UI.SetCSRF(h.d.GenerateCSRFToken(r))
|
||||
}
|
||||
|
||||
if err := h.d.LoginHookExecutor().PreLoginHook(w, r, f); err != nil {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
||||
return f, sess, nil
|
||||
}
|
||||
|
||||
if err := h.d.LoginFlowPersister().CreateLoginFlow(r.Context(), f); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// If the strategy supports fast login, we can attempt a fast path.
|
||||
// We execute the fast login here because it requires the flow to be created first.
|
||||
for _, s := range loginStrategies {
|
||||
if strategy, ok := s.(FastLoginStrategy); ok {
|
||||
if err := strategy.FastLogin2FA(w, r, f, sess); errors.Is(err, flow.ErrStrategyNotResponsible) {
|
||||
continue
|
||||
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
span.AddEvent(events.NewLoginInitiated(r.Context(), f.ID, ft.String(), f.Refresh, f.OrganizationID, string(f.RequestedAAL)))
|
||||
return nil, nil, err
|
||||
} else if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
span := trace.SpanFromContext(r.Context())
|
||||
span.AddEvent(events.NewLoginInitiated(r.Context(), f.ID, ft.String(), f.Refresh, f.OrganizationID, string(f.RequestedAAL)))
|
||||
return f, nil, nil
|
||||
}
|
||||
|
||||
func (h *Handler) FromOldFlow(w http.ResponseWriter, r *http.Request, of Flow) (*Flow, error) {
|
||||
nf, _, err := h.NewLoginFlow(w, r, of.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nf.RequestURL = of.RequestURL
|
||||
nf.IdentitySchema = of.IdentitySchema
|
||||
return nf, nil
|
||||
}
|
||||
|
||||
// Create Native Login Flow Parameters
|
||||
//
|
||||
// swagger:parameters createNativeLoginFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type createNativeLoginFlow struct {
|
||||
// Refresh a login session
|
||||
//
|
||||
// If set to true, this will refresh an existing login session by
|
||||
// asking the user to sign in again. This will reset the
|
||||
// authenticated_at time of the session.
|
||||
//
|
||||
// in: query
|
||||
Refresh bool `json:"refresh"`
|
||||
|
||||
// Request a Specific AuthenticationMethod Assurance Level
|
||||
//
|
||||
// Use this parameter to upgrade an existing session's authenticator assurance level (AAL). This
|
||||
// allows you to ask for multi-factor authentication. When an identity sign in using e.g. username+password,
|
||||
// the AAL is 1. If you wish to "upgrade" the session's security by asking the user to perform TOTP / WebAuth/ ...
|
||||
// you would set this to "aal2".
|
||||
//
|
||||
// in: query
|
||||
RequestAAL identity.AuthenticatorAssuranceLevel `json:"aal"`
|
||||
|
||||
// The Session Token of the Identity performing the settings flow.
|
||||
//
|
||||
// in: header
|
||||
SessionToken string `json:"X-Session-Token"`
|
||||
|
||||
// EnableSessionTokenExchangeCode requests the login flow to include a code that can be used to retrieve the session token
|
||||
// after the login flow has been completed.
|
||||
//
|
||||
// in: query
|
||||
EnableSessionTokenExchangeCode bool `json:"return_session_token_exchange_code"`
|
||||
|
||||
// The URL to return the browser to after the flow was completed.
|
||||
//
|
||||
// in: query
|
||||
ReturnTo string `json:"return_to"`
|
||||
|
||||
// An optional organization ID that should be used for logging this user in.
|
||||
// This parameter is only effective in the Ory Network.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
Organization string `json:"organization"`
|
||||
|
||||
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
|
||||
//
|
||||
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
|
||||
// of MFA credentials to choose from to perform the second factor instead.
|
||||
//
|
||||
// in: query
|
||||
Via string `json:"via"`
|
||||
|
||||
// An optional identity schema to use for the login flow.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
IdentitySchema string `json:"identity_schema"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/login/api frontend createNativeLoginFlow
|
||||
//
|
||||
// # Create Login Flow for Native Apps
|
||||
//
|
||||
// This endpoint initiates a login flow for native apps that do not use a browser, such as mobile devices, smart TVs, and so on.
|
||||
//
|
||||
// If a valid provided session cookie or session token is provided, a 400 Bad Request error
|
||||
// will be returned unless the URL query parameter `?refresh=true` is set.
|
||||
//
|
||||
// To fetch an existing login flow call `/self-service/login/flows?flow=<flow_id>`.
|
||||
//
|
||||
// You MUST NOT use this endpoint in client-side (Single Page Apps, ReactJS, AngularJS) nor server-side (Java Server
|
||||
// Pages, NodeJS, PHP, Golang, ...) browser applications. Using this endpoint in these applications will make
|
||||
// you vulnerable to a variety of CSRF attacks, including CSRF login attacks.
|
||||
//
|
||||
// In the case of an error, the `error.id` of the JSON response body can be one of:
|
||||
//
|
||||
// - `session_already_available`: The user is already signed in.
|
||||
// - `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
|
||||
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
|
||||
//
|
||||
// This endpoint MUST ONLY be used in scenarios such as native mobile apps (React Native, Objective C, Swift, Java, ...).
|
||||
//
|
||||
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: loginFlow
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-medium
|
||||
func (h *Handler) createNativeLoginFlow(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createNativeLoginFlow")
|
||||
r = r.WithContext(ctx)
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
f, _, err := h.NewLoginFlow(w, r, flow.TypeAPI)
|
||||
if err != nil {
|
||||
if !errors.Is(err, flow.ErrCompletedByStrategy) {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, f)
|
||||
}
|
||||
|
||||
// Initialize Browser Login Flow Parameters
|
||||
//
|
||||
// swagger:parameters createBrowserLoginFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type createBrowserLoginFlow struct {
|
||||
// Refresh a login session
|
||||
//
|
||||
// If set to true, this will refresh an existing login session by
|
||||
// asking the user to sign in again. This will reset the
|
||||
// authenticated_at time of the session.
|
||||
//
|
||||
// in: query
|
||||
Refresh bool `json:"refresh"`
|
||||
|
||||
// Request a Specific AuthenticationMethod Assurance Level
|
||||
//
|
||||
// Use this parameter to upgrade an existing session's authenticator assurance level (AAL). This
|
||||
// allows you to ask for multi-factor authentication. When an identity sign in using e.g. username+password,
|
||||
// the AAL is 1. If you wish to "upgrade" the session's security by asking the user to perform TOTP / WebAuth/ ...
|
||||
// you would set this to "aal2".
|
||||
//
|
||||
// in: query
|
||||
RequestAAL identity.AuthenticatorAssuranceLevel `json:"aal"`
|
||||
|
||||
// The URL to return the browser to after the flow was completed.
|
||||
//
|
||||
// in: query
|
||||
ReturnTo string `json:"return_to"`
|
||||
|
||||
// HTTP Cookies
|
||||
//
|
||||
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
|
||||
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
|
||||
//
|
||||
// in: header
|
||||
// name: Cookie
|
||||
Cookies string `json:"Cookie"`
|
||||
|
||||
// An optional Hydra login challenge. If present, Kratos will cooperate with
|
||||
// Ory Hydra to act as an OAuth2 identity provider.
|
||||
//
|
||||
// The value for this parameter comes from `login_challenge` URL Query parameter sent to your
|
||||
// application (e.g. `/login?login_challenge=abcde`).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
HydraLoginChallenge string `json:"login_challenge"`
|
||||
|
||||
// An optional organization ID that should be used for logging this user in.
|
||||
// This parameter is only effective in the Ory Network.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
Organization string `json:"organization"`
|
||||
|
||||
// Via should contain the identity's credential the code should be sent to. Only relevant in aal2 flows.
|
||||
//
|
||||
// DEPRECATED: This field is deprecated. Please remove it from your requests. The user will now see a choice
|
||||
// of MFA credentials to choose from to perform the second factor instead.
|
||||
//
|
||||
// in: query
|
||||
Via string `json:"via"`
|
||||
|
||||
// An optional identity schema to use for the login flow.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
IdentitySchema string `json:"identity_schema"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/login/browser frontend createBrowserLoginFlow
|
||||
//
|
||||
// # Create Login Flow for Browsers
|
||||
//
|
||||
// This endpoint initializes a browser-based user login flow. This endpoint will set the appropriate
|
||||
// cookies and anti-CSRF measures required for browser-based flows.
|
||||
//
|
||||
// If this endpoint is opened as a link in the browser, it will be redirected to
|
||||
// `selfservice.flows.login.ui_url` with the flow ID set as the query parameter `?flow=`. If a valid user session
|
||||
// exists already, the browser will be redirected to `urls.default_redirect_url` unless the query parameter
|
||||
// `?refresh=true` was set.
|
||||
//
|
||||
// If this endpoint is called via an AJAX request, the response contains the flow without a redirect. In the
|
||||
// case of an error, the `error.id` of the JSON response body can be one of:
|
||||
//
|
||||
// - `session_already_available`: The user is already signed in.
|
||||
// - `session_aal1_required`: Multi-factor auth (e.g. 2fa) was requested but the user has no session yet.
|
||||
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
|
||||
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
|
||||
//
|
||||
// The optional query parameter login_challenge is set when using Kratos with
|
||||
// Hydra in an OAuth2 flow. See the oauth2_provider.url configuration
|
||||
// option.
|
||||
//
|
||||
// This endpoint is NOT INTENDED for clients that do not have a browser (Chrome, Firefox, ...) as cookies are needed.
|
||||
//
|
||||
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: loginFlow
|
||||
// 303: emptyResponse
|
||||
// 400: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-medium
|
||||
func (h *Handler) createBrowserLoginFlow(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.createBrowserLoginFlow")
|
||||
r = r.WithContext(ctx)
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
var (
|
||||
hydraLoginRequest *hydraclientgo.OAuth2LoginRequest
|
||||
hydraLoginChallenge sqlxx.NullString
|
||||
)
|
||||
if r.URL.Query().Has("login_challenge") {
|
||||
var err error
|
||||
hydraLoginChallenge, err = hydra.GetLoginChallengeID(h.d.Config(), r)
|
||||
if err != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
hydraLoginRequest, err = h.d.Hydra().GetLoginRequest(ctx, string(hydraLoginChallenge))
|
||||
if err != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hydraLoginRequest.GetSkip() {
|
||||
q := r.URL.Query()
|
||||
q.Set("refresh", "true")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
// on OAuth2 flows, we need to use the RequestURL
|
||||
// as the ReturnTo URL.
|
||||
// This is because a user might want to switch between
|
||||
// different flows, such as login to registration and login to recovery.
|
||||
// After completing a complex flow, such as recovery, we want the user
|
||||
// to be redirected back to the original OAuth2 login flow.
|
||||
if hydraLoginRequest.RequestUrl != "" && h.d.Config().OAuth2ProviderOverrideReturnTo(ctx) {
|
||||
// replace the return_to query parameter
|
||||
q := r.URL.Query()
|
||||
q.Set("return_to", hydraLoginRequest.RequestUrl)
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
a, sess, err := h.NewLoginFlow(w, r, flow.TypeBrowser)
|
||||
if errors.Is(err, ErrAlreadyLoggedIn()) {
|
||||
if hydraLoginRequest != nil {
|
||||
if !hydraLoginRequest.GetSkip() {
|
||||
h.d.SelfServiceErrorManager().Forward(ctx, w, r, errors.WithStack(herodot.ErrForbidden().WithReason("ErrAlreadyLoggedIn indicated we can skip login, but Hydra asked us to refresh")))
|
||||
return
|
||||
}
|
||||
|
||||
rt, hydraErr := h.d.Hydra().AcceptLoginRequest(ctx,
|
||||
hydra.AcceptLoginRequestParams{
|
||||
LoginChallenge: string(hydraLoginChallenge),
|
||||
IdentityID: sess.IdentityID.String(),
|
||||
SessionID: sess.ID.String(),
|
||||
AuthenticationMethods: sess.AMR,
|
||||
})
|
||||
if hydraErr != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(ctx, w, r, hydraErr)
|
||||
return
|
||||
}
|
||||
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), err, rt)
|
||||
return
|
||||
}
|
||||
|
||||
returnTo, redirErr := redir.SecureRedirectTo(r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx),
|
||||
redir.SecureRedirectAllowSelfServiceURLs(h.d.Config().SelfPublicURL(ctx)),
|
||||
redir.SecureRedirectAllowURLs(h.d.Config().SelfServiceBrowserAllowedReturnToDomains(ctx)),
|
||||
)
|
||||
if redirErr != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(ctx, w, r, redirErr)
|
||||
return
|
||||
}
|
||||
|
||||
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), err, returnTo.String())
|
||||
return
|
||||
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
|
||||
return
|
||||
} else if err != nil {
|
||||
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
a.HydraLoginRequest = hydraLoginRequest
|
||||
|
||||
x.SendFlowCompletedAsRedirectOrJSON(w, r, h.d.Writer(), a, a.AppendTo(h.d.Config().SelfServiceFlowLoginUI(ctx)).String())
|
||||
}
|
||||
|
||||
// Get Login Flow Parameters
|
||||
//
|
||||
// swagger:parameters getLoginFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type getLoginFlow struct {
|
||||
// The Login Flow ID
|
||||
//
|
||||
// The value for this parameter comes from `flow` URL Query parameter sent to your
|
||||
// application (e.g. `/login?flow=abcde`).
|
||||
//
|
||||
// required: true
|
||||
// in: query
|
||||
ID string `json:"id"`
|
||||
|
||||
// HTTP Cookies
|
||||
//
|
||||
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
|
||||
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
|
||||
//
|
||||
// in: header
|
||||
// name: Cookie
|
||||
Cookies string `json:"Cookie"`
|
||||
}
|
||||
|
||||
// swagger:route GET /self-service/login/flows frontend getLoginFlow
|
||||
//
|
||||
// # Get Login Flow
|
||||
//
|
||||
// This endpoint returns a login flow's context with, for example, error details and other information.
|
||||
//
|
||||
// Browser flows expect the anti-CSRF cookie to be included in the request's HTTP Cookie Header.
|
||||
// For AJAX requests you must ensure that cookies are included in the request or requests will fail.
|
||||
//
|
||||
// If you use the browser-flow for server-side apps, the services need to run on a common top-level-domain
|
||||
// and you need to forward the incoming HTTP Cookie header to this endpoint:
|
||||
//
|
||||
// ```js
|
||||
// // pseudo-code example
|
||||
// router.get('/login', async function (req, res) {
|
||||
// const flow = await client.getLoginFlow(req.header('cookie'), req.query['flow'])
|
||||
//
|
||||
// res.render('login', flow)
|
||||
// })
|
||||
// ```
|
||||
//
|
||||
// This request may fail due to several reasons. The `error.id` can be one of:
|
||||
//
|
||||
// - `session_already_available`: The user is already signed in.
|
||||
// - `self_service_flow_expired`: The flow is expired and you should request a new one.
|
||||
//
|
||||
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: loginFlow
|
||||
// 403: errorGeneric
|
||||
// 404: errorGeneric
|
||||
// 410: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-high
|
||||
func (h *Handler) getLoginFlow(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.getLoginFlow")
|
||||
r = r.WithContext(ctx)
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
ar, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, x.ParseUUID(r.URL.Query().Get("id")))
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Browser flows must include the CSRF token
|
||||
//
|
||||
// Resolves: https://github.com/ory/kratos/issues/1282
|
||||
//
|
||||
// Test flows have their own scheme:
|
||||
// - choose_method (uncaptured): public read. The flow carries no PII
|
||||
// and was admin-created with a server-minted token that the browser
|
||||
// never received, so a CSRF check would always fail.
|
||||
// - captured: require the CSRF cookie to match f.CSRFToken. The OIDC
|
||||
// callback regenerated the cookie+token pair, so only the browser
|
||||
// that completed the round trip can read the captured DebugPayload.
|
||||
switch {
|
||||
case ar.IsTest():
|
||||
// TestContext is a derived field (loaded from internal_context); the
|
||||
// SQL fetch above doesn't populate it.
|
||||
if err := ar.LoadTestContext(); err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
if ar.TestContext != nil && ar.TestContext.DebugPayload != nil &&
|
||||
!nosurf.VerifyToken(h.d.GenerateCSRFToken(r), ar.CSRFToken) {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("missing or invalid CSRF token")))
|
||||
return
|
||||
}
|
||||
case ar.Type == flow.TypeBrowser && !nosurf.VerifyToken(h.d.GenerateCSRFToken(r), ar.CSRFToken):
|
||||
h.d.Writer().WriteError(w, r, nosurfx.CSRFErrorReason(r, h.d))
|
||||
return
|
||||
}
|
||||
|
||||
if ar.ExpiresAt.Before(h.d.Clock().Now()) {
|
||||
if ar.Type == flow.TypeBrowser {
|
||||
redirectURL := flow.GetFlowExpiredRedirectURL(ctx, h.d.Config(), RouteInitBrowserFlow, ar.ReturnTo)
|
||||
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
|
||||
WithReason("The login flow has expired. Redirect the user to the login flow init endpoint to initialize a new login flow.").
|
||||
WithDetail("redirect_to", redirectURL.String()).
|
||||
WithDetail("return_to", ar.ReturnTo)))
|
||||
return
|
||||
}
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(nosurfx.ErrGone().WithID(text.ErrIDSelfServiceFlowExpired).
|
||||
WithReason("The login flow has expired. Call the login flow init API endpoint to initialize a new login flow.").
|
||||
WithDetail("api", urlx.AppendPaths(h.d.Config().SelfPublicURL(ctx), RouteInitAPIFlow).String())))
|
||||
return
|
||||
}
|
||||
|
||||
if ar.OAuth2LoginChallenge != "" {
|
||||
hlr, err := h.d.Hydra().GetLoginRequest(ctx, string(ar.OAuth2LoginChallenge))
|
||||
if err != nil {
|
||||
// We don't redirect back to the third party on errors because Hydra doesn't
|
||||
// give us the 3rd party return_uri when it redirects to the login UI.
|
||||
h.d.SelfServiceErrorManager().Forward(ctx, w, r, err)
|
||||
return
|
||||
}
|
||||
ar.HydraLoginRequest = hlr
|
||||
}
|
||||
|
||||
h.d.Writer().Write(w, r, ar)
|
||||
}
|
||||
|
||||
// Update Login Flow Parameters
|
||||
//
|
||||
// swagger:parameters updateLoginFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type updateLoginFlow struct {
|
||||
// The Login Flow ID
|
||||
//
|
||||
// The value for this parameter comes from `flow` URL Query parameter sent to your
|
||||
// application (e.g. `/login?flow=abcde`).
|
||||
//
|
||||
// required: true
|
||||
// in: query
|
||||
Flow string `json:"flow"`
|
||||
|
||||
// in: body
|
||||
// required: true
|
||||
Body updateLoginFlowBody
|
||||
|
||||
// The Session Token of the Identity performing the settings flow.
|
||||
//
|
||||
// in: header
|
||||
SessionToken string `json:"X-Session-Token"`
|
||||
|
||||
// HTTP Cookies
|
||||
//
|
||||
// When using the SDK in a browser app, on the server side you must include the HTTP Cookie Header
|
||||
// sent by the client to your server here. This ensures that CSRF and session cookies are respected.
|
||||
//
|
||||
// in: header
|
||||
// name: Cookie
|
||||
Cookies string `json:"Cookie"`
|
||||
}
|
||||
|
||||
// swagger:model updateLoginFlowBody
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type updateLoginFlowBody struct{}
|
||||
|
||||
// swagger:route POST /self-service/login frontend updateLoginFlow
|
||||
//
|
||||
// # Submit a Login Flow
|
||||
//
|
||||
// Use this endpoint to complete a login flow. This endpoint
|
||||
// behaves differently for API and browser flows.
|
||||
//
|
||||
// API flows expect `application/json` to be sent in the body and responds with
|
||||
// - HTTP 200 and a application/json body with the session token on success;
|
||||
// - HTTP 410 if the original flow expired with the appropriate error messages set and optionally a `use_flow_id` parameter in the body;
|
||||
// - HTTP 400 on form validation errors.
|
||||
//
|
||||
// Browser flows expect a Content-Type of `application/x-www-form-urlencoded` or `application/json` to be sent in the body and respond with
|
||||
// - a HTTP 303 redirect to the post/after login URL or the `return_to` value if it was set and if the login succeeded;
|
||||
// - a HTTP 303 redirect to the login UI URL with the flow ID containing the validation errors otherwise.
|
||||
//
|
||||
// Browser flows with an accept header of `application/json` will not redirect but instead respond with
|
||||
// - HTTP 200 and a application/json body with the signed in identity and a `Set-Cookie` header on success;
|
||||
// - HTTP 303 redirect to a fresh login flow if the original flow expired with the appropriate error messages set;
|
||||
// - HTTP 400 on form validation errors.
|
||||
//
|
||||
// If this endpoint is called with `Accept: application/json` in the header, the response contains the flow without a redirect. In the
|
||||
// case of an error, the `error.id` of the JSON response body can be one of:
|
||||
//
|
||||
// - `session_already_available`: The user is already signed in.
|
||||
// - `security_csrf_violation`: Unable to fetch the flow because a CSRF violation occurred.
|
||||
// - `security_identity_mismatch`: The requested `?return_to` address is not allowed to be used. Adjust this in the configuration!
|
||||
// - `browser_location_change_required`: Usually sent when an AJAX request indicates that the browser needs to open a specific URL.
|
||||
// Most likely used in Social Sign In flows.
|
||||
//
|
||||
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Header:
|
||||
// - Set-Cookie
|
||||
//
|
||||
// Responses:
|
||||
// 200: successfulNativeLogin
|
||||
// 303: emptyResponse
|
||||
// 400: loginFlow
|
||||
// 410: errorGeneric
|
||||
// 422: errorBrowserLocationChangeRequired
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-low
|
||||
func (h *Handler) updateLoginFlow(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.flow.login.updateLoginFlow")
|
||||
ctx = semconv.ContextWithAttributes(ctx,
|
||||
attribute.String(events.AttributeKeySelfServiceStrategyUsed.String(), "login"),
|
||||
attribute.String(events.AttributeKeySelfServiceFlowName.String(), "login"),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
rid, err := flow.GetFlowID(r)
|
||||
if err != nil {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, nil, "", node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := h.d.LoginFlowPersister().GetLoginFlow(ctx, rid)
|
||||
if err != nil {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
// The full expansion is required: the session is handed to every login strategy below.
|
||||
sess, err := h.d.SessionManager().FetchFromRequest(ctx, r, session.ExpandEverything, identity.ExpandEverything)
|
||||
if err == nil {
|
||||
if f.Refresh {
|
||||
// If we want to refresh, continue the login
|
||||
goto continueLogin
|
||||
}
|
||||
|
||||
if f.RequestedAAL > sess.AuthenticatorAssuranceLevel {
|
||||
// If we want to upgrade AAL, continue the login
|
||||
goto continueLogin
|
||||
}
|
||||
|
||||
if x.IsJSONRequest(r) || f.Type == flow.TypeAPI {
|
||||
// We are not upgrading AAL, nor are we refreshing. Error!
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, errors.WithStack(ErrAlreadyLoggedIn()))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, h.d.Config().SelfServiceBrowserDefaultReturnTo(ctx).String(), http.StatusSeeOther)
|
||||
return
|
||||
} else if e := new(session.ErrNoActiveSessionFound); errors.As(err, &e) {
|
||||
// Only failure scenario here is if we try to upgrade the session to a higher AAL without actually
|
||||
// having a session.
|
||||
if f.RequestedAAL > identity.AuthenticatorAssuranceLevel1 {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, errors.WithStack(ErrSessionRequiredForHigherAAL()))
|
||||
return
|
||||
}
|
||||
|
||||
sess = session.NewInactiveSession()
|
||||
} else {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
continueLogin:
|
||||
if err := f.Valid(h.d.Clock()); err != nil {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, "", node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
|
||||
var ct identity.CredentialsType
|
||||
var i *identity.Identity
|
||||
var group node.UiNodeGroup
|
||||
for _, ss := range h.d.AllLoginStrategies() {
|
||||
interim, err := ss.Login(w, r, f, sess)
|
||||
group = ss.NodeGroup()
|
||||
if errors.Is(err, flow.ErrStrategyNotResponsible) {
|
||||
continue
|
||||
} else if errors.Is(err, flow.ErrCompletedByStrategy) {
|
||||
return
|
||||
} else if err != nil {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ss.ID(), group, err)
|
||||
return
|
||||
}
|
||||
|
||||
// What can happen is that we re-authenticate as another user. In this case, we need to use a completely fresh
|
||||
// session!
|
||||
if sess.IdentityID != uuid.Nil && sess.IdentityID != interim.ID {
|
||||
sess = session.NewInactiveSession()
|
||||
}
|
||||
|
||||
method := ss.CompletedAuthenticationMethod(ctx)
|
||||
sess.CompletedLoginForMethod(method)
|
||||
i = interim
|
||||
ct = ss.ID()
|
||||
break
|
||||
}
|
||||
|
||||
if i == nil {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, errors.WithStack(schema.NewNoLoginStrategyResponsible()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.d.LoginHookExecutor().PostLoginHook(w, r, group, f, i, sess, ""); err != nil {
|
||||
if errors.Is(err, ErrAddressNotVerified()) {
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, errors.WithStack(schema.NewAddressNotVerifiedError()))
|
||||
return
|
||||
}
|
||||
|
||||
h.d.LoginFlowErrorHandler().WriteFlowError(w, r, f, ct, node.DefaultGroup, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
994
selfservice/flow/login/handler_test.go
Normal file
994
selfservice/flow/login/handler_test.go
Normal file
@@ -0,0 +1,994 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gobuffalo/httptest"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
stdtotp "github.com/pquerna/otp/totp"
|
||||
"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/corpx"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/selfservice/strategy/totp"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/sqlxx"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
corpx.RegisterFakes()
|
||||
}
|
||||
|
||||
func TestFlowLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
fakeHydra := hydra.NewFake()
|
||||
reg.SetHydra(fakeHydra)
|
||||
|
||||
routerPublic := httprouterx.NewRouterPublic()
|
||||
ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, routerPublic, httprouterx.NewRouterAdminWithPrefix())
|
||||
loginTS := testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
||||
|
||||
returnToTS := testhelpers.NewRedirTS(t, "return_to", conf)
|
||||
errorTS := testhelpers.NewErrorTestServer(t, reg)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, config.Schemas{
|
||||
{ID: "default", URL: "file://./stub/password.schema.json"},
|
||||
{ID: "email", URL: "file://./stub/email.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "phone", URL: "file://./stub/phone.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "not-allowed", URL: "file://./stub/password.schema.json"},
|
||||
})
|
||||
conf.MustSet(ctx, config.ViperKeyDefaultIdentitySchemaID, "default")
|
||||
|
||||
assertion := func(body []byte, isForced, isApi bool) {
|
||||
r := gjson.GetBytes(body, "refresh")
|
||||
assert.True(t, r.Exists(), "%s", body)
|
||||
assert.Equal(t, isForced, r.Bool(), "%s", body)
|
||||
if isApi {
|
||||
assert.Equal(t, "api", gjson.GetBytes(body, "type").String())
|
||||
} else {
|
||||
assert.Equal(t, "browser", gjson.GetBytes(body, "type").String())
|
||||
}
|
||||
}
|
||||
|
||||
initAuthenticatedFlow := func(t *testing.T, extQuery url.Values, isAPI bool) (*http.Response, []byte) {
|
||||
route := login.RouteInitBrowserFlow
|
||||
if isAPI {
|
||||
route = login.RouteInitAPIFlow
|
||||
}
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+route, nil)
|
||||
req.URL.RawQuery = extQuery.Encode()
|
||||
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, routerPublic, req)
|
||||
if isAPI {
|
||||
assert.Len(t, res.Header.Get("Set-Cookie"), 0)
|
||||
}
|
||||
return res, body
|
||||
}
|
||||
|
||||
initUnauthenticatedFlow := func(t *testing.T, extQuery url.Values, isAPI bool) (*http.Response, []byte) {
|
||||
route := login.RouteInitBrowserFlow
|
||||
if isAPI {
|
||||
route = login.RouteInitAPIFlow
|
||||
}
|
||||
client := ts.Client()
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+route, nil)
|
||||
|
||||
req.URL.RawQuery = extQuery.Encode()
|
||||
res, err := client.Do(req)
|
||||
require.NoError(t, errors.WithStack(err))
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, errors.WithStack(err))
|
||||
require.NoError(t, res.Body.Close())
|
||||
return res, body
|
||||
}
|
||||
|
||||
initFlowWithAccept := func(t *testing.T, query url.Values, isAPI bool, accept string) (*http.Response, []byte) {
|
||||
route := login.RouteInitBrowserFlow
|
||||
if isAPI {
|
||||
route = login.RouteInitAPIFlow
|
||||
}
|
||||
c := ts.Client()
|
||||
req, err := http.NewRequest("GET", ts.URL+route+"?"+query.Encode(), nil)
|
||||
require.NoError(t, err)
|
||||
if accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
|
||||
res, err := c.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
return res, body
|
||||
}
|
||||
|
||||
initFlow := func(t *testing.T, query url.Values, isAPI bool) (*http.Response, []byte) {
|
||||
return initFlowWithAccept(t, query, isAPI, "")
|
||||
}
|
||||
|
||||
initSPAFlow := func(t *testing.T, query url.Values) (*http.Response, []byte) {
|
||||
return initFlowWithAccept(t, query, false, "application/json")
|
||||
}
|
||||
|
||||
id1mail, id2mail := x.NewUUID().String(), x.NewUUID().String()
|
||||
identity1 := &identity.Identity{
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Identifiers: []string{id1mail},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), // foobar
|
||||
},
|
||||
},
|
||||
State: identity.StateActive,
|
||||
Traits: identity.Traits(`{"username":"` + id1mail + `"}`),
|
||||
}
|
||||
identity2 := &identity.Identity{
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Identifiers: []string{id2mail},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), // foobar
|
||||
},
|
||||
},
|
||||
State: identity.StateActive,
|
||||
Traits: identity.Traits(`{"username":"` + id2mail + `"}`),
|
||||
}
|
||||
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), identity1))
|
||||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), identity2))
|
||||
|
||||
t.Run("lifecycle=submit", func(t *testing.T) {
|
||||
t.Run("interaction=unauthenticated", func(t *testing.T) {
|
||||
run := func(t *testing.T, tt flow.Type, aal string, values url.Values) (string, *http.Response) {
|
||||
f := login.Flow{
|
||||
Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(),
|
||||
UI: container.New(""), Refresh: false, RequestedAAL: identity.AuthenticatorAssuranceLevel(aal),
|
||||
}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
|
||||
|
||||
res, err := testhelpers.NewTestClient(t).PostForm(ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), values)
|
||||
require.NoError(t, err)
|
||||
body := x.MustReadAll(res.Body)
|
||||
require.NoError(t, res.Body.Close())
|
||||
return string(body), res
|
||||
}
|
||||
|
||||
t.Run("case=ensure aal can only be upgraded with a session on submit", func(t *testing.T) {
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
body, res := run(t, flow.TypeAPI, "aal2", url.Values{"method": {"password"}})
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
|
||||
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.Get(body, "error.reason").String())
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body, res := run(t, flow.TypeBrowser, "aal2", url.Values{"method": {"password"}})
|
||||
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
|
||||
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.Get(body, "reason").String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=end up with method missing when aal is ok", func(t *testing.T) {
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
body, res := run(t, flow.TypeAPI, "aal1", url.Values{"method": {"not-exist"}})
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
|
||||
assertx.EqualAsJSON(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.Get(body, "ui.messages.0.text").String(), body)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body, res := run(t, flow.TypeBrowser, "aal1", url.Values{"method": {"not-exist"}})
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
assertx.EqualAsJSON(t, text.NewErrorValidationLoginNoStrategyFound().Text, gjson.Get(body, "ui.messages.0.text").String(), body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=reset the session when refresh is true but identity is different", func(t *testing.T) {
|
||||
testhelpers.NewRedirSessionEchoTS(t, reg)
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
|
||||
})
|
||||
|
||||
run := func(t *testing.T, tt flow.Type) (string, string) {
|
||||
f := login.Flow{Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: false, RequestedAAL: "aal1"}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
|
||||
|
||||
hc := testhelpers.NewClientWithCookies(t)
|
||||
res, err := hc.PostForm(ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), url.Values{"method": {"password"}, "password_identifier": {id1mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
|
||||
require.NoError(t, err)
|
||||
firstSession := x.MustReadAll(res.Body)
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
f = login.Flow{Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: true, RequestedAAL: "aal1"}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
|
||||
|
||||
vv := testhelpers.EncodeFormAsJSON(t, tt == flow.TypeAPI, url.Values{"method": {"password"}, "password_identifier": {id2mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
|
||||
|
||||
req, err := http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(vv))
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt == flow.TypeAPI {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+gjson.GetBytes(firstSession, "session_token").String())
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
res, err = hc.Do(req)
|
||||
require.NoError(t, err)
|
||||
secondSession := x.MustReadAll(res.Body)
|
||||
require.NoError(t, res.Body.Close())
|
||||
return string(firstSession), string(secondSession)
|
||||
}
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
a, b := run(t, flow.TypeBrowser)
|
||||
|
||||
assert.Equal(t, id1mail, gjson.Get(a, "identity.traits.username").String())
|
||||
assert.Equal(t, id2mail, gjson.Get(b, "identity.traits.username").String())
|
||||
|
||||
assert.NotEmpty(t, gjson.Get(b, "id").String())
|
||||
assert.NotEqual(t, gjson.Get(b, "id").String(), gjson.Get(a, "id").String())
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
a, b := run(t, flow.TypeAPI)
|
||||
|
||||
assert.Equal(t, id1mail, gjson.Get(a, "session.identity.traits.username").String())
|
||||
assert.Equal(t, id2mail, gjson.Get(b, "session.identity.traits.username").String())
|
||||
|
||||
assert.NotEmpty(t, gjson.Get(a, "session_token").String())
|
||||
assert.NotEqual(t, gjson.Get(a, "session_token").String(), gjson.Get(b, "session_token").String())
|
||||
|
||||
assert.NotEmpty(t, gjson.Get(b, "session.id").String())
|
||||
assert.NotEqual(t, gjson.Get(b, "session.id").String(), gjson.Get(a, "id").String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=changed kratos session identifiers when refresh is true", func(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
// Setup flow
|
||||
f := login.Flow{Type: flow.TypeBrowser, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: false, RequestedAAL: "aal1"}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
|
||||
|
||||
// Submit Login
|
||||
hc := testhelpers.NewClientWithCookies(t)
|
||||
res, err := hc.PostForm(ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), url.Values{"method": {"password"}, "password_identifier": {id1mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check response and session cookie presence
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
require.Len(t, hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL+login.RouteGetFlow)), 1)
|
||||
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL))), "ory_kratos_session")
|
||||
cookies1 := hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL + login.RouteGetFlow))
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL+"/sessions/whoami", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err = hc.Do(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
firstSession := x.MustReadAll(res.Body)
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
// Refresh
|
||||
f = login.Flow{Type: flow.TypeBrowser, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(), UI: container.New(""), Refresh: true, RequestedAAL: "aal1"}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
|
||||
|
||||
vv := testhelpers.EncodeFormAsJSON(t, false, url.Values{"method": {"password"}, "password_identifier": {id1mail}, "password": {"foobar"}, "csrf_token": {nosurfx.FakeCSRFToken}})
|
||||
|
||||
req, err = http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(vv))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Submit Login
|
||||
res, err = hc.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check response and session cookie presence
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
require.Len(t, hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL+login.RouteGetFlow)), 1)
|
||||
require.Contains(t, fmt.Sprintf("%v", hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL))), "ory_kratos_session")
|
||||
cookies2 := hc.Jar.Cookies(urlx.ParseOrPanic(ts.URL + login.RouteGetFlow))
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+"/sessions/whoami", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err = hc.Do(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
secondSession := x.MustReadAll(res.Body)
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
// Sessions should still be resolvable despite different kratos session identifier due to nonce
|
||||
assert.NotEqual(t, cookies1[0].String(), cookies2[0].String())
|
||||
assert.Equal(t, id1mail, gjson.Get(string(firstSession), "identity.traits.username").String())
|
||||
assert.Equal(t, id1mail, gjson.Get(string(secondSession), "identity.traits.username").String())
|
||||
assert.Equal(t, gjson.Get(string(secondSession), "id").String(), gjson.Get(string(firstSession), "id").String())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=ensure aal is checked for upgradeability on session", func(t *testing.T) {
|
||||
run := func(t *testing.T, tt flow.Type, values url.Values) (string, *http.Response) {
|
||||
f := login.Flow{
|
||||
Type: tt, ExpiresAt: time.Now().Add(time.Minute), IssuedAt: time.Now(),
|
||||
UI: container.New(""), Refresh: false, RequestedAAL: "aal1",
|
||||
}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(values.Encode()))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, routerPublic, req)
|
||||
return string(body), res
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
body, res := run(t, flow.TypeAPI, url.Values{"method": {"password"}})
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
|
||||
assertx.EqualAsJSON(t, login.ErrAlreadyLoggedIn().Reason(), gjson.Get(body, "ui.messages.0.text").String(), body)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
_, res := run(t, flow.TypeBrowser, url.Values{"method": {"password"}})
|
||||
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should return an error because the request is expired", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLoginRequestLifespan, "50ms")
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLoginRequestLifespan, "10m")
|
||||
})
|
||||
|
||||
// Freeze the clock so the handler and the expected error read the same instant. The "expired N minutes
|
||||
// ago" reason is then deterministic instead of depending on the request round-trip time.
|
||||
now := time.Now().UTC()
|
||||
reg.SetClock(clock.NewMock(now))
|
||||
t.Cleanup(func() { reg.SetClock(clock.New()) })
|
||||
|
||||
expired := now.Add(-time.Minute)
|
||||
run := func(t *testing.T, tt flow.Type, aal string, values string, isSPA bool) (string, *http.Response) {
|
||||
f := login.Flow{
|
||||
Type: tt, ExpiresAt: expired, IssuedAt: now,
|
||||
UI: container.New(""), Refresh: false, RequestedAAL: identity.AuthenticatorAssuranceLevel(aal),
|
||||
}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), &f))
|
||||
|
||||
req, err := http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+f.ID.String(), strings.NewReader(values))
|
||||
require.NoError(t, err)
|
||||
|
||||
if isSPA {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
res, err := testhelpers.NewTestClient(t).Do(req)
|
||||
require.NoError(t, err)
|
||||
body := x.MustReadAll(res.Body)
|
||||
require.NoError(t, res.Body.Close())
|
||||
return string(body), res
|
||||
}
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
actual, res := run(t, flow.TypeAPI, "aal1", `{"method":"password"}`, false)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
|
||||
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String())
|
||||
assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(reg.Clock(), expired), json.RawMessage(actual), []string{"use_flow_id", "since"}, "expired", "%s", actual)
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
body, res := run(t, flow.TypeBrowser, "aal1", url.Values{"method": {"password"}}.Encode(), false)
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "expired", "%s", body)
|
||||
})
|
||||
|
||||
t.Run("type=SPA", func(t *testing.T) {
|
||||
actual, res := run(t, flow.TypeBrowser, "aal1", `{"method":"password"}`, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteSubmitFlow)
|
||||
assert.NotEqual(t, "00000000-0000-0000-0000-000000000000", gjson.Get(actual, "use_flow_id").String())
|
||||
assertx.EqualAsJSONExcept(t, flow.NewFlowExpiredError(reg.Clock(), expired), json.RawMessage(actual), []string{"use_flow_id", "since"}, "expired", "%s", actual)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=should return to settings flow after successful mfa login after recovery", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, config.HighestAvailableAAL)
|
||||
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeTOTP.String(), true)
|
||||
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToTS.URL})
|
||||
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceSettingsRequiredAAL, string(identity.AuthenticatorAssuranceLevel1))
|
||||
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, string(identity.AuthenticatorAssuranceLevel1))
|
||||
testhelpers.StrategyEnable(t, conf, identity.CredentialsTypeTOTP.String(), false)
|
||||
})
|
||||
|
||||
key, err := totp.NewKey(context.Background(), "foo", reg)
|
||||
require.NoError(t, err)
|
||||
email := testhelpers.RandomEmail()
|
||||
id := &identity.Identity{
|
||||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
"password": {
|
||||
Type: "password",
|
||||
Identifiers: []string{email},
|
||||
Config: sqlxx.JSONRawMessage(`{"hashed_password": "$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
|
||||
},
|
||||
},
|
||||
Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, email)),
|
||||
SchemaID: config.DefaultIdentityTraitsSchemaID,
|
||||
}
|
||||
|
||||
require.NoError(t, reg.IdentityManager().CreateIdentities(context.Background(), []*identity.Identity{id}, identity.ManagerAllowWriteProtectedTraits))
|
||||
|
||||
id.SetCredentials(identity.CredentialsTypeTOTP, identity.Credentials{
|
||||
Type: identity.CredentialsTypeTOTP,
|
||||
Identifiers: []string{id.ID.String()},
|
||||
Config: sqlxx.JSONRawMessage(`{"totp_url":"` + string(key.URL()) + `"}`),
|
||||
})
|
||||
require.NoError(t, reg.IdentityManager().Update(context.Background(), id, identity.ManagerAllowWriteProtectedTraits))
|
||||
|
||||
h := func(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := testhelpers.NewActiveSession(r, reg, id, time.Now().UTC(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||||
require.NoError(t, err)
|
||||
sess.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1
|
||||
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess))
|
||||
require.NoError(t, reg.SessionManager().IssueCookie(context.Background(), w, r, sess))
|
||||
require.Equal(t, identity.AuthenticatorAssuranceLevel1, sess.AuthenticatorAssuranceLevel)
|
||||
}
|
||||
|
||||
routerPublic.GET("/mock-session", h)
|
||||
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
|
||||
testhelpers.MockHydrateCookieClient(t, client, ts.URL+"/mock-session")
|
||||
|
||||
settingsURL := ts.URL + settings.RouteInitBrowserFlow + "?return_to=" + url.QueryEscape(returnToTS.URL)
|
||||
req, err := http.NewRequest("GET", settingsURL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// we initialize the settings flow with a session that has AAL1 set
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
// we expect the request to redirect to the login flow because the AAL1 session is not sufficient
|
||||
requestURL, err := url.Parse(resp.Request.Referer())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, login.RouteInitBrowserFlow, requestURL.Path)
|
||||
require.Equal(t, "aal2", requestURL.Query().Get("aal"))
|
||||
require.Equal(t, settingsURL, requestURL.Query().Get("return_to"))
|
||||
|
||||
// we expect to be on the login page now
|
||||
respURL := resp.Request.URL
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/login-ts", respURL.Path)
|
||||
flowID := respURL.Query().Get("flow")
|
||||
require.NotEmpty(t, flowID)
|
||||
|
||||
code, err := stdtotp.GenerateCode(key.Secret(), time.Now())
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+login.RouteGetFlow+"?id="+flowID, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := string(x.MustReadAll(resp.Body))
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
totpNode := gjson.Get(body, "ui.nodes.#(attributes.name==totp_code)").String()
|
||||
require.NotEmpty(t, totpNode)
|
||||
require.NotEmpty(t, gjson.Get(body, "ui.action").String())
|
||||
|
||||
csrfToken := gjson.Get(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String()
|
||||
|
||||
req, err = http.NewRequest("POST", ts.URL+login.RouteSubmitFlow+"?flow="+flowID, strings.NewReader(url.Values{
|
||||
"method": {"totp"},
|
||||
"totp_code": {code},
|
||||
"csrf_token": {csrfToken},
|
||||
}.Encode()))
|
||||
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
||||
|
||||
location, err := resp.Location()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, settings.RouteInitBrowserFlow, location.Path)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("lifecycle=init", func(t *testing.T) {
|
||||
t.Run("suite=identity schema in query", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
query url.Values
|
||||
wantErr bool
|
||||
wantIdentifier string
|
||||
}{{
|
||||
name: "not-allowed",
|
||||
query: url.Values{"identity_schema": {"not-allowed"}},
|
||||
wantErr: true,
|
||||
}, {
|
||||
name: "not-found",
|
||||
query: url.Values{"identity_schema": {"not-found"}},
|
||||
wantErr: true,
|
||||
}, {
|
||||
name: "phone",
|
||||
query: url.Values{"identity_schema": {"phone"}},
|
||||
wantIdentifier: "Phone Number",
|
||||
}, {
|
||||
name: "email",
|
||||
query: url.Values{"identity_schema": {"email"}},
|
||||
wantIdentifier: "E-Mail Address",
|
||||
}, {
|
||||
name: "default",
|
||||
query: url.Values{"identity_schema": {"default"}},
|
||||
wantIdentifier: "Username",
|
||||
}} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
t.Run("flow=api", func(t *testing.T) {
|
||||
res, body := initFlow(t, tc.query, true)
|
||||
if tc.wantErr {
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
return
|
||||
}
|
||||
assert.Equalf(t, tc.wantIdentifier, gjson.GetBytes(body, "ui.nodes.#(attributes.name==identifier).meta.label.text").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("flow=browser", func(t *testing.T) {
|
||||
res, body := initFlow(t, tc.query, false)
|
||||
if tc.wantErr {
|
||||
require.Contains(t, res.Request.URL.String(), errorTS.URL, "%s", body)
|
||||
assert.EqualValues(t, "Bad Request", gjson.GetBytes(body, "status").String(), "%s", body)
|
||||
return
|
||||
}
|
||||
assert.Equalf(t, tc.wantIdentifier, gjson.GetBytes(body, "ui.nodes.#(attributes.name==identifier).meta.label.context.title").String(), "%s", body)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flow=api", func(t *testing.T) {
|
||||
t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertion(body, false, true)
|
||||
assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
|
||||
})
|
||||
|
||||
t.Run("case=returns session exchange code with any truthy value", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToTS.URL, "https://example.com"})
|
||||
parameters := []string{"true", "True", "1"}
|
||||
|
||||
for _, param := range parameters {
|
||||
t.Run("return_session_token_exchange_code="+param, func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{
|
||||
"return_session_token_exchange_code": {param},
|
||||
"return_to": {"https://example.com/redirect"},
|
||||
}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
|
||||
assert.Equal(t, "https://example.com/redirect", gjson.GetBytes(body, "return_to").String())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=can not request refresh and aal at the same time on unauthenticated request", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "error.reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=can request refresh and aal at the same time on authenticated request", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertx.EqualAsJSON(t, "Please confirm this action by verifying that it is you.", gjson.GetBytes(body, "ui.messages.0.text").String(), "%s", body)
|
||||
assertx.EqualAsJSON(t, "Please complete the second authentication challenge.", gjson.GetBytes(body, "ui.messages.1.text").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=can not request aal2 on unauthenticated request", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{"aal": {"aal2"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "error.reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=ignores aal1 if session has aal1 already", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal1"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertx.EqualAsJSON(t, "A valid session was detected and thus login is not possible. Did you forget to set `?refresh=true`?", gjson.GetBytes(body, "error.reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=aal0 is not a valid value", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal0"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertx.EqualAsJSON(t, "Unable to parse AuthenticationMethod Assurance Level (AAL): expected one of [aal1, aal2] but got aal0", gjson.GetBytes(body, "error.reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=indicates two factor auth", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assert.Equal(t, gjson.GetBytes(body, "ui.messages.0.text").String(), text.NewInfoLoginMFA().Text)
|
||||
})
|
||||
|
||||
t.Run("case=does not set forced flag on unauthenticated request with refresh=true", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{"refresh": {"true"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertion(body, false, true)
|
||||
})
|
||||
|
||||
t.Run("case=does not set forced flag on authenticated request without refresh=true", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{}, true)
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
assertx.EqualAsJSON(t, login.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=does not set forced flag on authenticated request with refresh=false", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"false"}}, true)
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
assertx.EqualAsJSON(t, login.ErrAlreadyLoggedIn(), json.RawMessage(gjson.GetBytes(body, "error").Raw), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=does set forced flag on authenticated request with refresh=true", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertion(body, true, true)
|
||||
})
|
||||
|
||||
t.Run("case=check info message on authenticated request with refresh=true", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}}, true)
|
||||
assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow)
|
||||
assertion(body, true, true)
|
||||
assert.Equal(t, gjson.GetBytes(body, "ui.messages.0.text").String(), text.NewInfoLoginReAuth().Text)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("flow=browser", func(t *testing.T) {
|
||||
t.Run("case=does not set forced flag on unauthenticated request", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{}, false)
|
||||
assertion(body, false, false)
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=never returns a session token exchange code", func(t *testing.T) {
|
||||
_, body := initFlow(t, urlx.ParseOrPanic("/?return_session_token_exchange_code=true").Query(), false)
|
||||
assertion(body, false, false)
|
||||
assert.Empty(t, gjson.GetBytes(body, "session_token_exchange_code").String())
|
||||
})
|
||||
|
||||
t.Run("case=can not request refresh and aal at the same time on unauthenticated request", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
|
||||
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=can request refresh and aal at the same time on authenticated request", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}, "aal": {"aal2"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
assertx.EqualAsJSON(t, "Please confirm this action by verifying that it is you.", gjson.GetBytes(body, "ui.messages.0.text").String(), "%s", body)
|
||||
assertx.EqualAsJSON(t, "Please complete the second authentication challenge.", gjson.GetBytes(body, "ui.messages.1.text").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=redirects if aal2 is requested and set up already without refresh", func(t *testing.T) {
|
||||
res, _ := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}, "set_aal": {"aal2"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=can not request aal2 on unauthenticated request", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{"aal": {"aal2"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
|
||||
assertx.EqualAsJSON(t, "You can not requested a higher AAL (AAL2/AAL3) without an active session.", gjson.GetBytes(body, "reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=ignores aal1 if session has aal1 already", func(t *testing.T) {
|
||||
res, _ := initAuthenticatedFlow(t, url.Values{"aal": {"aal1"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=aal0 is not a valid value", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal0"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
|
||||
assertx.EqualAsJSON(t, "Unable to parse AuthenticationMethod Assurance Level (AAL): expected one of [aal1, aal2] but got aal0", gjson.GetBytes(body, "reason").String())
|
||||
})
|
||||
|
||||
t.Run("case=indicates two factor auth", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"aal": {"aal2"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
assert.Equal(t, gjson.GetBytes(body, "ui.messages.0.text").String(), text.NewInfoLoginMFA().Text)
|
||||
})
|
||||
|
||||
t.Run("case=makes request with JSON", func(t *testing.T) {
|
||||
res, body := initSPAFlow(t, url.Values{})
|
||||
assertion(body, false, false)
|
||||
assert.NotContains(t, res.Request.URL.String(), loginTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=does not set forced flag on unauthenticated request with refresh=true", func(t *testing.T) {
|
||||
res, body := initFlow(t, url.Values{"refresh": {"true"}}, false)
|
||||
assertion(body, false, false)
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=does not set forced flag on authenticated request without refresh=true", func(t *testing.T) {
|
||||
res, _ := initAuthenticatedFlow(t, url.Values{}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=does not set forced flag on authenticated request with refresh=false", func(t *testing.T) {
|
||||
res, _ := initAuthenticatedFlow(t, url.Values{"refresh": {"false"}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), returnToTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=does set forced flag on authenticated request with refresh=true", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"refresh": {"true"}}, false)
|
||||
assertion(body, true, false)
|
||||
assert.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=redirects with 303", func(t *testing.T) {
|
||||
c := &http.Client{}
|
||||
// don't get the reference, instead copy the values, so we don't alter the client directly.
|
||||
*c = *ts.Client()
|
||||
// prevent the redirect
|
||||
c.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
req, err := http.NewRequest("GET", ts.URL+login.RouteInitBrowserFlow, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := c.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
// here we check that the redirect status is 303
|
||||
require.Equal(t, http.StatusSeeOther, res.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("case=refuses to parse oauth2 login challenge when Hydra is not configured", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"login_challenge": {hydra.FakeValidLoginChallenge}}, false)
|
||||
require.Contains(t, res.Request.URL.String(), errorTS.URL)
|
||||
require.Contains(t, string(body), "refusing to parse")
|
||||
})
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://fake-hydra")
|
||||
|
||||
t.Run("case=oauth2 flow init should override return_to to the oauth2 request_url", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{"https://www.ory.com", "https://example.com"})
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderOverrideReturnTo, true)
|
||||
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderOverrideReturnTo, false)
|
||||
})
|
||||
|
||||
res, _ := initUnauthenticatedFlow(t, url.Values{
|
||||
"return_to": {"https://example.com"},
|
||||
"login_challenge": {hydra.FakeValidLoginChallenge},
|
||||
}, false)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
require.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
|
||||
c := ts.Client()
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+login.RouteGetFlow, nil)
|
||||
req.URL.RawQuery = url.Values{"id": {res.Request.URL.Query().Get("flow")}}.Encode()
|
||||
|
||||
res, err := c.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, errors.WithStack(err))
|
||||
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
assert.Equal(t, "https://www.ory.com", gjson.GetBytes(body, "return_to").Value())
|
||||
})
|
||||
|
||||
t.Run("case=invalid oauth2 login challenge returns 400 Bad Request", func(t *testing.T) {
|
||||
res, body := initAuthenticatedFlow(t, url.Values{"login_challenge": {hydra.FakeInvalidLoginChallenge}}, false)
|
||||
assert.Contains(t, res.Request.URL.String(), errorTS.URL)
|
||||
assert.Equal(t, int64(http.StatusBadRequest), gjson.GetBytes(body, "code").Int())
|
||||
assert.Contains(t, gjson.GetBytes(body, "reason").String(), "Unable to get OAuth 2.0 Login Challenge")
|
||||
})
|
||||
|
||||
t.Run("case=oauth2 flow init succeeds", func(t *testing.T) {
|
||||
res, _ := initAuthenticatedFlow(t, url.Values{"login_challenge": {hydra.FakeValidLoginChallenge}}, false)
|
||||
require.Contains(t, res.Request.URL.String(), loginTS.URL)
|
||||
})
|
||||
|
||||
t.Run("case=oauth2 flow init adds oauth2_login_request field", func(t *testing.T) {
|
||||
res, body := initSPAFlow(t, url.Values{"login_challenge": {hydra.FakeValidLoginChallenge}})
|
||||
assert.NotContains(t, res.Request.URL.String(), loginTS.URL)
|
||||
|
||||
assert.NotEmpty(t, gjson.GetBytes(body, "oauth2_login_request").Value(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=oauth2 flow with existing session and JSON request should not return null", func(t *testing.T) {
|
||||
// This test reproduces issue #10255 where the /self-service/login/browser endpoint
|
||||
// returns null when called with an existing session, a Hydra login challenge, and
|
||||
// an Accept: application/json header.
|
||||
|
||||
// Has a side effect on this test suite but since its running serial and there is no significantly easier way it's acceptable.
|
||||
fakeHydra.Skip = true
|
||||
t.Cleanup(func() {
|
||||
fakeHydra.Skip = false
|
||||
})
|
||||
|
||||
// Make a login request with an authenticated session, Hydra login challenge, and JSON accept
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", ts.URL+login.RouteInitBrowserFlow+"?login_challenge="+hydra.FakeValidLoginChallenge, nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
body, res := testhelpers.MockMakeAuthenticatedRequest(t, reg, conf, routerPublic, req)
|
||||
|
||||
// Before the fix, this would return 200 OK with "null" as body because of variable shadowing
|
||||
// After the fix, it should return a proper error response with ErrAlreadyLoggedIn
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "Response should be 400 Bad Request, got body: %s", body)
|
||||
assert.NotEqual(t, "null", string(body), "Response should not be null")
|
||||
assert.NotEmpty(t, gjson.GetBytes(body, "error.id").String(), "Should have error.id field, got body: %s", body)
|
||||
assert.Equal(t, "session_already_available", gjson.GetBytes(body, "error.id").String(), "Should return session_already_available error, got body: %s", body)
|
||||
assert.Contains(t, gjson.GetBytes(body, "error.reason").String(), "A valid session was detected", "Should have proper error reason, got body: %s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=relative redirect when self-service login ui is a relative URL", func(t *testing.T) {
|
||||
reg.Config().MustSet(ctx, config.ViperKeySelfServiceLoginUI, "/login-ts")
|
||||
assert.Regexp(
|
||||
t,
|
||||
"^/login-ts.*$",
|
||||
testhelpers.GetSelfServiceRedirectLocation(t, ts.URL+login.RouteInitBrowserFlow),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
public, _ := testhelpers.NewKratosServerWithCSRF(t, reg)
|
||||
_ = testhelpers.NewErrorTestServer(t, reg)
|
||||
returnToTS := testhelpers.NewRedirTS(t, "", conf)
|
||||
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/password.schema.json")
|
||||
conf.MustSet(ctx, config.ViperKeyIdentitySchemas, config.Schemas{
|
||||
{ID: "default", URL: "file://./stub/password.schema.json"},
|
||||
{ID: "email", URL: "file://./stub/email.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "phone", URL: "file://./stub/phone.schema.json", SelfserviceSelectable: true},
|
||||
{ID: "not-allowed", URL: "file://./stub/password.schema.json"},
|
||||
})
|
||||
|
||||
setupLoginUI := func(t *testing.T, c *http.Client) *httptest.Server {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// It is important that we use a HTTP request to fetch the flow because that will show us if CSRF works or not
|
||||
_, err := w.Write(testhelpers.EasyGetBody(t, c, public.URL+login.RouteGetFlow+"?id="+r.URL.Query().Get("flow")))
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLoginUI, ts.URL)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToTS.URL)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword), map[string]interface{}{
|
||||
"enabled": true,
|
||||
})
|
||||
|
||||
t.Run("case=fetching successful", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
setupLoginUI(t, client)
|
||||
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow)
|
||||
|
||||
assert.NotEmpty(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==csrf_token).attributes.value").String(), "%s", body)
|
||||
assert.NotEmpty(t, gjson.GetBytes(body, "id").String(), "%s", body)
|
||||
assert.Empty(t, gjson.GetBytes(body, "headers").Value(), "%s", body)
|
||||
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), gjson.GetBytes(body, "id").String(), "%s", body)
|
||||
assert.Contains(t, gjson.GetBytes(body, "ui.action").String(), public.URL, "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=csrf cookie missing", func(t *testing.T) {
|
||||
client := testhelpers.NewTestClient(t)
|
||||
setupLoginUI(t, client)
|
||||
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow)
|
||||
|
||||
assert.EqualValues(t, nosurfx.ErrInvalidCSRFToken().ReasonField, gjson.GetBytes(body, "error.reason").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=expired", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
setupLoginUI(t, client)
|
||||
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow)
|
||||
|
||||
// Expire the flow
|
||||
f, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
|
||||
require.NoError(t, err)
|
||||
f.ExpiresAt = time.Now().Add(-time.Second)
|
||||
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(context.Background(), f))
|
||||
|
||||
// Try the flow but it is expired
|
||||
res, body := testhelpers.EasyGet(t, client, public.URL+login.RouteGetFlow+"?id="+f.ID.String())
|
||||
assert.EqualValues(t, http.StatusGone, res.StatusCode)
|
||||
assert.Equal(t, public.URL+login.RouteInitBrowserFlow, gjson.GetBytes(body, "error.details.redirect_to").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=expired with return_to and schema_id", func(t *testing.T) {
|
||||
returnTo := returnToTS.URL
|
||||
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTo})
|
||||
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
setupLoginUI(t, client)
|
||||
body := testhelpers.EasyGetBody(t, client, public.URL+login.RouteInitBrowserFlow+"?return_to="+returnTo+"&identity_schema=email")
|
||||
|
||||
// Expire the flow
|
||||
f, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(body, "id").String()))
|
||||
require.NoError(t, err)
|
||||
f.ExpiresAt = time.Now().Add(-time.Second)
|
||||
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(context.Background(), f))
|
||||
|
||||
// Retrieve the flow and verify that return_to is in the response
|
||||
getURL := fmt.Sprintf("%s%s?id=%s&return_to=%s", public.URL, login.RouteGetFlow, f.ID, returnTo)
|
||||
getBody := testhelpers.EasyGetBody(t, client, getURL)
|
||||
assert.Equal(t, gjson.GetBytes(getBody, "error.details.return_to").String(), returnTo)
|
||||
|
||||
// submit the flow but it is expired
|
||||
u := public.URL + login.RouteSubmitFlow + "?flow=" + f.ID.String()
|
||||
res, err := client.PostForm(u, url.Values{"password_identifier": {"email@ory.sh"}, "csrf_token": {f.CSRFToken}, "password": {"password"}, "method": {"password"}})
|
||||
require.NoError(t, err)
|
||||
resBody, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
f, err = reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(gjson.GetBytes(resBody, "id").String()))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, public.URL+login.RouteInitBrowserFlow+"?return_to="+returnTo+"&identity_schema=email", f.RequestURL)
|
||||
})
|
||||
|
||||
t.Run("case=not found", func(t *testing.T) {
|
||||
client := testhelpers.NewClientWithCookies(t)
|
||||
setupLoginUI(t, client)
|
||||
|
||||
res, _ := testhelpers.EasyGet(t, client, public.URL+login.RouteGetFlow+"?id="+x.NewUUID().String())
|
||||
assert.EqualValues(t, http.StatusNotFound, res.StatusCode)
|
||||
})
|
||||
}
|
||||
464
selfservice/flow/login/hook.go
Normal file
464
selfservice/flow/login/hook.go
Normal file
@@ -0,0 +1,464 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/kratos/x/redir"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/sessiontokenexchange"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/events"
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
)
|
||||
|
||||
type (
|
||||
PreHookExecutor interface {
|
||||
ExecuteLoginPreHook(w http.ResponseWriter, r *http.Request, a *Flow) error
|
||||
}
|
||||
|
||||
PostHookExecutor interface {
|
||||
ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, g node.UiNodeGroup, a *Flow, s *session.Session) error
|
||||
}
|
||||
|
||||
HooksProvider interface {
|
||||
PreLoginHooks(ctx context.Context) ([]PreHookExecutor, error)
|
||||
PostLoginHooks(ctx context.Context, credentialsType identity.CredentialsType) ([]PostHookExecutor, error)
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
executorDependencies interface {
|
||||
config.Provider
|
||||
hydra.Provider
|
||||
identity.PrivilegedPoolProvider
|
||||
identity.ManagementProvider
|
||||
session.ManagementProvider
|
||||
session.PersistenceProvider
|
||||
nosurfx.CSRFTokenGeneratorProvider
|
||||
httpx.WriterProvider
|
||||
logrusx.Provider
|
||||
otelx.Provider
|
||||
sessiontokenexchange.PersistenceProvider
|
||||
HandlerProvider
|
||||
|
||||
FlowPersistenceProvider
|
||||
HooksProvider
|
||||
StrategyProvider
|
||||
}
|
||||
HookExecutor struct {
|
||||
d executorDependencies
|
||||
}
|
||||
HookExecutorProvider interface {
|
||||
LoginHookExecutor() *HookExecutor
|
||||
}
|
||||
)
|
||||
|
||||
func PostHookExecutorNames(e []PostHookExecutor) []string {
|
||||
names := make([]string, len(e))
|
||||
for k, ee := range e {
|
||||
names[k] = fmt.Sprintf("%T", ee)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func NewHookExecutor(d executorDependencies) *HookExecutor {
|
||||
return &HookExecutor{d: d}
|
||||
}
|
||||
|
||||
func (e *HookExecutor) checkAAL(ctx context.Context, s *session.Session, a *Flow) error {
|
||||
err := e.d.SessionManager().DoesSessionSatisfy(ctx, s, e.d.Config().SessionWhoAmIAAL(ctx))
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) {
|
||||
if a != nil && aalErr.PassReturnToAndLoginChallengeParameters(a.RequestURL) != nil {
|
||||
_ = aalErr.WithDetail("pass_request_params_error", "failed to pass request parameters to aalErr.RedirectTo")
|
||||
}
|
||||
return aalErr
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *HookExecutor) handleLoginError(_ http.ResponseWriter, r *http.Request, g node.UiNodeGroup, f *Flow, i *identity.Identity, flowError error) error {
|
||||
if f != nil {
|
||||
if i != nil {
|
||||
cont, err := container.NewFromStruct("", g, i.Traits, "traits")
|
||||
if err != nil {
|
||||
e.d.Logger().WithError(err).Warn("could not update flow UI")
|
||||
return err
|
||||
}
|
||||
|
||||
for _, n := range cont.Nodes {
|
||||
// we only set the value and not the whole field because we want to keep types from the initial form generation
|
||||
f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
|
||||
}
|
||||
}
|
||||
|
||||
if f.Type == flow.TypeBrowser {
|
||||
f.UI.SetCSRF(e.d.GenerateCSRFToken(r))
|
||||
}
|
||||
}
|
||||
|
||||
return flowError
|
||||
}
|
||||
|
||||
func (e *HookExecutor) PostLoginHook(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
g node.UiNodeGroup,
|
||||
f *Flow,
|
||||
i *identity.Identity,
|
||||
s *session.Session,
|
||||
provider string,
|
||||
) (err error) {
|
||||
ctx := r.Context()
|
||||
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "HookExecutor.PostLoginHook")
|
||||
r = r.WithContext(ctx)
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
// We need to set the identity here because we check the available AAL in maybeLinkCredentials.
|
||||
s.IdentityID = i.ID
|
||||
s.Identity = i
|
||||
|
||||
if err := e.maybeLinkCredentials(ctx, s, i, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := e.d.SessionManager().ActivateSession(r, s, i, time.Now().UTC()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c := e.d.Config()
|
||||
// Verify the redirect URL before we do any other processing.
|
||||
returnTo, err := redir.SecureRedirectTo(r,
|
||||
c.SelfServiceBrowserDefaultReturnTo(ctx),
|
||||
redir.SecureRedirectReturnTo(f.ReturnTo),
|
||||
redir.SecureRedirectUseSourceURL(f.RequestURL),
|
||||
redir.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(ctx)),
|
||||
redir.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(ctx)),
|
||||
redir.SecureRedirectOverrideDefaultReturnTo(c.SelfServiceFlowLoginReturnTo(ctx, f.Active.String())),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
span.SetAttributes(otelx.StringAttrs(map[string]string{
|
||||
"return_to": returnTo.String(),
|
||||
"flow_type": string(flow.TypeBrowser),
|
||||
"redirect_reason": "login successful",
|
||||
})...)
|
||||
|
||||
if f.Type == flow.TypeBrowser && x.IsJSONRequest(r) {
|
||||
f.AddContinueWith(flow.NewContinueWithRedirectBrowserTo(returnTo.String()))
|
||||
}
|
||||
|
||||
classified := s
|
||||
s = s.Declassified()
|
||||
|
||||
e.d.Logger().
|
||||
WithRequest(r).
|
||||
WithField("identity_id", i.ID).
|
||||
WithField("flow_method", f.Active).
|
||||
Debug("Running ExecuteLoginPostHook.")
|
||||
hooks, err := e.d.PostLoginHooks(ctx, f.Active)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, executor := range hooks {
|
||||
if err := executor.ExecuteLoginPostHook(w, r, g, f, s); err != nil {
|
||||
if errors.Is(err, ErrHookAbortFlow) {
|
||||
e.d.Logger().
|
||||
WithRequest(r).
|
||||
WithField("executor", fmt.Sprintf("%T", executor)).
|
||||
WithField("executor_position", k).
|
||||
WithField("executors", PostHookExecutorNames(hooks)).
|
||||
WithField("identity_id", i.ID).
|
||||
WithField("flow_method", f.Active).
|
||||
Debug("A ExecuteLoginPostHook hook aborted early.")
|
||||
|
||||
span.SetAttributes(attribute.String("redirect_reason", "aborted by hook"), attribute.String("executor", fmt.Sprintf("%T", executor)))
|
||||
|
||||
return nil
|
||||
}
|
||||
return e.handleLoginError(w, r, g, f, i, err)
|
||||
}
|
||||
|
||||
e.d.Logger().
|
||||
WithRequest(r).
|
||||
WithField("executor", fmt.Sprintf("%T", executor)).
|
||||
WithField("executor_position", k).
|
||||
WithField("executors", PostHookExecutorNames(hooks)).
|
||||
WithField("identity_id", i.ID).
|
||||
WithField("flow_method", f.Active).
|
||||
Debug("ExecuteLoginPostHook completed successfully.")
|
||||
}
|
||||
|
||||
if f.Type == flow.TypeAPI {
|
||||
span.SetAttributes(attribute.String("flow_type", string(flow.TypeAPI)))
|
||||
if err := e.d.SessionPersister().UpsertSession(ctx, s); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
e.d.Logger().
|
||||
WithRequest(r).
|
||||
WithField("session_id", s.ID).
|
||||
WithField("identity_id", i.ID).
|
||||
Info("Identity authenticated successfully and was issued an Ory Kratos Session Token.")
|
||||
|
||||
span.AddEvent(events.NewLoginSucceeded(ctx, &events.LoginSucceededOpts{
|
||||
SessionID: s.ID,
|
||||
IdentityID: i.ID,
|
||||
FlowID: f.ID,
|
||||
FlowType: string(f.Type),
|
||||
RequestedAAL: string(f.RequestedAAL),
|
||||
IsRefresh: f.Refresh,
|
||||
Method: f.Active.String(),
|
||||
SSOProvider: provider,
|
||||
}))
|
||||
if f.IDToken != "" {
|
||||
// We don't want to redirect with the code, if the flow was submitted with an ID token.
|
||||
// This is the case for Sign in with native Apple SDK or Google SDK.
|
||||
} else if handled, err := e.d.SessionManager().MaybeRedirectAPICodeFlow(w, r, f, s.ID, g); err != nil {
|
||||
return errors.WithStack(err)
|
||||
} else if handled {
|
||||
return nil
|
||||
}
|
||||
|
||||
response := &APIFlowResponse{
|
||||
Session: s,
|
||||
Token: s.Token,
|
||||
ContinueWith: f.ContinueWith(),
|
||||
}
|
||||
if e.checkAAL(ctx, classified, f) != nil {
|
||||
// If AAL is not satisfied, we omit the identity to preserve the user's privacy in case of a phishing attack.
|
||||
response.Session.Identity = nil
|
||||
}
|
||||
|
||||
e.d.Writer().Write(w, r, response)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.d.SessionManager().UpsertAndIssueCookie(ctx, w, r, s); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
e.d.Logger().
|
||||
WithRequest(r).
|
||||
WithField("identity_id", i.ID).
|
||||
WithField("session_id", s.ID).
|
||||
Info("Identity authenticated successfully and was issued an Ory Kratos Session Cookie.")
|
||||
|
||||
span.AddEvent(events.NewLoginSucceeded(ctx, &events.LoginSucceededOpts{
|
||||
SessionID: s.ID,
|
||||
FlowID: f.ID,
|
||||
IdentityID: i.ID, FlowType: string(f.Type), RequestedAAL: string(f.RequestedAAL), IsRefresh: f.Refresh, Method: f.Active.String(),
|
||||
SSOProvider: provider,
|
||||
}))
|
||||
|
||||
if x.IsJSONRequest(r) {
|
||||
span.SetAttributes(attribute.String("flow_type", "spa"))
|
||||
|
||||
// Browser flows rely on cookies. Adding tokens in the mix will confuse consumers.
|
||||
s.Token = ""
|
||||
|
||||
// If we detect that whoami would require a higher AAL, we redirect!
|
||||
if err := e.checkAAL(ctx, classified, f); err != nil {
|
||||
if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) {
|
||||
if data, _ := flow.DuplicateCredentials(f); data == nil {
|
||||
span.SetAttributes(attribute.String("return_to", aalErr.RedirectTo), attribute.String("redirect_reason", "requires aal2"))
|
||||
e.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(aalErr.RedirectTo))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case: If we are in a flow that wants to link credentials, we create a
|
||||
// new login flow here that asks for the require AAL, but also copies over the
|
||||
// internal context and the organization ID.
|
||||
r.URL, err = url.Parse(aalErr.RedirectTo)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
newFlow, _, err := e.d.LoginHandler().NewLoginFlow(w, r, flow.TypeBrowser,
|
||||
WithInternalContext(f.InternalContext),
|
||||
WithOrganizationID(f.OrganizationID),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
x.SendFlowCompletedAsRedirectOrJSON(w, r, e.d.Writer(), newFlow, newFlow.AppendTo(e.d.Config().SelfServiceFlowLoginUI(ctx)).String())
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// If Kratos is used as a Hydra login provider, we need to redirect back to Hydra by returning a 422 status
|
||||
// with the post login challenge URL as the body.
|
||||
if f.OAuth2LoginChallenge != "" {
|
||||
postChallengeURL, err := e.d.Hydra().AcceptLoginRequest(ctx,
|
||||
hydra.AcceptLoginRequestParams{
|
||||
LoginChallenge: string(f.OAuth2LoginChallenge),
|
||||
IdentityID: i.ID.String(),
|
||||
SessionID: s.ID.String(),
|
||||
AuthenticationMethods: s.AMR,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
span.SetAttributes(attribute.String("return_to", postChallengeURL), attribute.String("redirect_reason", "oauth2 login challenge"))
|
||||
e.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(postChallengeURL))
|
||||
return nil
|
||||
}
|
||||
|
||||
response := &APIFlowResponse{
|
||||
Session: s,
|
||||
ContinueWith: f.ContinueWith(),
|
||||
}
|
||||
e.d.Writer().Write(w, r, response)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we detect that whoami would require a higher AAL, we redirect!
|
||||
if err := e.checkAAL(ctx, classified, f); err != nil {
|
||||
if aalErr := new(session.ErrAALNotSatisfied); errors.As(err, &aalErr) {
|
||||
if data, _ := flow.DuplicateCredentials(f); data == nil {
|
||||
http.Redirect(w, r, aalErr.RedirectTo, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Special case: If we are in a flow that wants to link credentials, we create a
|
||||
// new login flow here that asks for the require AAL, but also copies over the
|
||||
// internal context and the organization ID.
|
||||
r.URL, err = url.Parse(aalErr.RedirectTo)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
newFlow, _, err := e.d.LoginHandler().NewLoginFlow(w, r, flow.TypeBrowser,
|
||||
WithInternalContext(f.InternalContext),
|
||||
WithOrganizationID(f.OrganizationID),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
x.SendFlowCompletedAsRedirectOrJSON(w, r, e.d.Writer(), newFlow, newFlow.AppendTo(e.d.Config().SelfServiceFlowLoginUI(ctx)).String())
|
||||
return nil
|
||||
}
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
finalReturnTo := returnTo.String()
|
||||
if f.OAuth2LoginChallenge != "" {
|
||||
rt, err := e.d.Hydra().AcceptLoginRequest(ctx,
|
||||
hydra.AcceptLoginRequestParams{
|
||||
LoginChallenge: string(f.OAuth2LoginChallenge),
|
||||
IdentityID: i.ID.String(),
|
||||
SessionID: s.ID.String(),
|
||||
AuthenticationMethods: s.AMR,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
finalReturnTo = rt
|
||||
span.SetAttributes(attribute.String("return_to", rt), attribute.String("redirect_reason", "oauth2 login challenge"))
|
||||
} else if f.ReturnToVerification != "" {
|
||||
finalReturnTo = f.ReturnToVerification
|
||||
span.SetAttributes(attribute.String("redirect_reason", "verification requested"))
|
||||
}
|
||||
|
||||
redir.ContentNegotiationRedirection(w, r, s, e.d.Writer(), finalReturnTo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *HookExecutor) PreLoginHook(w http.ResponseWriter, r *http.Request, a *Flow) error {
|
||||
hooks, err := e.d.PreLoginHooks(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range hooks {
|
||||
if err := h.ExecuteLoginPreHook(w, r, a); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeLinkCredentials links the identity with the credentials of the inner context of the login flow.
|
||||
func (e *HookExecutor) maybeLinkCredentials(ctx context.Context, sess *session.Session, ident *identity.Identity, loginFlow *Flow) (err error) {
|
||||
ctx, span := e.d.Tracer(ctx).Tracer().Start(ctx, "HookExecutor.PostLoginHook.maybeLinkCredentials")
|
||||
defer otelx.End(span, &err)
|
||||
|
||||
if e.checkAAL(ctx, sess, loginFlow) != nil {
|
||||
// we don't yet want to link credentials because the required AAL is not satisfied
|
||||
return nil
|
||||
}
|
||||
|
||||
lc, err := flow.DuplicateCredentials(loginFlow)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if lc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = e.checkDuplicateCredentialsIdentifierMatch(ctx, ident, lc.DuplicateIdentifier); err != nil {
|
||||
return err
|
||||
}
|
||||
strategy, err := e.d.AllLoginStrategies().Strategy(lc.CredentialsType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
linkableStrategy, ok := strategy.(LinkableStrategy)
|
||||
if !ok {
|
||||
// This should never happen because we check for this in the registration flow.
|
||||
return errors.Errorf("strategy is not linkable: %T", linkableStrategy)
|
||||
}
|
||||
|
||||
if err := linkableStrategy.Link(ctx, ident, lc.CredentialsConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = linkableStrategy.CompletedLogin(sess, lc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *HookExecutor) checkDuplicateCredentialsIdentifierMatch(ctx context.Context, i *identity.Identity, match string) error {
|
||||
if len(i.Credentials) == 0 {
|
||||
if err := e.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, identity.ExpandCredentials); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, credentials := range i.Credentials {
|
||||
for _, identifier := range credentials.Identifiers {
|
||||
if identifier == match {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return schema.NewLinkedCredentialsDoNotMatch()
|
||||
}
|
||||
536
selfservice/flow/login/hook_test.go
Normal file
536
selfservice/flow/login/hook_test.go
Normal file
@@ -0,0 +1,536 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hydra"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/contextx"
|
||||
)
|
||||
|
||||
func TestLoginExecutor(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
for _, strategy := range identity.AllCredentialTypes {
|
||||
t.Run("strategy="+strategy.String(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
reg.SetHydra(hydra.NewFake())
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/login.schema.json")
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, returnToServer.URL)
|
||||
_ = testhelpers.NewLoginUIFlowEchoServer(t, reg)
|
||||
|
||||
newServer := func(t *testing.T, ft flow.Type, useIdentity *identity.Identity, flowCallback ...func(*login.Flow)) *httptest.Server {
|
||||
router := http.NewServeMux()
|
||||
|
||||
router.HandleFunc("GET /login/pre", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginFlow, err := login.NewFlow(reg, r, ft)
|
||||
require.NoError(t, err)
|
||||
if testhelpers.SelfServiceHookLoginErrorHandler(t, w, r, reg.LoginHookExecutor().PreLoginHook(w, r, loginFlow)) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /login/post", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginFlow, err := login.NewFlow(reg, r, ft)
|
||||
require.NoError(t, err)
|
||||
loginFlow.Active = strategy
|
||||
loginFlow.RequestURL = x.RequestURL(r).String()
|
||||
for _, cb := range flowCallback {
|
||||
cb(loginFlow)
|
||||
}
|
||||
|
||||
sess := session.NewInactiveSession()
|
||||
sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||||
if useIdentity == nil {
|
||||
useIdentity = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
|
||||
}
|
||||
|
||||
testhelpers.SelfServiceHookLoginErrorHandler(t, w, r,
|
||||
reg.LoginHookExecutor().PostLoginHook(w, r, strategy.ToUiNodeGroup(), loginFlow, useIdentity, sess, ""))
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /login/post2fa", func(w http.ResponseWriter, r *http.Request) {
|
||||
loginFlow, err := login.NewFlow(reg, r, ft)
|
||||
require.NoError(t, err)
|
||||
loginFlow.Active = strategy
|
||||
loginFlow.RequestURL = x.RequestURL(r).String()
|
||||
for _, cb := range flowCallback {
|
||||
cb(loginFlow)
|
||||
}
|
||||
|
||||
sess := session.NewInactiveSession()
|
||||
sess.CompletedLoginFor(identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||||
sess.CompletedLoginFor(identity.CredentialsTypeTOTP, identity.AuthenticatorAssuranceLevel2)
|
||||
if useIdentity == nil {
|
||||
useIdentity = testhelpers.SelfServiceHookCreateFakeIdentity(t, reg)
|
||||
}
|
||||
|
||||
testhelpers.SelfServiceHookLoginErrorHandler(t, w, r,
|
||||
reg.LoginHookExecutor().PostLoginHook(w, r, strategy.ToUiNodeGroup(), loginFlow, useIdentity, sess, ""))
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(router)
|
||||
t.Cleanup(ts.Close)
|
||||
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
|
||||
return ts
|
||||
}
|
||||
|
||||
makeRequestPost := testhelpers.SelfServiceMakeLoginPostHookRequest
|
||||
viperSetPost := testhelpers.SelfServiceHookLoginViperSetPost
|
||||
|
||||
t.Run("method=PostLoginHook", func(t *testing.T) {
|
||||
t.Run("case=pass without hooks", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
|
||||
})
|
||||
|
||||
t.Run("case=pass without hooks if client is ajax", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
ts := newServer(t, flow.TypeBrowser, nil)
|
||||
res, body := makeRequestPost(t, ts, true, url.Values{})
|
||||
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.Contains(t, res.Request.URL.String(), ts.URL)
|
||||
assert.JSONEq(t, fmt.Sprintf(`[{"action":"redirect_browser_to","redirect_browser_to":"%s"}]`, returnToServer.URL), gjson.Get(body, "continue_with").Raw)
|
||||
})
|
||||
|
||||
t.Run("case=pass if hooks pass", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
viperSetPost(t, conf, strategy.String(), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
|
||||
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.Equal(t, returnToServer.URL, res.Request.URL.String())
|
||||
})
|
||||
|
||||
t.Run("case=fail if hooks fail", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
viperSetPost(t, conf, strategy.String(), []config.SelfServiceHook{{Name: "err", Config: []byte(`{"ExecuteLoginPostHook": "abort"}`)}})
|
||||
|
||||
ts := newServer(t, flow.TypeBrowser, nil)
|
||||
res, body := makeRequestPost(t, ts, false, url.Values{})
|
||||
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.Contains(t, res.Request.URL.String(), ts.URL)
|
||||
assert.Empty(t, body)
|
||||
})
|
||||
|
||||
t.Run("case=use return_to value", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnToServer.URL})
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{"return_to": {returnToServer.URL + "/kratos"}})
|
||||
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.Equal(t, returnToServer.URL+"/kratos", res.Request.URL.String())
|
||||
})
|
||||
|
||||
t.Run("case=use nested config value", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceLoginAfter+"."+config.DefaultBrowserReturnURL, returnToServer.URL+"/kratos")
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String())
|
||||
})
|
||||
|
||||
t.Run("case=use nested config value", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
testhelpers.SelfServiceHookLoginSetDefaultRedirectTo(t, conf, returnToServer.URL+"/not-kratos")
|
||||
testhelpers.SelfServiceHookLoginSetDefaultRedirectToStrategy(t, conf, strategy.String(), returnToServer.URL+"/kratos")
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, returnToServer.URL+"/kratos", res.Request.URL.String())
|
||||
})
|
||||
|
||||
t.Run("case=pass if hooks pass", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
viperSetPost(t, conf, strategy.String(), []config.SelfServiceHook{{Name: "err", Config: []byte(`{}`)}})
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), false, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
|
||||
})
|
||||
|
||||
t.Run("case=send a json response for API clients", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeAPI, nil), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.identity.id").String())
|
||||
})
|
||||
|
||||
t.Run("suite=handle login challenge with browser and application/json", func(t *testing.T) {
|
||||
t.Run("case=includes the return_to address for a valid challenge", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
withOAuthChallenge := func(f *login.Flow) {
|
||||
f.OAuth2LoginChallenge = hydra.FakeValidLoginChallenge
|
||||
}
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil, withOAuthChallenge), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusUnprocessableEntity, res.StatusCode, "%s", body)
|
||||
assert.Equal(t, hydra.FakePostLoginURL, gjson.Get(body, "redirect_browser_to").String(), "%s", body)
|
||||
})
|
||||
|
||||
t.Run("case=returns an error for an invalid challenge", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
withOAuthChallenge := func(f *login.Flow) {
|
||||
f.OAuth2LoginChallenge = hydra.FakeInvalidLoginChallenge
|
||||
}
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil, withOAuthChallenge), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusInternalServerError, res.StatusCode, "%s", body)
|
||||
assert.Equal(t, hydra.ErrFakeAcceptLoginRequestFailed.Error(), body, "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=pass without hooks for browser flow with application/json", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, nil), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.identity.id").String())
|
||||
assert.Empty(t, gjson.Get(body, "session.token").String())
|
||||
assert.Empty(t, gjson.Get(body, "session_token").String())
|
||||
})
|
||||
|
||||
t.Run("case=work normally if AAL is satisfied", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1")
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
useIdentity := &identity.Identity{Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword},
|
||||
identity.CredentialsTypeTOTP: {Type: identity.CredentialsTypeTOTP},
|
||||
}}
|
||||
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), useIdentity))
|
||||
|
||||
t.Run("browser client", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), false, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.EqualValues(t, returnToServer.URL, res.Request.URL.String())
|
||||
})
|
||||
|
||||
t.Run("api client returns the session with identity and the token", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeAPI, useIdentity), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.identity").String())
|
||||
assert.NotEmpty(t, gjson.Get(body, "session_token").String())
|
||||
})
|
||||
|
||||
t.Run("browser JSON client returns the session with identity but not the token", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.id").String())
|
||||
assert.NotEmpty(t, gjson.Get(body, "session.identity").String())
|
||||
assert.Empty(t, gjson.Get(body, "session_token").String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=redirect to login if AAL is too low", func(t *testing.T) {
|
||||
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "highest_available")
|
||||
t.Cleanup(func() {
|
||||
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, "aal1")
|
||||
})
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
|
||||
conf.MustSet(ctx, config.ViperKeyOAuth2ProviderURL, "https://hydra")
|
||||
|
||||
useIdentity := &identity.Identity{Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Config: []byte(`{"hashed_password": "$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`), Identifiers: []string{testhelpers.RandomEmail()}},
|
||||
identity.CredentialsTypeWebAuthn: {Type: identity.CredentialsTypeWebAuthn, Config: []byte(`{"credentials":[{"is_passwordless":false}]}`), Identifiers: []string{testhelpers.RandomEmail()}},
|
||||
}}
|
||||
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), useIdentity))
|
||||
|
||||
t.Run("browser client", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), false, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusNotFound, res.StatusCode, "%s", body)
|
||||
assert.Contains(t, res.Request.URL.String(), "/self-service/login/browser?aal=aal2")
|
||||
})
|
||||
|
||||
t.Run("browser client with login challenge", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), false, url.Values{
|
||||
"login_challenge": []string{hydra.FakeValidLoginChallenge},
|
||||
})
|
||||
require.EqualValuesf(t, http.StatusNotFound, res.StatusCode, "%s", body)
|
||||
|
||||
assert.Equal(t, res.Request.URL.Path, "/self-service/login/browser")
|
||||
assert.Equal(t, res.Request.URL.Query().Get("aal"), "aal2")
|
||||
assert.Equal(t, res.Request.URL.Query().Get("login_challenge"), hydra.FakeValidLoginChallenge)
|
||||
})
|
||||
|
||||
t.Run("api client returns the token and the session without the identity", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeAPI, useIdentity), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.Empty(t, gjson.Get(body, "session.identity").String())
|
||||
assert.NotEmpty(t, gjson.Get(body, "session_token").String())
|
||||
})
|
||||
|
||||
t.Run("browser JSON client", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), true, url.Values{})
|
||||
require.EqualValuesf(t, http.StatusUnprocessableEntity, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "redirect_browser_to").String())
|
||||
assert.Contains(t, gjson.Get(body, "redirect_browser_to").String(), "/self-service/login/browser?aal=aal2", "%s", body)
|
||||
})
|
||||
|
||||
t.Run("browser JSON client with login challenge", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, useIdentity), true, url.Values{
|
||||
"login_challenge": []string{hydra.FakeValidLoginChallenge},
|
||||
})
|
||||
require.EqualValuesf(t, http.StatusUnprocessableEntity, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.Get(body, "redirect_browser_to").String())
|
||||
|
||||
redirectBrowserTo, err := url.Parse(gjson.Get(body, "redirect_browser_to").String())
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, redirectBrowserTo.Path, "/self-service/login/browser")
|
||||
assert.Equal(t, redirectBrowserTo.Query().Get("aal"), "aal2")
|
||||
assert.Equal(t, redirectBrowserTo.Query().Get("login_challenge"), hydra.FakeValidLoginChallenge)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=maybe links credential", func(t *testing.T) {
|
||||
t.Cleanup(testhelpers.SelfServiceHookConfigReset(t, conf))
|
||||
conf.MustSet(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
|
||||
conf.MustSet(ctx, "selfservice.methods.totp.enabled", true)
|
||||
|
||||
email1, email2 := testhelpers.RandomEmail(), testhelpers.RandomEmail()
|
||||
passwordOnlyIdentity := &identity.Identity{Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Config: []byte(`{"hashed_password": "$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
|
||||
Identifiers: []string{email1},
|
||||
},
|
||||
}}
|
||||
twoFAIdentitiy := &identity.Identity{Credentials: map[identity.CredentialsType]identity.Credentials{
|
||||
identity.CredentialsTypePassword: {
|
||||
Type: identity.CredentialsTypePassword,
|
||||
Config: []byte(`{"hashed_password": "$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
|
||||
Identifiers: []string{email2},
|
||||
},
|
||||
identity.CredentialsTypeTOTP: {
|
||||
Type: identity.CredentialsTypeTOTP,
|
||||
Config: []byte(`{"totp_url":"otpauth://totp/test"}`),
|
||||
Identifiers: []string{email2},
|
||||
},
|
||||
}}
|
||||
require.NoError(t, reg.Persister().CreateIdentity(ctx, passwordOnlyIdentity))
|
||||
require.NoError(t, reg.Persister().CreateIdentity(ctx, twoFAIdentitiy))
|
||||
|
||||
credsOIDCPWOnly, err := identity.NewCredentialsOIDC(
|
||||
&identity.CredentialsOIDCEncryptedTokens{IDToken: "id-token", AccessToken: "access-token", RefreshToken: "refresh-token"},
|
||||
"my-provider",
|
||||
email1,
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
credsOIDC2FA, err := identity.NewCredentialsOIDC(
|
||||
&identity.CredentialsOIDCEncryptedTokens{IDToken: "id-token", AccessToken: "access-token", RefreshToken: "refresh-token"},
|
||||
"my-provider",
|
||||
email2,
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("sub-case=does not link after first factor when second factor is available", func(t *testing.T) {
|
||||
duplicateCredentialsData := flow.DuplicateCredentialsData{
|
||||
CredentialsType: identity.CredentialsTypeOIDC,
|
||||
CredentialsConfig: credsOIDC2FA.Config,
|
||||
DuplicateIdentifier: email2,
|
||||
}
|
||||
ts := newServer(t, flow.TypeBrowser, twoFAIdentitiy, func(l *login.Flow) {
|
||||
require.NoError(t, flow.SetDuplicateCredentials(l, duplicateCredentialsData))
|
||||
})
|
||||
res, _ := makeRequestPost(t, ts, false, url.Values{})
|
||||
|
||||
assert.Equal(t, reg.Config().SelfServiceFlowLoginUI(ctx).Host, res.Request.URL.Host)
|
||||
assert.Equal(t, reg.Config().SelfServiceFlowLoginUI(ctx).Path, res.Request.URL.Path)
|
||||
newFlowID := res.Request.URL.Query().Get("flow")
|
||||
assert.NotEmpty(t, newFlowID)
|
||||
|
||||
newFlow, err := reg.LoginFlowPersister().GetLoginFlow(ctx, uuid.Must(uuid.FromString(newFlowID)))
|
||||
require.NoError(t, err)
|
||||
newFlowDuplicateCredentialsData, err := flow.DuplicateCredentials(newFlow)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Duplicate credentials data should have been copied over
|
||||
assert.Equal(t, duplicateCredentialsData.CredentialsType, newFlowDuplicateCredentialsData.CredentialsType)
|
||||
assert.Equal(t, duplicateCredentialsData.DuplicateIdentifier, newFlowDuplicateCredentialsData.DuplicateIdentifier)
|
||||
assert.JSONEq(t, string(duplicateCredentialsData.CredentialsConfig), string(newFlowDuplicateCredentialsData.CredentialsConfig))
|
||||
|
||||
// AAL should be AAL2
|
||||
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, newFlow.RequestedAAL)
|
||||
|
||||
// TOTP nodes should be present
|
||||
found := false
|
||||
for _, n := range newFlow.UI.Nodes {
|
||||
if n.Group == node.TOTPGroup {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "could not find TOTP nodes in %+v", newFlow.UI.Nodes)
|
||||
|
||||
ident, err := reg.Persister().GetIdentity(ctx, twoFAIdentitiy.ID, identity.ExpandCredentials)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ident.Credentials, 2)
|
||||
})
|
||||
|
||||
t.Run("sub-case=links after second factor when second factor is available", func(t *testing.T) {
|
||||
ts := newServer(t, flow.TypeBrowser, twoFAIdentitiy, func(l *login.Flow) {
|
||||
require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{
|
||||
CredentialsType: identity.CredentialsTypeOIDC,
|
||||
CredentialsConfig: credsOIDC2FA.Config,
|
||||
DuplicateIdentifier: email2,
|
||||
}))
|
||||
})
|
||||
res, body := testhelpers.SelfServiceMakeHookRequest(t, ts, "/login/post2fa", false, url.Values{})
|
||||
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.Equalf(t, returnToServer.URL, res.Request.URL.String(), "%s", body)
|
||||
|
||||
ident, err := reg.Persister().GetIdentity(ctx, twoFAIdentitiy.ID, identity.ExpandCredentials)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ident.Credentials, 3)
|
||||
})
|
||||
|
||||
t.Run("sub-case=links matching identity", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, passwordOnlyIdentity, func(l *login.Flow) {
|
||||
require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{
|
||||
CredentialsType: identity.CredentialsTypeOIDC,
|
||||
CredentialsConfig: credsOIDCPWOnly.Config,
|
||||
DuplicateIdentifier: email1,
|
||||
}))
|
||||
}), false, url.Values{})
|
||||
require.Equalf(t, http.StatusOK, res.StatusCode, "%s", body)
|
||||
assert.Equalf(t, returnToServer.URL, res.Request.URL.String(), "%s", body)
|
||||
|
||||
ident, err := reg.Persister().GetIdentity(ctx, passwordOnlyIdentity.ID, identity.ExpandCredentials)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, ident.Credentials, 2)
|
||||
})
|
||||
|
||||
t.Run("sub-case=errors on non-matching identity", func(t *testing.T) {
|
||||
res, body := makeRequestPost(t, newServer(t, flow.TypeBrowser, passwordOnlyIdentity, func(l *login.Flow) {
|
||||
require.NoError(t, flow.SetDuplicateCredentials(l, flow.DuplicateCredentialsData{
|
||||
CredentialsType: identity.CredentialsTypeOIDC,
|
||||
CredentialsConfig: credsOIDCPWOnly.Config,
|
||||
DuplicateIdentifier: "wrong@example.com",
|
||||
}))
|
||||
}), false, url.Values{})
|
||||
require.EqualValues(t, http.StatusInternalServerError, res.StatusCode)
|
||||
assert.Equal(t, schema.NewLinkedCredentialsDoNotMatch().Error(), body, "%s", body)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("type=api", func(t *testing.T) {
|
||||
t.Run("method=PreLoginHook", testhelpers.TestSelfServicePreHook(
|
||||
config.ViperKeySelfServiceLoginBeforeHooks,
|
||||
testhelpers.SelfServiceMakeLoginPreHookRequest,
|
||||
func(t *testing.T) *httptest.Server {
|
||||
return newServer(t, flow.TypeAPI, nil)
|
||||
},
|
||||
conf,
|
||||
))
|
||||
})
|
||||
|
||||
t.Run("type=browser", func(t *testing.T) {
|
||||
t.Run("method=PreLoginHook", testhelpers.TestSelfServicePreHook(
|
||||
config.ViperKeySelfServiceLoginBeforeHooks,
|
||||
testhelpers.SelfServiceMakeLoginPreHookRequest,
|
||||
func(t *testing.T) *httptest.Server {
|
||||
return newServer(t, flow.TypeBrowser, nil)
|
||||
},
|
||||
conf,
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("method=checkAAL", func(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]interface{}{
|
||||
config.ViperKeyPublicBaseURL: returnToServer.URL,
|
||||
config.ViperKeySelfServiceBrowserDefaultReturnTo: returnToServer.URL,
|
||||
}), configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")))
|
||||
|
||||
t.Run("returns no error when sufficient", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(ctx, config.ViperKeySessionWhoAmIAAL, identity.AuthenticatorAssuranceLevel1)
|
||||
assert.NoError(t,
|
||||
login.CheckAALForTest(ctx, reg.LoginHookExecutor(), &session.Session{
|
||||
AMR: session.AuthenticationMethods{{
|
||||
Method: identity.CredentialsTypePassword,
|
||||
AAL: identity.AuthenticatorAssuranceLevel1,
|
||||
}},
|
||||
AuthenticatorAssuranceLevel: identity.AuthenticatorAssuranceLevel1,
|
||||
}, nil),
|
||||
)
|
||||
|
||||
ctx = contextx.WithConfigValue(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
|
||||
assert.NoError(t,
|
||||
login.CheckAALForTest(ctx, reg.LoginHookExecutor(), &session.Session{
|
||||
AMR: session.AuthenticationMethods{{
|
||||
Method: identity.CredentialsTypePassword,
|
||||
AAL: identity.AuthenticatorAssuranceLevel1,
|
||||
}, {
|
||||
Method: identity.CredentialsTypeLookup,
|
||||
AAL: identity.AuthenticatorAssuranceLevel2,
|
||||
}},
|
||||
AuthenticatorAssuranceLevel: identity.AuthenticatorAssuranceLevel2,
|
||||
}, nil),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("copies parameters to redirect URL when AAL is not sufficient", func(t *testing.T) {
|
||||
ctx := contextx.WithConfigValue(ctx, config.ViperKeySessionWhoAmIAAL, config.HighestAvailableAAL)
|
||||
aalErr := new(session.ErrAALNotSatisfied)
|
||||
require.ErrorAs(t,
|
||||
login.CheckAALForTest(ctx, reg.LoginHookExecutor(), &session.Session{
|
||||
AMR: session.AuthenticationMethods{{
|
||||
Method: identity.CredentialsTypePassword,
|
||||
AAL: identity.AuthenticatorAssuranceLevel1,
|
||||
}},
|
||||
AuthenticatorAssuranceLevel: identity.AuthenticatorAssuranceLevel1,
|
||||
Identity: &identity.Identity{
|
||||
InternalAvailableAAL: identity.NullableAuthenticatorAssuranceLevel{
|
||||
NullString: sql.NullString{String: string(identity.AuthenticatorAssuranceLevel2), Valid: true},
|
||||
},
|
||||
},
|
||||
}, &login.Flow{
|
||||
RequestURL: "https://www.ory.com/?return_to=https://www.ory.com/kratos&login_challenge=challenge",
|
||||
}),
|
||||
&aalErr,
|
||||
)
|
||||
assert.Equal(t, returnToServer.URL+"/self-service/login/browser?aal=aal2&login_challenge=challenge&return_to=https%3A%2F%2Fwww.ory.com%2Fkratos", aalErr.RedirectTo)
|
||||
})
|
||||
})
|
||||
}
|
||||
42
selfservice/flow/login/organizations.go
Normal file
42
selfservice/flow/login/organizations.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/session"
|
||||
)
|
||||
|
||||
var organizationFilter = []StrategyFilter{func(s Strategy) bool {
|
||||
a, b := s.(flow.OrganizationImplementor)
|
||||
return b && a.SupportsOrganizations()
|
||||
}}
|
||||
|
||||
func PrepareOrganizations(r *http.Request, f *Flow, sess *session.Session) []StrategyFilter {
|
||||
if f.RequestedAAL != identity.AuthenticatorAssuranceLevel1 {
|
||||
return []StrategyFilter{}
|
||||
}
|
||||
|
||||
if f.OrganizationID.Valid {
|
||||
return organizationFilter
|
||||
}
|
||||
|
||||
orgID := flow.ParseOrganizationFromURLQuery(r.Context(), r.URL.Query())
|
||||
if sess != nil && sess.Identity != nil && sess.Identity.OrganizationID.Valid {
|
||||
orgID = sess.Identity.OrganizationID
|
||||
}
|
||||
|
||||
if !orgID.Valid {
|
||||
return []StrategyFilter{}
|
||||
}
|
||||
|
||||
f.OrganizationID = orgID
|
||||
// We only apply the filter on AAL1, because the OIDC strategy can only satsify
|
||||
// AAL1.
|
||||
return organizationFilter
|
||||
}
|
||||
137
selfservice/flow/login/organizations_test.go
Normal file
137
selfservice/flow/login/organizations_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/session"
|
||||
)
|
||||
|
||||
func TestPrepareOrganizations(t *testing.T) {
|
||||
t.Run("should return empty filter if AAL is not AAL1", func(t *testing.T) {
|
||||
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel2}
|
||||
r := &http.Request{URL: new(url.URL)}
|
||||
sess := &session.Session{}
|
||||
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
assert.Empty(t, filters)
|
||||
})
|
||||
|
||||
t.Run("should return empty filter if OrganizationID is not set", func(t *testing.T) {
|
||||
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
|
||||
r := &http.Request{URL: new(url.URL)}
|
||||
sess := &session.Session{}
|
||||
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
assert.Empty(t, filters)
|
||||
})
|
||||
|
||||
t.Run("should return organization filter if OrganizationID is valid", func(t *testing.T) {
|
||||
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
|
||||
r := &http.Request{URL: new(url.URL)}
|
||||
sess := &session.Session{
|
||||
Identity: &identity.Identity{OrganizationID: uuid.NullUUID{Valid: true}},
|
||||
}
|
||||
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
require.NotEmpty(t, filters)
|
||||
assert.Equal(t, organizationFilter, filters)
|
||||
})
|
||||
|
||||
t.Run("should parse OrganizationID from URL query if not in session", func(t *testing.T) {
|
||||
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
|
||||
r := &http.Request{
|
||||
URL: &url.URL{
|
||||
RawQuery: "organization=123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
}
|
||||
sess := &session.Session{}
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
require.NotEmpty(t, filters)
|
||||
assert.Equal(t, organizationFilter, filters)
|
||||
})
|
||||
|
||||
t.Run("should use organization ID already in flow", func(t *testing.T) {
|
||||
orgID := uuid.NullUUID{UUID: uuid.Must(uuid.NewV4()), Valid: true}
|
||||
f := &Flow{
|
||||
RequestedAAL: identity.AuthenticatorAssuranceLevel1,
|
||||
OrganizationID: orgID,
|
||||
}
|
||||
r := &http.Request{URL: new(url.URL)}
|
||||
sess := &session.Session{}
|
||||
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
require.NotEmpty(t, filters)
|
||||
assert.Equal(t, organizationFilter, filters)
|
||||
assert.Equal(t, orgID, f.OrganizationID)
|
||||
})
|
||||
|
||||
t.Run("should prioritize session org ID over query param when both present", func(t *testing.T) {
|
||||
sessionOrgID := uuid.Must(uuid.NewV4())
|
||||
queryOrgID := uuid.Must(uuid.NewV4())
|
||||
|
||||
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
|
||||
r := &http.Request{
|
||||
URL: &url.URL{
|
||||
RawQuery: "organization=" + queryOrgID.String(),
|
||||
},
|
||||
}
|
||||
sess := &session.Session{
|
||||
Identity: &identity.Identity{
|
||||
OrganizationID: uuid.NullUUID{UUID: sessionOrgID, Valid: true},
|
||||
},
|
||||
}
|
||||
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
require.NotEmpty(t, filters)
|
||||
assert.Equal(t, organizationFilter, filters)
|
||||
assert.Equal(t, sessionOrgID, f.OrganizationID.UUID)
|
||||
})
|
||||
|
||||
t.Run("should not return filter when organization is set but AAL2 requested", func(t *testing.T) {
|
||||
orgID := uuid.NullUUID{UUID: uuid.Must(uuid.NewV4()), Valid: true}
|
||||
f := &Flow{
|
||||
RequestedAAL: identity.AuthenticatorAssuranceLevel2,
|
||||
OrganizationID: orgID,
|
||||
}
|
||||
r := &http.Request{URL: new(url.URL)}
|
||||
sess := &session.Session{}
|
||||
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
assert.Empty(t, filters)
|
||||
// Organization ID should remain set in the flow
|
||||
assert.Equal(t, orgID, f.OrganizationID)
|
||||
})
|
||||
|
||||
t.Run("should use organization ID from query when session has no org ID", func(t *testing.T) {
|
||||
queryOrgID := uuid.Must(uuid.NewV4())
|
||||
|
||||
f := &Flow{RequestedAAL: identity.AuthenticatorAssuranceLevel1}
|
||||
r := &http.Request{
|
||||
URL: &url.URL{
|
||||
RawQuery: "organization=" + queryOrgID.String(),
|
||||
},
|
||||
}
|
||||
sess := &session.Session{
|
||||
Identity: &identity.Identity{
|
||||
// No organization ID set
|
||||
OrganizationID: uuid.NullUUID{Valid: false},
|
||||
},
|
||||
}
|
||||
|
||||
filters := PrepareOrganizations(r, f, sess)
|
||||
require.NotEmpty(t, filters)
|
||||
assert.Equal(t, organizationFilter, filters)
|
||||
assert.Equal(t, queryOrgID, f.OrganizationID.UUID)
|
||||
})
|
||||
}
|
||||
25
selfservice/flow/login/persistence.go
Normal file
25
selfservice/flow/login/persistence.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
type (
|
||||
FlowPersister interface {
|
||||
UpdateLoginFlow(context.Context, *Flow) error
|
||||
CreateLoginFlow(context.Context, *Flow) error
|
||||
GetLoginFlow(context.Context, uuid.UUID) (*Flow, error)
|
||||
ForceLoginFlow(ctx context.Context, id uuid.UUID) error
|
||||
DeleteExpiredLoginFlows(context.Context, time.Time, int) error
|
||||
DeleteTestLoginFlow(context.Context, uuid.UUID) error
|
||||
}
|
||||
FlowPersistenceProvider interface {
|
||||
LoginFlowPersister() FlowPersister
|
||||
}
|
||||
)
|
||||
40
selfservice/flow/login/session.go
Normal file
40
selfservice/flow/login/session.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/session"
|
||||
)
|
||||
|
||||
// The Response for Login Flows via API
|
||||
//
|
||||
// swagger:model successfulNativeLogin
|
||||
type APIFlowResponse struct {
|
||||
// The Session Token
|
||||
//
|
||||
// A session token is equivalent to a session cookie, but it can be sent in the HTTP Authorization
|
||||
// Header:
|
||||
//
|
||||
// Authorization: bearer ${session-token}
|
||||
//
|
||||
// The session token is only issued for API flows, not for Browser flows!
|
||||
Token string `json:"session_token,omitempty"`
|
||||
|
||||
// The Session
|
||||
//
|
||||
// The session contains information about the user, the session device, and so on.
|
||||
// This is only available for API flows, not for Browser flows!
|
||||
//
|
||||
// required: true
|
||||
Session *session.Session `json:"session"`
|
||||
|
||||
// Contains a list of actions, that could follow this flow
|
||||
//
|
||||
// It can, for example, this will contain a reference to the verification flow, created as part of the user's
|
||||
// registration or the token of the session.
|
||||
//
|
||||
// required: false
|
||||
ContinueWith []flow.ContinueWith `json:"continue_with"`
|
||||
}
|
||||
30
selfservice/flow/login/sort.go
Normal file
30
selfservice/flow/login/sort.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
func sortNodes(ctx context.Context, n node.Nodes) error {
|
||||
return n.SortBySchema(ctx,
|
||||
node.SortByGroups([]node.UiNodeGroup{
|
||||
node.OpenIDConnectGroup,
|
||||
node.DefaultGroup,
|
||||
node.WebAuthnGroup,
|
||||
node.PasskeyGroup,
|
||||
node.CodeGroup,
|
||||
node.PasswordGroup,
|
||||
node.TOTPGroup,
|
||||
node.LookupGroup,
|
||||
}),
|
||||
node.SortUseOrder([]string{
|
||||
"csrf_token",
|
||||
"identifier",
|
||||
"password",
|
||||
}),
|
||||
)
|
||||
}
|
||||
17
selfservice/flow/login/state.go
Normal file
17
selfservice/flow/login/state.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import "github.com/ory/kratos/selfservice/flow"
|
||||
|
||||
// Login Flow State
|
||||
//
|
||||
// The state represents the state of the login flow.
|
||||
//
|
||||
// - choose_method: ask the user to choose a method (e.g. login account via email)
|
||||
// - sent_email: the email has been sent to the user
|
||||
// - passed_challenge: the request was successful and the login challenge was passed.
|
||||
//
|
||||
// swagger:model loginFlowState
|
||||
type State = flow.State
|
||||
64
selfservice/flow/login/strategy.go
Normal file
64
selfservice/flow/login/strategy.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
"github.com/ory/x/sqlxx"
|
||||
)
|
||||
|
||||
type Strategy interface {
|
||||
ID() identity.CredentialsType
|
||||
NodeGroup() node.UiNodeGroup
|
||||
Login(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (i *identity.Identity, err error)
|
||||
CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod
|
||||
}
|
||||
|
||||
type Strategies []Strategy
|
||||
|
||||
type LinkableStrategy interface {
|
||||
Link(ctx context.Context, i *identity.Identity, credentials sqlxx.JSONRawMessage) error
|
||||
CompletedLogin(sess *session.Session, data *flow.DuplicateCredentialsData) error
|
||||
SetDuplicateCredentials(f flow.InternalContexter, duplicateIdentifier string, credentials identity.Credentials, provider string) error
|
||||
}
|
||||
|
||||
type FastLoginStrategy interface {
|
||||
FastLogin1FA(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (err error)
|
||||
FastLogin2FA(w http.ResponseWriter, r *http.Request, f *Flow, sess *session.Session) (err error)
|
||||
}
|
||||
|
||||
func (s Strategies) Strategy(id identity.CredentialsType) (Strategy, error) {
|
||||
ids := make([]identity.CredentialsType, len(s))
|
||||
for k, ss := range s {
|
||||
ids[k] = ss.ID()
|
||||
if ss.ID() == id {
|
||||
return ss, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.Errorf(`unable to find strategy for %s have %v`, id, ids)
|
||||
}
|
||||
|
||||
func (s Strategies) MustStrategy(id identity.CredentialsType) Strategy {
|
||||
strategy, err := s.Strategy(id)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
type StrategyFilter func(strategy Strategy) bool
|
||||
|
||||
type StrategyProvider interface {
|
||||
AllLoginStrategies() Strategies
|
||||
LoginStrategies(ctx context.Context, filters ...StrategyFilter) Strategies
|
||||
}
|
||||
73
selfservice/flow/login/strategy_form_hydrator.go
Normal file
73
selfservice/flow/login/strategy_form_hydrator.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
stderr "errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/session"
|
||||
)
|
||||
|
||||
type UnifiedFormHydrator interface {
|
||||
ID() identity.CredentialsType
|
||||
PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *Flow) error
|
||||
}
|
||||
|
||||
type AAL1FormHydrator interface {
|
||||
ID() identity.CredentialsType
|
||||
PopulateLoginMethodFirstFactorRefresh(r *http.Request, sr *Flow, sess *session.Session) error
|
||||
PopulateLoginMethodFirstFactor(r *http.Request, sr *Flow) error
|
||||
|
||||
// PopulateLoginMethodIdentifierFirstCredentials populates the login form with the first factor credentials.
|
||||
// This method is called when the login flow is set to identifier first. The method will receive information
|
||||
// about the identity that is being used to log in and the identifier that was used to find the identity.
|
||||
//
|
||||
// The method should populate the login form with the credentials of the identity.
|
||||
//
|
||||
// If the method can not find any credentials (because the identity does not exist) idfirst.ErrNoCredentialsFound
|
||||
// must be returned. When returning idfirst.ErrNoCredentialsFound the strategy will appropriately deal with
|
||||
// account enumeration mitigation.
|
||||
//
|
||||
// This method does however need to take appropriate steps to show/hide certain fields depending on the account
|
||||
// enumeration configuration.
|
||||
PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *Flow, options ...FormHydratorModifier) error
|
||||
PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *Flow) error
|
||||
}
|
||||
|
||||
type AAL2FormHydrator interface {
|
||||
ID() identity.CredentialsType
|
||||
PopulateLoginMethodSecondFactor(r *http.Request, sr *Flow) error
|
||||
PopulateLoginMethodSecondFactorRefresh(r *http.Request, sr *Flow) error
|
||||
}
|
||||
|
||||
var ErrBreakLoginPopulate = stderr.New("skip rest of login form population")
|
||||
|
||||
type FormHydratorOptions struct {
|
||||
IdentityHint *identity.Identity
|
||||
Identifier string
|
||||
}
|
||||
|
||||
type FormHydratorModifier func(o *FormHydratorOptions)
|
||||
|
||||
func WithIdentityHint(i *identity.Identity) FormHydratorModifier {
|
||||
return func(o *FormHydratorOptions) {
|
||||
o.IdentityHint = i
|
||||
}
|
||||
}
|
||||
|
||||
func WithIdentifier(i string) FormHydratorModifier {
|
||||
return func(o *FormHydratorOptions) {
|
||||
o.Identifier = i
|
||||
}
|
||||
}
|
||||
|
||||
func NewFormHydratorOptions(modifiers []FormHydratorModifier) *FormHydratorOptions {
|
||||
o := new(FormHydratorOptions)
|
||||
for _, m := range modifiers {
|
||||
m(o)
|
||||
}
|
||||
return o
|
||||
}
|
||||
24
selfservice/flow/login/strategy_form_hydrator_test.go
Normal file
24
selfservice/flow/login/strategy_form_hydrator_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
)
|
||||
|
||||
func TestWithIdentityHint(t *testing.T) {
|
||||
expected := new(identity.Identity)
|
||||
opts := NewFormHydratorOptions([]FormHydratorModifier{WithIdentityHint(expected)})
|
||||
assert.Equal(t, expected, opts.IdentityHint)
|
||||
}
|
||||
|
||||
func TestWithIdentifier(t *testing.T) {
|
||||
expected := "identifier"
|
||||
opts := NewFormHydratorOptions([]FormHydratorModifier{WithIdentifier(expected)})
|
||||
assert.Equal(t, expected, opts.Identifier)
|
||||
}
|
||||
24
selfservice/flow/login/stub/email.schema.json
Normal file
24
selfservice/flow/login/stub/email.schema.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$id": "https://example.com/email.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"title": "E-Mail Address",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
selfservice/flow/login/stub/fake-session.schema.json
Normal file
11
selfservice/flow/login/stub/fake-session.schema.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
selfservice/flow/login/stub/login.schema.json
Normal file
16
selfservice/flow/login/stub/login.schema.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
selfservice/flow/login/stub/password.schema.json
Normal file
24
selfservice/flow/login/stub/password.schema.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$id": "https://example.com/person.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "Username",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
selfservice/flow/login/stub/phone.schema.json
Normal file
24
selfservice/flow/login/stub/phone.schema.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$id": "https://example.com/phone.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"title": "Phone Number",
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
selfservice/flow/login/stub/registration.schema.json
Normal file
16
selfservice/flow/login/stub/registration.schema.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
selfservice/flow/login/stub/updated.schema.json
Normal file
16
selfservice/flow/login/stub/updated.schema.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$id": "https://example.com/registration.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bar": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
272
selfservice/flow/login/test/persistence.go
Normal file
272
selfservice/flow/login/test/persistence.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
|
||||
"github.com/go-faker/faker/v4"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/persistence"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/sqlcon"
|
||||
)
|
||||
|
||||
func TestFlowPersister(ctx context.Context, p persistence.Persister) func(t *testing.T) {
|
||||
var clearids = func(r *login.Flow) {
|
||||
r.ID = uuid.UUID{}
|
||||
}
|
||||
|
||||
return func(t *testing.T) {
|
||||
_, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
|
||||
|
||||
t.Run("case=should error when the login flow does not exist", func(t *testing.T) {
|
||||
_, err := p.GetLoginFlow(ctx, x.NewUUID())
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
var newFlow = func(t *testing.T) *login.Flow {
|
||||
var r login.Flow
|
||||
require.NoError(t, faker.FakeData(&r))
|
||||
clearids(&r)
|
||||
return &r
|
||||
}
|
||||
|
||||
t.Run("case=should create with set ids", func(t *testing.T) {
|
||||
var r login.Flow
|
||||
require.NoError(t, faker.FakeData(&r))
|
||||
require.NoError(t, p.CreateLoginFlow(ctx, &r))
|
||||
})
|
||||
|
||||
t.Run("case=should create a new login flow and properly set IDs", func(t *testing.T) {
|
||||
r := newFlow(t)
|
||||
err := p.CreateLoginFlow(ctx, r)
|
||||
require.NoError(t, err, "%#v", err)
|
||||
assert.NotEqual(t, uuid.Nil, r.ID)
|
||||
})
|
||||
|
||||
t.Run("case=should create and fetch a login flow", func(t *testing.T) {
|
||||
expected := newFlow(t)
|
||||
err := p.CreateLoginFlow(ctx, expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual, err := p.GetLoginFlow(ctx, expected.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.EqualValues(t, expected.ID, actual.ID)
|
||||
x.AssertEqualTime(t, expected.IssuedAt, actual.IssuedAt)
|
||||
x.AssertEqualTime(t, expected.ExpiresAt, actual.ExpiresAt)
|
||||
assert.EqualValues(t, expected.RequestURL, actual.RequestURL)
|
||||
assert.EqualValues(t, expected.Active, actual.Active)
|
||||
assertx.EqualAsJSON(t, expected.UI, actual.UI, "expected:\t%s\nactual:\t%s", expected.UI, actual.UI)
|
||||
})
|
||||
|
||||
t.Run("case=should properly set the flow type", func(t *testing.T) {
|
||||
expected := newFlow(t)
|
||||
expected.Refresh = true
|
||||
expected.Type = flow.TypeAPI
|
||||
expected.UI = container.New("ory-sh")
|
||||
|
||||
err := p.CreateLoginFlow(ctx, expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual, err := p.GetLoginFlow(ctx, expected.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, flow.TypeAPI, actual.Type)
|
||||
|
||||
actual.UI = container.New("not-ory-sh")
|
||||
actual.Type = flow.TypeBrowser
|
||||
actual.Refresh = true
|
||||
|
||||
require.NoError(t, p.UpdateLoginFlow(ctx, actual))
|
||||
|
||||
actual, err = p.GetLoginFlow(ctx, actual.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, flow.TypeBrowser, actual.Type)
|
||||
assert.True(t, actual.Refresh)
|
||||
assert.Equal(t, "not-ory-sh", actual.UI.Action)
|
||||
})
|
||||
|
||||
t.Run("case=should not cause data loss when updating a request without changes", func(t *testing.T) {
|
||||
expected := newFlow(t)
|
||||
err := p.CreateLoginFlow(ctx, expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual, err := p.GetLoginFlow(ctx, expected.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, p.UpdateLoginFlow(ctx, actual))
|
||||
|
||||
actual, err = p.GetLoginFlow(ctx, expected.ID)
|
||||
require.NoError(t, err)
|
||||
assertx.EqualAsJSON(t, expected.UI, actual.UI)
|
||||
})
|
||||
|
||||
t.Run("case=should create and update and properly deal with internal context", func(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
in []byte
|
||||
expect string
|
||||
}{
|
||||
{in: []byte("[]"), expect: "{}"},
|
||||
{expect: "{}"},
|
||||
{in: []byte("null"), expect: "{}"},
|
||||
{in: []byte(`{"foo":"bar"}`), expect: `{"foo":"bar"}`},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("run=%d", k), func(t *testing.T) {
|
||||
r := newFlow(t)
|
||||
r.InternalContext = tc.in
|
||||
require.NoError(t, p.CreateLoginFlow(ctx, r))
|
||||
assert.Equal(t, tc.expect, string(r.InternalContext))
|
||||
|
||||
r.InternalContext = tc.in
|
||||
require.NoError(t, p.UpdateLoginFlow(ctx, r))
|
||||
assert.Equal(t, tc.expect, string(r.InternalContext))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=network", func(t *testing.T) {
|
||||
id := x.NewUUID()
|
||||
nid, p := testhelpers.NewNetwork(t, ctx, p)
|
||||
|
||||
t.Run("sets id on creation", func(t *testing.T) {
|
||||
expected := &login.Flow{ID: id, IssuedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}
|
||||
require.NoError(t, p.CreateLoginFlow(ctx, expected))
|
||||
assert.EqualValues(t, id, expected.ID)
|
||||
assert.EqualValues(t, nid, expected.NID)
|
||||
|
||||
actual, err := p.GetLoginFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, id, actual.ID)
|
||||
assert.EqualValues(t, nid, actual.NID)
|
||||
})
|
||||
|
||||
t.Run("can not update id", func(t *testing.T) {
|
||||
expected, err := p.GetLoginFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, p.UpdateLoginFlow(ctx, expected))
|
||||
|
||||
actual, err := p.GetLoginFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, id, actual.ID)
|
||||
assert.EqualValues(t, nid, actual.NID)
|
||||
})
|
||||
|
||||
t.Run("can not force on another network", func(t *testing.T) {
|
||||
expected, err := p.GetLoginFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, other := testhelpers.NewNetwork(t, ctx, p)
|
||||
|
||||
expected.RequestURL = "updated"
|
||||
require.Error(t, other.ForceLoginFlow(ctx, expected.ID))
|
||||
|
||||
_, err = other.GetLoginFlow(ctx, id)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
actual, err := p.GetLoginFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.False(t, actual.Refresh)
|
||||
})
|
||||
|
||||
t.Run("can not update on another network", func(t *testing.T) {
|
||||
expected, err := p.GetLoginFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, other := testhelpers.NewNetwork(t, ctx, p)
|
||||
|
||||
expected.RequestURL = "updated"
|
||||
require.Error(t, other.UpdateLoginFlow(ctx, expected))
|
||||
|
||||
_, err = other.GetLoginFlow(ctx, id)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
actual, err := p.GetLoginFlow(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, "updated", actual.RequestURL)
|
||||
})
|
||||
|
||||
t.Run("can not get on another network", func(t *testing.T) {
|
||||
_, p := testhelpers.NewNetwork(t, ctx, p)
|
||||
_, err := p.GetLoginFlow(ctx, id)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=test-flow persister helpers", func(t *testing.T) {
|
||||
// seedFlow creates a login flow under the given persister. When
|
||||
// isTest is true the flow is marked as a test flow.
|
||||
seedFlow := func(t *testing.T, p persistence.Persister, isTest bool, expiresAt time.Time) *login.Flow {
|
||||
t.Helper()
|
||||
f := &login.Flow{
|
||||
ID: x.NewUUID(),
|
||||
Type: flow.TypeBrowser,
|
||||
State: flow.StateChooseMethod,
|
||||
RequestURL: "https://example.com/self-service/login/browser",
|
||||
IssuedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
CSRFToken: "csrf",
|
||||
UI: container.New(""),
|
||||
InternalContext: []byte(`{}`),
|
||||
}
|
||||
if isTest {
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "google"}))
|
||||
}
|
||||
require.NoError(t, p.CreateLoginFlow(ctx, f))
|
||||
return f
|
||||
}
|
||||
|
||||
t.Run("DeleteTestLoginFlow rejects non-test-flow rows", func(t *testing.T) {
|
||||
_, p := testhelpers.NewNetwork(t, ctx, p)
|
||||
f := seedFlow(t, p, false, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
err := p.DeleteTestLoginFlow(ctx, f.ID)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
// Row must still be there.
|
||||
_, err = p.GetLoginFlow(ctx, f.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("DeleteTestLoginFlow rejects wrong NID", func(t *testing.T) {
|
||||
_, pA := testhelpers.NewNetwork(t, ctx, p)
|
||||
_, pB := testhelpers.NewNetwork(t, ctx, p)
|
||||
fA := seedFlow(t, pA, true, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
err := pB.DeleteTestLoginFlow(ctx, fA.ID)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
// Row survives from the owning NID's perspective.
|
||||
_, err = pA.GetLoginFlow(ctx, fA.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("DeleteTestLoginFlow removes only the targeted row", func(t *testing.T) {
|
||||
_, p := testhelpers.NewNetwork(t, ctx, p)
|
||||
a := seedFlow(t, p, true, time.Now().UTC().Add(time.Hour))
|
||||
b := seedFlow(t, p, true, time.Now().UTC().Add(time.Hour))
|
||||
|
||||
require.NoError(t, p.DeleteTestLoginFlow(ctx, a.ID))
|
||||
|
||||
_, err := p.GetLoginFlow(ctx, a.ID)
|
||||
require.ErrorIs(t, err, sqlcon.ErrNoRows())
|
||||
|
||||
_, err = p.GetLoginFlow(ctx, b.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
155
selfservice/flow/login/test_context.go
Normal file
155
selfservice/flow/login/test_context.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// InternalContextTestKey is the JSON path inside a login flow's
|
||||
// internal_context where test-mode data (provider id, debug payload) is
|
||||
// stored. Using a dedicated sub-namespace keeps test fields out of the
|
||||
// flow's first-class columns while still letting a single row carry them.
|
||||
const InternalContextTestKey = "test"
|
||||
|
||||
// Admin-test extension of a login flow. Populated only for flows created by
|
||||
// the admin test endpoint; included in the flow's API response so the admin
|
||||
// UI can render the pre-scoped provider and (once captured) the debug
|
||||
// round-trip result.
|
||||
//
|
||||
// swagger:model loginFlowTestContext
|
||||
type TestContext struct {
|
||||
// The ID of the OIDC provider this test flow targets.
|
||||
//
|
||||
// required: true
|
||||
ProviderID string `json:"provider_id"`
|
||||
|
||||
// Structured debug data captured during the provider round-trip.
|
||||
// Nil while the flow is still awaiting the callback.
|
||||
DebugPayload *DebugPayload `json:"debug_payload,omitempty"`
|
||||
}
|
||||
|
||||
// Diagnostic payload captured during a test-mode OIDC round-trip.
|
||||
// Contains the parsed claims, the Jsonnet mapper input and output, and any
|
||||
// schema validation errors. Bearer tokens (id_token, access_token,
|
||||
// refresh_token) are intentionally excluded to limit the blast radius of
|
||||
// the debug payload leaking through audit logs or admin browsers.
|
||||
//
|
||||
// swagger:model loginFlowTestDebugPayload
|
||||
type DebugPayload struct {
|
||||
// Claims extracted from the ID token.
|
||||
IDTokenClaims map[string]any `json:"id_token_claims,omitempty"`
|
||||
|
||||
// Claims returned from the provider's userinfo endpoint, if any.
|
||||
Userinfo map[string]any `json:"userinfo,omitempty"`
|
||||
|
||||
// URL of the Jsonnet mapper that was executed on the claims.
|
||||
JsonnetMapperURL string `json:"jsonnet_mapper_url,omitempty"`
|
||||
|
||||
// Input JSON that was fed into the Jsonnet mapper.
|
||||
JsonnetInput map[string]any `json:"jsonnet_input,omitempty"`
|
||||
|
||||
// Output JSON returned by the Jsonnet mapper.
|
||||
JsonnetOutput map[string]any `json:"jsonnet_output,omitempty"`
|
||||
|
||||
// Anything the Jsonnet mapper wrote to standard error.
|
||||
JsonnetStderr string `json:"jsonnet_stderr,omitempty"`
|
||||
|
||||
// Identity-schema validation errors produced from the mapped traits.
|
||||
SchemaValidationErrors []SchemaValidationError `json:"schema_validation_errors,omitempty"`
|
||||
|
||||
// A classified error if any step of the round-trip failed.
|
||||
Error *StepError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Classified failure of a step in an OIDC test round-trip.
|
||||
// Populated when any step (token exchange, claims decode, Jsonnet
|
||||
// evaluation, schema validation) cannot complete.
|
||||
//
|
||||
// swagger:model loginFlowTestStepError
|
||||
type StepError struct {
|
||||
// Machine-readable identifier of the failed step (for example,
|
||||
// "token_exchange" or "schema_validate").
|
||||
Step string `json:"step"`
|
||||
|
||||
// Short classification of the failure cause (for example,
|
||||
// "access_denied" or "traits_invalid").
|
||||
Reason string `json:"reason"`
|
||||
|
||||
// Human-readable message describing the failure.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// One identity-schema validation failure recorded while evaluating the
|
||||
// traits produced by the Jsonnet mapper.
|
||||
//
|
||||
// swagger:model loginFlowTestSchemaValidationError
|
||||
type SchemaValidationError struct {
|
||||
// JSON pointer to the field that failed validation.
|
||||
Path string `json:"path"`
|
||||
|
||||
// Human-readable description of the validation failure.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// IsTest reports whether this login flow is an admin-created test flow. The
|
||||
// dedicated column lets persistence filter without parsing internal_context.
|
||||
// A NULL or false test_flow column both read as false.
|
||||
func (f *Flow) IsTest() bool { return f.TestFlow.Valid && f.TestFlow.Bool }
|
||||
|
||||
// LoadTestContext parses internal_context into the derived TestContext field
|
||||
// so the flow's API response exposes it. A non-test flow or a flow with no
|
||||
// test payload results in TestContext being nil.
|
||||
func (f *Flow) LoadTestContext() error {
|
||||
if !f.IsTest() || len(f.InternalContext) == 0 {
|
||||
f.TestContext = nil
|
||||
return nil
|
||||
}
|
||||
var shell struct {
|
||||
Test *TestContext `json:"test"`
|
||||
}
|
||||
if err := json.Unmarshal(f.InternalContext, &shell); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
f.TestContext = shell.Test
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTestContext writes the derived TestContext back into internal_context
|
||||
// and flips the TestFlow column. Call this after mutating f.TestContext (for
|
||||
// example, when capturing a debug payload on the callback).
|
||||
func (f *Flow) SetTestContext(tc *TestContext) error {
|
||||
f.TestContext = tc
|
||||
if tc == nil {
|
||||
f.TestFlow = sql.NullBool{Valid: true, Bool: false}
|
||||
if len(f.InternalContext) == 0 {
|
||||
return nil
|
||||
}
|
||||
updated, err := sjson.DeleteBytes(f.InternalContext, InternalContextTestKey)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
f.InternalContext = updated
|
||||
return nil
|
||||
}
|
||||
|
||||
f.TestFlow = sql.NullBool{Valid: true, Bool: true}
|
||||
if len(f.InternalContext) == 0 {
|
||||
f.InternalContext = []byte("{}")
|
||||
}
|
||||
encoded, err := json.Marshal(tc)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
updated, err := sjson.SetRawBytes(f.InternalContext, InternalContextTestKey, encoded)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
f.InternalContext = updated
|
||||
return nil
|
||||
}
|
||||
164
selfservice/flow/login/test_context_test.go
Normal file
164
selfservice/flow/login/test_context_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/sqlxx"
|
||||
)
|
||||
|
||||
func TestFlow_IsTest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tf sql.NullBool
|
||||
want bool
|
||||
}{
|
||||
{name: "null", tf: sql.NullBool{Valid: false}, want: false},
|
||||
{name: "false", tf: sql.NullBool{Valid: true, Bool: false}, want: false},
|
||||
{name: "true", tf: sql.NullBool{Valid: true, Bool: true}, want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &login.Flow{TestFlow: tc.tf}
|
||||
assert.Equal(t, tc.want, f.IsTest())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlow_LoadTestContext_NonTestFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &login.Flow{
|
||||
TestFlow: sql.NullBool{Valid: false},
|
||||
InternalContext: sqlxx.JSONRawMessage(`{"test":{"provider_id":"google"},"foo":"bar"}`),
|
||||
}
|
||||
require.NoError(t, f.LoadTestContext())
|
||||
assert.Nil(t, f.TestContext)
|
||||
}
|
||||
|
||||
func TestFlow_LoadTestContext_EmptyInternalContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &login.Flow{
|
||||
TestFlow: sql.NullBool{Valid: true, Bool: true},
|
||||
InternalContext: nil,
|
||||
}
|
||||
require.NoError(t, f.LoadTestContext())
|
||||
assert.Nil(t, f.TestContext)
|
||||
}
|
||||
|
||||
func TestFlow_LoadTestContext_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
tc := &login.TestContext{
|
||||
ProviderID: "google",
|
||||
DebugPayload: &login.DebugPayload{
|
||||
IDTokenClaims: map[string]any{"sub": "x"},
|
||||
},
|
||||
}
|
||||
f := &login.Flow{InternalContext: sqlxx.JSONRawMessage(`{}`)}
|
||||
require.NoError(t, f.SetTestContext(tc))
|
||||
assert.True(t, f.IsTest())
|
||||
|
||||
// Simulate a round-trip through persistence by clearing the derived
|
||||
// field and reloading from InternalContext.
|
||||
f.TestContext = nil
|
||||
require.NoError(t, f.LoadTestContext())
|
||||
require.NotNil(t, f.TestContext)
|
||||
assert.Equal(t, "google", f.TestContext.ProviderID)
|
||||
require.NotNil(t, f.TestContext.DebugPayload)
|
||||
assert.Equal(t, "x", f.TestContext.DebugPayload.IDTokenClaims["sub"])
|
||||
}
|
||||
|
||||
func TestFlow_SetTestContext_Nil_RemovesKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &login.Flow{
|
||||
InternalContext: sqlxx.JSONRawMessage(`{"test":{"provider_id":"google"},"foo":"bar"}`),
|
||||
TestFlow: sql.NullBool{Valid: true, Bool: true},
|
||||
}
|
||||
require.NoError(t, f.SetTestContext(nil))
|
||||
|
||||
assert.False(t, f.IsTest())
|
||||
require.NoError(t, f.LoadTestContext())
|
||||
assert.Nil(t, f.TestContext)
|
||||
assert.Equal(t, "bar", gjson.GetBytes(f.InternalContext, "foo").String())
|
||||
assert.False(t, gjson.GetBytes(f.InternalContext, "test").Exists())
|
||||
}
|
||||
|
||||
func TestFlow_SetTestContext_Nil_EmptyInternalContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &login.Flow{InternalContext: nil}
|
||||
require.NoError(t, f.SetTestContext(nil))
|
||||
assert.Equal(t, sql.NullBool{Valid: true, Bool: false}, f.TestFlow)
|
||||
assert.True(t, len(f.InternalContext) == 0 || string(f.InternalContext) == "{}")
|
||||
}
|
||||
|
||||
func TestFlow_SetTestContext_PreservesSiblingKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := &login.Flow{
|
||||
InternalContext: sqlxx.JSONRawMessage(`{"foo":"bar","baz":1}`),
|
||||
}
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "x"}))
|
||||
|
||||
assert.Equal(t, "bar", gjson.GetBytes(f.InternalContext, "foo").String())
|
||||
assert.Equal(t, int64(1), gjson.GetBytes(f.InternalContext, "baz").Int())
|
||||
assert.Equal(t, "x", gjson.GetBytes(f.InternalContext, "test.provider_id").String())
|
||||
}
|
||||
|
||||
// newFlowForMarshal returns a minimal well-formed login flow suitable for
|
||||
// MarshalJSON testing.
|
||||
func newFlowForMarshal(t *testing.T) *login.Flow {
|
||||
t.Helper()
|
||||
return &login.Flow{
|
||||
ID: x.NewUUID(),
|
||||
UI: container.New(""),
|
||||
InternalContext: sqlxx.JSONRawMessage(`{}`),
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlow_MarshalJSON_TestFlowIncludesContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFlowForMarshal(t)
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{
|
||||
ProviderID: "google",
|
||||
DebugPayload: &login.DebugPayload{
|
||||
IDTokenClaims: map[string]any{"sub": "alice"},
|
||||
},
|
||||
}))
|
||||
|
||||
b, err := json.Marshal(*f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "google", gjson.GetBytes(b, "test_context.provider_id").String())
|
||||
assert.Equal(t, "alice", gjson.GetBytes(b, "test_context.debug_payload.id_token_claims.sub").String())
|
||||
}
|
||||
|
||||
func TestFlow_MarshalJSON_NonTestFlowOmitsContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFlowForMarshal(t)
|
||||
|
||||
b, err := json.Marshal(*f)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, gjson.GetBytes(b, "test_context").Exists())
|
||||
}
|
||||
|
||||
func TestFlow_MarshalJSON_UncapturedTestFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newFlowForMarshal(t)
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "google"}))
|
||||
|
||||
b, err := json.Marshal(*f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "google", gjson.GetBytes(b, "test_context.provider_id").String())
|
||||
assert.False(t, gjson.GetBytes(b, "test_context.debug_payload").Exists())
|
||||
}
|
||||
233
selfservice/flow/login/test_handler.go
Normal file
233
selfservice/flow/login/test_handler.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/nosurf"
|
||||
)
|
||||
|
||||
const (
|
||||
// RouteAdminCreateTestFlow is the admin route for creating a dry-run OIDC
|
||||
// test login flow. The admin router prepends /admin automatically, so the
|
||||
// full path is POST /admin/test-login-flows.
|
||||
RouteAdminCreateTestFlow = "/test-login-flows"
|
||||
|
||||
// RouteDeleteTestFlow is the public route for deleting a test login flow.
|
||||
// It is authorized by the flow's CSRF bearer token, not by a session.
|
||||
RouteDeleteTestFlow = "/self-service/login/test"
|
||||
|
||||
// maxProviderIDLength bounds the provider_id payload field. Provider IDs
|
||||
// are short identifiers; anything longer indicates user error or abuse.
|
||||
maxProviderIDLength = 255
|
||||
)
|
||||
|
||||
// TestStrategy populates a login flow's UI with a single submit button
|
||||
// for the given OIDC provider. The OIDC strategy implements this interface.
|
||||
// Defined in the login package to avoid an import cycle.
|
||||
type TestStrategy interface {
|
||||
PopulateTestLoginFlow(r *http.Request, f *Flow, providerID string) error
|
||||
}
|
||||
|
||||
// TestStrategyProvider exposes the enabled TestStrategy for the
|
||||
// current request context.
|
||||
type TestStrategyProvider interface {
|
||||
TestStrategy(ctx context.Context) TestStrategy
|
||||
}
|
||||
|
||||
// Parameters for creating a test OIDC login flow.
|
||||
//
|
||||
// swagger:parameters createTestLoginFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type createTestLoginFlow struct {
|
||||
// in: body
|
||||
// required: true
|
||||
Body createTestLoginFlowBody
|
||||
}
|
||||
|
||||
// Request body for creating a test OIDC login flow.
|
||||
//
|
||||
// swagger:model createTestLoginFlowBody
|
||||
type createTestLoginFlowBody struct {
|
||||
// ID of the OIDC provider to test. Must match a provider configured on
|
||||
// the project that serves this request.
|
||||
//
|
||||
// required: true
|
||||
// maxLength: 255
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
// swagger:route POST /admin/test-login-flows identity createTestLoginFlow
|
||||
//
|
||||
// # Create a test OIDC login flow
|
||||
//
|
||||
// Creates a dry-run OIDC test login flow pre-scoped to one provider. The
|
||||
// returned flow carries a single-submit UI and a CSRF bearer token. No
|
||||
// identity is persisted and no session is issued when the flow completes;
|
||||
// the captured debug data is returned in the flow's test_context.
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 201: loginFlow
|
||||
// 400: errorGeneric
|
||||
// 404: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-admin-low
|
||||
func (h *Handler) adminCreateTestLoginFlow(w http.ResponseWriter, r *http.Request) {
|
||||
// The strategy annotates this span with the provider lookup outcome
|
||||
// (test_login_flow.* attributes); without it, a failed lookup is
|
||||
// invisible in traces because the whole path is in-memory.
|
||||
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "login.Handler.adminCreateTestLoginFlow")
|
||||
defer span.End()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
var body createTestLoginFlowBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("could not parse request body")))
|
||||
return
|
||||
}
|
||||
if body.ProviderID == "" {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("provider_id is required")))
|
||||
return
|
||||
}
|
||||
if len(body.ProviderID) > maxProviderIDLength {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("provider_id must be at most %d characters", maxProviderIDLength)))
|
||||
return
|
||||
}
|
||||
|
||||
f, err := NewFlow(h.d, r, flow.TypeBrowser)
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Admin-created flows have no browser cookie to bind against; mint a
|
||||
// random UUID as the opaque CSRF bearer and validate it on submit.
|
||||
f.CSRFToken = x.NewUUID().String()
|
||||
|
||||
if err := f.SetTestContext(&TestContext{ProviderID: body.ProviderID}); err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
// Mark as "refresh" so the OIDC strategy's alreadyAuthenticated guard does
|
||||
// not short-circuit the submit if the admin's browser happens to carry an
|
||||
// unrelated Ory session cookie. Test flows always run the OIDC round-trip
|
||||
// regardless of session state.
|
||||
f.Refresh = true
|
||||
|
||||
strategy := h.d.TestStrategy(r.Context())
|
||||
if strategy == nil {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrInternalServerError().WithReason("OIDC strategy is not registered; test login flows cannot be created")))
|
||||
return
|
||||
}
|
||||
if err := strategy.PopulateTestLoginFlow(r, f, body.ProviderID); err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.d.LoginFlowPersister().CreateLoginFlow(r.Context(), f); err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.d.Writer().WriteCode(w, r, http.StatusCreated, f)
|
||||
}
|
||||
|
||||
// Delete Test Login Flow Parameters
|
||||
//
|
||||
// swagger:parameters deleteTestLoginFlow
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type deleteTestLoginFlow struct {
|
||||
// ID of the test login flow to delete.
|
||||
//
|
||||
// required: true
|
||||
// in: query
|
||||
ID string `json:"id"`
|
||||
|
||||
// HTTP Cookies. A captured test flow requires the ory_kratos_test_flow
|
||||
// cookie set by the OIDC callback; a flow still in the initial
|
||||
// choose-method state does not.
|
||||
//
|
||||
// in: header
|
||||
// name: Cookie
|
||||
Cookies string `json:"Cookie"`
|
||||
}
|
||||
|
||||
// swagger:route DELETE /self-service/login/test frontend deleteTestLoginFlow
|
||||
//
|
||||
// # Delete a test OIDC login flow
|
||||
//
|
||||
// Deletes a dry-run OIDC test login flow. A flow whose debug payload has
|
||||
// been captured requires the HMAC cookie set by the OIDC callback; a flow
|
||||
// still in the initial choose-method state is deletable with just the flow
|
||||
// ID (it carries no PII, and the admin may want to abandon it).
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 204: emptyResponse
|
||||
// 400: errorGeneric
|
||||
// 403: errorGeneric
|
||||
// 404: errorGeneric
|
||||
// default: errorGeneric
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: kratos-public-high
|
||||
func (h *Handler) deleteTestLoginFlow(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.FromString(r.URL.Query().Get("id"))
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("invalid flow id")))
|
||||
return
|
||||
}
|
||||
|
||||
// Load the flow and discriminate by whether a debug payload has been
|
||||
// captured. A return of sqlcon.ErrNoRows maps to 404 — we don't leak
|
||||
// whether the ID belongs to a non-test login flow vs. a missing row.
|
||||
f, err := h.d.LoginFlowPersister().GetLoginFlow(r.Context(), id)
|
||||
if err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !f.IsTest() {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrNotFound()))
|
||||
return
|
||||
}
|
||||
if err := f.LoadTestContext(); err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
if f.TestContext != nil && f.TestContext.DebugPayload != nil &&
|
||||
!nosurf.VerifyToken(h.d.GenerateCSRFToken(r), f.CSRFToken) {
|
||||
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("missing or invalid CSRF token")))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.d.LoginFlowPersister().DeleteTestLoginFlow(r.Context(), id); err != nil {
|
||||
h.d.Writer().WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
391
selfservice/flow/login/test_handler_test.go
Normal file
391
selfservice/flow/login/test_handler_test.go
Normal file
@@ -0,0 +1,391 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/selfservice/flow"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/ui/container"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/otelx"
|
||||
"github.com/ory/x/sqlxx"
|
||||
)
|
||||
|
||||
// newRegistryWithOIDCProvider returns a fast test registry configured with a
|
||||
// single generic OIDC provider whose ID is "google". The populator can find
|
||||
// the provider when the admin-create handler invokes it.
|
||||
func newRegistryWithOIDCProvider(t *testing.T) *driver.RegistryDefault {
|
||||
t.Helper()
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://../../strategy/oidc/stub/registration.schema.json")),
|
||||
configx.WithValues(testhelpers.MethodEnableConfig(identity.CredentialsTypeOIDC, true)),
|
||||
configx.WithValue(
|
||||
config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config.providers",
|
||||
[]map[string]any{{
|
||||
"provider": "generic",
|
||||
"id": "google",
|
||||
"label": "Google",
|
||||
"client_id": "invalid",
|
||||
"client_secret": "invalid",
|
||||
"issuer_url": "https://foobar/",
|
||||
"mapper_url": "file://../../strategy/oidc/stub/oidc.facebook.jsonnet",
|
||||
}},
|
||||
),
|
||||
)
|
||||
return reg
|
||||
}
|
||||
|
||||
func doAdminPost(t *testing.T, adminURL string, body any) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
res, err := testhelpers.NewTestClient(t).Post(adminURL+"/admin"+login.RouteAdminCreateTestFlow, "application/json", bytes.NewReader(b))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
respBody, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
return res, respBody
|
||||
}
|
||||
|
||||
func doDelete(t *testing.T, c *http.Client, u string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodDelete, u, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := c.Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
return res, body
|
||||
}
|
||||
|
||||
// newSeedTestFlow creates a minimal test login flow directly via the
|
||||
// persister to support cap-enforcement seeding.
|
||||
func newSeedTestFlow(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, exp time.Duration) *login.Flow {
|
||||
t.Helper()
|
||||
f := &login.Flow{
|
||||
ID: x.NewUUID(),
|
||||
Type: flow.TypeBrowser,
|
||||
State: flow.StateChooseMethod,
|
||||
RequestURL: "https://example.com/self-service/login/browser",
|
||||
IssuedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(exp),
|
||||
CSRFToken: nosurfx.FakeCSRFToken,
|
||||
UI: container.New(""),
|
||||
InternalContext: sqlxx.JSONRawMessage(`{}`),
|
||||
}
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{ProviderID: "google"}))
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f))
|
||||
return f
|
||||
}
|
||||
|
||||
func TestHandler_AdminCreateTestLoginFlow_HappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
_, admin := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
res, body := doAdminPost(t, admin.URL, map[string]string{"provider_id": "google"})
|
||||
require.Equal(t, http.StatusCreated, res.StatusCode, "%s", body)
|
||||
assert.NotEmpty(t, gjson.GetBytes(body, "id").String())
|
||||
assert.Equal(t, "google", gjson.GetBytes(body, "test_context.provider_id").String())
|
||||
assert.Equal(t, string(flow.StateChooseMethod), gjson.GetBytes(body, "state").String())
|
||||
|
||||
// UI contains a provider submit node scoped to google.
|
||||
found := false
|
||||
for _, n := range gjson.GetBytes(body, "ui.nodes").Array() {
|
||||
if n.Get("attributes.name").String() == "provider" && n.Get("attributes.value").String() == "google" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected provider=google submit node in UI: %s", body)
|
||||
}
|
||||
|
||||
// TestHandler_AdminCreateTestLoginFlow_Tracing asserts that the handler
|
||||
// emits a span recording which strategy served the request and what the
|
||||
// provider lookup saw. A production incident where the wrong strategy was
|
||||
// selected (and thus an empty provider list was searched) was invisible in
|
||||
// traces because this path had no instrumentation.
|
||||
func TestHandler_AdminCreateTestLoginFlow_Tracing(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
|
||||
reg.SetTracer(otelx.NewNoop().WithOTLP(provider.Tracer("test")))
|
||||
|
||||
_, admin := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
res, body := doAdminPost(t, admin.URL, map[string]string{"provider_id": "google"})
|
||||
require.Equal(t, http.StatusCreated, res.StatusCode, "%s", body)
|
||||
res, body = doAdminPost(t, admin.URL, map[string]string{"provider_id": "nonesuch"})
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode, "%s", body)
|
||||
|
||||
var spans []sdktrace.ReadOnlySpan
|
||||
for _, sp := range recorder.Ended() {
|
||||
if sp.Name() == "login.Handler.adminCreateTestLoginFlow" {
|
||||
spans = append(spans, sp)
|
||||
}
|
||||
}
|
||||
require.Len(t, spans, 2, "expected one handler span per request")
|
||||
|
||||
attrs := func(sp sdktrace.ReadOnlySpan) map[attribute.Key]attribute.Value {
|
||||
out := make(map[attribute.Key]attribute.Value, len(sp.Attributes()))
|
||||
for _, kv := range sp.Attributes() {
|
||||
out[kv.Key] = kv.Value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
found := attrs(spans[0])
|
||||
assert.Equal(t, "oidc", found["test_login_flow.strategy"].AsString())
|
||||
assert.Equal(t, int64(1), found["test_login_flow.available_providers"].AsInt64())
|
||||
assert.True(t, found["test_login_flow.provider_found"].AsBool())
|
||||
|
||||
notFound := attrs(spans[1])
|
||||
assert.Equal(t, "oidc", notFound["test_login_flow.strategy"].AsString())
|
||||
assert.Equal(t, int64(1), notFound["test_login_flow.available_providers"].AsInt64())
|
||||
assert.False(t, notFound["test_login_flow.provider_found"].AsBool())
|
||||
}
|
||||
|
||||
func TestHandler_AdminCreateTestLoginFlow_BadInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
_, admin := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "empty provider_id", body: map[string]string{}, wantStatus: http.StatusBadRequest},
|
||||
{name: "unknown provider_id", body: map[string]string{"provider_id": "nonesuch"}, wantStatus: http.StatusNotFound},
|
||||
{name: "provider_id too long", body: map[string]string{"provider_id": strings.Repeat("a", 256)}, wantStatus: http.StatusBadRequest},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
res, body := doAdminPost(t, admin.URL, tc.body)
|
||||
assert.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DeleteTestLoginFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type setup func(t *testing.T, reg *driver.RegistryDefault) *login.Flow
|
||||
|
||||
seedUncaptured := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedTestFlow(t, context.Background(), reg, time.Hour)
|
||||
}
|
||||
// seedCapturedMatching: captured flow whose CSRFToken matches what the
|
||||
// fake CSRF generator returns — simulates the browser that holds the
|
||||
// post-callback cookie.
|
||||
seedCapturedMatching := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, nosurfx.FakeCSRFToken)
|
||||
}
|
||||
// seedCapturedMismatch: captured flow with a CSRFToken that won't match,
|
||||
// simulating a request without (or with the wrong) CSRF cookie.
|
||||
seedCapturedMismatch := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, "some-other-token")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup setup
|
||||
wantStatus int
|
||||
wantDeleted bool
|
||||
}{
|
||||
{
|
||||
name: "uncaptured: 204",
|
||||
setup: seedUncaptured,
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantDeleted: true,
|
||||
},
|
||||
{
|
||||
name: "captured with mismatched CSRF token: 403",
|
||||
setup: seedCapturedMismatch,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "captured with matching CSRF token: 204",
|
||||
setup: seedCapturedMatching,
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantDeleted: true,
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
f := tc.setup(t, reg)
|
||||
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteDeleteTestFlow, f.ID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, u, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := public.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
|
||||
|
||||
_, getErr := reg.LoginFlowPersister().GetLoginFlow(ctx, f.ID)
|
||||
if tc.wantDeleted {
|
||||
assert.Error(t, getErr, "flow should be deleted")
|
||||
} else {
|
||||
assert.NoError(t, getErr, "flow should still exist")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_DeleteTestLoginFlow_NotATestFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
// Create a regular (non-test) login flow directly.
|
||||
f := &login.Flow{
|
||||
ID: x.NewUUID(),
|
||||
Type: flow.TypeBrowser,
|
||||
State: flow.StateChooseMethod,
|
||||
RequestURL: "https://example.com/self-service/login/browser",
|
||||
IssuedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
CSRFToken: nosurfx.FakeCSRFToken,
|
||||
UI: container.New(""),
|
||||
InternalContext: sqlxx.JSONRawMessage(`{}`),
|
||||
}
|
||||
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f))
|
||||
|
||||
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteDeleteTestFlow, f.ID)
|
||||
res, body := doDelete(t, public.Client(), u)
|
||||
assert.Equal(t, http.StatusNotFound, res.StatusCode, "%s", body)
|
||||
}
|
||||
|
||||
func TestHandler_DeleteTestLoginFlow_InvalidUUID(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
u := fmt.Sprintf("%s%s?id=not-a-uuid", public.URL, login.RouteDeleteTestFlow)
|
||||
res, body := doDelete(t, public.Client(), u)
|
||||
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", body)
|
||||
}
|
||||
|
||||
func TestHandler_GetTestLoginFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type setup func(t *testing.T, reg *driver.RegistryDefault) *login.Flow
|
||||
|
||||
seedUncaptured := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedTestFlow(t, context.Background(), reg, time.Hour)
|
||||
}
|
||||
seedCapturedMatching := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, nosurfx.FakeCSRFToken)
|
||||
}
|
||||
seedCapturedMismatch := func(t *testing.T, reg *driver.RegistryDefault) *login.Flow {
|
||||
return newSeedCapturedTestFlow(t, context.Background(), reg, time.Hour, "some-other-token")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup setup
|
||||
wantStatus int
|
||||
assertBody func(t *testing.T, body []byte)
|
||||
}{
|
||||
{
|
||||
name: "uncaptured: 200",
|
||||
setup: seedUncaptured,
|
||||
wantStatus: http.StatusOK,
|
||||
assertBody: func(t *testing.T, body []byte) {
|
||||
assert.Equal(t, "google", gjson.GetBytes(body, "test_context.provider_id").String())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "captured with mismatched CSRF token: 403",
|
||||
setup: seedCapturedMismatch,
|
||||
wantStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "captured with matching CSRF token: 200",
|
||||
setup: seedCapturedMatching,
|
||||
wantStatus: http.StatusOK,
|
||||
assertBody: func(t *testing.T, body []byte) {
|
||||
assert.Equal(t, "alice@example.com",
|
||||
gjson.GetBytes(body, "test_context.debug_payload.id_token_claims.sub").String())
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := newRegistryWithOIDCProvider(t)
|
||||
public, _ := testhelpers.NewKratosServer(t, reg)
|
||||
|
||||
f := tc.setup(t, reg)
|
||||
u := fmt.Sprintf("%s%s?id=%s", public.URL, login.RouteGetFlow, f.ID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
require.NoError(t, err)
|
||||
res, err := public.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = res.Body.Close() })
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantStatus, res.StatusCode, "%s", body)
|
||||
if tc.assertBody != nil {
|
||||
tc.assertBody(t, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newSeedCapturedTestFlow creates a test login flow already advanced to the
|
||||
// passed_challenge state with a DebugPayload populated, simulating what the
|
||||
// OIDC callback would have persisted. csrfToken is written to f.CSRFToken so
|
||||
// tests can simulate either the post-callback "matches the cookie" state or a
|
||||
// mismatch.
|
||||
func newSeedCapturedTestFlow(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, exp time.Duration, csrfToken string) *login.Flow {
|
||||
t.Helper()
|
||||
f := newSeedTestFlow(t, ctx, reg, exp)
|
||||
f.CSRFToken = csrfToken
|
||||
f.State = flow.StatePassedChallenge
|
||||
require.NoError(t, f.SetTestContext(&login.TestContext{
|
||||
ProviderID: "google",
|
||||
DebugPayload: &login.DebugPayload{
|
||||
IDTokenClaims: map[string]any{"sub": "alice@example.com"},
|
||||
},
|
||||
}))
|
||||
require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(ctx, f))
|
||||
return f
|
||||
}
|
||||
20
selfservice/flow/login/testsetup_test.go
Normal file
20
selfservice/flow/login/testsetup_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package login_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var returnToServer *httptest.Server
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
returnToServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}))
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
Reference in New Issue
Block a user