Files
upstream-ory-kratos/session/manager_http.go
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

549 lines
17 KiB
Go

// 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())
}