snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
624
session/session_test.go
Normal file
624
session/session_test.go
Normal file
@@ -0,0 +1,624 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user