Files
upstream-ory-kratos/session/session_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

625 lines
24 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session_test
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/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/x/configx"
"github.com/ory/x/contextx"
)
func TestAdminSessionMarshal(t *testing.T) {
t.Parallel()
metadataAdmin := []byte(`{"role":"admin"}`)
ident := &identity.Identity{
MetadataAdmin: metadataAdmin,
State: identity.StateActive,
}
sess := session.Session{
ID: x.NewUUID(),
Active: true,
Identity: ident,
ExpiresAt: time.Now().Add(time.Hour),
}
t.Run("case=regular Session omits metadata_admin", func(t *testing.T) {
t.Parallel()
b, err := json.Marshal(sess)
require.NoError(t, err)
assert.False(t, gjson.GetBytes(b, "identity.metadata_admin").Exists())
assert.Equal(t, sess.ID.String(), gjson.GetBytes(b, "id").String())
assert.True(t, gjson.GetBytes(b, "active").Bool())
})
t.Run("case=AdminSession includes metadata_admin", func(t *testing.T) {
t.Parallel()
b, err := json.Marshal(session.AdminSession(sess))
require.NoError(t, err)
assert.Equal(t, `{"role":"admin"}`, gjson.GetBytes(b, "identity.metadata_admin").Raw)
assert.Equal(t, sess.ID.String(), gjson.GetBytes(b, "id").String())
assert.True(t, gjson.GetBytes(b, "active").Bool())
})
t.Run("case=AdminSession uses runtime active semantics for expired sessions", func(t *testing.T) {
t.Parallel()
expiredSess := session.Session{
ID: x.NewUUID(),
Active: true,
Identity: ident,
ExpiresAt: time.Now().Add(-time.Minute),
}
b, err := json.Marshal(session.AdminSession(expiredSess))
require.NoError(t, err)
assert.Equal(t, `{"role":"admin"}`, gjson.GetBytes(b, "identity.metadata_admin").Raw)
assert.Equal(t, expiredSess.ID.String(), gjson.GetBytes(b, "id").String())
assert.False(t, gjson.GetBytes(b, "active").Bool())
})
t.Run("case=AdminSessions converts slice", func(t *testing.T) {
t.Parallel()
sessions := []session.Session{sess, sess}
adminSessions := session.AdminSessions(sessions)
require.Len(t, adminSessions, 2)
for _, as := range adminSessions {
b, err := json.Marshal(as)
require.NoError(t, err)
assert.Equal(t, `{"role":"admin"}`, gjson.GetBytes(b, "identity.metadata_admin").Raw)
assert.Equal(t, sess.ID.String(), gjson.GetBytes(b, "id").String())
assert.True(t, gjson.GetBytes(b, "active").Bool())
}
})
}
func TestSession(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
authAt := time.Now()
t.Run("case=active session", func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
i := new(identity.Identity)
i.State = identity.StateActive
i.NID = x.NewUUID()
s, err := testhelpers.NewActiveSession(req, reg, i, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
assert.True(t, s.IsActive())
require.NotEmpty(t, s.Token)
require.NotEmpty(t, s.LogoutToken)
assert.EqualValues(t, identity.CredentialsTypePassword, s.AMR[0].Method)
i = new(identity.Identity)
i.NID = x.NewUUID()
s, err = testhelpers.NewActiveSession(req, reg, i, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
assert.Nil(t, s)
assert.ErrorIs(t, err, session.ErrIdentityDisabled())
})
t.Run("case=expired", func(t *testing.T) {
assert.False(t, (&session.Session{ExpiresAt: time.Now().Add(time.Hour)}).IsActive())
assert.False(t, (&session.Session{Active: true}).IsActive())
})
t.Run("case=amr add", func(t *testing.T) {
s := session.NewInactiveSession()
s.CompletedLoginFor(identity.CredentialsTypeOIDC, identity.AuthenticatorAssuranceLevel1)
assert.EqualValues(t, identity.CredentialsTypeOIDC, s.AMR[0].Method)
s.CompletedLoginFor(identity.CredentialsTypeRecoveryLink, identity.AuthenticatorAssuranceLevel1)
assert.EqualValues(t, identity.CredentialsTypeOIDC, s.AMR[0].Method)
assert.EqualValues(t, identity.CredentialsTypeRecoveryLink, s.AMR[1].Method)
s.CompletedLoginFor(identity.CredentialsTypeRecoveryCode, identity.AuthenticatorAssuranceLevel1)
assert.EqualValues(t, identity.CredentialsTypeRecoveryCode, s.AMR[2].Method)
})
t.Run("case=activate", func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
s := session.NewInactiveSession()
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
assert.True(t, s.Active)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
assert.Equal(t, authAt, s.AuthenticatedAt)
s = session.NewInactiveSession()
require.ErrorIs(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateInactive}, authAt), session.ErrIdentityDisabled())
assert.False(t, s.Active)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
assert.Empty(t, s.AuthenticatedAt)
})
t.Run("case=client information reverse proxy forward", func(t *testing.T) {
for _, tc := range []struct {
input string
expected string
}{
{
input: "10.10.8.1, 172.19.2.7",
expected: "",
},
{
input: "217.73.188.139,162.158.203.149, 172.19.2.7",
expected: "162.158.203.149",
},
{
input: "122.122.122.122 , 123.123.123.123",
expected: "123.123.123.123",
},
} {
t.Run("case=parse "+tc.input, func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
req.Header.Set("X-Forwarded-For", tc.input)
s := session.NewInactiveSession()
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
assert.True(t, s.Active)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
assert.Equal(t, authAt, s.AuthenticatedAt)
assert.Equal(t, 1, len(s.Devices))
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
assert.Equal(t, tc.expected, *s.Devices[0].IPAddress)
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
assert.Equal(t, "", *s.Devices[0].Location)
})
}
})
t.Run("case=client information reverse proxy real IP set", func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
req.Header.Set("X-Real-IP", "54.155.246.155")
req.Header["X-Forwarded-For"] = []string{"54.155.246.232", "10.145.1.10"}
s := session.NewInactiveSession()
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
assert.True(t, s.Active)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
assert.Equal(t, authAt, s.AuthenticatedAt)
assert.Equal(t, 1, len(s.Devices))
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
assert.NotNil(t, s.Devices[0].UpdatedAt)
assert.NotNil(t, s.Devices[0].CreatedAt)
assert.Equal(t, "54.155.246.155", *s.Devices[0].IPAddress)
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
assert.Equal(t, "", *s.Devices[0].Location)
})
t.Run("case=client information CF true client IP set", func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
req.Header.Set("True-Client-IP", "54.155.246.155")
req.Header.Set("X-Forwarded-For", "217.73.188.139,162.158.203.149, 172.19.2.7")
s := session.NewInactiveSession()
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{State: identity.StateActive, NID: x.NewUUID()}, authAt))
assert.True(t, s.Active)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
assert.Equal(t, authAt, s.AuthenticatedAt)
assert.Equal(t, 1, len(s.Devices))
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
assert.NotNil(t, s.Devices[0].UpdatedAt)
assert.NotNil(t, s.Devices[0].CreatedAt)
assert.Equal(t, "54.155.246.155", *s.Devices[0].IPAddress)
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
assert.Equal(t, "", *s.Devices[0].Location)
})
t.Run("case=client information CF", func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
req.Header.Set("True-Client-IP", "54.155.246.232")
req.Header.Set("Cf-Ipcity", "Munich")
req.Header.Set("Cf-Ipcountry", "Germany")
s := session.NewInactiveSession()
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
assert.True(t, s.Active)
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
assert.Equal(t, authAt, s.AuthenticatedAt)
assert.Equal(t, 1, len(s.Devices))
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
assert.Equal(t, "54.155.246.232", *s.Devices[0].IPAddress)
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
assert.Equal(t, "Munich, Germany", *s.Devices[0].Location)
})
for k, tc := range []struct {
d string
methods []session.AuthenticationMethod
expected identity.AuthenticatorAssuranceLevel
}{
{
d: "no amr means no assurance",
expected: identity.NoAuthenticatorAssuranceLevel,
},
{
d: "password is aal1",
methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypePassword}},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "oidc is aal1",
methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypeOIDC}},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "recovery link is aal1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryLink},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "recovery code is aal1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryCode},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "mix of password, oidc, recovery link is still aal1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryLink},
{Method: identity.CredentialsTypeOIDC},
{Method: identity.CredentialsTypePassword},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "mix of password, oidc, recovery code is still aal1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryCode},
{Method: identity.CredentialsTypeOIDC},
{Method: identity.CredentialsTypePassword},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "just totp is gracefully aal1",
methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypeTOTP}},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "password + totp is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypePassword},
{Method: identity.CredentialsTypeTOTP},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "password + lookup is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypePassword},
{Method: identity.CredentialsTypeLookup},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "oidc + totp is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeOIDC},
{Method: identity.CredentialsTypeTOTP},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "oidc + lookup is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeOIDC},
{Method: identity.CredentialsTypeLookup},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "recovery link + totp is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryLink},
{Method: identity.CredentialsTypeTOTP},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "recovery code + totp is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryCode},
{Method: identity.CredentialsTypeTOTP},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "recovery link + lookup is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryLink},
{Method: identity.CredentialsTypeLookup},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "recovery code + lookup is aal2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryCode},
{Method: identity.CredentialsTypeLookup},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "recovery link + passwordless webauth is aal1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryLink},
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "recovery code + passwordless webauth is aal1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeRecoveryCode},
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "respects AAL on AAL1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "respects AAL on AAL2 without AAL1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel2},
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "respects AAL on AAL2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
} {
t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) {
s := session.NewInactiveSession()
for _, m := range tc.methods {
s.CompletedLoginFor(m.Method, m.AAL)
}
})
}
t.Run("case=federated AAL2 carry-over", func(t *testing.T) {
// A single OIDC or SAML method tagged AAL2 elevates the
// session to AAL2 on its own. This is the upstream-MFA
// carry-over path: the provider config matched the upstream
// `acr` or `amr` claims, so Ory trusts that the upstream IdP
// already performed a second factor. Local methods (password,
// TOTP, WebAuthn, lookup, recovery) still need to be paired
// with an AAL1 entry to reach AAL2.
for _, tc := range []struct {
d string
methods []session.AuthenticationMethod
expected identity.AuthenticatorAssuranceLevel
}{
{
d: "single OIDC method with explicit AAL2 is AAL2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel2},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "single SAML method with explicit AAL2 is AAL2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeSAML, AAL: identity.AuthenticatorAssuranceLevel2},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
{
d: "single password method tagged AAL2 stays AAL1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel2},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "single WebAuthn method tagged AAL2 stays AAL1",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2},
},
expected: identity.AuthenticatorAssuranceLevel1,
},
{
d: "OIDC AAL2 carry-over plus extra password AAL1 stays AAL2",
methods: []session.AuthenticationMethod{
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel2},
},
expected: identity.AuthenticatorAssuranceLevel2,
},
} {
t.Run("case="+tc.d, func(t *testing.T) {
s := session.NewInactiveSession()
for _, m := range tc.methods {
s.CompletedLoginFor(m.Method, m.AAL)
}
s.SetAuthenticatorAssuranceLevel()
assert.Equal(t, tc.expected, s.AuthenticatorAssuranceLevel)
})
}
})
t.Run("case=authentication method upstream acr/amr round-trip", func(t *testing.T) {
t.Parallel()
in := session.AuthenticationMethod{
Method: identity.CredentialsTypeOIDC,
AAL: identity.AuthenticatorAssuranceLevel2,
CompletedAt: time.Now().UTC().Round(time.Second),
Provider: "hydra",
Organization: "acme",
UpstreamACR: "urn:mfa",
UpstreamAMR: []string{"pwd", "mfa"},
}
value, err := in.Value()
require.NoError(t, err)
var out session.AuthenticationMethod
require.NoError(t, out.Scan(value))
assert.Equal(t, in, out)
// Also round-trip via a list, to exercise the AuthenticationMethods
// Scan/Value path used by the sessions table column.
methods := session.AuthenticationMethods{in}
listValue, err := methods.Value()
require.NoError(t, err)
var outList session.AuthenticationMethods
require.NoError(t, outList.Scan(listValue))
require.Len(t, outList, 1)
assert.Equal(t, in, outList[0])
})
t.Run("case=CompletedLoginForOIDC populates upstream claims", func(t *testing.T) {
t.Parallel()
s := session.NewInactiveSession()
s.CompletedLoginForOIDC(
identity.CredentialsTypeOIDC,
identity.AuthenticatorAssuranceLevel2,
"hydra",
"acme",
"urn:mfa",
[]string{"pwd", "mfa"},
)
require.Len(t, s.AMR, 1)
assert.Equal(t, identity.CredentialsTypeOIDC, s.AMR[0].Method)
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, s.AMR[0].AAL)
assert.Equal(t, "hydra", s.AMR[0].Provider)
assert.Equal(t, "acme", s.AMR[0].Organization)
assert.Equal(t, "urn:mfa", s.AMR[0].UpstreamACR)
assert.Equal(t, []string{"pwd", "mfa"}, s.AMR[0].UpstreamAMR)
assert.False(t, s.AMR[0].CompletedAt.IsZero())
})
t.Run("case=session refresh", func(t *testing.T) {
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
ctx := contextx.WithConfigValues(t.Context(), map[string]any{
config.ViperKeySessionLifespan: "24h",
config.ViperKeySessionRefreshMinTimeLeft: "12h",
})
i := new(identity.Identity)
i.State = identity.StateActive
i.NID = x.NewUUID()
s, err := testhelpers.NewActiveSession(req, reg, i, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
assert.False(t, s.CanBeRefreshed(ctx, reg.Config()), "fresh session is not refreshable")
s.ExpiresAt = s.ExpiresAt.Add(-12 * time.Hour)
assert.True(t, s.CanBeRefreshed(ctx, reg.Config()), "session is refreshable after 12hrs")
})
t.Run("case=organization id", func(t *testing.T) {
t.Run("case=returns uuid.Nil for nil session", func(t *testing.T) {
var s *session.Session
assert.Equal(t, uuid.Nil, s.OrganizationID())
})
t.Run("case=returns uuid.Nil for empty AMR", func(t *testing.T) {
s := &session.Session{}
assert.Equal(t, uuid.Nil, s.OrganizationID())
})
t.Run("case=returns uuid.Nil when AMR has no org-tagged entry", func(t *testing.T) {
s := &session.Session{AMR: session.AuthenticationMethods{
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
}}
assert.Equal(t, uuid.Nil, s.OrganizationID())
})
t.Run("case=returns uuid.Nil when the org is not a parseable UUID", func(t *testing.T) {
s := &session.Session{AMR: session.AuthenticationMethods{
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1, Organization: "not-a-uuid"},
}}
assert.Equal(t, uuid.Nil, s.OrganizationID())
})
t.Run("case=returns the org from the first AMR entry that carries a UUID", func(t *testing.T) {
orgA := uuid.Must(uuid.NewV4())
orgB := uuid.Must(uuid.NewV4())
s := &session.Session{AMR: session.AuthenticationMethods{
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1, Organization: orgA.String()},
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1, Organization: orgB.String()},
}}
assert.Equal(t, orgA, s.OrganizationID())
})
})
t.Run("case=refresh uses organization lifespan", func(t *testing.T) {
// Use a real config (not a mock provider) so Refresh exercises the
// actual Config.OrganizationSessionLifespan resolution.
orgWithOverride := uuid.Must(uuid.NewV4())
orgWithoutOverride := uuid.Must(uuid.NewV4())
_, oreg := 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"}},
},
}),
)
ctx := t.Context()
t.Run("case=no org in AMR uses project default", func(t *testing.T) {
s := &session.Session{AMR: session.AuthenticationMethods{
{Method: identity.CredentialsTypePassword},
}}
s.Refresh(ctx, oreg.Config())
assert.WithinDuration(t, time.Now().Add(24*time.Hour), s.ExpiresAt, 2*time.Second)
})
t.Run("case=org in AMR with override uses override", func(t *testing.T) {
s := &session.Session{AMR: session.AuthenticationMethods{
{Method: identity.CredentialsTypeOIDC, Organization: orgWithOverride.String()},
}}
s.Refresh(ctx, oreg.Config())
assert.WithinDuration(t, time.Now().Add(1*time.Hour), s.ExpiresAt, 2*time.Second)
})
t.Run("case=org in AMR without override uses project default", func(t *testing.T) {
s := &session.Session{AMR: session.AuthenticationMethods{
{Method: identity.CredentialsTypeOIDC, Organization: orgWithoutOverride.String()},
}}
s.Refresh(ctx, oreg.Config())
assert.WithinDuration(t, time.Now().Add(24*time.Hour), s.ExpiresAt, 2*time.Second)
})
})
}