// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package oidc import ( "bytes" "cmp" "context" _ "embed" "encoding/json" "fmt" "net/http" "slices" "time" "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" "github.com/ory/herodot" "github.com/ory/jsonschema/v3" "github.com/ory/x/decoderx" "github.com/ory/x/otelx" "github.com/ory/x/sqlxx" "github.com/ory/kratos/continuity" oidcv1 "github.com/ory/kratos/gen/oidc/v1" "github.com/ory/kratos/identity" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/settings" "github.com/ory/kratos/selfservice/strategy" "github.com/ory/kratos/session" "github.com/ory/kratos/text" "github.com/ory/kratos/ui/node" "github.com/ory/kratos/x" ) //go:embed .schema/settings.schema.json var settingsSchema []byte var ( _ settings.Strategy = new(Strategy) UnknownConnectionValidationError = &jsonschema.ValidationError{ Message: "can not unlink non-existing OpenID Connect connection", InstancePtr: "#/", } ) var ConnectionExistValidationError = &jsonschema.ValidationError{ Message: "can not link unknown or already existing OpenID Connect connection", InstancePtr: "#/", } var UnlinkAllFirstFactorConnectionsError = &jsonschema.ValidationError{ Message: "can not unlink OpenID Connect connection because it is the last remaining first factor credential", InstancePtr: "#/", } func (s *Strategy) SettingsStrategyID() string { return s.ID().String() } func (s *Strategy) decoderSettings(ctx context.Context, p *updateSettingsFlowWithOidcMethod, r *http.Request, settingsFlow *settings.Flow) error { schema := flow.IdentitySchema(settingsFlow.Identity.SchemaID) ds, err := schema.URL(ctx, s.d.Config()) if err != nil { return err } raw, err := sjson.SetBytes(settingsSchema, "properties.traits.$ref", ds.String()+"#/properties/traits") if err != nil { return errors.WithStack(err) } compiler, err := decoderx.HTTPRawJSONSchemaCompiler(raw) if err != nil { return errors.WithStack(err) } if err := decoderx.Decode(r, &p, compiler, decoderx.HTTPKeepRequestBody(true), decoderx.HTTPDecoderUseQueryAndBody(), decoderx.HTTPDecoderSetValidatePayloads(false), decoderx.HTTPDecoderAllowedMethods("POST", "GET"), decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil { return errors.WithStack(err) } return nil } func (s *Strategy) linkedProviders(conf *ConfigurationCollection, confidential *identity.Identity) ([]Provider, error) { creds, ok := confidential.GetCredentials(s.ID()) if !ok { return nil, nil } var available identity.CredentialsOIDC if err := json.Unmarshal(creds.Config, &available); err != nil { return nil, errors.WithStack(err) } var result []Provider for _, p := range available.Providers { prov, err := conf.Provider(p.Provider, s.d) if errors.Is(err, herodot.ErrNotFound()) { continue } else if err != nil { return nil, err } result = append(result, prov) } return result, nil } func (s *Strategy) linkableProviders(conf *ConfigurationCollection, confidential *identity.Identity) ([]Provider, error) { var available identity.CredentialsOIDC creds, ok := confidential.GetCredentials(s.ID()) if ok { if err := json.Unmarshal(creds.Config, &available); err != nil { return nil, errors.WithStack(err) } } var result []Provider for _, p := range conf.Providers { var found bool for _, pp := range available.Providers { if pp.Provider == p.ID { found = true break } } if !found { prov, err := conf.Provider(p.ID, s.d) if err != nil { return nil, err } result = append(result, prov) } } return result, nil } // settingsNodeGroup returns the UI node group for this strategy's settings // link/unlink nodes. SAML nodes use the SAML group so a sibling strategy's // group-scoped removal does not clobber them (both strategies share the OSS // PopulateSettingsMethod, which names every link/unlink node "link"/"unlink"). func (s *Strategy) settingsNodeGroup() node.UiNodeGroup { if s.credType == identity.CredentialsTypeSAML { return node.SAMLGroup } return node.OpenIDConnectGroup } func (s *Strategy) PopulateSettingsMethod(ctx context.Context, r *http.Request, id *identity.Identity, sr *settings.Flow) (err error) { ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.oidc.Strategy.PopulateSettingsMethod") defer otelx.End(span, &err) conf, err := s.Config(ctx) if err != nil { return err } linkable, err := s.linkableProviders(conf, id) if err != nil { return err } linked, err := s.linkedProviders(conf, id) if err != nil { return err } group := s.settingsNodeGroup() sr.UI.GetNodes().RemoveInGroup(group, "unlink", "link") sr.UI.SetCSRF(s.d.GenerateCSRFToken(r)) if sr.OrganizationID.Valid { boundToFlowOrg := id.OrganizationID.Valid && id.OrganizationID.UUID == sr.OrganizationID.UUID // Org-scoped settings: render only providers belonging to this org. // Already-linked providers render as disabled unlink nodes (the org // owns the binding, so the user cannot remove it). Unlinked providers // render as link nodes — disabled when the identity is already bound // to this org, active otherwise (first-time bind). linkedIDs := make(map[string]struct{}, len(linked)) for _, p := range linked { linkedIDs[p.Config().ID] = struct{}{} } for _, p := range conf.Providers { if !flowOrgMatchesProvider(sr, p.OrganizationID) { continue } _, alreadyLinked := linkedIDs[p.ID] var n *node.Node if alreadyLinked { n = NewUnlinkNode(p.ID, cmp.Or(p.Label, p.ID)) } else { n = NewLinkNode(p.ID, cmp.Or(p.Label, p.ID)) } n.Group = group if alreadyLinked || boundToFlowOrg { if attrs, ok := n.Attributes.(*node.InputAttributes); ok { attrs.Disabled = true } } sr.UI.GetNodes().Append(n) } if boundToFlowOrg { // Cloud OIDC and SAML org strategies both delegate here; dedupe so // the managed-by-organization message appears exactly once. hasMsg := slices.ContainsFunc(sr.UI.Messages, func(m text.Message) bool { return m.ID == text.InfoSelfServiceSettingsManagedByOrganization }) if !hasMsg { sr.UI.Messages.Add(text.NewInfoSelfServiceSettingsManagedByOrganization()) } } return nil } // Non-org settings flow: existing behavior. for _, l := range linkable { // We do not want to offer to link SSO providers in the regular settings flow. if l.Config().OrganizationID != "" { continue } sr.UI.GetNodes().Append(NewLinkNode(l.Config().ID, cmp.Or(l.Config().Label, l.Config().ID))) } count, err := s.d.IdentityManager().CountActiveFirstFactorCredentials(ctx, id) if err != nil { return err } if count > 1 { // This means that we're able to remove a connection because it is the last configured credential. If it is // removed, the identity is no longer able to sign in. for _, l := range linked { sr.UI.GetNodes().Append(NewUnlinkNode(l.Config().ID, cmp.Or(l.Config().Label, l.Config().ID))) } } return nil } // Update Settings Flow with OpenID Connect Method // // swagger:model updateSettingsFlowWithOidcMethod // //nolint:deadcode,unused //lint:ignore U1000 Used to generate Swagger and OpenAPI definitions type updateSettingsFlowWithOidcMethod struct { // Method // // Should be set to profile when trying to update a profile. // // required: true Method string `json:"method"` // Link this provider // // Either this or `unlink` must be set. // // type: string // in: body Link string `json:"link"` // Unlink this provider // // Either this or `link` must be set. // // type: string // in: body Unlink string `json:"unlink"` // Flow ID is the flow's ID. // // in: query FlowID string `json:"flow"` // The identity's traits // // in: body Traits json.RawMessage `json:"traits"` // UpstreamParameters are the parameters that are passed to the upstream identity provider. // // These parameters are optional and depend on what the upstream identity provider supports. // Supported parameters are: // - `hd` (string): The `hd` parameter limits the login/registration process to a Google Organization, e.g. `mycollege.edu`. // - `prompt` (string): The `prompt` specifies whether the Authorization Server prompts the End-User for reauthentication and consent, e.g. `select_account`. // - `acr_values` (string): The `acr_values` specifies the Authentication Context Class Reference values for the authorization request. // // required: false UpstreamParameters json.RawMessage `json:"upstream_parameters"` // Transient data to pass along to any webhooks // // required: false TransientPayload json.RawMessage `json:"transient_payload,omitempty" form:"transient_payload"` } func (p *updateSettingsFlowWithOidcMethod) GetFlowID() uuid.UUID { return x.ParseUUID(p.FlowID) } func (p *updateSettingsFlowWithOidcMethod) SetFlowID(rid uuid.UUID) { p.FlowID = rid.String() } func (s *Strategy) Settings(ctx context.Context, w http.ResponseWriter, r *http.Request, f *settings.Flow, ss *session.Session) (_ *settings.UpdateContext, err error) { ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.oidc.Strategy.Settings") defer otelx.End(span, &err) var p updateSettingsFlowWithOidcMethod if err := s.decoderSettings(ctx, &p, r, f); err != nil { return nil, err } f.TransientPayload = p.TransientPayload if f.OrganizationID.Valid && len(p.Unlink) > 0 { return nil, errors.WithStack(herodot.ErrBadRequest().WithReason("Cannot unlink an organization-scoped credential through the settings flow.")) } ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, f, ss, settings.ContinuityKey(s.SettingsStrategyID()), &p) if errors.Is(err, settings.ErrContinuePreviousAction) { if !s.d.Config().SelfServiceStrategy(ctx, s.SettingsStrategyID()).Enabled { return nil, s.handleMethodNotAllowedError(errors.WithStack(herodot.ErrNotFound().WithReason(strategy.EndpointDisabledMessage))) } if len(p.Link) > 0 { if err := s.initLinkProvider(ctx, w, r, ctxUpdate, &p); err != nil { return nil, err } return ctxUpdate, nil } else if len(p.Unlink) > 0 { if err := s.unlinkProvider(ctx, w, r, ctxUpdate, &p); err != nil { return nil, err } return ctxUpdate, nil } return nil, s.handleSettingsError(ctx, w, r, ctxUpdate, &p, errors.WithStack(herodot.ErrBadRequest().WithReason("Expected either link or unlink to be set when continuing flow but both are unset."))) } else if err != nil { return nil, s.handleSettingsError(ctx, w, r, ctxUpdate, &p, err) } if len(p.Link)+len(p.Unlink) == 0 { span.SetAttributes(attribute.String("not_responsible_reason", "neither link nor unlink set")) return nil, errors.WithStack(flow.ErrStrategyNotResponsible) } if !s.d.Config().SelfServiceStrategy(ctx, s.SettingsStrategyID()).Enabled { return nil, s.handleMethodNotAllowedError(errors.WithStack(herodot.ErrNotFound().WithReason(strategy.EndpointDisabledMessage))) } switch l, u := len(p.Link), len(p.Unlink); { case l > 0 && u > 0: return nil, s.handleSettingsError(ctx, w, r, ctxUpdate, &p, errors.WithStack(&jsonschema.ValidationError{ Message: "it is not possible to link and unlink providers in the same request", InstancePtr: "#/", })) case l > 0: if err := s.initLinkProvider(ctx, w, r, ctxUpdate, &p); err != nil { return nil, err } return ctxUpdate, nil case u > 0: if err := s.unlinkProvider(ctx, w, r, ctxUpdate, &p); err != nil { return nil, err } return ctxUpdate, nil } // this case should never be reached as we previously checked whether link and unlink are both empty return nil, errors.WithStack(flow.ErrStrategyNotResponsible) } func (s *Strategy) isLinkable(ctx context.Context, ctxUpdate *settings.UpdateContext, toLink string) (*identity.Identity, error) { providers, err := s.Config(ctx) if err != nil { return nil, err } i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID) if err != nil { return nil, err } linkable, err := s.linkableProviders(providers, i) if err != nil { return nil, err } var found bool for _, available := range linkable { if toLink == available.Config().ID { // The provider must match the flow's organization scope. An // org-scoped flow may link only providers of that organization; a // non-org flow may link only providers with no organization. We // reuse ConnectionExistValidationError rather than a distinct // "wrong organization" message so an attacker cannot probe which // providers another organization has configured. if !flowOrgMatchesProvider(ctxUpdate.Flow, available.Config().OrganizationID) { return nil, errors.WithStack(ConnectionExistValidationError) } found = true break } } if !found { return nil, errors.WithStack(ConnectionExistValidationError) } return i, nil } // flowOrgMatchesProvider reports whether a provider with organization id // providerOrg may be linked through flow f. An org-scoped flow accepts only // providers of that organization; a non-org flow accepts only providers with // no organization. func flowOrgMatchesProvider(f *settings.Flow, providerOrg string) bool { if !f.OrganizationID.Valid { return providerOrg == "" } providerOrgID, err := uuid.FromString(providerOrg) if err != nil { return false } return providerOrgID == f.OrganizationID.UUID } func (s *Strategy) initLinkProvider(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithOidcMethod) error { if _, err := s.isLinkable(ctx, ctxUpdate, p.Link); err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } if !s.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(settings.NewFlowNeedsReAuth())) } provider, err := s.Provider(ctx, p.Link) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } req, err := s.validateFlow(ctx, r, ctxUpdate.Flow.ID, oidcv1.FlowKind_FLOW_KIND_SETTINGS) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } state, pkce, err := s.GenerateState(ctx, provider, ctxUpdate.Flow, x.RequestBaseURL(r)) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } if _, err := s.d.ContinuityManager().Pause(ctx, w, r, sessionName, continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(ctx)), continuity.WithPayload(&AuthCodeContainer{ State: state, FlowID: ctxUpdate.Flow.ID.String(), Traits: p.Traits, }), continuity.WithLifespan(time.Minute*30)); err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } var up map[string]string if err := json.NewDecoder(bytes.NewBuffer(p.UpstreamParameters)).Decode(&up); err != nil { return err } codeURL, err := getAuthRedirectURL(ctx, provider, req, state, up, pkce) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } if x.IsJSONRequest(r) { s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) } else { http.Redirect(w, r, codeURL, http.StatusSeeOther) } return errors.WithStack(flow.ErrCompletedByStrategy) } func (s *Strategy) linkProvider(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, token *identity.CredentialsOIDCEncryptedTokens, claims *Claims, provider Provider) error { p := &updateSettingsFlowWithOidcMethod{ Link: provider.Config().ID, FlowID: ctxUpdate.Flow.ID.String(), } if !s.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(settings.NewFlowNeedsReAuth())) } i, err := s.isLinkable(ctx, ctxUpdate, p.Link) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } if err := s.linkCredentials(ctx, i, token, provider.Config().ID, claims.Subject, provider.Config().OrganizationID); err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } // Add authentication method to session after // linking with 3rd party OIDC provider if err := s.d.SessionManager().SessionAddAuthenticationMethods( ctx, ctxUpdate.Session.ID, session.AuthenticationMethod{ Method: s.ID(), AAL: provider.Config().AALForClaims(claims), Provider: provider.Config().ID, Organization: provider.Config().OrganizationID, UpstreamACR: claims.ACR, UpstreamAMR: claims.AMR, }); err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } if err := s.d.SettingsHookExecutor().PostSettingsHook(ctx, w, r, s.SettingsStrategyID(), ctxUpdate, i, settings.WithCallback(func(ctxUpdate *settings.UpdateContext) error { // Credential population is done by PostSettingsHook on ctxUpdate.Session.Identity return s.PopulateSettingsMethod(ctx, r, ctxUpdate.Session.Identity, ctxUpdate.Flow) })); err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } return nil } func (s *Strategy) unlinkProvider(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithOidcMethod) error { if !s.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(settings.NewFlowNeedsReAuth())) } providers, err := s.Config(ctx) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.Identity.ID) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } availableProviders, err := s.linkedProviders(providers, i) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } var cc identity.CredentialsOIDC creds, err := i.ParseCredentials(s.ID(), &cc) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } count, err := s.d.IdentityManager().CountActiveFirstFactorCredentials(ctx, i) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } if count < 2 { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(UnlinkAllFirstFactorConnectionsError)) } var found bool var updatedProviders []identity.CredentialsOIDCProvider var updatedIdentifiers []string for _, available := range availableProviders { if p.Unlink == available.Config().ID { for _, link := range cc.Providers { if link.Provider != p.Unlink { updatedIdentifiers = append(updatedIdentifiers, identity.OIDCUniqueID(link.Provider, link.Subject)) updatedProviders = append(updatedProviders, link) } else { found = true } } } } if !found { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(UnknownConnectionValidationError)) } creds.Identifiers = updatedIdentifiers creds.Config, err = json.Marshal(&identity.CredentialsOIDC{Providers: updatedProviders}) if err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(err)) } i.Credentials[s.ID()] = *creds if err := s.d.SettingsHookExecutor().PostSettingsHook(ctx, w, r, s.SettingsStrategyID(), ctxUpdate, i, settings.WithCallback(func(ctxUpdate *settings.UpdateContext) error { // Credential population is done by PostSettingsHook on ctxUpdate.Session.Identity return s.PopulateSettingsMethod(ctx, r, ctxUpdate.Session.Identity, ctxUpdate.Flow) })); err != nil { return s.handleSettingsError(ctx, w, r, ctxUpdate, p, err) } return errors.WithStack(flow.ErrCompletedByStrategy) } func (s *Strategy) handleSettingsError(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p *updateSettingsFlowWithOidcMethod, err error) error { // Do not pause flow if the flow type is an API flow as we can't save cookies in those flows. if e := new(settings.FlowNeedsReAuth); errors.As(err, &e) && ctxUpdate.Flow != nil && ctxUpdate.Flow.Type == flow.TypeBrowser { if _, err := s.d.ContinuityManager().Pause(ctx, w, r, settings.ContinuityKey(s.SettingsStrategyID()), continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(ctx)), settings.ContinuityOptions(p, ctxUpdate.Session.Identity)...); err != nil { return err } } if ctxUpdate.Flow != nil { ctxUpdate.Flow.UI.ResetMessages() ctxUpdate.Flow.UI.SetCSRF(s.d.GenerateCSRFToken(r)) } return err } func (s *Strategy) Link(ctx context.Context, i *identity.Identity, credentialsConfig sqlxx.JSONRawMessage) (err error) { ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.oidc.Strategy.Link") defer otelx.End(span, &err) var credentialsOIDCConfig identity.CredentialsOIDC if err := json.Unmarshal(credentialsConfig, &credentialsOIDCConfig); err != nil { return err } if len(credentialsOIDCConfig.Providers) != 1 { return errors.New("no oidc provider was set") } credentialsOIDCProvider := credentialsOIDCConfig.Providers[0] if err := s.linkCredentials( ctx, i, // The tokens in this credential are coming from the existing identity. Hence, the values are already encrypted. credentialsOIDCProvider.GetTokens(), credentialsOIDCProvider.Provider, credentialsOIDCProvider.Subject, credentialsOIDCProvider.Organization, ); err != nil { return err } if err := s.d.IdentityManager().Update(ctx, i, identity.ManagerAllowWriteProtectedTraits); err != nil { return err } return nil } func (s *Strategy) CompletedLogin(sess *session.Session, data *flow.DuplicateCredentialsData) error { var credentialsOIDCConfig identity.CredentialsOIDC if err := json.Unmarshal(data.CredentialsConfig, &credentialsOIDCConfig); err != nil { return err } if len(credentialsOIDCConfig.Providers) != 1 { return errors.New("no oidc provider was set") } credentialsOIDCProvider := credentialsOIDCConfig.Providers[0] sess.CompletedLoginForWithProvider( s.ID(), identity.AuthenticatorAssuranceLevel1, credentialsOIDCProvider.Provider, credentialsOIDCProvider.Organization, ) return nil } func (s *Strategy) SetDuplicateCredentials(f flow.InternalContexter, duplicateIdentifier string, credentials identity.Credentials, provider string) error { var credentialsOIDCConfig identity.CredentialsOIDC if err := json.Unmarshal(credentials.Config, &credentialsOIDCConfig); err != nil { return err } // We want to only set the provider in the credentials config that was used to authenticate the user. for _, p := range credentialsOIDCConfig.Providers { if p.Provider == provider { credentialsOIDCConfig.Providers = []identity.CredentialsOIDCProvider{p} config, err := json.Marshal(credentialsOIDCConfig) if err != nil { return err } return flow.SetDuplicateCredentials(f, flow.DuplicateCredentialsData{ CredentialsType: s.ID(), CredentialsConfig: config, DuplicateIdentifier: duplicateIdentifier, }) } } return fmt.Errorf("provider %q not found in credentials", provider) }