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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
{
"exp": 1675209660,
"iat": 1675209600,
"iss": "http://localhost/",
"nbf": 1675209600,
"sid": "432caf86-c1d8-401c-978a-8da89133f78b",
"sub": "7458af86-c1d8-401c-978a-8da89133f78b"
}

View File

@@ -0,0 +1,8 @@
{
"exp": 1675209660,
"iat": 1675209600,
"iss": "http://localhost/",
"nbf": 1675209600,
"sid": "432caf86-c1d8-401c-978a-8da89133f78b",
"sub": "7458af86-c1d8-401c-978a-8da89133f78b"
}

View File

@@ -0,0 +1,13 @@
{
"aal": "aal1",
"exp": 1675209660,
"external_id": "external-id",
"foo": "bar",
"iat": 1675209600,
"iss": "http://localhost/",
"nbf": 1675209600,
"schema_id": "default",
"second_claim": 1675209660,
"sid": "432caf86-c1d8-401c-978a-8da89133f78b",
"sub": "external-id"
}

View File

@@ -0,0 +1,13 @@
{
"aal": "aal1",
"exp": 1675209660,
"external_id": "external-id",
"foo": "bar",
"iat": 1675209600,
"iss": "http://localhost/",
"nbf": 1675209600,
"schema_id": "default",
"second_claim": 1675209660,
"sid": "432caf86-c1d8-401c-978a-8da89133f78b",
"sub": "7458af86-c1d8-401c-978a-8da89133f78b"
}

20
session/error.go Normal file
View File

@@ -0,0 +1,20 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"net/http"
"github.com/ory/herodot"
)
func RespondWithJSONErrorOnAuthenticated(h herodot.Writer, err error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.WriteError(w, r, err)
}
}
func ErrNoSessionFound() *herodot.DefaultError {
return herodot.ErrUnauthorized().WithReasonf("No valid session credentials found in the request.")
}

50
session/expand.go Normal file
View File

@@ -0,0 +1,50 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"strings"
"github.com/ory/x/sqlxx"
)
// Expandable controls what fields to expand for sessions.
type Expandable = sqlxx.Expandable
const (
// ExpandSessionDevices expands devices related to the session
ExpandSessionDevices Expandable = "Devices"
// ExpandSessionIdentity expands Identity related to the session
ExpandSessionIdentity Expandable = "Identity"
ExpandSessionIdentityRecoveryAddress Expandable = "Identity.RecoveryAddresses"
ExpandSessionIdentityVerifiableAddress Expandable = "Identity.VerifiableAddresses"
)
var expandablesMap = map[string]Expandable{
"devices": ExpandSessionDevices,
"identity": ExpandSessionIdentity,
}
// Expandables is a list of Expandable values.
type Expandables = sqlxx.Expandables
func ParseExpandable(in string) (Expandable, bool) {
e, ok := expandablesMap[strings.ToLower(in)]
return e, ok
}
// ExpandNothing expands nothing
var ExpandNothing Expandables
// ExpandDefault expands the default fields of a session
// - Associated Identity
var ExpandDefault = Expandables{
ExpandSessionIdentity,
}
// ExpandEverything expands all the fields of a session.
var ExpandEverything = Expandables{
ExpandSessionDevices,
ExpandSessionIdentity,
}

35
session/expand_test.go Normal file
View File

@@ -0,0 +1,35 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestExpandableSearch_ExistingExpand(t *testing.T) {
e := ExpandEverything
assert.True(t, e.Has(ExpandSessionDevices))
}
func TestExpandableSearch_NonExistingExpandOption(t *testing.T) {
e := ExpandEverything
assert.False(t, e.Has("SomeExpand"))
}
func TestExpandables_ToEager_skips_Identity(t *testing.T) {
e := ExpandEverything
res := e.ToEager()
assert.ElementsMatch(t, []string{string(ExpandSessionDevices), string(ExpandSessionIdentity)}, res)
}
func TestExpandables_ExpandNothing_IsEmpty(t *testing.T) {
e := ExpandNothing
assert.Len(t, e, 0)
}

1436
session/handler.go Normal file

File diff suppressed because it is too large Load Diff

1439
session/handler_test.go Normal file

File diff suppressed because it is too large Load Diff

19
session/helper.go Normal file
View File

@@ -0,0 +1,19 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"net/http"
"strings"
)
func bearerTokenFromRequest(r *http.Request) (string, bool) {
parts := strings.Split(r.Header.Get("Authorization"), " ")
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
return parts[1], true
}
return "", false
}

55
session/helper_test.go Normal file
View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBearerTokenFromRequest(t *testing.T) {
for k, tc := range []struct {
h http.Header
t string
f bool
}{
{
h: http.Header{"Authorization": {"Bearer token"}},
t: "token", f: true,
},
{
h: http.Header{"Authorization": {"bearer token"}},
t: "token", f: true,
},
{
h: http.Header{"Authorization": {"beaRer token"}},
t: "token", f: true,
},
{
h: http.Header{"Authorization": {"BEARER token"}},
t: "token", f: true,
},
{
h: http.Header{"Authorization": {"notbearer token"}},
},
{
h: http.Header{"Authorization": {"token"}},
},
{
h: http.Header{"Authorization": {}},
},
{
h: http.Header{},
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
token, found := bearerTokenFromRequest(&http.Request{Header: tc.h})
assert.Equal(t, tc.f, found)
assert.Equal(t, tc.t, token)
})
}
}

191
session/manager.go Normal file
View File

@@ -0,0 +1,191 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"context"
"net/http"
"net/url"
"time"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/swagger"
"github.com/gofrs/uuid"
"github.com/ory/herodot"
)
// ErrNoActiveSessionFound is returned when no active cookie session could be found in the request.
type ErrNoActiveSessionFound struct {
*herodot.DefaultError `json:"error"`
// True when the request had no credentials in it.
CredentialsMissing bool
}
// NewErrNoActiveSessionFound creates a new ErrNoActiveSessionFound
func NewErrNoActiveSessionFound() *ErrNoActiveSessionFound {
return &ErrNoActiveSessionFound{
DefaultError: herodot.ErrUnauthorized().WithID(text.ErrIDNoActiveSession).WithError("request does not have a valid authentication session").WithReason("No active session was found in this request."),
}
}
// NewErrNoCredentialsForSession creates a new NewErrNoCredentialsForSession
func NewErrNoCredentialsForSession() *ErrNoActiveSessionFound {
e := NewErrNoActiveSessionFound()
e.CredentialsMissing = true
return e
}
func (e *ErrNoActiveSessionFound) EnhanceJSONError() interface{} {
return e
}
// Is returned when an active session was found but the requested AAL is not satisfied.
//
// swagger:model errorAuthenticatorAssuranceLevelNotSatisfied
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type errorAuthenticatorAssuranceLevelNotSatisfied struct {
Error swagger.GenericError `json:"error"`
// Points to where to redirect the user to next.
RedirectTo string `json:"redirect_browser_to"`
}
// ErrAALNotSatisfied is returned when an active session was found but the requested AAL is not satisfied.
type ErrAALNotSatisfied struct {
*herodot.DefaultError `json:"error"`
RedirectTo string `json:"redirect_browser_to"`
}
func (e *ErrAALNotSatisfied) EnhanceJSONError() interface{} {
return e
}
func (e *ErrAALNotSatisfied) PassReturnToAndLoginChallengeParameters(requestURL string) error {
req, err := url.Parse(requestURL)
if err != nil {
return err
}
u, err := url.Parse(e.RedirectTo)
if err != nil {
return err
}
q := u.Query()
hlc := req.Query().Get("login_challenge")
if len(hlc) != 0 {
q.Set("login_challenge", hlc)
}
returnTo := req.Query().Get("return_to")
if len(returnTo) != 0 {
q.Set("return_to", returnTo)
}
u.RawQuery = q.Encode()
e.RedirectTo = u.String()
return nil
}
// NewErrAALNotSatisfied creates a new ErrAALNotSatisfied.
func NewErrAALNotSatisfied(redirectTo string) *ErrAALNotSatisfied {
return &ErrAALNotSatisfied{
RedirectTo: redirectTo,
DefaultError: &herodot.DefaultError{
IDField: text.ErrIDHigherAALRequired,
StatusField: http.StatusText(http.StatusForbidden),
ErrorField: "Session does not fulfill the requested Authenticator Assurance Level",
ReasonField: "An active session was found but it does not fulfill the requested Authenticator Assurance Level. Please verify yourself with a second factor to resolve this issue.",
CodeField: http.StatusForbidden,
DetailsField: map[string]interface{}{
"redirect_browser_to": redirectTo,
},
},
}
}
// Manager handles identity sessions.
type Manager interface {
// UpsertAndIssueCookie stores a session in the database and issues a cookie by calling IssueCookie.
//
// Also regenerates CSRF tokens due to assumed principal change.
UpsertAndIssueCookie(context.Context, http.ResponseWriter, *http.Request, *Session) error
// IssueCookie issues a cookie for the given session.
//
// Also regenerates CSRF tokens due to assumed principal change.
IssueCookie(context.Context, http.ResponseWriter, *http.Request, *Session) error
// RefreshCookie checks if the request uses an outdated cookie and refreshes the cookie if needed.
RefreshCookie(context.Context, http.ResponseWriter, *http.Request, *Session) error
// FetchFromRequest creates an HTTP session using cookies.
//
// sessionExpand controls which session associations (devices, identity) are loaded, and
// identityExpand controls which identity relations are loaded when sessionExpand includes
// ExpandSessionIdentity. Pass ExpandEverything/identity.ExpandEverything unless you are
// certain the caller never reads an association: expanding the identity's credentials in
// particular is by far the most expensive part of fetching a session, so hot paths that
// never read Identity.Credentials (such as /sessions/whoami) can pass
// identity.ExpandEverythingButCredentials, and callers that only need session columns
// (ID, IdentityID, AAL, ...) can pass ExpandNothing/identity.ExpandNothing. AAL
// enforcement stays intact: DoesSessionSatisfy loads the identity and hydrates the
// credentials lazily when it has to.
FetchFromRequest(ctx context.Context, r *http.Request, sessionExpand Expandables, identityExpand identity.Expandables) (*Session, error)
// FetchFromRequestContext returns the session from the context or if that is unset, falls back to FetchFromRequest.
FetchFromRequestContext(context.Context, *http.Request) (*Session, error)
// SessionActiveForRequest reports whether the request carries an active session, without loading the identity.
SessionActiveForRequest(context.Context, *http.Request) error
// PurgeFromRequest removes an HTTP session.
PurgeFromRequest(context.Context, http.ResponseWriter, *http.Request) error
// DoesSessionSatisfy answers if a session is satisfying the AAL of a user.
//
// The matcher value can be one of:
//
// - `highest_available`: If set requires the user to upgrade their session to the highest available AAL for that user.
// - `aal1`: Requires the user to have authenticated with at least one authentication factor.
//
// This method is implemented in such a way, that if a second factor is found for the user, it is always assumed
// that the user is able to authenticate with it. This means that if a user has a second factor, the user is always
// asked to authenticate with it if `highest_available` is set and the session's AAL is `aal1`.
DoesSessionSatisfy(ctx context.Context, sess *Session, matcher string, opts ...ManagerOptions) error
// SessionAddAuthenticationMethods adds one or more authentication method to the session
// and refreshes the session's authenticated_at timestamp, because completing an
// authentication method counts as an authentication event.
SessionAddAuthenticationMethods(ctx context.Context, sid uuid.UUID, methods ...AuthenticationMethod) error
// MaybeRedirectAPICodeFlow for API+Code flows redirects the user to the return_to URL and adds the code query parameter.
// `handled` is true if the request a redirect was written, false otherwise.
MaybeRedirectAPICodeFlow(w http.ResponseWriter, r *http.Request, f flow.Flow, sessionID uuid.UUID, uiNode node.UiNodeGroup) (handled bool, err error)
// ActivateSession activates a session.
//
// This method is used to activate a session after a user authenticated with a first or second factor. It sets
// all computed values (e.g. authenticator assurance level) and updates the session object but does not store
// the session in the database or on the client device.
ActivateSession(r *http.Request, session *Session, i *identity.Identity, authenticatedAt time.Time) error
// IsPrivileged checks if a session can be considered privileged.
// https://ory.com/docs/kratos/session-management/session-lifespan#privileged-sessions
IsPrivileged(ctx context.Context, session *Session) bool
}
type ManagementProvider interface {
SessionManager() Manager
}

548
session/manager_http.go Normal file
View File

@@ -0,0 +1,548 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"context"
stderrors "errors"
"net/http"
"net/url"
"time"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/x/logrusx"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/x/events"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/ui/node"
"github.com/ory/x/otelx"
"github.com/ory/x/randx"
"github.com/gorilla/sessions"
"github.com/ory/x/urlx"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/kratos/driver/config"
"github.com/ory/x/sqlcon"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/transaction"
)
func ErrNoAALAvailable() *herodot.DefaultError {
return herodot.ErrForbidden().WithReasonf("Unable to detect available authentication methods. Perform account recovery or contact support.")
}
type (
managerHTTPDependencies interface {
config.Provider
identity.PoolProvider
identity.PrivilegedPoolProvider
identity.ManagementProvider
x.CookieProvider
logrusx.Provider
nosurfx.CSRFProvider
otelx.Provider
transaction.PersistenceProvider
PersistenceProvider
sessiontokenexchange.PersistenceProvider
}
ManagerHTTP struct {
cookieName func(ctx context.Context) string
r managerHTTPDependencies
}
)
func NewManagerHTTP(r managerHTTPDependencies) *ManagerHTTP {
return &ManagerHTTP{
r: r,
cookieName: func(ctx context.Context) string {
return r.Config().SessionName(ctx)
},
}
}
type options struct {
requestURL string
upsertAAL bool
}
type ManagerOptions func(*options)
// WithRequestURL passes along query parameters from the requestURL to the new URL (if any exist)
func WithRequestURL(requestURL string) ManagerOptions {
return func(opts *options) {
opts.requestURL = requestURL
}
}
// UpsertAAL will update the available AAL of the identity if it was previoulsy unset. This is used to migrate
// identities from older versions of Ory Kratos.
func UpsertAAL(opts *options) {
opts.upsertAAL = true
}
func (s *ManagerHTTP) UpsertAndIssueCookie(ctx context.Context, w http.ResponseWriter, r *http.Request, ss *Session) (err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.UpsertAndIssueCookie")
defer otelx.End(span, &err)
if err := s.r.SessionPersister().UpsertSession(ctx, ss); err != nil {
return err
}
if err := s.IssueCookie(ctx, w, r, ss); err != nil {
return err
}
return nil
}
func (s *ManagerHTTP) RefreshCookie(ctx context.Context, w http.ResponseWriter, r *http.Request, session *Session) (err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.RefreshCookie")
defer otelx.End(span, &err)
// If it is a session token there is nothing to do.
_, cookieErr := r.Cookie(s.cookieName(r.Context()))
if errors.Is(cookieErr, http.ErrNoCookie) {
return nil
}
cookie, err := s.getCookie(r)
if err != nil {
return err
}
expiresAt := getCookieExpiry(cookie)
if expiresAt == nil || expiresAt.Before(session.ExpiresAt) {
if err := s.IssueCookie(ctx, w, r, session); err != nil {
return err
}
}
return nil
}
func (s *ManagerHTTP) IssueCookie(ctx context.Context, w http.ResponseWriter, r *http.Request, session *Session) (err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.IssueCookie")
defer otelx.End(span, &err)
cookie, err := s.r.CookieManager(ctx).Get(r, s.cookieName(ctx))
// Fix for https://github.com/ory/kratos/issues/1695
if err != nil && cookie == nil {
return errors.WithStack(err)
}
if s.r.Config().SessionPath(ctx) != "" {
cookie.Options.Path = s.r.Config().SessionPath(ctx)
}
if domain := s.r.Config().SessionDomain(ctx); domain != "" {
cookie.Options.Domain = domain
}
if alias := s.r.Config().SelfPublicURL(ctx); s.r.Config().SelfPublicURL(ctx).String() != alias.String() {
// If a domain alias is detected use that instead.
cookie.Options.Domain = alias.Hostname()
cookie.Options.Path = alias.Path
}
// Rotate the anti-CSRF token unless the request was already authenticated
// as the same identity: regenerate when there is no prior active session or
// when it belongs to a different identity. This decision only needs the
// prior session's identity ID, so we deliberately do not expand the
// identity. Expanding it here loads the full identity graph, which on a
// multi-region deployment can trigger a slow cross-region read (observed at
// ~2.4s on the login path) purely to read a single UUID.
var priorIdentityID uuid.UUID
hasPriorSession := false
if token := s.extractToken(r.WithContext(ctx)); token != "" {
if prior, ferr := s.r.SessionPersister().GetSessionByToken(ctx, token, ExpandNothing, identity.ExpandNothing); ferr == nil && prior.IsActive() {
priorIdentityID = prior.IdentityID
hasPriorSession = true
}
}
if !hasPriorSession || priorIdentityID != session.IdentityID {
_ = s.r.CSRFHandler().RegenerateToken(w, r)
}
if s.r.Config().SessionSameSiteMode(ctx) != 0 {
cookie.Options.SameSite = s.r.Config().SessionSameSiteMode(ctx)
}
cookie.Options.MaxAge = 0
if s.r.Config().SessionPersistentCookie(ctx) {
if session.ExpiresAt.IsZero() {
cookie.Options.MaxAge = int(s.r.Config().OrganizationSessionLifespan(ctx, session.OrganizationID()).Seconds())
} else {
cookie.Options.MaxAge = int(time.Until(session.ExpiresAt).Seconds())
}
}
cookie.Values["session_token"] = session.Token
cookie.Values["expires_at"] = session.ExpiresAt.UTC().Format(time.RFC3339Nano)
cookie.Values["nonce"] = randx.MustString(8, randx.Alpha) // Guarantee new kratos session identifier
if err := cookie.Save(r, w); err != nil {
return errors.WithStack(err)
}
return nil
}
func getCookieExpiry(s *sessions.Session) *time.Time {
expiresAt, ok := s.Values["expires_at"].(string)
if !ok {
return nil
}
n, err := time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return nil
}
return &n
}
func (s *ManagerHTTP) getCookie(r *http.Request) (*sessions.Session, error) {
return s.r.CookieManager(r.Context()).Get(r, s.cookieName(r.Context()))
}
func (s *ManagerHTTP) extractToken(r *http.Request) string {
ctx, span := s.r.Tracer(r.Context()).Tracer().Start(r.Context(), "sessions.ManagerHTTP.extractToken")
defer span.End()
if token := r.Header.Get("X-Session-Token"); len(token) > 0 {
return token
}
cookie, err := s.getCookie(r.WithContext(ctx))
if err != nil {
token, _ := bearerTokenFromRequest(r.WithContext(ctx))
return token
}
token, ok := cookie.Values["session_token"].(string)
if ok {
return token
}
token, _ = bearerTokenFromRequest(r.WithContext(ctx))
return token
}
func (s *ManagerHTTP) FetchFromRequestContext(ctx context.Context, r *http.Request) (_ *Session, err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.FetchFromRequestContext")
defer otelx.End(span, &err)
if sess, ok := ctx.Value(sessionInContextKey).(*Session); ok {
return sess, nil
}
// Don't reduce the expansion here unless you want bad performance down the line (because we
// constantly are unsure if we have the full data fetched or not).
return s.FetchFromRequest(ctx, r, ExpandEverything, identity.ExpandEverything)
}
func (s *ManagerHTTP) FetchFromRequest(ctx context.Context, r *http.Request, sessionExpand Expandables, identityExpand identity.Expandables) (_ *Session, err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.FetchFromRequest",
trace.WithAttributes(
attribute.StringSlice("session.expand", sessionExpand.ToEager()),
attribute.StringSlice("identity.expand", identityExpand.ToEager()),
))
defer endSpanIgnoreNoActiveSession(span, &err)
token := s.extractToken(r.WithContext(ctx))
if token == "" {
return nil, errors.WithStack(NewErrNoCredentialsForSession())
}
se, err := s.r.SessionPersister().GetSessionByToken(ctx, token, sessionExpand, identityExpand)
if err != nil {
if errors.Is(err, herodot.ErrNotFound()) || errors.Is(err, sqlcon.ErrNoRows()) {
return nil, errors.WithStack(NewErrNoActiveSessionFound())
}
return nil, err
}
trace.SpanFromContext(ctx).AddEvent(events.NewSessionChecked(ctx, se.ID, se.IdentityID))
if !se.IsActive() {
return nil, errors.WithStack(NewErrNoActiveSessionFound())
}
return se, nil
}
// endSpanIgnoreNoActiveSession ends the span without recording ErrNoActiveSessionFound as a span
// error, because a request without an active session is an expected condition on these paths.
func endSpanIgnoreNoActiveSession(span trace.Span, err *error) {
if _, ok := stderrors.AsType[*ErrNoActiveSessionFound](*err); ok {
span.End()
} else {
otelx.End(span, err)
}
}
// SessionActiveForRequest reports whether the request carries a token for an
// active session, without loading the identity. It returns nil when an active
// session exists and the same errors as FetchFromRequest otherwise, so callers
// that only branch on the error keep identical behavior while skipping the
// cross-region identity load.
func (s *ManagerHTTP) SessionActiveForRequest(ctx context.Context, r *http.Request) error {
_, err := s.FetchFromRequest(ctx, r, ExpandNothing, identity.ExpandNothing)
return err
}
func (s *ManagerHTTP) PurgeFromRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) (err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.PurgeFromRequest")
defer otelx.End(span, &err)
if token, ok := bearerTokenFromRequest(r); ok {
_, err := s.r.SessionPersister().RevokeSessionByToken(ctx, token)
return errors.WithStack(err)
}
cookie, _ := s.r.CookieManager(r.Context()).Get(r, s.cookieName(ctx))
token, ok := cookie.Values["session_token"].(string)
if !ok {
return nil
}
if _, err := s.r.SessionPersister().RevokeSessionByToken(ctx, token); err != nil {
return errors.WithStack(err)
}
cookie.Options.MaxAge = -1
if err := cookie.Save(r, w); err != nil {
return errors.WithStack(err)
}
return nil
}
func (s *ManagerHTTP) DoesSessionSatisfy(ctx context.Context, sess *Session, requestedAAL string, opts ...ManagerOptions) (err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.DoesSessionSatisfy")
defer otelx.End(span, &err)
sess.SetAuthenticatorAssuranceLevel()
// If we already have AAL2 there is no need to check further because it is the highest AAL.
if sess.AuthenticatorAssuranceLevel == identity.AuthenticatorAssuranceLevel2 {
return nil
}
managerOpts := &options{}
for _, o := range opts {
o(managerOpts)
}
loginURL := urlx.AppendPaths(s.r.Config().SelfPublicURL(ctx), "/self-service/login/browser")
query := url.Values{
"aal": {"aal2"},
}
// return to the requestURL if it was set
if managerOpts.requestURL != "" {
query.Set("return_to", managerOpts.requestURL)
}
loginURL.RawQuery = query.Encode()
switch requestedAAL {
case string(identity.AuthenticatorAssuranceLevel1):
if sess.AuthenticatorAssuranceLevel >= identity.AuthenticatorAssuranceLevel1 {
return nil
}
return NewErrAALNotSatisfied(loginURL.String())
case config.HighestAvailableAAL:
if sess.AuthenticatorAssuranceLevel == identity.AuthenticatorAssuranceLevel2 {
// The session has AAL2, nothing to check.
return nil
}
// The session is AAL1, we asked for `highest_available` AAL, so the only thing we can do
// is actually check what authentication methods the identity has.
if sess.Identity == nil {
// This is nil if the session did not expand the identity field.
sess.Identity, err = s.r.IdentityPool().GetIdentity(ctx, sess.IdentityID, identity.ExpandNothing)
if err != nil {
return err
}
}
if aal, ok := sess.Identity.InternalAvailableAAL.ToAAL(); ok && aal == identity.AuthenticatorAssuranceLevel2 {
// Identity gives us AAL2, but the session is still AAL1. We need to upgrade the session.
return NewErrAALNotSatisfied(loginURL.String())
}
// Identity AAL is not 2, we refresh:
// The identity was apparently fetched without credentials. Let's hydrate them.
if len(sess.Identity.Credentials) == 0 {
if err := s.r.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, sess.Identity, identity.ExpandCredentials); err != nil {
return err
}
}
// Great, now we determine the identity's available AAL
if err := sess.Identity.SetAvailableAAL(ctx, s.r.IdentityManager()); err != nil {
return err
}
// We override the result with our newly computed values
available, valid := sess.Identity.InternalAvailableAAL.ToAAL()
if !valid {
// Unlikely to happen because SetAvailableAAL will either return an error, or a valid value - but not no error and an invalid value.
return errors.WithStack(x.PseudoPanic.WithReasonf("Unable to determine available authentication methods for session: %s", sess.ID))
}
switch available {
case identity.NoAuthenticatorAssuranceLevel:
// The identity has AAL0, the session has AAL1, we're good.
return nil
case identity.AuthenticatorAssuranceLevel1:
// The identity has AAL1, the session has AAL1, we're good.
return nil
case identity.AuthenticatorAssuranceLevel2:
// The identity has AAL2, the session has AAL1, we need to upgrade the session.
// Since we ended up here, it also means that `sess.Identity.InternalAvailableAAL` was `aal1` and is now `aal2`.
// Let's update the database.
if managerOpts.upsertAAL {
if err := s.r.PrivilegedIdentityPool().UpdateIdentityColumns(ctx, sess.Identity, "available_aal"); err != nil {
return err
}
}
}
return NewErrAALNotSatisfied(loginURL.String())
}
return errors.Errorf("requested unknown aal: %s", requestedAAL)
}
func (s *ManagerHTTP) SessionAddAuthenticationMethods(ctx context.Context, sid uuid.UUID, ams ...AuthenticationMethod) (err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.SessionAddAuthenticationMethods")
defer otelx.End(span, &err)
// Since we added the method, it also means that we have authenticated it
sess, err := s.r.SessionPersister().GetSession(ctx, sid, ExpandNothing)
if err != nil {
return err
}
for _, m := range ams {
sess.CompletedLoginForMethod(m)
}
// Completing an authentication method is an authentication event, so the
// session's authenticated_at is refreshed as well. This matches login
// flows, which set authenticated_at when a second factor completes.
sess.AuthenticatedAt = time.Now().UTC()
sess.SetAuthenticatorAssuranceLevel()
return s.r.SessionPersister().UpsertSession(ctx, sess)
}
func (s *ManagerHTTP) MaybeRedirectAPICodeFlow(w http.ResponseWriter, r *http.Request, f flow.Flow, sessionID uuid.UUID, uiNode node.UiNodeGroup) (handled bool, err error) {
ctx, span := s.r.Tracer(r.Context()).Tracer().Start(r.Context(), "sessions.ManagerHTTP.MaybeRedirectAPICodeFlow")
defer otelx.End(span, &err)
if uiNode != node.OpenIDConnectGroup {
return false, nil
}
code, ok, _ := s.r.SessionTokenExchangePersister().CodeForFlow(ctx, f.GetID())
if !ok {
return false, nil
}
returnTo := s.r.Config().SelfServiceBrowserDefaultReturnTo(ctx)
if redirecter, ok := f.(flow.FlowWithRedirect); ok {
r, err := redir.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.r)...)
if err == nil {
returnTo = r
}
}
if err = s.r.SessionTokenExchangePersister().UpdateSessionOnExchanger(ctx, f.GetID(), sessionID); err != nil {
return false, errors.WithStack(err)
}
q := returnTo.Query()
q.Set("code", code.ReturnToCode)
returnTo.RawQuery = q.Encode()
http.Redirect(w, r, returnTo.String(), http.StatusSeeOther)
return true, nil
}
func (s *ManagerHTTP) ActivateSession(r *http.Request, session *Session, i *identity.Identity, authenticatedAt time.Time) (err error) {
ctx, span := s.r.Tracer(r.Context()).Tracer().Start(r.Context(), "sessions.ManagerHTTP.ActivateSession", trace.WithAttributes(
attribute.String("session.id", session.ID.String()),
attribute.String("identity.id", session.ID.String()),
attribute.String("authenticated_at", session.ID.String()),
))
defer otelx.End(span, &err)
if i == nil {
return errors.WithStack(x.PseudoPanic.WithReasonf("Identity must not be nil when activating a session."))
}
if !i.IsActive() {
return errors.WithStack(ErrIdentityDisabled().WithDetail("identity_id", i.ID))
}
if err := s.r.IdentityManager().RefreshAvailableAAL(ctx, i); err != nil {
return err
}
session.Identity = i
session.IdentityID = i.ID
session.Active = true
session.IssuedAt = authenticatedAt
session.ExpiresAt = authenticatedAt.Add(s.r.Config().OrganizationSessionLifespan(ctx, session.OrganizationID()))
session.AuthenticatedAt = authenticatedAt
session.SetSessionDeviceInformation(r.WithContext(ctx))
session.SetAuthenticatorAssuranceLevel()
span.SetAttributes(
attribute.String("identity.available_aal", session.Identity.InternalAvailableAAL.String),
)
return nil
}
func (s *ManagerHTTP) IsPrivileged(ctx context.Context, session *Session) bool {
if session == nil {
return false
}
if session.Identity == nil {
return false
}
if session.AuthenticatedAt.IsZero() {
return false
}
privilegedSessionLifespan := s.r.Config().SelfServiceFlowSettingsPrivilegedSessionMaxAge(ctx)
if privilegedSessionLifespan <= 0 {
return true
}
return session.AuthenticatedAt.Add(privilegedSessionLifespan).After(time.Now())
}

1299
session/manager_http_test.go Normal file

File diff suppressed because it is too large Load Diff

93
session/manager_test.go Normal file
View File

@@ -0,0 +1,93 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/herodot"
"github.com/ory/kratos/session"
)
func TestErrAALNotSatisfied_PassReturnToAndLoginChallengeParameters(t *testing.T) {
cases := []struct {
name string
instance *session.ErrAALNotSatisfied
requestURL string
wantErr assert.ErrorAssertionFunc
expected string
}{
{
name: "no parameter",
instance: &session.ErrAALNotSatisfied{
DefaultError: &herodot.DefaultError{},
RedirectTo: "https://localhost/?foo=bar",
},
requestURL: "https://localhost:1234/",
wantErr: assert.NoError,
expected: "https://localhost/?foo=bar",
},
{
name: "pass return_to parameter",
instance: &session.ErrAALNotSatisfied{
DefaultError: &herodot.DefaultError{},
RedirectTo: "https://localhost/?foo=bar",
},
requestURL: "https://localhost:1234/?return_to=https%3A%2F%2Fory.sh",
wantErr: assert.NoError,
expected: "https://localhost/?foo=bar&return_to=https%3A%2F%2Fory.sh",
},
{
name: "pass login_challenge parameter",
instance: &session.ErrAALNotSatisfied{
DefaultError: &herodot.DefaultError{},
RedirectTo: "https://localhost/?foo=bar",
},
requestURL: "https://localhost:1234/?login_challenge=badee1",
wantErr: assert.NoError,
expected: "https://localhost/?foo=bar&login_challenge=badee1",
},
{
name: "pass login_challenge and return_to parameters",
instance: &session.ErrAALNotSatisfied{
DefaultError: &herodot.DefaultError{},
RedirectTo: "https://localhost/?foo=bar",
},
requestURL: "https://localhost:1234/?return_to=https%3A%2F%2Fory.sh&login_challenge=badee1",
wantErr: assert.NoError,
expected: "https://localhost/?foo=bar&login_challenge=badee1&return_to=https%3A%2F%2Fory.sh",
},
{
name: "invalid RedirectTo URL",
instance: &session.ErrAALNotSatisfied{
DefaultError: &herodot.DefaultError{},
RedirectTo: "https://user:{{{@localhost/?foo=bar",
},
requestURL: "https://localhost:1234/?return_to=https%3A%2F%2Fory.sh",
wantErr: assert.Error,
},
{
name: "invalid request URL URL",
instance: &session.ErrAALNotSatisfied{
DefaultError: &herodot.DefaultError{},
RedirectTo: "https://localhost/?foo=bar",
},
requestURL: "https://user:{{{@localhost:1234/?return_to=https%3A%2F%2Fory.sh",
wantErr: assert.Error,
},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("case=%s", tc.name), func(t *testing.T) {
err := tc.instance.PassReturnToAndLoginChallengeParameters(tc.requestURL)
tc.wantErr(t, err)
if err == nil {
assert.Equal(t, tc.expected, tc.instance.RedirectTo)
}
})
}
}

116
session/persistence.go Normal file
View File

@@ -0,0 +1,116 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"context"
"time"
"github.com/ory/pop/v6"
"github.com/ory/kratos/identity"
"github.com/ory/x/pagination/keysetpagination"
"github.com/gofrs/uuid"
)
type PersistenceProvider interface {
SessionPersister() Persister
}
// RevokedSession identifies the session revoked by RevokeSessionByToken so
// callers can attach the IDs to observability events. Grouping the two IDs in a
// struct avoids confusing the session ID with the identity ID at the call site.
type RevokedSession struct {
// ID is the ID of the revoked session.
ID uuid.UUID
// IdentityID is the ID of the identity that owned the revoked session.
IdentityID uuid.UUID
}
type Persister interface {
GetConnection(ctx context.Context) *pop.Connection
// GetSession retrieves a session from the store.
GetSession(ctx context.Context, sid uuid.UUID, expandables Expandables) (*Session, error)
// ListSessions retrieves all sessions.
ListSessions(ctx context.Context, active *bool, paginatorOpts []keysetpagination.Option, expandables Expandables) ([]Session, *keysetpagination.Paginator, error)
// ListSessionsByIdentity retrieves sessions for an identity from the store.
ListSessionsByIdentity(ctx context.Context, iID uuid.UUID, active *bool, page, perPage int, except uuid.UUID, expandables Expandables) ([]Session, int64, error)
// UpsertSession inserts or updates a session into / in the store.
UpsertSession(ctx context.Context, s *Session) error
// ExtendSession updates the expiry of a session.
ExtendSession(ctx context.Context, sessionID uuid.UUID) error
// DeleteSession removes a session from the store.
DeleteSession(ctx context.Context, id uuid.UUID) error
// DeleteSessionsByIdentity removes all active session from the store for the given identity.
DeleteSessionsByIdentity(ctx context.Context, identity uuid.UUID) error
// GetSessionByToken gets the session associated with the given token.
//
// Functionality is similar to GetSession but accepts a session token
// instead of a session ID.
GetSessionByToken(ctx context.Context, token string, expandables Expandables, identityExpandables identity.Expandables) (*Session, error)
// DeleteExpiredSessions deletes sessions that expired before the given time.
DeleteExpiredSessions(context.Context, time.Time, int) error
// DeleteSessionByToken deletes a session associated with the given token.
//
// Functionality is similar to DeleteSession but accepts a session token
// instead of a session ID.
DeleteSessionByToken(context.Context, string) error
// RevokeSessionByToken marks a session inactive with the given token and
// returns the IDs of the revoked session so callers can emit observability
// events without a separate GetSessionByToken round trip.
// Returns sqlcon.ErrNoRows() if no matching session exists in the caller's
// network. The returned IDs are uuid.Nil in the error case.
RevokeSessionByToken(ctx context.Context, token string) (RevokedSession, error)
// RevokeSessionById marks a session inactive with the specified uuid
RevokeSessionById(ctx context.Context, sID uuid.UUID) error
// RevokeSession marks a given session inactive.
RevokeSession(ctx context.Context, iID, sID uuid.UUID) error
// RevokeSessionsIdentityExcept marks all except the given session of an identity inactive. It returns the number of sessions that were revoked.
RevokeSessionsIdentityExcept(ctx context.Context, iID, sID uuid.UUID) (int, error)
// RevokeSessionsByIdentities marks all active sessions inactive for the given identity IDs. Returns the number of rows updated.
RevokeSessionsByIdentities(ctx context.Context, identityIDs []uuid.UUID) (int, error)
// RevokeSessionsByIDs marks the listed sessions inactive (only ones currently active). Returns the number of rows updated.
RevokeSessionsByIDs(ctx context.Context, sessionIDs []uuid.UUID) (int, error)
// RevokeAllSessions deactivates up to `limit` currently-active sessions
// in the caller's network in a single SQL statement and returns the
// number of rows actually updated (in the range [0, limit]).
// Already-inactive sessions are skipped. A returned count below `limit`
// signals that no more matching rows remain.
RevokeAllSessions(ctx context.Context, limit int) (int, error)
// DeleteSessionsByIdentities permanently deletes all sessions belonging to the given identity IDs. Returns the number of rows deleted.
DeleteSessionsByIdentities(ctx context.Context, identityIDs []uuid.UUID) (int, error)
// DeleteSessionsByIDs permanently deletes the listed sessions. Returns the number of rows deleted.
DeleteSessionsByIDs(ctx context.Context, sessionIDs []uuid.UUID) (int, error)
// DeleteAllSessions permanently deletes up to `limit` sessions in the
// caller's network in a single SQL statement and returns the number of
// rows actually deleted (in the range [0, limit]). A returned count
// below `limit` signals that no more matching rows remain.
DeleteAllSessions(ctx context.Context, limit int) (int, error)
}
type DevicePersister interface {
CreateDevice(ctx context.Context, d *Device) error
}

436
session/session.go Normal file
View File

@@ -0,0 +1,436 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"context"
"database/sql/driver"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/x/httpx"
"github.com/ory/x/pagination/keysetpagination"
"github.com/ory/x/randx"
)
func ErrIdentityDisabled() *herodot.DefaultError {
return herodot.ErrBadRequest().WithID(text.ErrIDIdentityDisabled).WithError("identity is disabled").WithReason("This account was disabled.")
}
// lifespanProvider supplies the session lifespan, with optional per-organization overrides.
type lifespanProvider interface {
SessionLifespan(ctx context.Context) time.Duration
OrganizationSessionLifespan(ctx context.Context, orgID uuid.UUID) time.Duration
}
type refreshWindowProvider interface {
SessionRefreshMinTimeLeft(ctx context.Context) time.Duration
}
// Device corresponding to a Session
//
// swagger:model sessionDevice
type Device struct {
// Device record ID
//
// required: true
ID uuid.UUID `json:"id" faker:"-" db:"id"`
// SessionID is a helper struct field for gobuffalo.pop.
SessionID uuid.UUID `json:"-" faker:"-" db:"session_id"`
// IPAddress of the client
IPAddress *string `json:"ip_address" faker:"ptr_ipv4" db:"ip_address"`
// UserAgent of the client
UserAgent *string `json:"user_agent" faker:"-" db:"user_agent"`
// Geo Location corresponding to the IP Address
Location *string `json:"location" faker:"ptr_geo_location" db:"location"`
// Time of capture
CreatedAt time.Time `json:"-" faker:"-" db:"created_at"`
// Last updated at
UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
IdentityID *uuid.UUID `json:"-" faker:"-" db:"identity_id"`
}
func (Device) TableName() string { return "session_devices" }
// A Session
//
// swagger:model session
type Session struct {
// Session ID
//
// required: true
ID uuid.UUID `json:"id" faker:"-" db:"id"`
// Active state. If false the session is no longer active.
Active bool `json:"active" db:"active"`
// The Session Expiry
//
// When this session expires at.
ExpiresAt time.Time `json:"expires_at" db:"expires_at" faker:"time_type"`
// The Session Authentication Timestamp
//
// When this session was authenticated at. If multi-factor authentication was used this
// is the time when the last factor was authenticated (e.g. the TOTP code challenge was completed).
AuthenticatedAt time.Time `json:"authenticated_at" db:"authenticated_at" faker:"time_type"`
// AuthenticationMethod Assurance Level (AAL)
//
// The authenticator assurance level can be one of "aal1", "aal2", or "aal3". A higher number means that it is harder
// for an attacker to compromise the account.
//
// Generally, "aal1" implies that one authentication factor was used while AAL2 implies that two factors (e.g.
// password + TOTP) have been used.
//
// To learn more about these levels please head over to: https://www.ory.com/kratos/docs/concepts/credentials
AuthenticatorAssuranceLevel identity.AuthenticatorAssuranceLevel `faker:"aal_type" db:"aal" json:"authenticator_assurance_level"`
// Authentication Method References (AMR)
//
// A list of authentication methods (e.g. password, oidc, ...) used to issue this session.
AMR AuthenticationMethods `db:"authentication_methods" json:"authentication_methods"`
// The Session Issuance Timestamp
//
// When this session was issued at. Usually equal or close to `authenticated_at`.
IssuedAt time.Time `json:"issued_at" db:"issued_at" faker:"time_type"`
// The Logout Token
//
// Use this token to log out a user.
LogoutToken string `json:"-" db:"logout_token"`
// The Session Identity
//
// The identity that authenticated this session.
//
// If 2FA is required for the user, and the authentication process only solved the first factor, this field will be
// null until the session has been fully authenticated with the second factor.
Identity *identity.Identity `json:"identity" faker:"identity" db:"-" belongs_to:"identities" fk_id:"IdentityID"`
// Devices has history of all endpoints where the session was used
Devices []Device `json:"devices" faker:"-" has_many:"session_devices" fk_id:"session_id"`
// IdentityID is a helper struct field for gobuffalo.pop.
IdentityID uuid.UUID `json:"-" faker:"-" db:"identity_id"`
// CreatedAt is a helper struct field for gobuffalo.pop.
CreatedAt time.Time `json:"-" faker:"-" db:"created_at"`
// UpdatedAt is a helper struct field for gobuffalo.pop.
UpdatedAt time.Time `json:"-" faker:"-" db:"updated_at"`
// Tokenized is the tokenized (e.g. JWT) version of the session.
//
// It is only set when the `tokenize_as` query parameter was set to a valid tokenize template during calls to `/session/whoami`.
Tokenized string `json:"tokenized,omitempty" faker:"-" db:"-"`
// The Session Token
//
// The token of this session.
Token string `json:"-" db:"token"`
NID uuid.UUID `json:"-" faker:"-" db:"nid"`
}
func (s Session) PageToken() keysetpagination.PageToken {
return keysetpagination.MapPageToken{
"id": s.ID.String(),
"created_at": s.CreatedAt.Format(x.MapPaginationDateFormat),
}
}
func (Session) DefaultPageToken() keysetpagination.PageToken {
return keysetpagination.MapPageToken{
"id": uuid.Nil.String(),
"created_at": time.Date(2200, 12, 31, 23, 59, 59, 0, time.UTC).Format(x.MapPaginationDateFormat),
}
}
func (Session) TableName() string { return "sessions" }
// AdminSession is a Session that preserves metadata_admin on the embedded
// identity during JSON marshaling. Use this type (or AdminSessions for slices)
// when writing responses from admin endpoints.
type AdminSession Session
func (s AdminSession) MarshalJSON() ([]byte, error) {
type sessionAlias Session
type adminSession struct {
sessionAlias
Identity *identity.WithAdminMetadataInJSON `json:"identity"`
}
session := Session(s)
session.Active = session.IsActive()
return json.Marshal(adminSession{
sessionAlias: sessionAlias(session),
Identity: (*identity.WithAdminMetadataInJSON)(session.Identity),
})
}
// AdminSessions converts a slice of Session to a slice of AdminSession for
// admin endpoint responses that need to include metadata_admin.
func AdminSessions(sessions []Session) []AdminSession {
result := make([]AdminSession, len(sessions))
for i, s := range sessions {
result[i] = AdminSession(s)
}
return result
}
func (s *Session) CompletedLoginForMethod(method AuthenticationMethod) {
method.CompletedAt = time.Now().UTC()
s.AMR = append(s.AMR, method)
}
func (s *Session) CompletedLoginFor(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel) {
s.CompletedLoginForMethod(AuthenticationMethod{Method: method, AAL: aal})
}
func (s *Session) CompletedLoginForWithProvider(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel, providerID string, organizationID string) {
s.CompletedLoginForMethod(AuthenticationMethod{
Method: method,
AAL: aal,
Provider: providerID,
Organization: organizationID,
})
}
// CompletedLoginForOIDC appends an OIDC authentication method to the
// session and records the upstream `acr` / `amr` claim values for
// auditing. Use this when the caller has access to the upstream claims;
// otherwise use CompletedLoginForWithProvider.
func (s *Session) CompletedLoginForOIDC(method identity.CredentialsType, aal identity.AuthenticatorAssuranceLevel, providerID, organizationID, upstreamACR string, upstreamAMR []string) {
s.CompletedLoginForMethod(AuthenticationMethod{
Method: method,
AAL: aal,
Provider: providerID,
Organization: organizationID,
UpstreamACR: upstreamACR,
UpstreamAMR: upstreamAMR,
})
}
func (s *Session) AuthenticatedVia(method identity.CredentialsType) bool {
for _, authMethod := range s.AMR {
if authMethod.Method == method {
return true
}
}
return false
}
func (s *Session) SetAuthenticatorAssuranceLevel() {
if len(s.AMR) == 0 {
s.AuthenticatorAssuranceLevel = identity.NoAuthenticatorAssuranceLevel
return
}
var isAAL1, isAAL2 bool
for _, amr := range s.AMR {
switch amr.AAL {
case identity.AuthenticatorAssuranceLevel1:
isAAL1 = true
case identity.AuthenticatorAssuranceLevel2:
isAAL2 = true
if m := amr.Method; m == identity.CredentialsTypeOIDC || m == identity.CredentialsTypeSAML {
// OIDC or SAML can elevate a session to AAL2 on their own
// because the upstream provider performed the MFA itself
// (carried over via the `acr` / `amr` claims and the provider's
// AAL2ACRValues / AAL2AMRValues config).
isAAL1 = true
}
}
}
if isAAL1 && isAAL2 {
s.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel2
} else {
// AAL1 covers every other case: single first-factor methods (password,
// OIDC without upstream MFA), an unpaired second-factor entry, or only
// recovery credentials.
s.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1
}
}
func NewInactiveSession() *Session {
return &Session{
ID: uuid.Nil,
Token: x.OrySessionToken + randx.MustString(32, randx.AlphaNum),
LogoutToken: x.OryLogoutToken + randx.MustString(32, randx.AlphaNum),
Active: false,
AuthenticatorAssuranceLevel: identity.NoAuthenticatorAssuranceLevel,
}
}
func (s *Session) SetSessionDeviceInformation(r *http.Request) {
device := Device{
SessionID: s.ID,
IdentityID: new(s.IdentityID),
IPAddress: new(httpx.ClientIP(r)),
}
agent := r.Header["User-Agent"]
if len(agent) > 0 {
device.UserAgent = new(strings.Join(agent, " "))
}
var clientGeoLocation []string
if r.Header.Get("Cf-Ipcity") != "" {
clientGeoLocation = append(clientGeoLocation, r.Header.Get("Cf-Ipcity"))
}
if r.Header.Get("Cf-Ipcountry") != "" {
clientGeoLocation = append(clientGeoLocation, r.Header.Get("Cf-Ipcountry"))
}
loc := strings.Join(clientGeoLocation, ", ")
device.Location = &loc
s.Devices = append(s.Devices, device)
}
func (s Session) Declassified() *Session {
s.Identity = s.Identity.CopyWithoutCredentials()
return &s
}
func (s *Session) IsActive() bool {
return s.Active && s.ExpiresAt.After(time.Now()) && (s.Identity == nil || s.Identity.IsActive())
}
func (s *Session) Refresh(ctx context.Context, c lifespanProvider) *Session {
s.ExpiresAt = time.Now().Add(c.OrganizationSessionLifespan(ctx, s.OrganizationID())).UTC()
return s
}
// OrganizationID returns the organization ID that issued this session, taken
// from the first AMR entry whose Organization field parses as a UUID. Returns
// uuid.Nil if no AMR entry carries a parseable organization ID, or if s is
// nil.
func (s *Session) OrganizationID() uuid.UUID {
if s == nil {
return uuid.Nil
}
for _, m := range s.AMR {
if m.Organization == "" {
continue
}
id, err := uuid.FromString(m.Organization)
if err != nil {
continue
}
return id
}
return uuid.Nil
}
func (s *Session) MarshalJSON() ([]byte, error) {
type ss Session
out := ss(*s)
out.Active = s.IsActive()
return json.Marshal(out)
}
func (s *Session) CanBeRefreshed(ctx context.Context, c refreshWindowProvider) bool {
return s.ExpiresAt.Add(-c.SessionRefreshMinTimeLeft(ctx)).Before(time.Now())
}
// List of (Used) AuthenticationMethods
//
// A list of authenticators which were used to authenticate the session.
//
// swagger:model sessionAuthenticationMethods
type AuthenticationMethods []AuthenticationMethod
// AuthenticationMethod identifies an authentication method
//
// A singular authenticator used during authentication / login.
//
// swagger:model sessionAuthenticationMethod
type AuthenticationMethod struct {
// The method used in this authenticator.
Method identity.CredentialsType `json:"method"`
// The AAL this method introduced.
AAL identity.AuthenticatorAssuranceLevel `json:"aal" faker:"aal_type"`
// When the authentication challenge was completed.
CompletedAt time.Time `json:"completed_at"`
// OIDC or SAML provider id used for authentication
Provider string `json:"provider,omitempty"`
// The Organization id used for authentication
Organization string `json:"organization,omitempty"`
// UpstreamACR is the `acr` claim reported by the upstream OIDC
// provider, if any. Populated only for OIDC login methods when the
// upstream ID token contained an `acr` claim.
UpstreamACR string `json:"upstream_acr,omitempty"`
// UpstreamAMR is the `amr` claim reported by the upstream OIDC
// provider, if any. Populated only for OIDC login methods when the
// upstream ID token contained an `amr` claim.
UpstreamAMR []string `json:"upstream_amr,omitempty"`
}
// Scan implements the Scanner interface.
func (n *AuthenticationMethod) Scan(value interface{}) error {
if value == nil {
return nil
}
v := fmt.Sprintf("%s", value)
if len(v) == 0 {
return nil
}
return errors.WithStack(json.Unmarshal([]byte(v), n))
}
// Value implements the driver Valuer interface.
func (n AuthenticationMethod) Value() (driver.Value, error) {
value, err := json.Marshal(n)
if err != nil {
return nil, errors.WithStack(err)
}
return string(value), nil
}
// Scan implements the Scanner interface.
func (n *AuthenticationMethods) Scan(value interface{}) error {
if value == nil {
return nil
}
v := fmt.Sprintf("%s", value)
if len(v) == 0 {
return nil
}
return errors.WithStack(json.Unmarshal([]byte(v), n))
}
// Value implements the driver Valuer interface.
func (n AuthenticationMethods) Value() (driver.Value, error) {
value, err := json.Marshal(n)
if err != nil {
return nil, errors.WithStack(err)
}
return string(value), nil
}

624
session/session_test.go Normal file
View 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)
})
})
}

View File

@@ -0,0 +1,7 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {}
}

View File

@@ -0,0 +1,29 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
{
"keys": [
{
"use": "sig",
"kty": "EC",
"kid": "247f1420-e581-4023-88e0-07ee662f80da",
"crv": "P-256",
"alg": "ES256",
"x": "1odGSu9bvVq_9QqqNny8TvvUElscLYoTExxhnomYOgQ",
"y": "pa4d4Ql1lO86PBnQ8efYzSzW9nUrsfLlomn3RIpH2Ic",
"d": "kPoEy2OcUeHobxp9jK00YKTs0CBoRTMWZJoPOe9K5hQ"
}
]
}

View File

@@ -0,0 +1,14 @@
{
"keys": [
{
"use": "sig",
"kty": "EC",
"kid": "bc7f7afc-6742-427c-bb9e-164fe0f8b6a7",
"crv": "P-521",
"alg": "ES512",
"x": "ASj36HQOpsWiaGyzK1F0GkxXRt37R01M-OCWFk8rFqH8UnFBk0qnCmVYWv3pwVPPsN0CfFiaXTrV1gUSapkkDgWY",
"y": "ALf5bqXExUq6FzQNQg01hDhR2lOKzkrC02Bc6Alld8Zji3-echbimNZltoOi4MhXbSJeWHpU8wzb3v9XAAW4eovn",
"d": "ALP0Sf7cmcELc9CQ2bWd6Qs-YxMu0N9EYZhDmR6qbYdGnvv-lcGy_ySoEJD0vPMKagA8PHDvFhC7ORwP-sBIJ4O_"
}
]

View File

@@ -0,0 +1,14 @@
{
"keys": [
{
"use": "sig",
"kty": "EC",
"kid": "bc7f7afc-6742-427c-bb9e-164fe0f8b6a7",
"crv": "P-521",
"alg": "ES512",
"x": "ASj36HQOpsWiaGyzK1F0GkxXRt37R01M-OCWFk8rFqH8UnFBk0qnCmVYWv3pwVPPsN0CfFiaXTrV1gUSapkkDgWY",
"y": "ALf5bqXExUq6FzQNQg01hDhR2lOKzkrC02Bc6Alld8Zji3-echbimNZltoOi4MhXbSJeWHpU8wzb3v9XAAW4eovn",
"d": "ALP0Sf7cmcELc9CQ2bWd6Qs-YxMu0N9EYZhDmR6qbYdGnvv-lcGy_ySoEJD0vPMKagA8PHDvFhC7ORwP-sBIJ4O_"
}
]
}

View File

@@ -0,0 +1,18 @@
{
"keys": [
{
"use": "sig",
"kty": "RSA",
"kid": "95311ff8-ff91-486b-9ad9-21df8bdc95d7",
"alg": "RS512",
"n": "6_ygtx-8qvTeN7ts_qFJCuOIEyxOnUpggbx04cG3vqjtyfzZMfi0wlidgMH3zhglij2MwC5lPLbze5n4lGQk26s8bl0uhdWlFHO_44hN3l2NVbPcocVZDWwqOdct2qRx1sEdRAt-P1a-2gxYN4HaemER9lgZSgbikJhmL3EEKhcr0QklUZyMcUnbaHAopzdiMKpnykR28-SXEizBi7JTI0hRDgCVmjuCRsciI5GAFy-nQ5n3Ofm0x8wGflKN1RAeWvolpakb7YJgQAXKQhOY3huoHlr-sh3ZO9vQjBgVQ1AM3k-z4OiQjJwvgogfLa1lSLmh9_Ax3LJQ5iax-aN9yQ",
"e": "AQAB",
"d": "DjZhy4WazEUBGSQtlUxLZN99M4Jonap8E3QxKeOL2Gy-HXsf7ZWH9Wh22-lSrlPf9upsDqr8p-Jw2ZHVWcKKQbyXYCI2ihLq5UdvWBm-btT9jRrO_-Mt0NQh2uftuAxNWty4kX-Ls-7agbFaosUsTlCIT2jQ5RdzD7hN9y98S7iJ6oUiBKdc7cRkNj2lGqowwM_IR4IoU2rY2pC7OtHZg_BKU8UnSYcPopn9tnobpr4E_AA1x49iUOkEny3wSXEfP2gEqb2Frqsf342b0pOtiq4WuiAopG2vBfMCKGddDBG_fFsxp858i03G9S4IuMDYk1uSeXd0zc_nb0GxFjXpXQ",
"p": "-_rHgqAtRJpeUbdTWhWQxlUFp-g0Yt1WCnKhCYXzgwM1kG-m9mzHW9mEJxTc0olygeo2A0vJ7htZJpd9NtJkD6g5yDZ7GIm5Emr9fNk4glz8nkVV9udU2gv6hVe83XU86cI4B4XWXhjdNvjQd7ohO7kBwB3uBmekVRm6ZaUCD-c",
"q": "78CGezUjroDlUA5CiF75_apyBgrEI41eNiOPwN9X7Jy-3zpvecY9ZfIuyf5O1yNkC_zjanGEKom6L3iqcAV_1-wzGMOMvtIwhmkTPT9g0f1Z1utRQVljyXFBml7__xb9cCfq62YnH7irsM5nbe9Txziq2zvBjWCkkjJwTBPWDs8",
"dp": "lXjhuJ8Du1pG8Ppqu1lnk_8DZ-LakHrzeyccV-XZ2bGhqJhS1oMYj2esePJrO4jFIEOq3rGqi1A1xiq-4DJVoOQNwrJuutOXsVE-JT1FxC8cu1Yt9FSthNruNQMiycut4oyPaAcAbrkZIG7gWuVSqXbJjwkyFSKN3N1yMLF9U6k",
"dq": "MLapnHcbnOVLsoxzMEo7-TKcoGWnnKGots9a8hFvSABBOBIjfFavOvuOTjSByGzEczsa6hHOjOYXEnYuCzzS0QiJCUsSWeNTQLww0I0EGyajDmwZwnFrOQ7uCXOsCCSfsh4qOVI0ONnI6M_HbCrolt4IuSrXFObCCYJ-FrchEzk",
"qi": "fBkpKoiyeXnwBYezKcxkeu6E2F2tWiRgRikM-s50kRu2fGgmAZ-nIIHU5glPXCPbn4IolURxeLm8ZZyCCo2tNPWiAQV2C7UF04Kh8QANULoUdpObjplLEWQuCDgz5m9EkR4Sx6FGGA1TvUpYCjks8YxvdSIa56o3l72I49aL1t8"
}
]
}

View File

@@ -0,0 +1,13 @@
local claims = std.extVar('claims');
local session = std.extVar('session');
{
claims: {
foo: "bar",
sub: "can not be overwritten",
schema_id: session.identity.schema_id,
aal: session.authenticator_assurance_level,
second_claim: claims.exp,
[if std.objectHas(session.identity, 'external_id') then 'external_id']: session.identity.external_id,
}
}

973
session/test/persistence.go Normal file
View File

@@ -0,0 +1,973 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/go-faker/faker/v4"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/persistence"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
"github.com/ory/x/contextx"
"github.com/ory/x/dbal"
"github.com/ory/x/pagination/keysetpagination"
"github.com/ory/x/randx"
"github.com/ory/x/sqlcon"
)
func TestPersister(ctx context.Context, conf *config.Config, p interface {
persistence.Persister
},
) func(t *testing.T) {
return func(t *testing.T) {
_, p := testhelpers.NewNetworkUnlessExisting(t, ctx, p)
t.Run("case=not found", func(t *testing.T) {
_, err := p.GetSession(ctx, x.NewUUID(), session.ExpandNothing)
require.Error(t, err)
})
t.Run("case=create session", func(t *testing.T) {
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
expected.Active = true
expected.AMR = session.AuthenticationMethods{
{Method: identity.CredentialsTypePassword, CompletedAt: time.Now().UTC().Round(time.Second)},
{Method: identity.CredentialsTypeOIDC, CompletedAt: time.Now().UTC().Round(time.Second)},
}
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
var expectedSessionDevice session.Device
require.NoError(t, faker.FakeData(&expectedSessionDevice))
expected.Devices = []session.Device{
expectedSessionDevice,
}
assert.Equal(t, uuid.Nil, expected.ID)
require.NoError(t, p.UpsertSession(ctx, &expected))
assert.NotEqual(t, uuid.Nil, expected.ID)
check := func(actual *session.Session, err error) {
require.NoError(t, err)
assert.Equal(t, expected.Identity.ID, actual.Identity.ID)
assert.NotEmpty(t, actual.Identity.SchemaURL)
assert.NotEmpty(t, actual.Identity.SchemaID)
assert.Equal(t, expected.ID, actual.ID)
assert.Equal(t, expected.Active, actual.Active)
assert.Equal(t, expected.Token, actual.Token)
assert.EqualValues(t, expected.ExpiresAt.Unix(), actual.ExpiresAt.Unix())
assert.Equal(t, expected.AuthenticatedAt.Unix(), actual.AuthenticatedAt.Unix())
assert.Equal(t, expected.IssuedAt.Unix(), actual.IssuedAt.Unix())
assert.Equal(t, expected.AuthenticatorAssuranceLevel, actual.AuthenticatorAssuranceLevel)
assert.Equal(t, expected.AMR, actual.AMR)
}
checkDevices := func(actual []session.Device, err error) {
require.NoError(t, err)
assert.Equal(t, len(expected.Devices), len(actual))
for i, d := range actual {
assert.Equal(t, expected.Devices[i].SessionID, d.SessionID)
assert.Equal(t, expected.Devices[i].NID, d.NID)
assert.Equal(t, *expected.Devices[i].IPAddress, *d.IPAddress)
assert.Equal(t, expected.Devices[i].UserAgent, d.UserAgent)
assert.Equal(t, *expected.Devices[i].Location, *d.Location)
assert.Equal(t, *expected.Devices[i].IdentityID, *d.IdentityID)
}
}
t.Run("method=get by id", func(t *testing.T) {
sess, err := p.GetSession(ctx, expected.ID, session.ExpandEverything)
check(sess, err)
checkDevices(sess.Devices, err)
t.Run("on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetSession(ctx, expected.ID, session.ExpandEverything)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("method=get by token", func(t *testing.T) {
sess, err := p.GetSessionByToken(ctx, expected.Token, session.ExpandEverything, identity.ExpandDefault)
check(sess, err)
checkDevices(sess.Devices, err)
t.Run("on another network", func(t *testing.T) {
_, p := testhelpers.NewNetwork(t, ctx, p)
_, err := p.GetSessionByToken(ctx, expected.Token, session.ExpandNothing, identity.ExpandDefault)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("case=update session", func(t *testing.T) {
expected.AuthenticatorAssuranceLevel = identity.AuthenticatorAssuranceLevel1
require.NoError(t, p.UpsertSession(ctx, &expected))
actual, err := p.GetSessionByToken(ctx, expected.Token, session.ExpandDefault, identity.ExpandDefault)
check(actual, err)
assert.Equal(t, identity.AuthenticatorAssuranceLevel1, actual.AuthenticatorAssuranceLevel)
})
t.Run("case=remove amr and update", func(t *testing.T) {
expected.AMR = nil
require.NoError(t, p.UpsertSession(ctx, &expected))
actual, err := p.GetSessionByToken(ctx, expected.Token, session.ExpandDefault, identity.ExpandDefault)
check(actual, err)
assert.Empty(t, actual.AMR)
})
})
t.Run("case=list sessions", func(t *testing.T) {
var identity1 identity.Identity
require.NoError(t, faker.FakeData(&identity1))
// Second identity to test listing by identity isolation
var identity2 identity.Identity
var identity2Session session.Session
require.NoError(t, faker.FakeData(&identity2))
require.NoError(t, faker.FakeData(&identity2Session))
// Create seed identities
_, l := testhelpers.NewNetwork(t, ctx, p)
require.NoError(t, l.CreateIdentity(ctx, &identity1))
require.NoError(t, l.CreateIdentity(ctx, &identity2))
seedSessionIDs := make([]uuid.UUID, 5)
seedSessionsList := make([]session.Session, 5)
now := time.Now().UTC()
for j := range seedSessionsList {
require.NoError(t, faker.FakeData(&seedSessionsList[j]))
seedSessionsList[j].Identity = &identity1
seedSessionsList[j].Active = j%2 == 0
if seedSessionsList[j].Active {
seedSessionsList[j].ExpiresAt = time.Now().UTC().Add(time.Hour)
} else {
seedSessionsList[j].ExpiresAt = time.Now().UTC().Add(-time.Hour)
}
var device session.Device
require.NoError(t, faker.FakeData(&device))
seedSessionsList[j].Devices = []session.Device{
device,
}
require.NoError(t, l.UpsertSession(ctx, &seedSessionsList[j]))
require.NoError(t, p.GetConnection(ctx).
RawQuery(
"UPDATE sessions SET created_at = ?, updated_at = ? WHERE id = ?",
now.Add(time.Duration(j)*time.Minute).Round(time.Second),
now.Add(time.Duration(j)*time.Minute).Round(time.Second),
seedSessionsList[j].ID).
Exec())
seedSessionIDs[j] = seedSessionsList[j].ID
}
identity2Session.Identity = &identity2
identity2Session.Active = true
identity2Session.ExpiresAt = time.Now().UTC().Add(time.Hour)
require.NoError(t, l.UpsertSession(ctx, &identity2Session))
for _, tc := range []struct {
desc string
except uuid.UUID
expectedSessionIds []uuid.UUID
active *bool
}{
{
desc: "all",
expectedSessionIds: seedSessionIDs,
},
{
desc: "except one",
except: seedSessionsList[0].ID,
expectedSessionIds: []uuid.UUID{
seedSessionIDs[1],
seedSessionIDs[2],
seedSessionIDs[3],
seedSessionIDs[4],
},
},
{
desc: "active only",
active: new(true),
expectedSessionIds: []uuid.UUID{
seedSessionIDs[0],
seedSessionIDs[2],
seedSessionIDs[4],
},
},
{
desc: "active only and except",
active: new(true),
except: seedSessionsList[0].ID,
expectedSessionIds: []uuid.UUID{
seedSessionIDs[2],
seedSessionIDs[4],
},
},
{
desc: "inactive only",
active: new(false),
expectedSessionIds: []uuid.UUID{
seedSessionIDs[1],
seedSessionIDs[3],
},
},
{
desc: "inactive only and except",
active: new(false),
except: seedSessionsList[3].ID,
expectedSessionIds: []uuid.UUID{
seedSessionIDs[1],
},
},
} {
t.Run("case=by Identity "+tc.desc, func(t *testing.T) {
actual, total, err := l.ListSessionsByIdentity(ctx, identity1.ID, tc.active, 1, 10, tc.except, session.ExpandEverything)
require.NoError(t, err)
actualSessionIds := make([]uuid.UUID, 0)
for _, s := range actual {
actualSessionIds = append(actualSessionIds, s.ID)
}
assert.Equal(t, int64(len(tc.expectedSessionIds)), total)
assert.ElementsMatch(t, tc.expectedSessionIds, actualSessionIds)
})
}
t.Run("case=by Identity on other network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
actual, total, err := other.ListSessionsByIdentity(ctx, identity1.ID, nil, 1, 10, uuid.Nil, session.ExpandNothing)
require.NoError(t, err)
require.Equal(t, int64(0), total)
assert.Len(t, actual, 0)
})
for _, tc := range []struct {
desc string
except uuid.UUID
expected []session.Session
active *bool
}{
{
desc: "all",
expected: append(seedSessionsList, identity2Session),
},
{
desc: "active only",
active: new(true),
expected: []session.Session{
seedSessionsList[0],
seedSessionsList[2],
seedSessionsList[4],
identity2Session,
},
},
{
desc: "inactive only",
active: new(false),
expected: []session.Session{
seedSessionsList[1],
seedSessionsList[3],
},
},
} {
t.Run("case=all "+tc.desc, func(t *testing.T) {
paginatorOpts := make([]keysetpagination.Option, 0)
actual, nextPage, err := l.ListSessions(ctx, tc.active, paginatorOpts, session.ExpandEverything)
require.NoError(t, err, "%+v", err)
require.Equal(t, len(tc.expected), len(actual))
assert.Equal(t, true, nextPage.IsLast())
mapPageToken := nextPage.Token().Parse("")
assert.Equal(t, uuid.Nil.String(), mapPageToken["id"])
assert.Equal(t, 250, nextPage.Size())
for _, es := range tc.expected {
found := false
for _, as := range actual {
if as.ID == es.ID {
found = true
assert.Equal(t, len(es.Devices), len(as.Devices))
assert.Equal(t, es.Identity.ID.String(), as.Identity.ID.String())
}
}
assert.True(t, found)
}
})
}
t.Run("case=all sessions pagination only one page", func(t *testing.T) {
paginatorOpts := make([]keysetpagination.Option, 0)
actual, page, err := l.ListSessions(ctx, nil, paginatorOpts, session.ExpandEverything)
require.NoError(t, err)
require.Equal(t, 6, len(actual))
assert.Equal(t, true, page.IsLast())
mapPageToken := page.Token().Parse("")
assert.Equal(t, uuid.Nil.String(), mapPageToken["id"])
assert.Equal(t, 250, page.Size())
})
t.Run("case=all sessions pagination multiple pages", func(t *testing.T) {
paginatorOpts := make([]keysetpagination.Option, 0)
paginatorOpts = append(paginatorOpts, keysetpagination.WithSize(3))
firstPageItems, page1, err := l.ListSessions(ctx, nil, paginatorOpts, session.ExpandEverything)
require.NoError(t, err)
assert.Len(t, firstPageItems, 3)
assert.Equal(t, false, page1.IsLast())
mapPageToken := page1.Token().Parse("")
assert.Equal(t, firstPageItems[len(firstPageItems)-1].ID.String(), mapPageToken["id"])
assert.Equal(t, 3, page1.Size())
// Validate secondPageItems page
secondPageItems, page2, err := l.ListSessions(ctx, nil, page1.ToOptions(), session.ExpandEverything)
require.NoError(t, err)
assert.Len(t, secondPageItems, 3)
acutalIDs := make([]uuid.UUID, 0)
for _, s := range append(firstPageItems, secondPageItems...) {
acutalIDs = append(acutalIDs, s.ID)
}
expect := append(seedSessionIDs, identity2Session.ID)
require.Len(t, acutalIDs, len(expect))
assert.ElementsMatch(t, expect, acutalIDs)
assert.True(t, page2.IsLast())
assert.Equal(t, 3, page2.Size())
})
})
t.Run("case=delete session", func(t *testing.T) {
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected))
t.Run("on another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
err := other.DeleteSession(ctx, expected.ID)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
_, err = p.GetSession(ctx, expected.ID, session.ExpandNothing)
assert.NoError(t, err)
})
require.NoError(t, p.DeleteSession(ctx, expected.ID))
_, err := p.GetSession(ctx, expected.ID, session.ExpandNothing)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
})
t.Run("case=delete session by token", func(t *testing.T) {
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected))
t.Run("on another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
err := other.DeleteSessionByToken(ctx, expected.Token)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
_, err = p.GetSessionByToken(ctx, expected.Token, session.ExpandNothing, identity.ExpandDefault)
assert.NoError(t, err)
})
require.NoError(t, p.DeleteSessionByToken(ctx, expected.Token))
_, err := p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.Error(t, err)
})
t.Run("case=revoke session by token", func(t *testing.T) {
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
expected.Active = true
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected))
actual, err := p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actual.Active)
t.Run("on another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
revoked, err := other.RevokeSessionByToken(ctx, expected.Token)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
assert.Equal(t, uuid.Nil, revoked.ID, "session id must be nil when not found")
assert.Equal(t, uuid.Nil, revoked.IdentityID, "identity id must be nil when not found")
actual, err = p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actual.Active)
})
revoked, err := p.RevokeSessionByToken(ctx, expected.Token)
require.NoError(t, err)
assert.Equal(t, expected.ID, revoked.ID, "must return the session id")
assert.Equal(t, expected.IdentityID, revoked.IdentityID, "must return the identity id")
actual, err = p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.False(t, actual.Active)
// Re-revoking an already-inactive session is silently
// idempotent — the CTE-based implementation returns the matched
// row, not the flipped one, so the IDs come back regardless.
revoked, err = p.RevokeSessionByToken(ctx, expected.Token)
require.NoError(t, err,
"re-revoking an already-inactive session must succeed silently on %s",
p.GetConnection(ctx).Dialect.Name())
assert.Equal(t, expected.ID, revoked.ID, "matched id must be returned on re-revoke")
assert.Equal(t, expected.IdentityID, revoked.IdentityID, "matched identity id must be returned on re-revoke")
actual, err = p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err, "session must still exist after re-revoke")
assert.False(t, actual.Active, "session must remain inactive after re-revoke")
t.Run("returns ErrNoRows for an unknown token", func(t *testing.T) {
revoked, err := p.RevokeSessionByToken(ctx, "this-token-does-not-exist-"+x.NewUUID().String())
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
assert.Equal(t, uuid.Nil, revoked.ID)
assert.Equal(t, uuid.Nil, revoked.IdentityID)
})
})
t.Run("case=revoke session by id", func(t *testing.T) {
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
expected.Active = true
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected))
actual, err := p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actual.Active)
t.Run("on another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
err := other.RevokeSessionById(ctx, expected.ID)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
actual, err = p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actual.Active)
})
require.NoError(t, p.RevokeSessionById(ctx, expected.ID))
actual, err = p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.False(t, actual.Active)
// Re-revoking an already-inactive session is silently
// idempotent — the admin disableSession handler relies on it.
require.NoError(t, p.RevokeSessionById(ctx, expected.ID),
"re-revoking an already-inactive session must succeed silently on %s",
p.GetConnection(ctx).Dialect.Name())
actual, err = p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err, "session must still exist after re-revoke")
assert.False(t, actual.Active, "session must remain inactive after re-revoke")
t.Run("returns ErrNoRows for an unknown id", func(t *testing.T) {
err := p.RevokeSessionById(ctx, x.NewUUID())
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
})
})
t.Run("method=revoke other sessions for identity", func(t *testing.T) {
// here we set up 2 identities with each having 2 sessions
sessions := make([]session.Session, 4)
for i := range sessions {
require.NoError(t, faker.FakeData(&sessions[i]))
}
require.NoError(t, p.CreateIdentity(ctx, sessions[0].Identity))
require.NoError(t, p.CreateIdentity(ctx, sessions[2].Identity))
sessions[1].IdentityID, sessions[1].Identity = sessions[0].IdentityID, sessions[0].Identity
sessions[3].IdentityID, sessions[3].Identity = sessions[2].IdentityID, sessions[2].Identity
for i := range sessions {
sessions[i].Active = true
require.NoError(t, p.UpsertSession(ctx, &sessions[i]))
}
t.Run("on another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
n, err := other.RevokeSessionsIdentityExcept(ctx, sessions[0].IdentityID, sessions[0].ID)
require.NoError(t, err)
assert.Equal(t, 0, n)
for _, s := range sessions {
actual, err := p.GetSession(ctx, s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actual.Active)
}
})
n, err := p.RevokeSessionsIdentityExcept(ctx, sessions[0].IdentityID, sessions[0].ID)
require.NoError(t, err)
assert.Equal(t, 1, n)
actual, total, err := p.ListSessionsByIdentity(ctx, sessions[0].IdentityID, nil, 1, 10, uuid.Nil, session.ExpandNothing)
require.NoError(t, err)
require.Len(t, actual, 2)
require.Equal(t, int64(2), total)
if actual[0].ID == sessions[0].ID {
assert.True(t, actual[0].Active)
assert.False(t, actual[1].Active)
} else {
assert.Equal(t, actual[0].ID, sessions[1].ID)
assert.True(t, actual[1].Active)
assert.False(t, actual[0].Active)
}
otherIdentitiesSessions, total, err := p.ListSessionsByIdentity(ctx, sessions[2].IdentityID, nil, 1, 10, uuid.Nil, session.ExpandNothing)
require.NoError(t, err)
require.Len(t, actual, 2)
require.Equal(t, int64(2), total)
for _, s := range otherIdentitiesSessions {
assert.True(t, s.Active)
}
t.Run("returns matched count, including already-inactive sessions", func(t *testing.T) {
// One identity, three sessions: the keeper, one already-
// inactive other, one active other. Returned count must be 2
// (both "other" sessions matched the predicate) — not 1
// (only the active "other" was actually flipped). The CTE on
// CRDB/Postgres must preserve matched-count, not flipped-count.
var keeper session.Session
require.NoError(t, faker.FakeData(&keeper))
keeper.Active = true
require.NoError(t, p.CreateIdentity(ctx, keeper.Identity))
require.NoError(t, p.UpsertSession(ctx, &keeper))
makeSibling := func() session.Session {
var s session.Session
require.NoError(t, faker.FakeData(&s))
s.Identity = keeper.Identity
s.IdentityID = keeper.IdentityID
s.Active = true
require.NoError(t, p.UpsertSession(ctx, &s))
return s
}
alreadyInactive := makeSibling()
require.NoError(t, p.RevokeSessionById(ctx, alreadyInactive.ID))
_ = makeSibling() // stillActive — will be flipped by the call below
n, err := p.RevokeSessionsIdentityExcept(ctx, keeper.IdentityID, keeper.ID)
require.NoError(t, err)
assert.Equal(t, 2, n,
"matched count must include already-inactive sessions on %s",
p.GetConnection(ctx).Dialect.Name())
})
})
t.Run("method=revoke specific session for identity", func(t *testing.T) {
sessions := make([]session.Session, 2)
for i := range sessions {
require.NoError(t, faker.FakeData(&sessions[i]))
}
require.NoError(t, p.CreateIdentity(ctx, sessions[0].Identity))
sessions[1].IdentityID, sessions[1].Identity = sessions[0].IdentityID, sessions[0].Identity
for i := range sessions {
sessions[i].Active = true
require.NoError(t, p.UpsertSession(ctx, &sessions[i]))
}
t.Run("on another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
require.NoError(t, other.RevokeSession(ctx, sessions[0].IdentityID, sessions[0].ID))
for _, s := range sessions {
actual, err := p.GetSession(ctx, s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actual.Active)
}
})
require.NoError(t, p.RevokeSession(ctx, sessions[0].IdentityID, sessions[0].ID))
actual, total, err := p.ListSessionsByIdentity(ctx, sessions[0].IdentityID, nil, 1, 10, uuid.Nil, session.ExpandNothing)
require.NoError(t, err)
require.Len(t, actual, 2)
require.Equal(t, int64(2), total)
if actual[0].ID == sessions[0].ID {
assert.False(t, actual[0].Active)
assert.True(t, actual[1].Active)
} else {
assert.Equal(t, actual[0].ID, sessions[1].ID)
assert.False(t, actual[1].Active)
assert.True(t, actual[0].Active)
}
// Re-revoking an already-inactive session must succeed silently —
// the `AND active = true` predicate added on the CRDB/Postgres path
// must not change the function's nil-on-no-rows contract.
require.NoError(t, p.RevokeSession(ctx, sessions[0].IdentityID, sessions[0].ID),
"re-revoking an already-inactive session must succeed silently")
// Wrong identity_id for an existing session id is a silent no-op,
// not an error.
require.NoError(t, p.RevokeSession(ctx, x.NewUUID(), sessions[1].ID))
actual1, err := p.GetSession(ctx, sessions[1].ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, actual1.Active, "session must stay active when identity_id mismatches")
})
t.Run("case=delete session for", func(t *testing.T) {
var expected1 session.Session
var expected2 session.Session
require.NoError(t, faker.FakeData(&expected1))
require.NoError(t, p.CreateIdentity(ctx, expected1.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected1))
require.NoError(t, faker.FakeData(&expected2))
expected2.Identity = expected1.Identity
expected2.IdentityID = expected1.IdentityID
require.NoError(t, p.UpsertSession(ctx, &expected2))
t.Run("on another network", func(t *testing.T) {
_, other := testhelpers.NewNetwork(t, ctx, p)
err := other.DeleteSessionsByIdentity(ctx, expected2.IdentityID)
assert.ErrorIs(t, err, sqlcon.ErrNoRows())
_, err = p.GetSession(ctx, expected1.ID, session.ExpandNothing)
require.NoError(t, err)
})
require.NoError(t, p.DeleteSessionsByIdentity(ctx, expected2.IdentityID))
_, err := p.GetSession(ctx, expected1.ID, session.ExpandNothing)
require.Error(t, err)
_, err = p.GetSession(ctx, expected2.ID, session.ExpandNothing)
require.Error(t, err)
})
t.Run("network isolation", func(t *testing.T) {
nid1, p := testhelpers.NewNetwork(t, ctx, p)
nid2, _ := testhelpers.NewNetwork(t, ctx, p)
iid1, iid2 := x.NewUUID(), x.NewUUID()
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at) VALUES (?, ?, 'default', '{}', ?, ?)", iid1, nid1, time.Now(), time.Now()).Exec())
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO identities (id, nid, schema_id, traits, created_at, updated_at) VALUES (?, ?, 'default', '{}', ?, ?)", iid2, nid2, time.Now(), time.Now()).Exec())
t1, t2 := randx.MustString(32, randx.AlphaNum), randx.MustString(32, randx.AlphaNum)
sid1, sid2 := x.NewUUID(), x.NewUUID()
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO sessions (id, nid, identity_id, token, expires_at,authenticated_at, created_at, updated_at, logout_token, authentication_methods) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", sid1, nid1, iid1, t1, time.Now().Add(time.Hour), time.Now(), time.Now(), time.Now(), randx.MustString(32, randx.AlphaNum), "[]").Exec())
require.NoError(t, p.GetConnection(ctx).RawQuery("INSERT INTO sessions (id, nid, identity_id, token, expires_at,authenticated_at, created_at, updated_at, logout_token, authentication_methods) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", sid2, nid2, iid2, t2, time.Now().Add(time.Hour), time.Now(), time.Now(), time.Now(), randx.MustString(32, randx.AlphaNum), "[]").Exec())
_, err := p.GetSession(ctx, sid1, session.ExpandEverything)
require.NoError(t, err)
_, err = p.GetSession(ctx, sid2, session.ExpandNothing)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
_, err = p.GetSessionByToken(ctx, t1, session.ExpandNothing, identity.ExpandDefault)
require.NoError(t, err)
_, err = p.GetSessionByToken(ctx, t2, session.ExpandNothing, identity.ExpandDefault)
require.ErrorIs(t, err, sqlcon.ErrNoRows())
})
t.Run("extend session lifespan but min time is not yet reached", func(t *testing.T) {
ctx := contextx.WithConfigValues(ctx, map[string]any{config.ViperKeySessionRefreshMinTimeLeft: 2 * time.Hour})
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
expected.ExpiresAt = time.Now().Add(time.Hour * 10).Round(time.Second).UTC()
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected))
require.NoError(t, p.ExtendSession(ctx, expected.ID))
actual, err := p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, expected.ExpiresAt, actual.ExpiresAt)
})
t.Run("extend session lifespan", func(t *testing.T) {
ctx := contextx.WithConfigValues(ctx, map[string]any{config.ViperKeySessionRefreshMinTimeLeft: 2 * time.Hour})
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
expected.ExpiresAt = time.Now().Add(time.Hour).UTC()
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected))
expectedExpiry := expected.Refresh(ctx, conf).ExpiresAt
require.NoError(t, p.ExtendSession(ctx, expected.ID))
actual, err := p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.GreaterOrEqual(t, 10*time.Second, expectedExpiry.Sub(actual.ExpiresAt).Abs())
})
t.Run("extend session lifespan on CockroachDB", func(t *testing.T) {
if p.GetConnection(ctx).Dialect.Name() != dbal.DriverCockroachDB {
t.Skip("Skipping test because driver is not CockroachDB")
}
ctx := contextx.WithConfigValue(ctx, config.ViperKeySessionRefreshMinTimeLeft, 2*time.Hour)
var expected session.Session
require.NoError(t, faker.FakeData(&expected))
expected.ExpiresAt = time.Now().Add(time.Hour).UTC()
require.NoError(t, p.CreateIdentity(ctx, expected.Identity))
require.NoError(t, p.UpsertSession(ctx, &expected))
expectedExpiry := expected.Refresh(ctx, conf).ExpiresAt
foundExpectedCockroachError := atomic.Bool{}
g := errgroup.Group{}
for range 10 {
g.Go(func() error {
err := p.ExtendSession(ctx, expected.ID)
if errors.Is(err, sqlcon.ErrNoRows()) {
foundExpectedCockroachError.Store(true)
return nil
}
return err
})
}
require.NoError(t, g.Wait())
actual, err := p.GetSession(ctx, expected.ID, session.ExpandNothing)
require.NoError(t, err)
assert.LessOrEqual(t, expectedExpiry.Sub(actual.ExpiresAt).Abs(), 10*time.Second)
assert.True(t, foundExpectedCockroachError.Load(), "We expect to find a not found error caused by ... FOR UPDATE SKIP LOCKED")
})
// Helpers for the manage-sessions persister batch tests below. Each
// helper creates an active session belonging to a fresh identity and
// returns both, so callers can mutate state independently across
// subtests without sharing fixtures.
newActiveSession := func(t *testing.T) *session.Session {
t.Helper()
var s session.Session
require.NoError(t, faker.FakeData(&s))
s.Active = true
require.NoError(t, p.CreateIdentity(ctx, s.Identity))
require.NoError(t, p.UpsertSession(ctx, &s))
return &s
}
isActive := func(t *testing.T, sID uuid.UUID) bool {
t.Helper()
s, err := p.GetSession(ctx, sID, session.ExpandNothing)
require.NoError(t, err)
return s.Active
}
exists := func(t *testing.T, sID uuid.UUID) bool {
t.Helper()
_, err := p.GetSession(ctx, sID, session.ExpandNothing)
if errors.Is(err, sqlcon.ErrNoRows()) {
return false
}
require.NoError(t, err)
return true
}
t.Run("case=RevokeSessionsByIdentities updates only matching active rows", func(t *testing.T) {
s1 := newActiveSession(t)
s2 := newActiveSession(t)
other := newActiveSession(t)
n, err := p.RevokeSessionsByIdentities(ctx, []uuid.UUID{s1.IdentityID, s2.IdentityID})
require.NoError(t, err)
assert.Equal(t, 2, n)
assert.False(t, isActive(t, s1.ID))
assert.False(t, isActive(t, s2.ID))
assert.True(t, isActive(t, other.ID), "untargeted session must stay active")
n, err = p.RevokeSessionsByIdentities(ctx, []uuid.UUID{s1.IdentityID})
require.NoError(t, err)
assert.Equal(t, 0, n, "already-inactive sessions must not be re-touched")
})
t.Run("case=RevokeSessionsByIdentities is network-isolated", func(t *testing.T) {
s := newActiveSession(t)
_, other := testhelpers.NewNetwork(t, ctx, p)
n, err := other.RevokeSessionsByIdentities(ctx, []uuid.UUID{s.IdentityID})
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.True(t, isActive(t, s.ID))
})
t.Run("case=RevokeSessionsByIdentities with empty slice is no-op", func(t *testing.T) {
n, err := p.RevokeSessionsByIdentities(ctx, nil)
require.NoError(t, err)
assert.Equal(t, 0, n)
})
t.Run("case=RevokeSessionsByIDs updates only matching active rows", func(t *testing.T) {
s1 := newActiveSession(t)
s2 := newActiveSession(t)
untouched := newActiveSession(t)
n, err := p.RevokeSessionsByIDs(ctx, []uuid.UUID{s1.ID, s2.ID})
require.NoError(t, err)
assert.Equal(t, 2, n)
assert.False(t, isActive(t, s1.ID))
assert.False(t, isActive(t, s2.ID))
assert.True(t, isActive(t, untouched.ID))
n, err = p.RevokeSessionsByIDs(ctx, []uuid.UUID{s1.ID})
require.NoError(t, err)
assert.Equal(t, 0, n, "already-inactive sessions must not be re-touched")
})
t.Run("case=RevokeSessionsByIDs is network-isolated", func(t *testing.T) {
s := newActiveSession(t)
_, other := testhelpers.NewNetwork(t, ctx, p)
n, err := other.RevokeSessionsByIDs(ctx, []uuid.UUID{s.ID})
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.True(t, isActive(t, s.ID))
})
t.Run("case=RevokeSessionsByIDs with empty slice is no-op", func(t *testing.T) {
n, err := p.RevokeSessionsByIDs(ctx, nil)
require.NoError(t, err)
assert.Equal(t, 0, n)
})
t.Run("case=RevokeAllSessions deactivates only currently-active rows in this network", func(t *testing.T) {
_, scoped := testhelpers.NewNetwork(t, ctx, p)
active1 := newActiveSession(t)
active2 := newActiveSession(t)
// Create one session that's already inactive, in the original network.
alreadyInactive := newActiveSession(t)
require.NoError(t, p.RevokeSessionById(ctx, alreadyInactive.ID))
// Create a session in the other network — must remain untouched.
var foreign session.Session
require.NoError(t, faker.FakeData(&foreign))
foreign.Active = true
require.NoError(t, scoped.CreateIdentity(ctx, foreign.Identity))
require.NoError(t, scoped.UpsertSession(ctx, &foreign))
n, err := p.RevokeAllSessions(ctx, 100)
require.NoError(t, err)
assert.GreaterOrEqual(t, n, 2, "must have flipped active1 and active2 at least")
assert.False(t, isActive(t, active1.ID))
assert.False(t, isActive(t, active2.ID))
// The foreign session must still be active when read via its own network.
fs, err := scoped.GetSession(ctx, foreign.ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, fs.Active, "foreign-network session must not be touched")
})
t.Run("case=RevokeAllSessions caps the work at limit", func(t *testing.T) {
// Isolated network so previous tests can't influence the count.
_, scoped := testhelpers.NewNetwork(t, ctx, p)
ids := make([]uuid.UUID, 3)
for i := range ids {
var s session.Session
require.NoError(t, faker.FakeData(&s))
s.Active = true
require.NoError(t, scoped.CreateIdentity(ctx, s.Identity))
require.NoError(t, scoped.UpsertSession(ctx, &s))
ids[i] = s.ID
}
n, err := scoped.RevokeAllSessions(ctx, 2)
require.NoError(t, err)
assert.Equal(t, 2, n, "must flip exactly limit rows")
activeCount := 0
for _, id := range ids {
s, err := scoped.GetSession(ctx, id, session.ExpandNothing)
require.NoError(t, err)
if s.Active {
activeCount++
}
}
assert.Equal(t, 1, activeCount, "limit must leave one session active")
})
t.Run("case=DeleteSessionsByIdentities removes matching rows", func(t *testing.T) {
s1 := newActiveSession(t)
s2 := newActiveSession(t)
survivor := newActiveSession(t)
n, err := p.DeleteSessionsByIdentities(ctx, []uuid.UUID{s1.IdentityID, s2.IdentityID})
require.NoError(t, err)
assert.Equal(t, 2, n)
assert.False(t, exists(t, s1.ID))
assert.False(t, exists(t, s2.ID))
assert.True(t, exists(t, survivor.ID))
})
t.Run("case=DeleteSessionsByIdentities is network-isolated", func(t *testing.T) {
s := newActiveSession(t)
_, other := testhelpers.NewNetwork(t, ctx, p)
n, err := other.DeleteSessionsByIdentities(ctx, []uuid.UUID{s.IdentityID})
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.True(t, exists(t, s.ID))
})
t.Run("case=DeleteSessionsByIDs removes matching rows", func(t *testing.T) {
s1 := newActiveSession(t)
s2 := newActiveSession(t)
survivor := newActiveSession(t)
n, err := p.DeleteSessionsByIDs(ctx, []uuid.UUID{s1.ID, s2.ID})
require.NoError(t, err)
assert.Equal(t, 2, n)
assert.False(t, exists(t, s1.ID))
assert.False(t, exists(t, s2.ID))
assert.True(t, exists(t, survivor.ID))
})
t.Run("case=DeleteSessionsByIDs is network-isolated", func(t *testing.T) {
s := newActiveSession(t)
_, other := testhelpers.NewNetwork(t, ctx, p)
n, err := other.DeleteSessionsByIDs(ctx, []uuid.UUID{s.ID})
require.NoError(t, err)
assert.Equal(t, 0, n)
assert.True(t, exists(t, s.ID))
})
t.Run("case=DeleteAllSessions removes only this network's rows", func(t *testing.T) {
_, scoped := testhelpers.NewNetwork(t, ctx, p)
s1 := newActiveSession(t)
s2 := newActiveSession(t)
var foreign session.Session
require.NoError(t, faker.FakeData(&foreign))
foreign.Active = true
require.NoError(t, scoped.CreateIdentity(ctx, foreign.Identity))
require.NoError(t, scoped.UpsertSession(ctx, &foreign))
n, err := p.DeleteAllSessions(ctx, 100)
require.NoError(t, err)
assert.GreaterOrEqual(t, n, 2)
assert.False(t, exists(t, s1.ID))
assert.False(t, exists(t, s2.ID))
fs, err := scoped.GetSession(ctx, foreign.ID, session.ExpandNothing)
require.NoError(t, err)
assert.NotNil(t, fs, "foreign-network session must survive")
})
}
}

181
session/tokenizer.go Normal file
View File

@@ -0,0 +1,181 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session
import (
"context"
"encoding/json"
"time"
"github.com/dgraph-io/ristretto/v2"
"github.com/gofrs/uuid"
"github.com/golang-jwt/jwt/v5"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel/trace"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/events"
"github.com/ory/x/fetcher"
"github.com/ory/x/httpx"
"github.com/ory/x/jsonnetsecure"
"github.com/ory/x/jwksx"
"github.com/ory/x/otelx"
)
type (
tokenizerDependencies interface {
jsonnetsecure.VMProvider
otelx.Provider
httpx.ClientProvider
config.Provider
x.JWKSFetchProvider
}
Tokenizer struct {
r tokenizerDependencies
nowFunc func() time.Time
cache *ristretto.Cache[[]byte, []byte]
}
TokenizerProvider interface {
SessionTokenizer() *Tokenizer
}
)
func NewTokenizer(r tokenizerDependencies) *Tokenizer {
cache, _ := ristretto.NewCache(&ristretto.Config[[]byte, []byte]{
MaxCost: 50 << 20, // 50MB,
NumCounters: 500_000, // 1kB per snippet -> 50k snippets -> 500k counters
BufferItems: 64,
})
return &Tokenizer{r: r, nowFunc: time.Now, cache: cache}
}
func (s *Tokenizer) SetNowFunc(t func() time.Time) {
s.nowFunc = t
}
func SetSubjectClaim(claims jwt.MapClaims, session *Session, subjectSource string) error {
switch subjectSource {
case "", "id":
claims["sub"] = session.IdentityID.String()
case "external_id":
if session.Identity.ExternalID == "" {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("The session's identity does not have an external ID set, but it is required for the subject claim."))
}
claims["sub"] = session.Identity.ExternalID.String()
default:
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unknown subject source %q", subjectSource))
}
return nil
}
func (s *Tokenizer) TokenizeSession(ctx context.Context, template string, session *Session) (err error) {
ctx, span := s.r.Tracer(ctx).Tracer().Start(ctx, "sessions.ManagerHTTP.TokenizeSession")
defer otelx.End(span, &err)
tpl, err := s.r.Config().TokenizeTemplate(ctx, template)
if err != nil {
return err
}
key, err := s.r.JWKSFetcher().ResolveKey(
ctx,
tpl.JWKSURL,
jwksx.WithCacheEnabled(),
jwksx.WithCacheTTL(time.Hour),
jwksx.WithAllowedSchemes("file", "base64")) // HTTP(S) makes no sense here.
if err != nil {
if errors.Is(err, jwksx.ErrUnableToFindKeyID) {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("Could not find key a suitable key for tokenization in the JWKS url."))
}
return err
}
alg := jwt.GetSigningMethod(key.Algorithm())
if alg == nil {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("The JSON Web Key must include a valid \"alg\" parameter but \"%s\" was given.", key.Algorithm()))
}
vm, err := s.r.JsonnetVM(ctx)
if err != nil {
return err
}
now := s.nowFunc()
token := jwt.New(alg)
token.Header["kid"] = key.KeyID()
claims := jwt.MapClaims{
"jti": uuid.Must(uuid.NewV4()).String(),
"iss": s.r.Config().SelfPublicURL(ctx).String(),
"exp": now.Add(tpl.TTL).Unix(),
"sid": session.ID.String(),
"nbf": now.Unix(),
"iat": now.Unix(),
}
if err = SetSubjectClaim(claims, session, tpl.SubjectSource); err != nil {
return err
}
if mapper := tpl.ClaimsMapperURL; len(mapper) > 0 {
sessionRaw, err := json.Marshal(session)
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithWrap(err).WithReasonf("Unable to encode session to JSON."))
}
claimsRaw, err := json.Marshal(&claims)
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithWrap(err).WithReasonf("Unable to encode claims to JSON."))
}
vm.ExtCode("session", string(sessionRaw))
vm.ExtCode("claims", string(claimsRaw))
httpClient := s.r.HTTPClient(ctx)
fetcher := fetcher.NewFetcher(fetcher.WithClient(httpClient), fetcher.WithCache(s.cache, 60*time.Minute))
jsonnet, err := fetcher.FetchContext(ctx, mapper)
if err != nil {
return err
}
evaluated, err := vm.EvaluateAnonymousSnippet(tpl.ClaimsMapperURL, jsonnet.String())
if err != nil {
trace.SpanFromContext(ctx).AddEvent(events.NewJsonnetMappingFailed(
ctx, err, jsonnet.Bytes(), evaluated, "", "",
))
return errors.WithStack(herodot.ErrBadRequest().WithWrap(err).WithDebug(err.Error()).WithReasonf("Unable to execute tokenizer JsonNet."))
}
evaluatedClaims := gjson.Get(evaluated, "claims")
if !evaluatedClaims.IsObject() {
trace.SpanFromContext(ctx).AddEvent(events.NewJsonnetMappingFailed(
ctx, err, jsonnet.Bytes(), evaluated, "", "",
))
return errors.WithStack(herodot.ErrBadRequest().WithWrap(err).WithReasonf("Expected tokenizer JsonNet to return a claims object but it did not."))
}
if err := json.Unmarshal([]byte(evaluatedClaims.Raw), &claims); err != nil {
return errors.WithStack(herodot.ErrBadRequest().WithWrap(err).WithReasonf("Unable to encode tokenized claims."))
}
}
if err = SetSubjectClaim(claims, session, tpl.SubjectSource); err != nil {
return err
}
var privateKey interface{}
if err := key.Raw(&privateKey); err != nil {
return errors.WithStack(herodot.ErrBadRequest().WithWrap(err).WithReasonf("Unable to decode the given private key."))
}
token.Claims = claims
result, err := token.SignedString(privateKey)
if err != nil {
return errors.WithStack(herodot.ErrBadRequest().WithWrap(err).WithReasonf("Unable to sign JSON Web Token."))
}
trace.SpanFromContext(ctx).AddEvent(events.NewSessionJWTIssued(ctx, session.ID, session.IdentityID, tpl.TTL))
session.Tokenized = result
return nil
}

166
session/tokenizer_test.go Normal file
View File

@@ -0,0 +1,166 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package session_test
import (
"context"
_ "embed"
"net/http/httptest"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"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/x/configx"
"github.com/ory/x/contextx"
"github.com/ory/x/snapshotx"
)
//go:embed stub/jwk.es256.json
var es256Key []byte
//go:embed stub/jwk.es512.json
var es512Key []byte
func validateTokenized(t *testing.T, raw string, key []byte) *jwt.Token {
token, err := jwt.Parse(
raw,
func(token *jwt.Token) (target interface{}, _ error) {
set, err := jwk.Parse(key)
if err != nil {
return nil, err
}
key, _ := set.Key(0)
if pk, err := key.PublicKey(); err != nil {
return nil, err
} else if err := pk.Raw(&target); err != nil {
return nil, err
}
return target, nil
},
// We use a fixed time function for snapshot testing, and thus can not validate claims.
jwt.WithoutClaimsValidation(),
)
require.NoError(t, err)
return token
}
func setTokenizeConfig(ctx context.Context, templateID, keyFile, mapper string) context.Context {
return contextx.WithConfigValue(ctx, config.ViperKeySessionTokenizerTemplates+"."+templateID, &config.SessionTokenizeFormat{
TTL: time.Minute,
JWKSURL: "file://stub/" + keyFile,
ClaimsMapperURL: mapper,
})
}
func setTokenizeConfigWitSubjectSource(ctx context.Context, templateID, keyFile, mapper, subjectSource string) context.Context {
return contextx.WithConfigValue(ctx, config.ViperKeySessionTokenizerTemplates+"."+templateID, &config.SessionTokenizeFormat{
TTL: time.Minute,
JWKSURL: "file://stub/" + keyFile,
ClaimsMapperURL: mapper,
SubjectSource: subjectSource,
})
}
func TestTokenizer(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValue(config.ViperKeyPublicBaseURL, "http://localhost/"),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
)
tkn := session.NewTokenizer(reg)
nowDate := time.Date(2023, 02, 01, 00, 00, 00, 0, time.UTC)
tkn.SetNowFunc(func() time.Time {
return nowDate.Round(time.Second)
})
r := httptest.NewRequest("GET", "/sessions/whoami", nil)
i := identity.NewIdentity("default")
i.ID = uuid.FromStringOrNil("7458af86-c1d8-401c-978a-8da89133f78b")
i.ExternalID = "external-id"
i.NID = uuid.Must(uuid.NewV4())
s, err := testhelpers.NewActiveSession(r, reg, i, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
s.ID = uuid.FromStringOrNil("432caf86-c1d8-401c-978a-8da89133f78b")
iWithoutExtID := identity.NewIdentity("default")
iWithoutExtID.ID = uuid.FromStringOrNil("710678c5-7761-455a-9e3b-be66e3019da2")
iWithoutExtID.NID = i.NID
s2, err := testhelpers.NewActiveSession(r, reg, iWithoutExtID, time.Now(), identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
require.NoError(t, err)
s2.ID = uuid.FromStringOrNil("44de370d-c8ae-4e2c-b943-5e9d9cc385da")
t.Run("case=es256-without-jsonnet", func(t *testing.T) {
tid := "es256-no-template"
ctx := setTokenizeConfig(t.Context(), tid, "jwk.es256.json", "")
require.NoError(t, tkn.TokenizeSession(ctx, tid, s))
token := validateTokenized(t, s.Tokenized, es256Key)
resultClaims := token.Claims.(jwt.MapClaims)
assert.Equal(t, i.ID.String(), resultClaims["sub"])
assert.Equal(t, s.ID.String(), resultClaims["sid"])
assert.NotEmpty(t, resultClaims["jti"])
assert.EqualValues(t, resultClaims["exp"], nowDate.Add(time.Minute).Unix())
snapshotx.SnapshotT(t, token.Claims, snapshotx.ExceptPaths("jti"))
})
t.Run("case=es512-without-jsonnet", func(t *testing.T) {
tid := "es512-no-template"
ctx := setTokenizeConfig(t.Context(), tid, "jwk.es512.json", "")
require.NoError(t, tkn.TokenizeSession(ctx, tid, s))
token := validateTokenized(t, s.Tokenized, es512Key)
snapshotx.SnapshotT(t, token.Claims, snapshotx.ExceptPaths("jti"))
})
t.Run("case=rs512-with-jsonnet", func(t *testing.T) {
tid := "rs512-template"
ctx := setTokenizeConfigWitSubjectSource(t.Context(), tid, "jwk.es512.json", "file://stub/rs512-template.jsonnet", "id")
require.NoError(t, tkn.TokenizeSession(ctx, tid, s))
token := validateTokenized(t, s.Tokenized, es512Key)
snapshotx.SnapshotT(t, token.Claims, snapshotx.ExceptPaths("jti"))
})
t.Run("case=rs512-with-external_id-in-sub", func(t *testing.T) {
tid := "rs512-template"
ctx := setTokenizeConfigWitSubjectSource(t.Context(), tid, "jwk.es512.json", "file://stub/rs512-template.jsonnet", "external_id")
require.NoError(t, tkn.TokenizeSession(ctx, tid, s))
token := validateTokenized(t, s.Tokenized, es512Key)
snapshotx.SnapshotT(t, token.Claims, snapshotx.ExceptPaths("jti"))
})
t.Run("case=rs512-with-empty-external_id-in-sub", func(t *testing.T) {
tid := "rs512-template"
ctx := setTokenizeConfigWitSubjectSource(t.Context(), tid, "jwk.es512.json", "file://stub/rs512-template.jsonnet", "external_id")
// This should fail because the identity does not have an external ID set.
require.Error(t, tkn.TokenizeSession(ctx, tid, s2))
})
t.Run("case=rs512-with-broken-keyfile", func(t *testing.T) {
tid := "rs512-template"
ctx := setTokenizeConfig(t.Context(), tid, "jwk.es512.broken.json", "file://stub/rs512-template.jsonnet")
err := tkn.TokenizeSession(ctx, tid, s)
require.ErrorIs(t, err, herodot.ErrBadRequest())
})
}