ory-am/hydra

View on GitHub
flow/flow.go

Summary

Maintainability
A
1 hr
Test Coverage
// Copyright © 2022 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package flow

import (
    "context"
    "time"

    "github.com/gobuffalo/pop/v6"
    "github.com/gofrs/uuid"
    "github.com/pkg/errors"

    "github.com/ory/hydra/v2/aead"
    "github.com/ory/hydra/v2/client"
    "github.com/ory/hydra/v2/oauth2/flowctx"
    "github.com/ory/hydra/v2/x"
    "github.com/ory/x/sqlcon"
    "github.com/ory/x/sqlxx"
)

// FlowState* constants enumerate the states of a flow. The below graph
// describes possible flow state transitions.
//
// graph TD
//
//    LOGIN_INITIALIZED --> LOGIN_UNUSED
//    LOGIN_UNUSED --> LOGIN_USED
//    LOGIN_UNUSED --> LOGIN_ERROR
//    LOGIN_USED --> CONSENT_INITIALIZED
//    CONSENT_INITIALIZED --> CONSENT_UNUSED
//    CONSENT_UNUSED --> CONSENT_UNUSED
//    CONSENT_UNUSED --> CONSENT_USED
//    CONSENT_UNUSED --> CONSENT_ERROR
const (
    // FlowStateLoginInitialized applies before the login app either
    // accepts or rejects the login request.
    FlowStateLoginInitialized = int16(1)

    // FlowStateLoginUnused indicates that the login has been authenticated, but
    // the User Agent hasn't picked up the result yet.
    FlowStateLoginUnused = int16(2)

    // FlowStateLoginUsed indicates that the User Agent is requesting consent and
    // Hydra has invalidated the login request. This is a short-lived state
    // because the transition to FlowStateConsentInitialized should happen while
    // handling the request that triggered the transition to FlowStateLoginUsed.
    FlowStateLoginUsed = int16(3)

    // FlowStateConsentInitialized applies while Hydra waits for a consent request
    // to be accepted or rejected.
    FlowStateConsentInitialized = int16(4)

    FlowStateConsentUnused = int16(5)
    FlowStateConsentUsed   = int16(6)

    // TODO: Refactor error handling to persist error codes instead of JSON
    // strings. Currently we persist errors as JSON strings in the LoginError
    // and ConsentError fields. This shouldn't be necessary because the different
    // errors are enumerable; most of them have error codes defined in Fosite. It
    // is possible to define a mapping between error codes and the metadata that
    // is currently persisted with each erred Flow. This mapping would be used in
    // GetConsentRequest, HandleConsentRequest, GetHandledLoginRequest, etc. An
    // ErrorContext field can be introduced later if it becomes necessary.
    // If the above is implemented, merge the LoginError and ConsentError fields
    // and use the following FlowStates when converting to/from
    // [Handled]{Login|Consent}Request:
    FlowStateLoginError   = int16(128)
    FlowStateConsentError = int16(129)
)

// Flow is an abstraction used in the persistence layer to unify LoginRequest,
// HandledLoginRequest, ConsentRequest, and AcceptOAuth2ConsentRequest.
//
// TODO: Deprecate the structs that are made obsolete by the Flow concept.
// Context: Before Flow was introduced, the API and the database used the same
// structs, LoginRequest and HandledLoginRequest. These two tables and structs
// were merged into a new concept, Flow, in order to optimize the persistence
// layer. We currently limit the use of Flow to the persistence layer and keep
// using the original structs in the API in order to minimize the impact of the
// database refactoring on the API.
type Flow struct {
    // ID is the identifier ("login challenge") of the login request. It is used to
    // identify the session.
    //
    // required: true
    ID  string    `db:"login_challenge"`
    NID uuid.UUID `db:"nid"`

    // RequestedScope contains the OAuth 2.0 Scope requested by the OAuth 2.0 Client.
    //
    // required: true
    RequestedScope sqlxx.StringSliceJSONFormat `db:"requested_scope"`

    // RequestedAudience contains the access token audience as requested by the OAuth 2.0 Client.
    //
    // required: true
    RequestedAudience sqlxx.StringSliceJSONFormat `db:"requested_at_audience"`

    // LoginSkip, if true, implies that the client has requested the same scopes from the same user previously.
    // If true, you can skip asking the user to grant the requested scopes, and simply forward the user to the redirect URL.
    //
    // This feature allows you to update / set session information.
    //
    // required: true
    LoginSkip bool `db:"login_skip"`

    // Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope
    // requested by the OAuth 2.0 client. If this value is set and `skip` is true, you MUST include this subject type
    // when accepting the login request, or the request will fail.
    //
    // required: true
    Subject string `db:"subject"`

    // OpenIDConnectContext provides context for the (potential) OpenID Connect context. Implementation of these
    // values in your app are optional but can be useful if you want to be fully compliant with the OpenID Connect spec.
    OpenIDConnectContext *OAuth2ConsentRequestOpenIDConnectContext `db:"oidc_context"`

    // Client is the OAuth 2.0 Client that initiated the request.
    //
    // required: true
    Client *client.Client `db:"-"`

    ClientID string `db:"client_id"`

    // RequestURL is the original OAuth 2.0 Authorization URL requested by the OAuth 2.0 client. It is the URL which
    // initiates the OAuth 2.0 Authorization Code or OAuth 2.0 Implicit flow. This URL is typically not needed, but
    // might come in handy if you want to deal with additional request parameters.
    //
    // required: true
    RequestURL string `db:"request_url"`

    // SessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag)
    // this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false)
    // this will be a new random value. This value is used as the "sid" parameter in the ID Token and in OIDC Front-/Back-
    // channel logout. Its value can generally be used to associate consecutive login requests by a certain user.
    SessionID sqlxx.NullString `db:"login_session_id"`

    // IdentityProviderSessionID is the session ID of the end-user that authenticated.
    // If specified, we will use this value to propagate the logout.
    IdentityProviderSessionID sqlxx.NullString `db:"identity_provider_session_id"`

    LoginVerifier string `db:"login_verifier"`
    LoginCSRF     string `db:"login_csrf"`

    LoginInitializedAt sqlxx.NullTime `db:"login_initialized_at"`
    RequestedAt        time.Time      `db:"requested_at"`

    State int16 `db:"state"`

    // LoginRemember, if set to true, tells ORY Hydra to remember this user by telling the user agent (browser) to store
    // a cookie with authentication data. If the same user performs another OAuth 2.0 Authorization Request, he/she
    // will not be asked to log in again.
    LoginRemember bool `db:"login_remember"`

    // LoginRememberFor sets how long the authentication should be remembered for in seconds. If set to `0`, the
    // authorization will be remembered for the duration of the browser session (using a session cookie).
    LoginRememberFor int `db:"login_remember_for"`

    // LoginExtendSessionLifespan, if set to true, session cookie expiry time will be updated when session is
    // refreshed (login skip=true).
    LoginExtendSessionLifespan bool `db:"login_extend_session_lifespan"`

    // ACR sets the Authentication AuthorizationContext Class Reference value for this authentication session. You can use it
    // to express that, for example, a user authenticated using two factor authentication.
    ACR string `db:"acr"`

    // AMR sets the Authentication Methods References value for this
    // authentication session. You can use it to specify the method a user used to
    // authenticate. For example, if the acr indicates a user used two factor
    // authentication, the amr can express they used a software-secured key.
    AMR sqlxx.StringSliceJSONFormat `db:"amr"`

    // ForceSubjectIdentifier forces the "pairwise" user ID of the end-user that authenticated. The "pairwise" user ID refers to the
    // (Pairwise Identifier Algorithm)[http://openid.net/specs/openid-connect-core-1_0.html#PairwiseAlg] of the OpenID
    // Connect specification. It allows you to set an obfuscated subject ("user") identifier that is unique to the client.
    //
    // Please note that this changes the user ID on endpoint /userinfo and sub claim of the ID Token. It does not change the
    // sub claim in the OAuth 2.0 Introspection.
    //
    // Per default, ORY Hydra handles this value with its own algorithm. In case you want to set this yourself
    // you can use this field. Please note that setting this field has no effect if `pairwise` is not configured in
    // ORY Hydra or the OAuth 2.0 Client does not expect a pairwise identifier (set via `subject_type` key in the client's
    // configuration).
    //
    // Please also be aware that ORY Hydra is unable to properly compute this value during authentication. This implies
    // that you have to compute this value on every authentication process (probably depending on the client ID or some
    // other unique value).
    //
    // If you fail to compute the proper value, then authentication processes which have id_token_hint set might fail.
    ForceSubjectIdentifier string `db:"forced_subject_identifier"`

    // Context is an optional object which can hold arbitrary data. The data will be made available when fetching the
    // consent request under the "context" field. This is useful in scenarios where login and consent endpoints share
    // data.
    Context sqlxx.JSONRawMessage `db:"context"`

    // LoginWasUsed set to true means that the login request was already handled.
    // This can happen on form double-submit or other errors. If this is set we
    // recommend redirecting the user to `request_url` to re-initiate the flow.
    LoginWasUsed bool `db:"login_was_used"`

    LoginError           *RequestDeniedError `db:"login_error"`
    LoginAuthenticatedAt sqlxx.NullTime      `db:"login_authenticated_at"`

    // ConsentChallengeID is the identifier ("authorization challenge") of the consent authorization request. It is used to
    // identify the session.
    //
    // required: true
    ConsentChallengeID sqlxx.NullString `db:"consent_challenge_id"`

    // ConsentSkip, if true, implies that the client has requested the same scopes from the same user previously.
    // If true, you must not ask the user to grant the requested scopes. You must however either allow or deny the
    // consent request using the usual API call.
    ConsentSkip     bool             `db:"consent_skip"`
    ConsentVerifier sqlxx.NullString `db:"consent_verifier"`
    ConsentCSRF     sqlxx.NullString `db:"consent_csrf"`

    // GrantedScope sets the scope the user authorized the client to use. Should be a subset of `requested_scope`.
    GrantedScope sqlxx.StringSliceJSONFormat `db:"granted_scope"`

    // GrantedAudience sets the audience the user authorized the client to use. Should be a subset of `requested_access_token_audience`.
    GrantedAudience sqlxx.StringSliceJSONFormat `db:"granted_at_audience"`

    // ConsentRemember, if set to true, tells ORY Hydra to remember this consent authorization and reuse it if the same
    // client asks the same user for the same, or a subset of, scope.
    ConsentRemember bool `db:"consent_remember"`

    // ConsentRememberFor sets how long the consent authorization should be remembered for in seconds. If set to `0`, the
    // authorization will be remembered indefinitely.
    ConsentRememberFor *int `db:"consent_remember_for"`

    // ConsentHandledAt contains the timestamp the consent request was handled.
    ConsentHandledAt sqlxx.NullTime `db:"consent_handled_at"`

    // ConsentWasHandled set to true means that the request was already handled.
    // This can happen on form double-submit or other errors. If this is set we
    // recommend redirecting the user to `request_url` to re-initiate the flow.
    ConsentWasHandled  bool                     `db:"consent_was_used"`
    ConsentError       *RequestDeniedError      `db:"consent_error"`
    SessionIDToken     sqlxx.MapStringInterface `db:"session_id_token" faker:"-"`
    SessionAccessToken sqlxx.MapStringInterface `db:"session_access_token" faker:"-"`
}

func NewFlow(r *LoginRequest) *Flow {
    return &Flow{
        ID:                     r.ID,
        RequestedScope:         r.RequestedScope,
        RequestedAudience:      r.RequestedAudience,
        LoginSkip:              r.Skip,
        Subject:                r.Subject,
        OpenIDConnectContext:   r.OpenIDConnectContext,
        Client:                 r.Client,
        ClientID:               r.ClientID,
        RequestURL:             r.RequestURL,
        SessionID:              r.SessionID,
        LoginWasUsed:           r.WasHandled,
        ForceSubjectIdentifier: r.ForceSubjectIdentifier,
        LoginVerifier:          r.Verifier,
        LoginCSRF:              r.CSRF,
        LoginAuthenticatedAt:   r.AuthenticatedAt,
        RequestedAt:            r.RequestedAt,
        State:                  FlowStateLoginInitialized,
    }
}

func (f *Flow) HandleLoginRequest(h *HandledLoginRequest) error {
    if f.LoginWasUsed {
        return errors.WithStack(x.ErrConflict.WithHint("The login request was already used and can no longer be changed."))
    }

    if f.State != FlowStateLoginInitialized && f.State != FlowStateLoginUnused && f.State != FlowStateLoginError {
        return errors.Errorf("invalid flow state: expected %d/%d/%d, got %d", FlowStateLoginInitialized, FlowStateLoginUnused, FlowStateLoginError, f.State)
    }

    if f.ID != h.ID {
        return errors.Errorf("flow ID %s does not match HandledLoginRequest ID %s", f.ID, h.ID)
    }

    if f.Subject != "" && h.Subject != "" && f.Subject != h.Subject {
        return errors.Errorf("flow Subject %s does not match the HandledLoginRequest Subject %s", f.Subject, h.Subject)
    }

    if f.ForceSubjectIdentifier != "" && h.ForceSubjectIdentifier != "" && f.ForceSubjectIdentifier != h.ForceSubjectIdentifier {
        return errors.Errorf("flow ForceSubjectIdentifier %s does not match the HandledLoginRequest ForceSubjectIdentifier %s", f.ForceSubjectIdentifier, h.ForceSubjectIdentifier)
    }

    if h.Error != nil {
        f.State = FlowStateLoginError
    } else {
        f.State = FlowStateLoginUnused
    }

    if f.Context != nil {
        f.Context = h.Context
    }

    f.ID = h.ID
    f.Subject = h.Subject
    f.ForceSubjectIdentifier = h.ForceSubjectIdentifier
    f.LoginError = h.Error

    f.IdentityProviderSessionID = sqlxx.NullString(h.IdentityProviderSessionID)
    f.LoginRemember = h.Remember
    f.LoginRememberFor = h.RememberFor
    f.LoginExtendSessionLifespan = h.ExtendSessionLifespan
    f.ACR = h.ACR
    f.AMR = h.AMR
    f.LoginWasUsed = h.WasHandled
    f.LoginAuthenticatedAt = h.AuthenticatedAt
    return nil
}

func (f *Flow) GetHandledLoginRequest() HandledLoginRequest {
    return HandledLoginRequest{
        ID:                        f.ID,
        Remember:                  f.LoginRemember,
        RememberFor:               f.LoginRememberFor,
        ExtendSessionLifespan:     f.LoginExtendSessionLifespan,
        ACR:                       f.ACR,
        AMR:                       f.AMR,
        Subject:                   f.Subject,
        IdentityProviderSessionID: f.IdentityProviderSessionID.String(),
        ForceSubjectIdentifier:    f.ForceSubjectIdentifier,
        Context:                   f.Context,
        WasHandled:                f.LoginWasUsed,
        Error:                     f.LoginError,
        LoginRequest:              f.GetLoginRequest(),
        RequestedAt:               f.RequestedAt,
        AuthenticatedAt:           f.LoginAuthenticatedAt,
    }
}

func (f *Flow) GetLoginRequest() *LoginRequest {
    return &LoginRequest{
        ID:                     f.ID,
        RequestedScope:         f.RequestedScope,
        RequestedAudience:      f.RequestedAudience,
        Skip:                   f.LoginSkip,
        Subject:                f.Subject,
        OpenIDConnectContext:   f.OpenIDConnectContext,
        Client:                 f.Client,
        ClientID:               f.ClientID,
        RequestURL:             f.RequestURL,
        SessionID:              f.SessionID,
        WasHandled:             f.LoginWasUsed,
        ForceSubjectIdentifier: f.ForceSubjectIdentifier,
        Verifier:               f.LoginVerifier,
        CSRF:                   f.LoginCSRF,
        AuthenticatedAt:        f.LoginAuthenticatedAt,
        RequestedAt:            f.RequestedAt,
    }
}

// InvalidateLoginRequest shifts the flow state to FlowStateLoginUsed. This
// transition is executed upon login completion.
func (f *Flow) InvalidateLoginRequest() error {
    if f.State != FlowStateLoginUnused && f.State != FlowStateLoginError {
        return errors.Errorf("invalid flow state: expected %d or %d, got %d", FlowStateLoginUnused, FlowStateLoginError, f.State)
    }
    if f.LoginWasUsed {
        return errors.New("login verifier has already been used")
    }
    f.LoginWasUsed = true
    f.State = FlowStateLoginUsed
    return nil
}

func (f *Flow) HandleConsentRequest(r *AcceptOAuth2ConsentRequest) error {
    if time.Time(r.HandledAt).IsZero() {
        return errors.New("refusing to handle a consent request with null HandledAt")
    }

    if f.ConsentWasHandled {
        return x.ErrConflict.WithHint("The consent request was already used and can no longer be changed.")
    }

    if f.State != FlowStateConsentInitialized && f.State != FlowStateConsentUnused && f.State != FlowStateConsentError {
        return errors.Errorf("invalid flow state: expected %d/%d/%d, got %d", FlowStateConsentInitialized, FlowStateConsentUnused, FlowStateConsentError, f.State)
    }

    if f.ConsentChallengeID.String() != r.ID {
        return errors.Errorf("flow.ConsentChallengeID %s doesn't match AcceptOAuth2ConsentRequest.ID %s", f.ConsentChallengeID.String(), r.ID)
    }

    if r.Error != nil {
        f.State = FlowStateConsentError
    } else if r.WasHandled {
        f.State = FlowStateConsentUsed
    } else {
        f.State = FlowStateConsentUnused
    }

    f.GrantedScope = r.GrantedScope
    f.GrantedAudience = r.GrantedAudience
    f.ConsentRemember = r.Remember
    f.ConsentRememberFor = &r.RememberFor
    f.ConsentHandledAt = r.HandledAt
    f.ConsentWasHandled = r.WasHandled
    f.ConsentError = r.Error
    if r.Context != nil {
        f.Context = r.Context
    }

    if r.Session != nil {
        f.SessionIDToken = r.Session.IDToken
        f.SessionAccessToken = r.Session.AccessToken
    }
    return nil
}

func (f *Flow) InvalidateConsentRequest() error {
    if f.ConsentWasHandled {
        return errors.New("consent verifier has already been used")
    }
    if f.State != FlowStateConsentUnused && f.State != FlowStateConsentError {
        return errors.Errorf("unexpected flow state: expected %d or %d, got %d", FlowStateConsentUnused, FlowStateConsentError, f.State)
    }

    f.ConsentWasHandled = true
    f.State = FlowStateConsentUsed
    return nil
}

func (f *Flow) GetConsentRequest() *OAuth2ConsentRequest {
    cs := OAuth2ConsentRequest{
        ID:                     f.ConsentChallengeID.String(),
        RequestedScope:         f.RequestedScope,
        RequestedAudience:      f.RequestedAudience,
        Skip:                   f.ConsentSkip,
        Subject:                f.Subject,
        OpenIDConnectContext:   f.OpenIDConnectContext,
        Client:                 f.Client,
        ClientID:               f.ClientID,
        RequestURL:             f.RequestURL,
        LoginChallenge:         sqlxx.NullString(f.ID),
        LoginSessionID:         f.SessionID,
        ACR:                    f.ACR,
        AMR:                    f.AMR,
        Context:                f.Context,
        WasHandled:             f.ConsentWasHandled,
        ForceSubjectIdentifier: f.ForceSubjectIdentifier,
        Verifier:               f.ConsentVerifier.String(),
        CSRF:                   f.ConsentCSRF.String(),
        AuthenticatedAt:        f.LoginAuthenticatedAt,
        RequestedAt:            f.RequestedAt,
    }
    if cs.AMR == nil {
        cs.AMR = []string{}
    }
    return &cs
}

func (f *Flow) GetHandledConsentRequest() *AcceptOAuth2ConsentRequest {
    crf := 0
    if f.ConsentRememberFor != nil {
        crf = *f.ConsentRememberFor
    }
    return &AcceptOAuth2ConsentRequest{
        ID:                 f.ConsentChallengeID.String(),
        GrantedScope:       f.GrantedScope,
        GrantedAudience:    f.GrantedAudience,
        Session:            &AcceptOAuth2ConsentRequestSession{AccessToken: f.SessionAccessToken, IDToken: f.SessionIDToken},
        Remember:           f.ConsentRemember,
        RememberFor:        crf,
        HandledAt:          f.ConsentHandledAt,
        WasHandled:         f.ConsentWasHandled,
        Context:            f.Context,
        ConsentRequest:     f.GetConsentRequest(),
        Error:              f.ConsentError,
        RequestedAt:        f.RequestedAt,
        AuthenticatedAt:    f.LoginAuthenticatedAt,
        SessionIDToken:     f.SessionIDToken,
        SessionAccessToken: f.SessionAccessToken,
    }
}

func (Flow) TableName() string {
    return "hydra_oauth2_flow"
}

func (f *Flow) BeforeSave(_ *pop.Connection) error {
    if f.Client != nil {
        f.ClientID = f.Client.GetID()
    }
    if f.State == FlowStateLoginUnused && string(f.Context) == "" {
        f.Context = sqlxx.JSONRawMessage("{}")
    }
    return nil
}

func (f *Flow) AfterFind(c *pop.Connection) error {
    // TODO Populate the client field in FindInDB and FindByConsentChallengeID in
    // order to avoid accessing the database twice.
    f.AfterSave(c)
    f.Client = &client.Client{}
    return sqlcon.HandleError(c.Where("id = ? AND nid = ?", f.ClientID, f.NID).First(f.Client))
}

func (f *Flow) AfterSave(_ *pop.Connection) {
    if f.SessionAccessToken == nil {
        f.SessionAccessToken = make(map[string]interface{})
    }
    if f.SessionIDToken == nil {
        f.SessionIDToken = make(map[string]interface{})
    }
}

type CipherProvider interface {
    FlowCipher() *aead.XChaCha20Poly1305
}

// ToLoginChallenge converts the flow into a login challenge.
func (f *Flow) ToLoginChallenge(ctx context.Context, cipherProvider CipherProvider) (string, error) {
    return flowctx.Encode(ctx, cipherProvider.FlowCipher(), f, flowctx.AsLoginChallenge)
}

// ToLoginVerifier converts the flow into a login verifier.
func (f *Flow) ToLoginVerifier(ctx context.Context, cipherProvider CipherProvider) (string, error) {
    return flowctx.Encode(ctx, cipherProvider.FlowCipher(), f, flowctx.AsLoginVerifier)
}

// ToConsentChallenge converts the flow into a consent challenge.
func (f *Flow) ToConsentChallenge(ctx context.Context, cipherProvider CipherProvider) (string, error) {
    return flowctx.Encode(ctx, cipherProvider.FlowCipher(), f, flowctx.AsConsentChallenge)
}

// ToConsentVerifier converts the flow into a consent verifier.
func (f *Flow) ToConsentVerifier(ctx context.Context, cipherProvider CipherProvider) (string, error) {
    return flowctx.Encode(ctx, cipherProvider.FlowCipher(), f, flowctx.AsConsentVerifier)
}