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
995 lines
45 KiB
Go
995 lines
45 KiB
Go
// 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)
|
|
})
|
|
}
|