hydra/flow/flow.go

569 lines
22 KiB
Go

// Copyright © 2022 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package flow
import (
"context"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/ory/hydra/v2/aead"
"github.com/ory/hydra/v2/client"
"github.com/ory/pop/v6"
"github.com/ory/x/pointerx"
"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.
//
// stateDiagram-v2
// [*] --> DEVICE_UNUSED: GET /oauth2/device/verify
// DEVICE_UNUSED --> DEVICE_USED: submit user code
// DEVICE_USED --> LOGIN_UNUSED: to verifier
// [*] --> LOGIN_UNUSED: GET /oauth2/auth
// LOGIN_UNUSED --> LOGIN_UNUSED: accept login
// LOGIN_UNUSED --> LOGIN_USED: submit login verifier
// LOGIN_UNUSED --> LOGIN_ERROR: reject login
// LOGIN_ERROR --> [*]
// LOGIN_USED --> CONSENT_UNUSED
// CONSENT_UNUSED --> CONSENT_UNUSED: accept consent
// CONSENT_UNUSED --> CONSENT_USED: submit consent verifier
// CONSENT_UNUSED --> CONSENT_ERROR: reject consent
// CONSENT_ERROR --> [*]
// CONSENT_USED --> [*]
type State int16
const (
// FlowStateLoginInitialized is not used anymore, but is kept for
// backwards compatibility. New flows start at FlowStateLoginUnused.
FlowStateLoginInitialized = State(1)
// FlowStateLoginUnused indicates that the login has been authenticated, but
// the User Agent hasn't picked up the result yet.
FlowStateLoginUnused = State(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 = State(3)
// FlowStateConsentInitialized is not used anymore, but is kept for
// backwards compatibility. New flows start at FlowStateConsentUnused.
FlowStateConsentInitialized = State(4)
FlowStateConsentUnused = State(5)
FlowStateConsentUsed = State(6)
// DeviceFlowStateInitialized is not used anymore, but is kept for
// backwards compatibility. New flows start at DeviceFlowStateUnused.
DeviceFlowStateInitialized = State(7)
// DeviceFlowStateUnused indicates that the login has been authenticated, but
// the User Agent hasn't picked up the result yet.
DeviceFlowStateUnused = State(8)
// DeviceFlowStateUsed 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 DeviceFlowStateConsentInitialized should happen while
// handling the request that triggered the transition to DeviceFlowStateUsed.
DeviceFlowStateUsed = State(9)
// 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 = State(128)
FlowStateConsentError = State(129)
)
func (s State) ConsentWasUsed() bool { return s == FlowStateConsentUsed || s == FlowStateConsentError }
func (s State) LoginWasUsed() bool { return s == FlowStateLoginUsed || s == FlowStateLoginError }
func (s State) IsAny(expected ...State) error {
for _, e := range expected {
if s == e {
return nil
}
}
return errors.Errorf("invalid flow state: expected one of %v, got %d", expected, s)
}
// 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 of the login request.
//
// The struct field is named ID for compatibility with gobuffalo/pop, and is
// the primary key in the database.
//
// The database column should be named `login_challenge_id`, but is not for
// historical reasons.
//
// This is not the same as the login session ID.
ID string `db:"login_challenge" json:"i"`
NID uuid.UUID `db:"nid" json:"n"`
// RequestedScope contains the OAuth 2.0 Scope requested by the OAuth 2.0 Client.
//
// required: true
RequestedScope sqlxx.StringSliceJSONFormat `db:"requested_scope" json:"rs,omitempty"`
// RequestedAudience contains the access token audience as requested by the OAuth 2.0 Client.
//
// required: true
RequestedAudience sqlxx.StringSliceJSONFormat `db:"requested_at_audience" json:"ra,omitempty"`
// 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:"-" json:"ls,omitempty"`
// 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" json:"s,omitempty"`
// 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" json:"oc"`
// Client is the OAuth 2.0 Client that initiated the request.
//
// required: true
Client *client.Client `db:"-" json:"c,omitempty"`
ClientID string `db:"client_id" json:"ci,omitempty"`
// 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" json:"r,omitempty"`
// 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" json:"si,omitempty"`
// 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:"-" json:"is,omitempty"`
LoginCSRF string `db:"-" json:"lc,omitempty"`
RequestedAt time.Time `db:"requested_at" json:"ia,omitempty"`
State State `db:"-" json:"q,omitempty"`
// 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:"-" json:"lr,omitempty"`
// 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:"-" json:"lf,omitempty"`
// LoginExtendSessionLifespan, if set to true, session cookie expiry time will be updated when session is
// refreshed (login skip=true).
LoginExtendSessionLifespan bool `db:"-" json:"ll,omitempty"`
// 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" json:"a,omitempty"`
// 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" json:"am,omitempty"`
// 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:"-" json:"fs,omitempty"`
// 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" json:"ct"`
LoginError *RequestDeniedError `db:"-" json:"le,omitempty"`
LoginAuthenticatedAt sqlxx.NullTime `db:"-" json:"la,omitempty"`
// DeviceChallengeID is the device request's challenge ID
DeviceChallengeID sqlxx.NullString `db:"device_challenge_id" json:"di,omitempty"`
// DeviceCodeRequestID is the device request's ID
DeviceCodeRequestID sqlxx.NullString `db:"device_code_request_id" json:"dr,omitempty"`
// DeviceCSRF is the device request's CSRF
DeviceCSRF sqlxx.NullString `db:"-" json:"dc,omitempty"`
// DeviceHandledAt contains the timestamp the device user_code verification request was handled
DeviceHandledAt sqlxx.NullTime `db:"-" json:"dh,omitempty"`
// ConsentRequestID is the identifier of the consent request.
// The database column should be named `consent_request_id`, but is not for historical reasons.
ConsentRequestID sqlxx.NullString `db:"consent_challenge_id" json:"cc,omitempty"`
// 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" json:"cs,omitempty"`
ConsentCSRF sqlxx.NullString `db:"-" json:"cr,omitempty"`
// GrantedScope sets the scope the user authorized the client to use. Should be a subset of `requested_scope`.
GrantedScope sqlxx.StringSliceJSONFormat `db:"granted_scope" json:"gs,omitempty"`
// 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" json:"ga,omitempty"`
// 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" json:"ce,omitempty"`
// 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" json:"cf"`
// ConsentHandledAt contains the timestamp the consent request was handled.
ConsentHandledAt sqlxx.NullTime `db:"consent_handled_at" json:"ch,omitempty"`
ConsentError *RequestDeniedError `db:"-" json:"cx"`
SessionIDToken sqlxx.MapStringInterface `db:"session_id_token" faker:"-" json:"st"`
SessionAccessToken sqlxx.MapStringInterface `db:"session_access_token" faker:"-" json:"sa"`
}
// HandleDeviceUserAuthRequest updates the flows fields from a handled request.
func (f *Flow) HandleDeviceUserAuthRequest(h *HandledDeviceUserAuthRequest) error {
if err := f.State.IsAny(DeviceFlowStateInitialized, DeviceFlowStateUnused); err != nil {
return err
}
f.State = DeviceFlowStateUnused
f.Client = h.Client
f.ClientID = h.Client.GetID()
f.DeviceCodeRequestID = sqlxx.NullString(h.DeviceCodeRequestID)
f.DeviceHandledAt = sqlxx.NullTime(time.Now().UTC())
f.RequestedScope = h.RequestedScope
f.RequestedAudience = h.RequestedAudience
return nil
}
// InvalidateDeviceRequest shifts the flow state to DeviceFlowStateUsed. This
// transition is executed upon device completion.
func (f *Flow) InvalidateDeviceRequest() error {
if err := f.State.IsAny(DeviceFlowStateUnused); err != nil {
return err
}
f.State = DeviceFlowStateUsed
return nil
}
func (f *Flow) HandleLoginRequest(h *HandledLoginRequest) error {
if err := f.State.IsAny(FlowStateLoginInitialized, FlowStateLoginUnused, FlowStateLoginError); err != nil {
return err
}
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)
}
f.State = FlowStateLoginUnused
if f.Context != nil {
f.Context = h.Context
}
f.Subject = h.Subject
f.ForceSubjectIdentifier = h.ForceSubjectIdentifier
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
return nil
}
func (f *Flow) HandleLoginError(er *RequestDeniedError) error {
if err := f.State.IsAny(FlowStateLoginInitialized, FlowStateLoginUnused, FlowStateLoginError); err != nil {
return err
}
f.State = FlowStateLoginError
f.LoginError = er
// force-reset values
f.Subject = ""
f.ForceSubjectIdentifier = ""
f.LoginAuthenticatedAt = sqlxx.NullTime{}
f.IdentityProviderSessionID = ""
f.LoginRemember = false
f.LoginRememberFor = 0
f.LoginExtendSessionLifespan = false
f.ACR = ""
f.AMR = nil
return nil
}
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,
RequestURL: f.RequestURL,
SessionID: f.SessionID,
}
}
// InvalidateLoginRequest shifts the flow state to FlowStateLoginUsed. This
// transition is executed upon login completion.
func (f *Flow) InvalidateLoginRequest() error {
if err := f.State.IsAny(FlowStateLoginUnused, FlowStateLoginError); err != nil {
return err
}
if f.State == FlowStateLoginUnused {
f.State = FlowStateLoginUsed
} else {
// FlowStateLoginError is already a terminal state, so we don't need to do anything here.
}
return nil
}
func (f *Flow) HandleConsentRequest(r *AcceptOAuth2ConsentRequest) error {
if err := f.State.IsAny(FlowStateConsentInitialized, FlowStateConsentUnused, FlowStateConsentError); err != nil {
return err
}
f.State = FlowStateConsentUnused
f.GrantedScope = r.GrantedScope
f.GrantedAudience = r.GrantedAudience
f.ConsentRemember = r.Remember
f.ConsentRememberFor = &r.RememberFor
f.ConsentHandledAt = sqlxx.NullTime(time.Now().UTC())
f.ConsentError = nil
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) HandleConsentError(er *RequestDeniedError) error {
if err := f.State.IsAny(FlowStateConsentInitialized, FlowStateConsentUnused, FlowStateConsentError); err != nil {
return err
}
f.State = FlowStateConsentError
f.ConsentError = er
f.ConsentHandledAt = sqlxx.NullTime(time.Now().UTC())
// force-reset values
f.GrantedScope = nil
f.GrantedAudience = nil
f.ConsentRemember = false
f.ConsentRememberFor = nil
return nil
}
func (f *Flow) InvalidateConsentRequest() error {
if err := f.State.IsAny(FlowStateConsentUnused, FlowStateConsentError); err != nil {
return err
}
if f.State == FlowStateConsentUnused {
f.State = FlowStateConsentUsed
} else {
// FlowStateConsentError is already a terminal state, so we don't need to do anything here.
}
return nil
}
func (f *Flow) GetConsentRequest(challenge string) *OAuth2ConsentRequest {
cs := OAuth2ConsentRequest{
Challenge: challenge,
ConsentRequestID: f.ConsentRequestID.String(),
RequestedScope: f.RequestedScope,
RequestedAudience: f.RequestedAudience,
Skip: f.ConsentSkip,
Subject: f.Subject,
OpenIDConnectContext: f.OpenIDConnectContext,
Client: f.Client,
RequestURL: f.RequestURL,
LoginChallenge: sqlxx.NullString(f.ID),
LoginSessionID: f.SessionID,
ACR: f.ACR,
AMR: f.AMR,
Context: f.Context,
}
// set some defaults for the API
if cs.RequestedAudience == nil {
cs.RequestedAudience = []string{}
}
if cs.AMR == nil {
cs.AMR = []string{}
}
return &cs
}
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.
if f.ClientID == "" {
return nil
}
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
}
// ToDeviceChallenge converts the flow into a device challenge.
func (f *Flow) ToDeviceChallenge(ctx context.Context, cipherProvider CipherProvider) (string, error) {
return Encode(ctx, cipherProvider.FlowCipher(), f, AsDeviceChallenge)
}
// ToDeviceVerifier converts the flow into a device verifier.
func (f *Flow) ToDeviceVerifier(ctx context.Context, cipherProvider CipherProvider) (string, error) {
return Encode(ctx, cipherProvider.FlowCipher(), f, AsDeviceVerifier)
}
// ToLoginChallenge converts the flow into a login challenge.
func (f Flow) ToLoginChallenge(ctx context.Context, cipherProvider CipherProvider) (challenge string, err error) {
if f.Client != nil {
f.ClientID = f.Client.GetID()
}
return Encode(ctx, cipherProvider.FlowCipher(), f, AsLoginChallenge)
}
// ToLoginVerifier converts the flow into a login verifier.
func (f Flow) ToLoginVerifier(ctx context.Context, cipherProvider CipherProvider) (verifier string, err error) {
if f.Client != nil {
f.ClientID = f.Client.GetID()
}
return Encode(ctx, cipherProvider.FlowCipher(), f, AsLoginVerifier)
}
// ToConsentChallenge converts the flow into a consent challenge.
func (f Flow) ToConsentChallenge(ctx context.Context, cipherProvider CipherProvider) (challenge string, err error) {
if f.Client != nil {
f.ClientID = f.Client.GetID()
}
return Encode(ctx, cipherProvider.FlowCipher(), f, AsConsentChallenge)
}
// ToConsentVerifier converts the flow into a consent verifier.
func (f Flow) ToConsentVerifier(ctx context.Context, cipherProvider CipherProvider) (verifier string, err error) {
if f.Client != nil {
f.ClientID = f.Client.GetID()
}
return Encode(ctx, cipherProvider.FlowCipher(), f, AsConsentVerifier)
}
func (f Flow) ToListConsentSessionResponse() *OAuth2ConsentSession {
s := &OAuth2ConsentSession{
ConsentRequestID: f.ConsentRequestID.String(),
GrantedScope: f.GrantedScope,
GrantedAudience: f.GrantedAudience,
RememberFor: pointerx.Deref(f.ConsentRememberFor),
Session: &AcceptOAuth2ConsentRequestSession{AccessToken: f.SessionAccessToken, IDToken: f.SessionIDToken},
Remember: f.ConsentRemember,
HandledAt: f.ConsentHandledAt,
Context: f.Context,
ConsentRequest: f.GetConsentRequest( /* No longer available and no longer needed: challenge = */ ""),
}
s.ConsentRequest.Client.Secret = "" // do not leak client secret in response
// set some defaults for the API
if s.GrantedAudience == nil {
s.GrantedAudience = []string{}
}
return s
}