Files
upstream-ory-kratos/session/manager_http_test.go
upstream-mirror c60cc7ca75
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
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

1300 lines
54 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
})
}