snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
20
session/error.go
Normal 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
50
session/expand.go
Normal 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
35
session/expand_test.go
Normal 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
1436
session/handler.go
Normal file
File diff suppressed because it is too large
Load Diff
1439
session/handler_test.go
Normal file
1439
session/handler_test.go
Normal file
File diff suppressed because it is too large
Load Diff
19
session/helper.go
Normal file
19
session/helper.go
Normal 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
55
session/helper_test.go
Normal 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
191
session/manager.go
Normal 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
548
session/manager_http.go
Normal 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
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
93
session/manager_test.go
Normal 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
116
session/persistence.go
Normal 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
436
session/session.go
Normal 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
624
session/session_test.go
Normal file
@@ -0,0 +1,624 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package session_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/contextx"
|
||||
)
|
||||
|
||||
func TestAdminSessionMarshal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metadataAdmin := []byte(`{"role":"admin"}`)
|
||||
ident := &identity.Identity{
|
||||
MetadataAdmin: metadataAdmin,
|
||||
State: identity.StateActive,
|
||||
}
|
||||
sess := session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Active: true,
|
||||
Identity: ident,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
t.Run("case=regular Session omits metadata_admin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := json.Marshal(sess)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, gjson.GetBytes(b, "identity.metadata_admin").Exists())
|
||||
assert.Equal(t, sess.ID.String(), gjson.GetBytes(b, "id").String())
|
||||
assert.True(t, gjson.GetBytes(b, "active").Bool())
|
||||
})
|
||||
|
||||
t.Run("case=AdminSession includes metadata_admin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
b, err := json.Marshal(session.AdminSession(sess))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{"role":"admin"}`, gjson.GetBytes(b, "identity.metadata_admin").Raw)
|
||||
assert.Equal(t, sess.ID.String(), gjson.GetBytes(b, "id").String())
|
||||
assert.True(t, gjson.GetBytes(b, "active").Bool())
|
||||
})
|
||||
|
||||
t.Run("case=AdminSession uses runtime active semantics for expired sessions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expiredSess := session.Session{
|
||||
ID: x.NewUUID(),
|
||||
Active: true,
|
||||
Identity: ident,
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
}
|
||||
|
||||
b, err := json.Marshal(session.AdminSession(expiredSess))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{"role":"admin"}`, gjson.GetBytes(b, "identity.metadata_admin").Raw)
|
||||
assert.Equal(t, expiredSess.ID.String(), gjson.GetBytes(b, "id").String())
|
||||
assert.False(t, gjson.GetBytes(b, "active").Bool())
|
||||
})
|
||||
|
||||
t.Run("case=AdminSessions converts slice", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
sessions := []session.Session{sess, sess}
|
||||
adminSessions := session.AdminSessions(sessions)
|
||||
require.Len(t, adminSessions, 2)
|
||||
for _, as := range adminSessions {
|
||||
b, err := json.Marshal(as)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{"role":"admin"}`, gjson.GetBytes(b, "identity.metadata_admin").Raw)
|
||||
assert.Equal(t, sess.ID.String(), gjson.GetBytes(b, "id").String())
|
||||
assert.True(t, gjson.GetBytes(b, "active").Bool())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSession(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
|
||||
)
|
||||
authAt := time.Now()
|
||||
|
||||
t.Run("case=active session", func(t *testing.T) {
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||||
|
||||
i := new(identity.Identity)
|
||||
i.State = identity.StateActive
|
||||
i.NID = x.NewUUID()
|
||||
s, err := testhelpers.NewActiveSession(req, reg, i, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, s.IsActive())
|
||||
require.NotEmpty(t, s.Token)
|
||||
require.NotEmpty(t, s.LogoutToken)
|
||||
assert.EqualValues(t, identity.CredentialsTypePassword, s.AMR[0].Method)
|
||||
|
||||
i = new(identity.Identity)
|
||||
i.NID = x.NewUUID()
|
||||
s, err = testhelpers.NewActiveSession(req, reg, i, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||||
assert.Nil(t, s)
|
||||
assert.ErrorIs(t, err, session.ErrIdentityDisabled())
|
||||
})
|
||||
|
||||
t.Run("case=expired", func(t *testing.T) {
|
||||
assert.False(t, (&session.Session{ExpiresAt: time.Now().Add(time.Hour)}).IsActive())
|
||||
assert.False(t, (&session.Session{Active: true}).IsActive())
|
||||
})
|
||||
|
||||
t.Run("case=amr add", func(t *testing.T) {
|
||||
s := session.NewInactiveSession()
|
||||
s.CompletedLoginFor(identity.CredentialsTypeOIDC, identity.AuthenticatorAssuranceLevel1)
|
||||
assert.EqualValues(t, identity.CredentialsTypeOIDC, s.AMR[0].Method)
|
||||
s.CompletedLoginFor(identity.CredentialsTypeRecoveryLink, identity.AuthenticatorAssuranceLevel1)
|
||||
assert.EqualValues(t, identity.CredentialsTypeOIDC, s.AMR[0].Method)
|
||||
assert.EqualValues(t, identity.CredentialsTypeRecoveryLink, s.AMR[1].Method)
|
||||
s.CompletedLoginFor(identity.CredentialsTypeRecoveryCode, identity.AuthenticatorAssuranceLevel1)
|
||||
assert.EqualValues(t, identity.CredentialsTypeRecoveryCode, s.AMR[2].Method)
|
||||
})
|
||||
|
||||
t.Run("case=activate", func(t *testing.T) {
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||||
|
||||
s := session.NewInactiveSession()
|
||||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
|
||||
assert.True(t, s.Active)
|
||||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
|
||||
assert.Equal(t, authAt, s.AuthenticatedAt)
|
||||
|
||||
s = session.NewInactiveSession()
|
||||
require.ErrorIs(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateInactive}, authAt), session.ErrIdentityDisabled())
|
||||
assert.False(t, s.Active)
|
||||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
|
||||
assert.Empty(t, s.AuthenticatedAt)
|
||||
})
|
||||
|
||||
t.Run("case=client information reverse proxy forward", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "10.10.8.1, 172.19.2.7",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
input: "217.73.188.139,162.158.203.149, 172.19.2.7",
|
||||
expected: "162.158.203.149",
|
||||
},
|
||||
{
|
||||
input: "122.122.122.122 , 123.123.123.123",
|
||||
expected: "123.123.123.123",
|
||||
},
|
||||
} {
|
||||
t.Run("case=parse "+tc.input, func(t *testing.T) {
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||||
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
|
||||
req.Header.Set("X-Forwarded-For", tc.input)
|
||||
|
||||
s := session.NewInactiveSession()
|
||||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
|
||||
assert.True(t, s.Active)
|
||||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
|
||||
assert.Equal(t, authAt, s.AuthenticatedAt)
|
||||
assert.Equal(t, 1, len(s.Devices))
|
||||
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
|
||||
assert.Equal(t, tc.expected, *s.Devices[0].IPAddress)
|
||||
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
|
||||
assert.Equal(t, "", *s.Devices[0].Location)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=client information reverse proxy real IP set", func(t *testing.T) {
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||||
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
|
||||
req.Header.Set("X-Real-IP", "54.155.246.155")
|
||||
req.Header["X-Forwarded-For"] = []string{"54.155.246.232", "10.145.1.10"}
|
||||
|
||||
s := session.NewInactiveSession()
|
||||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
|
||||
assert.True(t, s.Active)
|
||||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
|
||||
assert.Equal(t, authAt, s.AuthenticatedAt)
|
||||
assert.Equal(t, 1, len(s.Devices))
|
||||
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
|
||||
assert.NotNil(t, s.Devices[0].UpdatedAt)
|
||||
assert.NotNil(t, s.Devices[0].CreatedAt)
|
||||
assert.Equal(t, "54.155.246.155", *s.Devices[0].IPAddress)
|
||||
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
|
||||
assert.Equal(t, "", *s.Devices[0].Location)
|
||||
})
|
||||
|
||||
t.Run("case=client information CF true client IP set", func(t *testing.T) {
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||||
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
|
||||
req.Header.Set("True-Client-IP", "54.155.246.155")
|
||||
req.Header.Set("X-Forwarded-For", "217.73.188.139,162.158.203.149, 172.19.2.7")
|
||||
|
||||
s := session.NewInactiveSession()
|
||||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{State: identity.StateActive, NID: x.NewUUID()}, authAt))
|
||||
assert.True(t, s.Active)
|
||||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
|
||||
assert.Equal(t, authAt, s.AuthenticatedAt)
|
||||
assert.Equal(t, 1, len(s.Devices))
|
||||
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
|
||||
assert.NotNil(t, s.Devices[0].UpdatedAt)
|
||||
assert.NotNil(t, s.Devices[0].CreatedAt)
|
||||
assert.Equal(t, "54.155.246.155", *s.Devices[0].IPAddress)
|
||||
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
|
||||
assert.Equal(t, "", *s.Devices[0].Location)
|
||||
})
|
||||
|
||||
t.Run("case=client information CF", func(t *testing.T) {
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||||
req.Header["User-Agent"] = []string{"Mozilla/5.0 (X11; Linux x86_64)", "AppleWebKit/537.36 (KHTML, like Gecko)", "Chrome/51.0.2704.103 Safari/537.36"}
|
||||
req.Header.Set("True-Client-IP", "54.155.246.232")
|
||||
req.Header.Set("Cf-Ipcity", "Munich")
|
||||
req.Header.Set("Cf-Ipcountry", "Germany")
|
||||
|
||||
s := session.NewInactiveSession()
|
||||
require.NoError(t, reg.SessionManager().ActivateSession(req, s, &identity.Identity{NID: x.NewUUID(), State: identity.StateActive}, authAt))
|
||||
assert.True(t, s.Active)
|
||||
assert.Equal(t, identity.NoAuthenticatorAssuranceLevel, s.AuthenticatorAssuranceLevel)
|
||||
assert.Equal(t, authAt, s.AuthenticatedAt)
|
||||
assert.Equal(t, 1, len(s.Devices))
|
||||
assert.Equal(t, s.ID.String(), s.Devices[0].SessionID.String())
|
||||
assert.Equal(t, "54.155.246.232", *s.Devices[0].IPAddress)
|
||||
assert.Equal(t, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36", *s.Devices[0].UserAgent)
|
||||
assert.Equal(t, "Munich, Germany", *s.Devices[0].Location)
|
||||
})
|
||||
|
||||
for k, tc := range []struct {
|
||||
d string
|
||||
methods []session.AuthenticationMethod
|
||||
expected identity.AuthenticatorAssuranceLevel
|
||||
}{
|
||||
{
|
||||
d: "no amr means no assurance",
|
||||
expected: identity.NoAuthenticatorAssuranceLevel,
|
||||
},
|
||||
{
|
||||
d: "password is aal1",
|
||||
methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypePassword}},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "oidc is aal1",
|
||||
methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypeOIDC}},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "recovery link is aal1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryLink},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "recovery code is aal1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryCode},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "mix of password, oidc, recovery link is still aal1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryLink},
|
||||
{Method: identity.CredentialsTypeOIDC},
|
||||
{Method: identity.CredentialsTypePassword},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "mix of password, oidc, recovery code is still aal1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryCode},
|
||||
{Method: identity.CredentialsTypeOIDC},
|
||||
{Method: identity.CredentialsTypePassword},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "just totp is gracefully aal1",
|
||||
methods: []session.AuthenticationMethod{{Method: identity.CredentialsTypeTOTP}},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "password + totp is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypePassword},
|
||||
{Method: identity.CredentialsTypeTOTP},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "password + lookup is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypePassword},
|
||||
{Method: identity.CredentialsTypeLookup},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "oidc + totp is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeOIDC},
|
||||
{Method: identity.CredentialsTypeTOTP},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "oidc + lookup is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeOIDC},
|
||||
{Method: identity.CredentialsTypeLookup},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "recovery link + totp is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryLink},
|
||||
{Method: identity.CredentialsTypeTOTP},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "recovery code + totp is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryCode},
|
||||
{Method: identity.CredentialsTypeTOTP},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "recovery link + lookup is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryLink},
|
||||
{Method: identity.CredentialsTypeLookup},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "recovery code + lookup is aal2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryCode},
|
||||
{Method: identity.CredentialsTypeLookup},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "recovery link + passwordless webauth is aal1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryLink},
|
||||
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "recovery code + passwordless webauth is aal1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeRecoveryCode},
|
||||
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "respects AAL on AAL1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "respects AAL on AAL2 without AAL1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "respects AAL on AAL2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d/description=%s", k, tc.d), func(t *testing.T) {
|
||||
s := session.NewInactiveSession()
|
||||
for _, m := range tc.methods {
|
||||
s.CompletedLoginFor(m.Method, m.AAL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("case=federated AAL2 carry-over", func(t *testing.T) {
|
||||
// A single OIDC or SAML method tagged AAL2 elevates the
|
||||
// session to AAL2 on its own. This is the upstream-MFA
|
||||
// carry-over path: the provider config matched the upstream
|
||||
// `acr` or `amr` claims, so Ory trusts that the upstream IdP
|
||||
// already performed a second factor. Local methods (password,
|
||||
// TOTP, WebAuthn, lookup, recovery) still need to be paired
|
||||
// with an AAL1 entry to reach AAL2.
|
||||
for _, tc := range []struct {
|
||||
d string
|
||||
methods []session.AuthenticationMethod
|
||||
expected identity.AuthenticatorAssuranceLevel
|
||||
}{
|
||||
{
|
||||
d: "single OIDC method with explicit AAL2 is AAL2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "single SAML method with explicit AAL2 is AAL2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeSAML, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
{
|
||||
d: "single password method tagged AAL2 stays AAL1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "single WebAuthn method tagged AAL2 stays AAL1",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypeWebAuthn, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel1,
|
||||
},
|
||||
{
|
||||
d: "OIDC AAL2 carry-over plus extra password AAL1 stays AAL2",
|
||||
methods: []session.AuthenticationMethod{
|
||||
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel2},
|
||||
},
|
||||
expected: identity.AuthenticatorAssuranceLevel2,
|
||||
},
|
||||
} {
|
||||
t.Run("case="+tc.d, func(t *testing.T) {
|
||||
s := session.NewInactiveSession()
|
||||
for _, m := range tc.methods {
|
||||
s.CompletedLoginFor(m.Method, m.AAL)
|
||||
}
|
||||
s.SetAuthenticatorAssuranceLevel()
|
||||
assert.Equal(t, tc.expected, s.AuthenticatorAssuranceLevel)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=authentication method upstream acr/amr round-trip", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := session.AuthenticationMethod{
|
||||
Method: identity.CredentialsTypeOIDC,
|
||||
AAL: identity.AuthenticatorAssuranceLevel2,
|
||||
CompletedAt: time.Now().UTC().Round(time.Second),
|
||||
Provider: "hydra",
|
||||
Organization: "acme",
|
||||
UpstreamACR: "urn:mfa",
|
||||
UpstreamAMR: []string{"pwd", "mfa"},
|
||||
}
|
||||
|
||||
value, err := in.Value()
|
||||
require.NoError(t, err)
|
||||
|
||||
var out session.AuthenticationMethod
|
||||
require.NoError(t, out.Scan(value))
|
||||
assert.Equal(t, in, out)
|
||||
|
||||
// Also round-trip via a list, to exercise the AuthenticationMethods
|
||||
// Scan/Value path used by the sessions table column.
|
||||
methods := session.AuthenticationMethods{in}
|
||||
listValue, err := methods.Value()
|
||||
require.NoError(t, err)
|
||||
|
||||
var outList session.AuthenticationMethods
|
||||
require.NoError(t, outList.Scan(listValue))
|
||||
require.Len(t, outList, 1)
|
||||
assert.Equal(t, in, outList[0])
|
||||
})
|
||||
|
||||
t.Run("case=CompletedLoginForOIDC populates upstream claims", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := session.NewInactiveSession()
|
||||
s.CompletedLoginForOIDC(
|
||||
identity.CredentialsTypeOIDC,
|
||||
identity.AuthenticatorAssuranceLevel2,
|
||||
"hydra",
|
||||
"acme",
|
||||
"urn:mfa",
|
||||
[]string{"pwd", "mfa"},
|
||||
)
|
||||
require.Len(t, s.AMR, 1)
|
||||
assert.Equal(t, identity.CredentialsTypeOIDC, s.AMR[0].Method)
|
||||
assert.Equal(t, identity.AuthenticatorAssuranceLevel2, s.AMR[0].AAL)
|
||||
assert.Equal(t, "hydra", s.AMR[0].Provider)
|
||||
assert.Equal(t, "acme", s.AMR[0].Organization)
|
||||
assert.Equal(t, "urn:mfa", s.AMR[0].UpstreamACR)
|
||||
assert.Equal(t, []string{"pwd", "mfa"}, s.AMR[0].UpstreamAMR)
|
||||
assert.False(t, s.AMR[0].CompletedAt.IsZero())
|
||||
})
|
||||
|
||||
t.Run("case=session refresh", func(t *testing.T) {
|
||||
req := testhelpers.NewTestHTTPRequest(t, "GET", "/sessions/whoami", nil)
|
||||
|
||||
ctx := contextx.WithConfigValues(t.Context(), map[string]any{
|
||||
config.ViperKeySessionLifespan: "24h",
|
||||
config.ViperKeySessionRefreshMinTimeLeft: "12h",
|
||||
})
|
||||
i := new(identity.Identity)
|
||||
i.State = identity.StateActive
|
||||
i.NID = x.NewUUID()
|
||||
s, err := testhelpers.NewActiveSession(req, reg, i, authAt, identity.CredentialsTypePassword, identity.AuthenticatorAssuranceLevel1)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, s.CanBeRefreshed(ctx, reg.Config()), "fresh session is not refreshable")
|
||||
|
||||
s.ExpiresAt = s.ExpiresAt.Add(-12 * time.Hour)
|
||||
assert.True(t, s.CanBeRefreshed(ctx, reg.Config()), "session is refreshable after 12hrs")
|
||||
})
|
||||
|
||||
t.Run("case=organization id", func(t *testing.T) {
|
||||
t.Run("case=returns uuid.Nil for nil session", func(t *testing.T) {
|
||||
var s *session.Session
|
||||
assert.Equal(t, uuid.Nil, s.OrganizationID())
|
||||
})
|
||||
|
||||
t.Run("case=returns uuid.Nil for empty AMR", func(t *testing.T) {
|
||||
s := &session.Session{}
|
||||
assert.Equal(t, uuid.Nil, s.OrganizationID())
|
||||
})
|
||||
|
||||
t.Run("case=returns uuid.Nil when AMR has no org-tagged entry", func(t *testing.T) {
|
||||
s := &session.Session{AMR: session.AuthenticationMethods{
|
||||
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
}}
|
||||
assert.Equal(t, uuid.Nil, s.OrganizationID())
|
||||
})
|
||||
|
||||
t.Run("case=returns uuid.Nil when the org is not a parseable UUID", func(t *testing.T) {
|
||||
s := &session.Session{AMR: session.AuthenticationMethods{
|
||||
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1, Organization: "not-a-uuid"},
|
||||
}}
|
||||
assert.Equal(t, uuid.Nil, s.OrganizationID())
|
||||
})
|
||||
|
||||
t.Run("case=returns the org from the first AMR entry that carries a UUID", func(t *testing.T) {
|
||||
orgA := uuid.Must(uuid.NewV4())
|
||||
orgB := uuid.Must(uuid.NewV4())
|
||||
s := &session.Session{AMR: session.AuthenticationMethods{
|
||||
{Method: identity.CredentialsTypePassword, AAL: identity.AuthenticatorAssuranceLevel1},
|
||||
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1, Organization: orgA.String()},
|
||||
{Method: identity.CredentialsTypeOIDC, AAL: identity.AuthenticatorAssuranceLevel1, Organization: orgB.String()},
|
||||
}}
|
||||
assert.Equal(t, orgA, s.OrganizationID())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=refresh uses organization lifespan", func(t *testing.T) {
|
||||
// Use a real config (not a mock provider) so Refresh exercises the
|
||||
// actual Config.OrganizationSessionLifespan resolution.
|
||||
orgWithOverride := uuid.Must(uuid.NewV4())
|
||||
orgWithoutOverride := uuid.Must(uuid.NewV4())
|
||||
_, oreg := pkg.NewFastRegistryWithMocks(t,
|
||||
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/identity.schema.json")),
|
||||
configx.WithValues(map[string]any{
|
||||
config.ViperKeySessionLifespan: "24h",
|
||||
"selfservice.methods.b2b.config.organizations": []map[string]any{
|
||||
{"id": orgWithOverride.String(), "domains": []string{"override.example.com"}, "session_lifespan": "1h"},
|
||||
{"id": orgWithoutOverride.String(), "domains": []string{"default.example.com"}},
|
||||
},
|
||||
}),
|
||||
)
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("case=no org in AMR uses project default", func(t *testing.T) {
|
||||
s := &session.Session{AMR: session.AuthenticationMethods{
|
||||
{Method: identity.CredentialsTypePassword},
|
||||
}}
|
||||
s.Refresh(ctx, oreg.Config())
|
||||
assert.WithinDuration(t, time.Now().Add(24*time.Hour), s.ExpiresAt, 2*time.Second)
|
||||
})
|
||||
|
||||
t.Run("case=org in AMR with override uses override", func(t *testing.T) {
|
||||
s := &session.Session{AMR: session.AuthenticationMethods{
|
||||
{Method: identity.CredentialsTypeOIDC, Organization: orgWithOverride.String()},
|
||||
}}
|
||||
s.Refresh(ctx, oreg.Config())
|
||||
assert.WithinDuration(t, time.Now().Add(1*time.Hour), s.ExpiresAt, 2*time.Second)
|
||||
})
|
||||
|
||||
t.Run("case=org in AMR without override uses project default", func(t *testing.T) {
|
||||
s := &session.Session{AMR: session.AuthenticationMethods{
|
||||
{Method: identity.CredentialsTypeOIDC, Organization: orgWithoutOverride.String()},
|
||||
}}
|
||||
s.Refresh(ctx, oreg.Config())
|
||||
assert.WithinDuration(t, time.Now().Add(24*time.Hour), s.ExpiresAt, 2*time.Second)
|
||||
})
|
||||
})
|
||||
}
|
||||
7
session/stub/fake-session.schema.json
Normal file
7
session/stub/fake-session.schema.json
Normal 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": {}
|
||||
}
|
||||
29
session/stub/identity.schema.json
Normal file
29
session/stub/identity.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
session/stub/jwk.es256.json
Normal file
14
session/stub/jwk.es256.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
session/stub/jwk.es512.broken.json
Normal file
14
session/stub/jwk.es512.broken.json
Normal 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_"
|
||||
}
|
||||
]
|
||||
|
||||
14
session/stub/jwk.es512.json
Normal file
14
session/stub/jwk.es512.json
Normal 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_"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
session/stub/jwk.rs512.json
Normal file
18
session/stub/jwk.rs512.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
session/stub/rs512-template.jsonnet
Normal file
13
session/stub/rs512-template.jsonnet
Normal 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
973
session/test/persistence.go
Normal 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
181
session/tokenizer.go
Normal 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
166
session/tokenizer_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user