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
700 lines
23 KiB
Go
700 lines
23 KiB
Go
// 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)
|
|
}
|