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
1300 lines
54 KiB
Go
1300 lines
54 KiB
Go
// Copyright © 2023 Ory Corp
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
package session_test
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"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/session"
|
||
"github.com/ory/kratos/x"
|
||
"github.com/ory/kratos/x/nosurfx"
|
||
"github.com/ory/nosurf"
|
||
"github.com/ory/x/configx"
|
||
"github.com/ory/x/contextx"
|
||
"github.com/ory/x/httprouterx"
|
||
"github.com/ory/x/urlx"
|
||
)
|
||
|
||
var _ nosurf.Handler = new(mockCSRFHandler)
|
||
|
||
type mockCSRFHandler struct {
|
||
c int
|
||
}
|
||
|
||
func (f *mockCSRFHandler) DisablePath(string) {}
|
||
func (f *mockCSRFHandler) DisableGlob(string) {}
|
||
func (f *mockCSRFHandler) DisableGlobs(...string) {}
|
||
func (f *mockCSRFHandler) IgnoreGlob(string) {}
|
||
func (f *mockCSRFHandler) IgnoreGlobs(...string) {}
|
||
func (f *mockCSRFHandler) ExemptPath(string) {}
|
||
func (f *mockCSRFHandler) IgnorePath(string) {}
|
||
func (f *mockCSRFHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}
|
||
|
||
func (f *mockCSRFHandler) RegenerateToken(_ http.ResponseWriter, _ *http.Request) string {
|
||
f.c++
|
||
return nosurfx.FakeCSRFToken
|
||
}
|
||
|
||
func newAAL2Identity() *identity.Identity {
|
||
return &identity.Identity{
|
||
SchemaID: "default",
|
||
Traits: []byte("{}"),
|
||
State: identity.StateActive,
|
||
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()},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func newAAL1Identity() *identity.Identity {
|
||
return &identity.Identity{
|
||
SchemaID: "default",
|
||
Traits: []byte("{}"),
|
||
State: identity.StateActive,
|
||
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()},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestManagerHTTP(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ctx := context.Background()
|
||
|
||
t.Run("case=regenerate csrf on principal change", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||
mock := new(mockCSRFHandler)
|
||
reg.WithCSRFHandler(mock)
|
||
|
||
require.NoError(t, reg.SessionManager().IssueCookie(t.Context(), httptest.NewRecorder(), new(http.Request), new(session.Session)))
|
||
assert.Equal(t, 1, mock.c)
|
||
})
|
||
|
||
t.Run("case=csrf rotation depends on the prior session identity", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ctx := t.Context()
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/fake-session.schema.json")),
|
||
)
|
||
mock := new(mockCSRFHandler)
|
||
reg.WithCSRFHandler(mock)
|
||
|
||
// Two persisted, active sessions for two different identities.
|
||
idA := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, &idA))
|
||
sA, err := testhelpers.NewActiveSession(req, reg, &idA, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sA))
|
||
|
||
idB := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, &idB))
|
||
sB, err := testhelpers.NewActiveSession(req, reg, &idB, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sB))
|
||
|
||
// issue calls IssueCookie with priorToken as the request's existing
|
||
// session token and returns how many times the anti-CSRF token was
|
||
// rotated by that call.
|
||
issue := func(t *testing.T, priorToken string, issued *session.Session) int {
|
||
before := mock.c
|
||
r := httptest.NewRequest("GET", "https://example.com/", nil)
|
||
if priorToken != "" {
|
||
r.Header.Set("X-Session-Token", priorToken)
|
||
}
|
||
require.NoError(t, reg.SessionManager().IssueCookie(ctx, httptest.NewRecorder(), r, issued))
|
||
return mock.c - before
|
||
}
|
||
|
||
t.Run("no prior session rotates", func(t *testing.T) {
|
||
assert.Equal(t, 1, issue(t, "", sA))
|
||
})
|
||
t.Run("same identity keeps the token", func(t *testing.T) {
|
||
assert.Equal(t, 0, issue(t, sA.Token, sA))
|
||
})
|
||
t.Run("different identity rotates", func(t *testing.T) {
|
||
assert.Equal(t, 1, issue(t, sA.Token, sB))
|
||
})
|
||
t.Run("unknown prior token rotates", func(t *testing.T) {
|
||
assert.Equal(t, 1, issue(t, "unknown-token", sA))
|
||
})
|
||
})
|
||
|
||
t.Run("case=cookie settings", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(map[string]any{
|
||
"dev": false,
|
||
config.ViperKeyPublicBaseURL: "https://baseurl.com/base_url",
|
||
}),
|
||
)
|
||
mock := new(mockCSRFHandler)
|
||
reg.WithCSRFHandler(mock)
|
||
s := &session.Session{Identity: new(identity.Identity)}
|
||
|
||
getCookie := func(ctx context.Context, t *testing.T, req *http.Request) *http.Cookie {
|
||
rec := httptest.NewRecorder()
|
||
require.NoError(t, reg.SessionManager().IssueCookie(ctx, rec, req, s))
|
||
require.Len(t, rec.Result().Cookies(), 1)
|
||
return rec.Result().Cookies()[0]
|
||
}
|
||
|
||
t.Run("case=immutability", func(t *testing.T) {
|
||
cookie1 := getCookie(t.Context(), t, testhelpers.NewTestHTTPRequest(t, "GET", "https://baseurl.com/bar", nil))
|
||
cookie2 := getCookie(t.Context(), t, testhelpers.NewTestHTTPRequest(t, "GET", "https://baseurl.com/bar", nil))
|
||
|
||
assert.NotEqual(t, cookie1.Value, cookie2.Value)
|
||
})
|
||
|
||
t.Run("case=with default options", func(t *testing.T) {
|
||
actual := getCookie(t.Context(), t, httptest.NewRequest("GET", "https://baseurl.com/bar", nil))
|
||
assert.EqualValues(t, "", actual.Domain, "Domain is empty because unset as a config option")
|
||
assert.EqualValues(t, "/", actual.Path, "Path is the default /")
|
||
assert.EqualValues(t, http.SameSiteLaxMode, actual.SameSite)
|
||
assert.True(t, actual.HttpOnly)
|
||
assert.True(t, actual.Secure)
|
||
})
|
||
|
||
t.Run("case=with base cookie customization", func(t *testing.T) {
|
||
ctx := contextx.WithConfigValues(t.Context(), map[string]any{
|
||
config.ViperKeyCookiePath: "/cookie",
|
||
config.ViperKeyCookieDomain: "cookie.com",
|
||
config.ViperKeyCookieSameSite: "Strict",
|
||
})
|
||
|
||
actual := getCookie(ctx, t, httptest.NewRequest("GET", "https://baseurl.com/bar", nil))
|
||
assert.EqualValues(t, "cookie.com", actual.Domain, "Domain is empty because unset as a config option")
|
||
assert.EqualValues(t, "/cookie", actual.Path, "Path is the default /")
|
||
assert.EqualValues(t, http.SameSiteStrictMode, actual.SameSite)
|
||
assert.True(t, actual.HttpOnly)
|
||
assert.True(t, actual.Secure)
|
||
})
|
||
|
||
t.Run("case=with base session customization", func(t *testing.T) {
|
||
ctx := contextx.WithConfigValues(t.Context(), map[string]any{
|
||
config.ViperKeySessionPath: "/session",
|
||
config.ViperKeySessionDomain: "session.com",
|
||
config.ViperKeySessionSameSite: "None",
|
||
})
|
||
|
||
actual := getCookie(ctx, t, httptest.NewRequest("GET", "https://baseurl.com/bar", nil))
|
||
assert.EqualValues(t, "session.com", actual.Domain, "Domain is empty because unset as a config option")
|
||
assert.EqualValues(t, "/session", actual.Path, "Path is the default /")
|
||
assert.EqualValues(t, http.SameSiteNoneMode, actual.SameSite)
|
||
assert.True(t, actual.HttpOnly)
|
||
assert.True(t, actual.Secure)
|
||
})
|
||
})
|
||
|
||
t.Run("suite=SessionActivate", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
|
||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
|
||
)
|
||
|
||
i := &identity.Identity{
|
||
Traits: []byte("{}"), State: identity.StateActive,
|
||
Credentials: map[identity.CredentialsType]identity.Credentials{
|
||
identity.CredentialsTypePassword: {Type: identity.CredentialsTypePassword, Identifiers: []string{x.NewUUID().String()}, Config: []byte(`{"hashed_password":"foo"}`)},
|
||
},
|
||
}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
|
||
assert.EqualValues(t, i.InternalAvailableAAL.String, "")
|
||
|
||
sess := session.NewInactiveSession()
|
||
require.NoError(t, reg.SessionManager().ActivateSession(req, sess, i, time.Now().UTC()))
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess))
|
||
|
||
actual, err := reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandEverything)
|
||
require.NoError(t, err)
|
||
|
||
assert.EqualValues(t, true, actual.Active)
|
||
assert.NotZero(t, actual.IssuedAt)
|
||
assert.True(t, time.Now().Before(actual.ExpiresAt))
|
||
require.Len(t, actual.Devices, 1)
|
||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, i.InternalAvailableAAL.String)
|
||
|
||
actualIdentity, err := reg.IdentityPool().GetIdentity(ctx, i.ID, identity.ExpandNothing)
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, actualIdentity.InternalAvailableAAL.String)
|
||
})
|
||
|
||
t.Run("suite=SessionActivate applies the organization session lifespan", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
orgWithOverride := x.NewUUID()
|
||
orgWithoutOverride := x.NewUUID()
|
||
|
||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
|
||
configx.WithValues(map[string]any{
|
||
config.ViperKeySessionLifespan: "24h",
|
||
"selfservice.methods.b2b.config.organizations": []map[string]any{
|
||
{"id": orgWithOverride.String(), "domains": []string{"override.example.com"}, "session_lifespan": "1h"},
|
||
{"id": orgWithoutOverride.String(), "domains": []string{"default.example.com"}},
|
||
},
|
||
}),
|
||
)
|
||
|
||
i := &identity.Identity{Traits: []byte("{}"), State: identity.StateActive}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
|
||
|
||
activate := func(t *testing.T, org string) *session.Session {
|
||
authAt := time.Now().UTC()
|
||
sess := session.NewInactiveSession()
|
||
if org != "" {
|
||
sess.AMR = session.AuthenticationMethods{{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1, Organization: org}}
|
||
}
|
||
require.NoError(t, reg.SessionManager().ActivateSession(req, sess, i, authAt))
|
||
return sess
|
||
}
|
||
|
||
t.Run("case=org with override uses the override", func(t *testing.T) {
|
||
sess := activate(t, orgWithOverride.String())
|
||
assert.WithinDuration(t, sess.IssuedAt.Add(1*time.Hour), sess.ExpiresAt, 5*time.Second)
|
||
})
|
||
|
||
t.Run("case=org without override uses the project default", func(t *testing.T) {
|
||
sess := activate(t, orgWithoutOverride.String())
|
||
assert.WithinDuration(t, sess.IssuedAt.Add(24*time.Hour), sess.ExpiresAt, 5*time.Second)
|
||
})
|
||
|
||
t.Run("case=no org uses the project default", func(t *testing.T) {
|
||
sess := activate(t, "")
|
||
assert.WithinDuration(t, sess.IssuedAt.Add(24*time.Hour), sess.ExpiresAt, 5*time.Second)
|
||
})
|
||
})
|
||
|
||
t.Run("suite=SessionAddAuthenticationMethod", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
|
||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
|
||
)
|
||
|
||
i := &identity.Identity{Traits: []byte("{}"), State: identity.StateActive}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
|
||
sess := session.NewInactiveSession()
|
||
// Activate with a stale timestamp so the refresh below is observable.
|
||
staleAuthenticatedAt := time.Now().Add(-time.Hour).UTC()
|
||
require.NoError(t, reg.SessionManager().ActivateSession(req, sess, i, staleAuthenticatedAt))
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), sess))
|
||
require.NoError(t, reg.SessionManager().SessionAddAuthenticationMethods(context.Background(), sess.ID,
|
||
session.AuthenticationMethod{
|
||
Method: identity.CredentialsTypeOIDC,
|
||
AAL: identity.AuthenticatorAssuranceLevel1,
|
||
},
|
||
session.AuthenticationMethod{
|
||
Method: identity.CredentialsTypeWebAuthn,
|
||
AAL: identity.AuthenticatorAssuranceLevel2,
|
||
}))
|
||
assert.Len(t, sess.AMR, 0)
|
||
|
||
actual, err := reg.SessionPersister().GetSession(context.Background(), sess.ID, session.ExpandNothing)
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, actual.AuthenticatorAssuranceLevel)
|
||
for _, amr := range actual.AMR {
|
||
assert.True(t, amr.Method == identity.CredentialsTypeWebAuthn || amr.Method == identity.CredentialsTypeOIDC)
|
||
}
|
||
assert.Len(t, actual.AMR, 2)
|
||
|
||
// Completing an authentication method counts as an authentication
|
||
// event, so the timestamp must be refreshed from its stale value.
|
||
assert.WithinDuration(t, time.Now().UTC(), actual.AuthenticatedAt, 5*time.Second)
|
||
})
|
||
|
||
t.Run("suite=lifecycle", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/fake-session.schema.json")),
|
||
)
|
||
|
||
var s *session.Session
|
||
rp := httprouterx.NewRouterPublic()
|
||
rp.GET("/session/revoke", func(w http.ResponseWriter, r *http.Request) {
|
||
require.NoError(t, reg.SessionManager().PurgeFromRequest(r.Context(), w, r))
|
||
w.WriteHeader(http.StatusOK)
|
||
})
|
||
|
||
rp.GET("/session/set", func(w http.ResponseWriter, r *http.Request) {
|
||
require.NoError(t, reg.SessionManager().UpsertAndIssueCookie(r.Context(), w, r, s))
|
||
w.WriteHeader(http.StatusOK)
|
||
})
|
||
|
||
rp.GET("/session/get", func(w http.ResponseWriter, r *http.Request) {
|
||
sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r, session.ExpandEverything, identity.ExpandEverything)
|
||
if err != nil {
|
||
t.Logf("Got error on lookup: %s %T", err, errors.Unwrap(err))
|
||
reg.Writer().WriteError(w, r, err)
|
||
return
|
||
}
|
||
reg.Writer().Write(w, r, sess)
|
||
})
|
||
|
||
rp.GET("/session/get-middleware", reg.SessionHandler().IsAuthenticated(func(w http.ResponseWriter, r *http.Request) {
|
||
sess, err := reg.SessionManager().FetchFromRequestContext(r.Context(), r)
|
||
if err != nil {
|
||
t.Logf("Got error on lookup: %s %T", err, errors.Unwrap(err))
|
||
reg.Writer().WriteError(w, r, err)
|
||
return
|
||
}
|
||
reg.Writer().Write(w, r, sess)
|
||
}, session.RedirectOnUnauthenticated("https://failed.com")))
|
||
|
||
pts := httptest.NewServer(nosurfx.NewTestCSRFHandler(rp, reg))
|
||
t.Cleanup(pts.Close)
|
||
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, pts.URL)
|
||
reg.RegisterPublicRoutes(context.Background(), rp)
|
||
|
||
t.Run("case=valid", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
conf.MustSet(req.Context(), config.ViperKeySessionLifespan, "1m")
|
||
|
||
i := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
|
||
s, _ = testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
|
||
c := testhelpers.NewClientWithCookies(t)
|
||
testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set")
|
||
|
||
res, err := c.Get(pts.URL + "/session/get")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||
|
||
res, err = c.Get(pts.URL + "/session/get-middleware")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||
})
|
||
|
||
t.Run("case=key rotation", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
original := conf.GetProvider(ctx).Strings(config.ViperKeySecretsCookie)
|
||
t.Cleanup(func() {
|
||
conf.MustSet(ctx, config.ViperKeySecretsCookie, original)
|
||
})
|
||
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1m")
|
||
conf.MustSet(ctx, config.ViperKeySecretsCookie, []string{"foo"})
|
||
|
||
i := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
|
||
s, _ = testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
|
||
c := testhelpers.NewClientWithCookies(t)
|
||
testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set")
|
||
|
||
res, err := c.Get(pts.URL + "/session/get")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||
|
||
conf.MustSet(ctx, config.ViperKeySecretsCookie, []string{"bar", "foo"})
|
||
res, err = c.Get(pts.URL + "/session/get")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||
})
|
||
|
||
t.Run("case=no panic on invalid cookie name", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1m")
|
||
conf.MustSet(ctx, config.ViperKeySessionName, "$%˜\"")
|
||
t.Cleanup(func() {
|
||
conf.MustSet(ctx, config.ViperKeySessionName, "")
|
||
})
|
||
|
||
rp.GET("/session/set/invalid", func(w http.ResponseWriter, r *http.Request) {
|
||
require.Error(t, reg.SessionManager().UpsertAndIssueCookie(r.Context(), w, r, s))
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
})
|
||
|
||
i := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
|
||
s, _ = testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
|
||
c := testhelpers.NewClientWithCookies(t)
|
||
res, err := c.Get(pts.URL + "/session/set/invalid")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusInternalServerError, res.StatusCode)
|
||
})
|
||
|
||
t.Run("case=valid bearer auth as fallback", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1m")
|
||
|
||
i := identity.Identity{Traits: []byte("{}"), State: identity.StateActive}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
|
||
s, err := testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), s))
|
||
require.NotEmpty(t, s.Token)
|
||
|
||
req, err = http.NewRequest("GET", pts.URL+"/session/get", nil)
|
||
require.NoError(t, err)
|
||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||
|
||
c := testhelpers.NewTestClient(t)
|
||
res, err := c.Do(req)
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||
})
|
||
|
||
t.Run("case=valid x-session-token auth even if bearer is set", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1m")
|
||
|
||
i := identity.Identity{Traits: []byte("{}"), State: identity.StateActive}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
|
||
s, err := testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), s))
|
||
|
||
req, err = http.NewRequest("GET", pts.URL+"/session/get", nil)
|
||
require.NoError(t, err)
|
||
req.Header.Set("Authorization", "Bearer invalid")
|
||
req.Header.Set("X-Session-Token", s.Token)
|
||
|
||
c := testhelpers.NewTestClient(t)
|
||
res, err := c.Do(req)
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||
})
|
||
|
||
t.Run("case=expired", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1ns")
|
||
t.Cleanup(func() {
|
||
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1m")
|
||
})
|
||
|
||
i := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
|
||
s, _ = testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
|
||
c := testhelpers.NewClientWithCookies(t)
|
||
testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set")
|
||
|
||
time.Sleep(time.Nanosecond * 2)
|
||
|
||
res, err := c.Get(pts.URL + "/session/get")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode)
|
||
})
|
||
|
||
t.Run("case=revoked", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
i := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
|
||
s, _ = testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
|
||
s, _ = testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
|
||
c := testhelpers.NewClientWithCookies(t)
|
||
testhelpers.MockHydrateCookieClient(t, c, pts.URL+"/session/set")
|
||
|
||
res, err := c.Get(pts.URL + "/session/revoke")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||
|
||
res, err = c.Get(pts.URL + "/session/get")
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, http.StatusUnauthorized, res.StatusCode)
|
||
})
|
||
|
||
t.Run("case=respects AAL config", func(t *testing.T) {
|
||
conf.MustSet(ctx, config.ViperKeySessionLifespan, "1m")
|
||
|
||
t.Run("required_aal=aal2", func(t *testing.T) {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
run := func(t *testing.T, complete []identity.CredentialsType, requested string, i *identity.Identity, expectedError error) {
|
||
s := session.NewInactiveSession()
|
||
for _, m := range complete {
|
||
aal := identity.AuthenticatorAssuranceLevel1
|
||
switch m {
|
||
case identity.CredentialsTypeTOTP,
|
||
identity.CredentialsTypeWebAuthn,
|
||
identity.CredentialsTypeLookup:
|
||
aal = identity.AuthenticatorAssuranceLevel2
|
||
}
|
||
s.CompletedLoginFor(m, aal)
|
||
}
|
||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, i, time.Now().UTC()))
|
||
err := reg.SessionManager().DoesSessionSatisfy(ctx, s, requested)
|
||
if expectedError != nil {
|
||
assert.EqualExportedValues(t, expectedError, err)
|
||
} else {
|
||
require.NoError(t, err)
|
||
}
|
||
}
|
||
|
||
test := func(t *testing.T, idAAL1, idAAL2 *identity.Identity) {
|
||
t.Run("fulfilled for aal2 if identity has aal2", func(t *testing.T) {
|
||
run(t, []identity.CredentialsType{identity.CredentialsTypePassword, identity.CredentialsTypeWebAuthn}, config.HighestAvailableAAL, idAAL2, nil)
|
||
})
|
||
|
||
t.Run("rejected for aal1 if identity has aal2", func(t *testing.T) {
|
||
returnURL := urlx.AppendPaths(reg.Config().SelfPublicURL(ctx), "/self-service/login/browser")
|
||
returnURL.RawQuery = "aal=aal2"
|
||
run(t, []identity.CredentialsType{identity.CredentialsTypePassword}, config.HighestAvailableAAL, idAAL2,
|
||
session.NewErrAALNotSatisfied(returnURL.String()))
|
||
})
|
||
|
||
t.Run("fulfilled for aal1 if identity has aal2 but config is aal1", func(t *testing.T) {
|
||
run(t, []identity.CredentialsType{identity.CredentialsTypePassword}, "aal1", idAAL2, nil)
|
||
})
|
||
|
||
t.Run("fulfilled for aal2 if identity has aal1", func(t *testing.T) {
|
||
run(t, []identity.CredentialsType{identity.CredentialsTypePassword}, "aal1", idAAL2, nil)
|
||
})
|
||
|
||
t.Run("fulfilled for aal1 if identity has aal1", func(t *testing.T) {
|
||
run(t, []identity.CredentialsType{identity.CredentialsTypePassword}, "aal1", idAAL1, nil)
|
||
})
|
||
}
|
||
|
||
t.Run("identity available AAL is not hydrated", func(t *testing.T) {
|
||
idAAL2 := newAAL2Identity()
|
||
idAAL1 := newAAL1Identity()
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), idAAL1))
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), idAAL2))
|
||
test(t, idAAL1, idAAL2)
|
||
})
|
||
|
||
t.Run("identity available AAL is hydrated and updated in the DB", func(t *testing.T) {
|
||
// We do not create the identity in the database, proving that we do not need
|
||
// to do any DB roundtrips in this case.
|
||
idAAL1 := newAAL2Identity()
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), idAAL1))
|
||
|
||
s := session.NewInactiveSession()
|
||
s.CompletedLoginFor(identity.CredentialsTypePassword, "")
|
||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, idAAL1, time.Now().UTC()))
|
||
require.Error(t, reg.SessionManager().DoesSessionSatisfy(ctx, s, config.HighestAvailableAAL, session.UpsertAAL))
|
||
|
||
result, err := reg.IdentityPool().GetIdentity(context.Background(), idAAL1.ID, identity.ExpandNothing)
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, result.InternalAvailableAAL.String)
|
||
})
|
||
|
||
t.Run("identity available AAL is hydrated without DB", func(t *testing.T) {
|
||
// We do not create the identity in the database, proving that we do not need
|
||
// to do any DB roundtrips in this case.
|
||
idAAL2 := newAAL2Identity()
|
||
idAAL2.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.AuthenticatorAssuranceLevel2)
|
||
|
||
idAAL1 := newAAL1Identity()
|
||
idAAL1.InternalAvailableAAL = identity.NewNullableAuthenticatorAssuranceLevel(identity.AuthenticatorAssuranceLevel1)
|
||
|
||
test(t, idAAL1, idAAL2)
|
||
})
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
func TestDoesSessionSatisfy(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
conf, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
|
||
)
|
||
|
||
passwordEmpty := func() identity.Credentials {
|
||
return identity.Credentials{Type: identity.CredentialsTypePassword, Config: []byte(`{}`), Identifiers: []string{testhelpers.RandomEmail()}}
|
||
}
|
||
password := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypePassword,
|
||
Identifiers: []string{testhelpers.RandomEmail()},
|
||
Config: []byte(`{"hashed_password": "$argon2id$v=19$m=32,t=2,p=4$cm94YnRVOW5jZzFzcVE4bQ$MNzk5BtR2vUhrp6qQEjRNw"}`),
|
||
}
|
||
}
|
||
passwordMigration := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypePassword,
|
||
Identifiers: []string{testhelpers.RandomEmail()},
|
||
Config: []byte(`{"use_password_migration_hook":true}`),
|
||
}
|
||
}
|
||
|
||
code := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeCodeAuth,
|
||
Identifiers: []string{testhelpers.RandomEmail()},
|
||
Config: []byte(`{"address_type":"email","used_at":{"Time":"0001-01-01T00:00:00Z","Valid":false}}`),
|
||
}
|
||
}
|
||
|
||
codeV2 := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeCodeAuth,
|
||
Identifiers: []string{testhelpers.RandomEmail()},
|
||
Config: []byte(`{"addresses":[{"channel":"email","address":"test@ory.sh"}]}`),
|
||
}
|
||
}
|
||
|
||
codeEmpty := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeCodeAuth,
|
||
Identifiers: []string{},
|
||
Config: []byte(`{}`),
|
||
}
|
||
}
|
||
|
||
oidc := func() identity.Credentials {
|
||
sub := testhelpers.RandomEmail()
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeOIDC,
|
||
Config: []byte(fmt.Sprintf(`{"providers":[{"subject":"%s","provider":"hydra","initial_id_token":"65794a6862","initial_access_token":"5234475274","initial_refresh_token":"32787233"}]}`, sub)),
|
||
Identifiers: []string{"hydra:" + sub},
|
||
}
|
||
}
|
||
// oidcEmpty := identity.Credentials{
|
||
// Type: identity.CredentialsTypeOIDC,
|
||
// Config: []byte(`{}`),
|
||
// Identifiers: []string{"hydra:0.fywegkf7hd@ory.sh"},
|
||
// }
|
||
|
||
lookupSecrets := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeLookup,
|
||
Config: []byte(`{"recovery_codes": [{"code": "abcde", "used_at": null}]}`),
|
||
}
|
||
}
|
||
// lookupSecretsEmpty := identity.Credentials{
|
||
// Type: identity.CredentialsTypeLookup,
|
||
// Config: []byte(`{}`),
|
||
// }
|
||
|
||
totp := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeTOTP,
|
||
Config: []byte(`{"totp_url": "otpauth://totp/..."}`),
|
||
}
|
||
}
|
||
// totpEmpty := identity.Credentials{
|
||
// Type: identity.CredentialsTypeTOTP,
|
||
// Config: []byte(`{}`),
|
||
// }
|
||
|
||
// passkey
|
||
passkey := func() identity.Credentials {
|
||
return identity.Credentials{ // passkey
|
||
Type: identity.CredentialsTypePasskey,
|
||
Config: []byte(`{"credentials":[{}]}`),
|
||
Identifiers: []string{testhelpers.RandomEmail()},
|
||
}
|
||
}
|
||
// passkeyEmpty := identity.Credentials{ // passkey
|
||
// Type: identity.CredentialsTypePasskey,
|
||
// Config: []byte(`{"credentials":null}`),
|
||
// Identifiers: []string{testhelpers.RandomEmail()},
|
||
// }
|
||
|
||
// webAuthn
|
||
mfaWebAuth := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeWebAuthn,
|
||
Config: []byte(`{"credentials":[{"is_passwordless":false}]}`),
|
||
Identifiers: []string{testhelpers.RandomEmail()},
|
||
}
|
||
}
|
||
passwordlessWebAuth := func() identity.Credentials {
|
||
return identity.Credentials{
|
||
Type: identity.CredentialsTypeWebAuthn,
|
||
Config: []byte(`{"credentials":[{"is_passwordless":true}]}`),
|
||
Identifiers: []string{testhelpers.RandomEmail()},
|
||
}
|
||
}
|
||
webAuthEmpty := func() identity.Credentials {
|
||
return identity.Credentials{Type: identity.CredentialsTypeWebAuthn, Config: []byte(`{}`), Identifiers: []string{testhelpers.RandomEmail()}}
|
||
}
|
||
|
||
amrs := map[identity.CredentialsType]session.AuthenticationMethod{}
|
||
for _, strat := range reg.AllLoginStrategies() {
|
||
amrs[strat.ID()] = strat.CompletedAuthenticationMethod(t.Context())
|
||
}
|
||
|
||
for _, tc := range []struct {
|
||
desc string
|
||
withContext func(*testing.T, context.Context) context.Context
|
||
errAs error
|
||
errIs error
|
||
matcher identity.AuthenticatorAssuranceLevel
|
||
creds []identity.Credentials
|
||
withAMR session.AuthenticationMethods
|
||
sessionManagerOptions []session.ManagerOptions
|
||
expectedFunc func(t *testing.T, err error, tcError error)
|
||
}{
|
||
{
|
||
desc: "with highest_available a password user is aal1",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a password migration user is aal1 if password migration is enabled",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{passwordMigration()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.password_migration.enabled": true,
|
||
})
|
||
},
|
||
// No error
|
||
},
|
||
{
|
||
// This is not an error because DoesSessionSatisfy always assumes at least aal1
|
||
desc: "with highest_available a password migration user is aal1 if password migration is disabled",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{passwordMigration()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.password_migration.enabled": false,
|
||
})
|
||
},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a otp code user is aal1",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{code()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeCodeAuth]},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a otp codeV2 user is aal1",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{codeV2()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeCodeAuth]},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a empty mfa code user is aal1",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{codeEmpty()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeCodeAuth]},
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.code.mfa_enabled": true,
|
||
})
|
||
},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a password user with empty mfa code is aal1",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), codeEmpty()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.code.passwordless_enabled": false,
|
||
"selfservice.methods.code.mfa_enabled": true,
|
||
})
|
||
},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a oidc user is aal1",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{oidc()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeOIDC]},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a passkey user is aal1",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{passkey()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePasskey]},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a recovery token user is aal1 even if they have no credentials",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryLink]},
|
||
// No error
|
||
},
|
||
{
|
||
desc: "with highest_available a recovery code user is aal1 even if they have no credentials",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryCode]},
|
||
// No error
|
||
},
|
||
// Test a recovery method with an identity that has only 2fa methods enabled.
|
||
{
|
||
desc: "with highest_available a recovery link user requires aal2 if they have 2fa totp configured",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{totp()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryLink]},
|
||
errIs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "with highest_available a recovery code user requires aal2 if they have 2fa lookup configured",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{lookupSecrets()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryCode]},
|
||
errIs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "with highest_available a recovery code user requires aal2 if they have 2fa lookup configured",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{mfaWebAuth()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryCode]},
|
||
errIs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "with highest_available a recovery code user requires aal2 if they have many 2fa methods configured",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{lookupSecrets(), mfaWebAuth(), totp()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryCode]},
|
||
errIs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "with highest_available a recovery link user requires aal2 if they have 2fa code configured",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{code()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryLink]},
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.code.passwordless_enabled": false,
|
||
"selfservice.methods.code.mfa_enabled": true,
|
||
})
|
||
},
|
||
errIs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "with highest_available a recovery link user requires aal2 if they have 2fa code v2 configured",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{codeV2()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryLink]},
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.code.passwordless_enabled": false,
|
||
"selfservice.methods.code.mfa_enabled": true,
|
||
})
|
||
},
|
||
errIs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
|
||
// Legacy tests
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal0, credential=code",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{totp()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypeRecoveryCode]},
|
||
errIs: session.ErrNoAALAvailable(),
|
||
},
|
||
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal1, credential=password",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal1, credential=password+webauth_empty",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), webAuthEmpty()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal1, credential=password+webauth_passwordless",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), passwordlessWebAuth()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal2, credential=password+webauth_mfa",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), mfaWebAuth()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
errAs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal2, credential=password+totp",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), totp()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
errAs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal2, credential=password+code-mfa",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), code()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
errAs: new(session.ErrAALNotSatisfied),
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.code.passwordless_enabled": false,
|
||
"selfservice.methods.code.mfa_enabled": true,
|
||
})
|
||
},
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal2, credential=password+codeV2-mfa",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), codeV2()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
errAs: new(session.ErrAALNotSatisfied),
|
||
withContext: func(t *testing.T, ctx context.Context) context.Context {
|
||
return contextx.WithConfigValues(ctx, map[string]any{
|
||
"selfservice.methods.code.passwordless_enabled": false,
|
||
"selfservice.methods.code.mfa_enabled": true,
|
||
})
|
||
},
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal2, credential=password+lookup_secrets",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), lookupSecrets()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword]},
|
||
errAs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal2, credential=password+webauth_mfa",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), mfaWebAuth()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword], {Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1}},
|
||
errAs: new(session.ErrAALNotSatisfied),
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal2, credential=password+webauth_passwordless",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), passwordlessWebAuth()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword], {Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1}},
|
||
},
|
||
{
|
||
desc: "has=aal2, requested=highest, available=aal2, credential=password+webauth_mfa",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), mfaWebAuth()},
|
||
withAMR: session.AuthenticationMethods{amrs[identity.CredentialsTypePassword], {Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2}},
|
||
},
|
||
|
||
// oidc
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal1, credential=oidc_and_empties",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{oidc(), webAuthEmpty(), passwordEmpty()},
|
||
withAMR: session.AuthenticationMethods{{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1}},
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal1, credential=code and totp",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{code(), totp()},
|
||
withAMR: session.AuthenticationMethods{{Method: identity.CredentialsTypeCodeAuth, AAL: identity.AuthenticatorAssuranceLevel1}},
|
||
errAs: session.NewErrAALNotSatisfied(urlx.CopyWithQuery(urlx.AppendPaths(conf.SelfPublicURL(t.Context()), "/self-service/login/browser"), url.Values{"aal": {"aal2"}, "return_to": {"https://myapp.com/settings?id=123"}}).String()),
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal1, credentials=password+webauthn_mfa, recovery with session manager options",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), mfaWebAuth()},
|
||
withAMR: session.AuthenticationMethods{{Method: identity.CredentialsTypeRecoveryCode}},
|
||
errAs: session.NewErrAALNotSatisfied(urlx.CopyWithQuery(urlx.AppendPaths(conf.SelfPublicURL(t.Context()), "/self-service/login/browser"), url.Values{"aal": {"aal2"}, "return_to": {"https://myapp.com/settings?id=123"}}).String()),
|
||
sessionManagerOptions: []session.ManagerOptions{session.WithRequestURL("https://myapp.com/settings?id=123")},
|
||
expectedFunc: func(t *testing.T, err error, tcError error) {
|
||
require.Contains(t, err.(*session.ErrAALNotSatisfied).RedirectTo, "myapp.com")
|
||
require.Equal(t, tcError.(*session.ErrAALNotSatisfied).RedirectTo, err.(*session.ErrAALNotSatisfied).RedirectTo)
|
||
},
|
||
},
|
||
{
|
||
desc: "has=aal1, requested=highest, available=aal1, credentials=password+webauthn_mfa, recovery without session manager options",
|
||
matcher: config.HighestAvailableAAL,
|
||
creds: []identity.Credentials{password(), mfaWebAuth()},
|
||
withAMR: session.AuthenticationMethods{{Method: identity.CredentialsTypeRecoveryCode}},
|
||
errAs: session.NewErrAALNotSatisfied(urlx.CopyWithQuery(urlx.AppendPaths(conf.SelfPublicURL(t.Context()), "/self-service/login/browser"), url.Values{"aal": {"aal2"}}).String()),
|
||
expectedFunc: func(t *testing.T, err error, tcError error) {
|
||
require.Equal(t, tcError.(*session.ErrAALNotSatisfied).RedirectTo, err.(*session.ErrAALNotSatisfied).RedirectTo)
|
||
},
|
||
},
|
||
} {
|
||
t.Run(tc.desc, func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ctx := t.Context()
|
||
if tc.withContext != nil {
|
||
ctx = tc.withContext(t, ctx)
|
||
}
|
||
|
||
id := identity.NewIdentity("default")
|
||
for _, c := range tc.creds {
|
||
id.SetCredentials(c.Type, c)
|
||
}
|
||
require.NoError(t, reg.IdentityManager().Create(ctx, id, identity.ManagerAllowWriteProtectedTraits))
|
||
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
s := session.NewInactiveSession()
|
||
for _, m := range tc.withAMR {
|
||
s.CompletedLoginFor(m.Method, m.AAL)
|
||
}
|
||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, id, time.Now().UTC()))
|
||
|
||
err := reg.SessionManager().DoesSessionSatisfy(ctx, s, string(tc.matcher), tc.sessionManagerOptions...)
|
||
if tc.errAs != nil || tc.errIs != nil {
|
||
if tc.expectedFunc != nil {
|
||
tc.expectedFunc(t, err, tc.errAs)
|
||
}
|
||
require.ErrorAs(t, err, &tc.errAs)
|
||
} else if tc.errIs != nil {
|
||
if tc.expectedFunc != nil {
|
||
tc.expectedFunc(t, err, tc.errIs)
|
||
}
|
||
require.ErrorIs(t, err, tc.errIs)
|
||
} else {
|
||
require.NoError(t, err)
|
||
}
|
||
|
||
// This should still work even if the session does not have identity data attached yet ...
|
||
s.Identity = nil
|
||
err = reg.SessionManager().DoesSessionSatisfy(ctx, s, string(tc.matcher), tc.sessionManagerOptions...)
|
||
if tc.errAs != nil {
|
||
if tc.expectedFunc != nil {
|
||
// If there is no identity, we can't expect the error to contain the identity
|
||
// schema in the RedirectTo URL.
|
||
var errAALNotSatisfied *session.ErrAALNotSatisfied
|
||
errors.As(tc.errAs, &errAALNotSatisfied)
|
||
u := x.Must(url.Parse(errAALNotSatisfied.RedirectTo))
|
||
q := u.Query()
|
||
q.Del("identity_schema")
|
||
u.RawQuery = q.Encode()
|
||
|
||
tc.expectedFunc(t, err, session.NewErrAALNotSatisfied(u.String()))
|
||
} else {
|
||
assert.ErrorAs(t, err, &tc.errAs)
|
||
}
|
||
} else {
|
||
assert.NoError(t, err)
|
||
}
|
||
|
||
// ... or no credentials attached.
|
||
s.Identity = id
|
||
s.Identity.Credentials = nil
|
||
err = reg.SessionManager().DoesSessionSatisfy(ctx, s, string(tc.matcher), tc.sessionManagerOptions...)
|
||
if tc.errAs != nil {
|
||
if tc.expectedFunc != nil {
|
||
tc.expectedFunc(t, err, tc.errAs)
|
||
} else {
|
||
assert.ErrorAs(t, err, &tc.errAs)
|
||
}
|
||
} else {
|
||
assert.NoError(t, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestFetchFromRequestMinimalExpansion(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ctx := t.Context()
|
||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/fake-session.schema.json")),
|
||
)
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
|
||
i := identity.Identity{Traits: []byte("{}")}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, &i))
|
||
s, err := testhelpers.NewActiveSession(req, reg, &i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
|
||
|
||
withToken := func(token string) *http.Request {
|
||
r := httptest.NewRequest("GET", "https://example.com/", nil)
|
||
if token != "" {
|
||
r.Header.Set("X-Session-Token", token)
|
||
}
|
||
return r
|
||
}
|
||
|
||
t.Run("ExpandNothing returns the session without the identity and devices", func(t *testing.T) {
|
||
got, err := reg.SessionManager().FetchFromRequest(ctx, withToken(s.Token), session.ExpandNothing, identity.ExpandNothing)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, s.ID, got.ID)
|
||
assert.Equal(t, i.ID, got.IdentityID)
|
||
assert.Nil(t, got.Identity, "identity must not be loaded")
|
||
assert.Empty(t, got.Devices, "devices must not be loaded")
|
||
})
|
||
|
||
t.Run("expands only the requested session associations", func(t *testing.T) {
|
||
got, err := reg.SessionManager().FetchFromRequest(ctx, withToken(s.Token), session.Expandables{session.ExpandSessionDevices}, identity.ExpandNothing)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, s.ID, got.ID)
|
||
assert.Nil(t, got.Identity, "identity must not be loaded")
|
||
assert.NotEmpty(t, got.Devices, "devices must be loaded")
|
||
})
|
||
|
||
t.Run("errors without a token", func(t *testing.T) {
|
||
_, err := reg.SessionManager().FetchFromRequest(ctx, withToken(""), session.ExpandNothing, identity.ExpandNothing)
|
||
require.Error(t, err)
|
||
})
|
||
|
||
t.Run("errors for an unknown token", func(t *testing.T) {
|
||
_, err := reg.SessionManager().FetchFromRequest(ctx, withToken("unknown-token"), session.ExpandNothing, identity.ExpandNothing)
|
||
e := new(session.ErrNoActiveSessionFound)
|
||
assert.ErrorAs(t, err, &e)
|
||
})
|
||
|
||
t.Run("SessionActiveForRequest is nil for an active session", func(t *testing.T) {
|
||
assert.NoError(t, reg.SessionManager().SessionActiveForRequest(ctx, withToken(s.Token)))
|
||
})
|
||
|
||
t.Run("SessionActiveForRequest errors for missing or unknown tokens", func(t *testing.T) {
|
||
require.Error(t, reg.SessionManager().SessionActiveForRequest(ctx, withToken("")))
|
||
require.Error(t, reg.SessionManager().SessionActiveForRequest(ctx, withToken("unknown-token")))
|
||
})
|
||
}
|
||
|
||
func TestFetchFromRequestExpandEverythingButCredentials(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/fake-session.schema.json")),
|
||
)
|
||
|
||
newFetchRequest := func(t *testing.T, token string) *http.Request {
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
req.Header.Set("X-Session-Token", token)
|
||
return req
|
||
}
|
||
|
||
t.Run("case=does not expand identity credentials but expands everything else", func(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := t.Context()
|
||
|
||
i := newAAL2Identity()
|
||
email := testhelpers.RandomEmail()
|
||
i.VerifiableAddresses = []identity.VerifiableAddress{{Value: email, Via: identity.AddressTypeEmail}}
|
||
i.RecoveryAddresses = []identity.RecoveryAddress{{Value: email, Via: identity.AddressTypeEmail}}
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
|
||
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
sess, err := testhelpers.NewActiveSession(req, reg, i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||
|
||
fetched, err := reg.SessionManager().FetchFromRequest(ctx, newFetchRequest(t, sess.Token), session.ExpandEverything, identity.ExpandEverythingButCredentials)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, fetched.Identity)
|
||
assert.Empty(t, fetched.Identity.Credentials, "credentials must not be expanded")
|
||
assert.NotEmpty(t, fetched.Identity.VerifiableAddresses, "verifiable addresses must still be expanded")
|
||
assert.NotEmpty(t, fetched.Identity.RecoveryAddresses, "recovery addresses must still be expanded")
|
||
|
||
// Control: the regular fetch expands the credentials.
|
||
full, err := reg.SessionManager().FetchFromRequest(ctx, newFetchRequest(t, sess.Token), session.ExpandEverything, identity.ExpandEverything)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, full.Identity)
|
||
assert.NotEmpty(t, full.Identity.Credentials)
|
||
})
|
||
|
||
t.Run("case=aal highest_available is still enforced without expanded credentials", func(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := t.Context()
|
||
|
||
// The identity has a second factor configured, but its available AAL is not yet
|
||
// persisted in the database.
|
||
i := newAAL2Identity()
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
|
||
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
sess, err := testhelpers.NewActiveSession(req, reg, i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||
|
||
// The available AAL is unknown, so DoesSessionSatisfy must lazily hydrate the
|
||
// credentials to determine it.
|
||
fetched, err := reg.SessionManager().FetchFromRequest(ctx, newFetchRequest(t, sess.Token), session.ExpandEverything, identity.ExpandEverythingButCredentials)
|
||
require.NoError(t, err)
|
||
require.NotNil(t, fetched.Identity)
|
||
require.Empty(t, fetched.Identity.Credentials)
|
||
|
||
err = reg.SessionManager().DoesSessionSatisfy(ctx, fetched, config.HighestAvailableAAL, session.UpsertAAL)
|
||
aalErr, ok := errors.AsType[*session.ErrAALNotSatisfied](err)
|
||
require.Truef(t, ok, "expected *session.ErrAALNotSatisfied but got %v", err)
|
||
assert.NotEmpty(t, aalErr.RedirectTo)
|
||
|
||
// The lazily computed available AAL was persisted through the UpsertAAL option.
|
||
result, err := reg.IdentityPool().GetIdentity(ctx, i.ID, identity.ExpandNothing)
|
||
require.NoError(t, err)
|
||
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel2, result.InternalAvailableAAL.String)
|
||
|
||
// A fresh fetch now uses the precomputed available AAL and never hydrates the
|
||
// credentials.
|
||
fetched, err = reg.SessionManager().FetchFromRequest(ctx, newFetchRequest(t, sess.Token), session.ExpandEverything, identity.ExpandEverythingButCredentials)
|
||
require.NoError(t, err)
|
||
require.Empty(t, fetched.Identity.Credentials)
|
||
|
||
err = reg.SessionManager().DoesSessionSatisfy(ctx, fetched, config.HighestAvailableAAL, session.UpsertAAL)
|
||
_, ok = errors.AsType[*session.ErrAALNotSatisfied](err)
|
||
require.Truef(t, ok, "expected *session.ErrAALNotSatisfied but got %v", err)
|
||
assert.Empty(t, fetched.Identity.Credentials, "the precomputed available AAL path must not hydrate the credentials")
|
||
})
|
||
|
||
t.Run("case=missing token returns ErrNoActiveSessionFound with missing credentials", func(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
_, err := reg.SessionManager().FetchFromRequest(t.Context(), testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil), session.ExpandEverything, identity.ExpandEverythingButCredentials)
|
||
noSess, ok := errors.AsType[*session.ErrNoActiveSessionFound](err)
|
||
require.Truef(t, ok, "expected *session.ErrNoActiveSessionFound but got %v", err)
|
||
assert.True(t, noSess.CredentialsMissing)
|
||
})
|
||
|
||
t.Run("case=inactive session returns ErrNoActiveSessionFound", func(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := t.Context()
|
||
|
||
i := newAAL1Identity()
|
||
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
|
||
|
||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||
sess, err := testhelpers.NewActiveSession(req, reg, i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||
require.NoError(t, err)
|
||
sess.Active = false
|
||
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
|
||
|
||
_, err = reg.SessionManager().FetchFromRequest(ctx, newFetchRequest(t, sess.Token), session.ExpandEverything, identity.ExpandEverythingButCredentials)
|
||
_, ok := errors.AsType[*session.ErrNoActiveSessionFound](err)
|
||
require.Truef(t, ok, "expected *session.ErrNoActiveSessionFound but got %v", err)
|
||
})
|
||
}
|