diff --git a/driver/config/config.go b/driver/config/config.go index 6af7e7c17c5c..630a9f182c7b 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -183,6 +183,7 @@ const ( ViperKeyIgnoreNetworkErrors = "selfservice.methods.password.config.ignore_network_errors" ViperKeyTOTPIssuer = "selfservice.methods.totp.config.issuer" ViperKeyOIDCBaseRedirectURL = "selfservice.methods.oidc.config.base_redirect_uri" + ViperKeyOid2BaseRedirectURL = "selfservice.methods.oid2.config.base_redirect_uri" ViperKeyWebAuthnRPDisplayName = "selfservice.methods.webauthn.config.rp.display_name" ViperKeyWebAuthnRPID = "selfservice.methods.webauthn.config.rp.id" ViperKeyWebAuthnRPOrigin = "selfservice.methods.webauthn.config.rp.origin" @@ -590,6 +591,10 @@ func (p *Config) OIDCRedirectURIBase(ctx context.Context) *url.URL { return p.GetProvider(ctx).URIF(ViperKeyOIDCBaseRedirectURL, p.SelfPublicURL(ctx)) } +func (p *Config) Oid2RedirectURIBase(ctx context.Context) *url.URL { + return p.GetProvider(ctx).URIF(ViperKeyOid2BaseRedirectURL, p.SelfPublicURL(ctx)) +} + func (p *Config) IdentityTraitsSchemas(ctx context.Context) (ss Schemas, err error) { if err = p.GetProvider(ctx).Koanf.Unmarshal(ViperKeyIdentitySchemas, &ss); err != nil { return ss, nil diff --git a/driver/registry_default.go b/driver/registry_default.go index 9317846d81f0..a8fef67b0ce7 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -6,6 +6,7 @@ package driver import ( "context" "crypto/sha256" + "github.com/ory/kratos/selfservice/strategy/oid2" "net/http" "strings" "sync" @@ -313,6 +314,7 @@ func (m *RegistryDefault) selfServiceStrategies() []any { // Construct the default list of strategies m.selfserviceStrategies = []any{ password.NewStrategy(m), + oid2.NewStrategy(m), oidc.NewStrategy(m), profile.NewStrategy(m), code.NewStrategy(m), diff --git a/embedx/config.schema.json b/embedx/config.schema.json index a836c21a7af5..b29d0d43842a 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -639,6 +639,45 @@ } ] }, + "selfServiceOid2Provider": { + "type": "object", + "properties": { + "id": { + "type": "string", + "examples": [ + "steam" + ] + }, + "provider": { + "title": "Provider", + "description": "Can be one of generic, steam.", + "type": "string", + "enum": [ + "generic", + "steam" + ], + "examples": [ + "steam" + ] + }, + "label": { + "title": "Optional string which will be used when generating labels for UI buttons.", + "type": "string" + }, + "discovery_url": { + "type": "string", + "format": "uri", + "examples": [ + "https://accounts.google.com/o/oauth2/v2/auth" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "provider" + ] + }, "selfServiceHooks": { "type": "array", "items": { @@ -1721,6 +1760,42 @@ } } } + }, + "oid2": { + "type": "object", + "title": "Specify OpenID 2.0 Configuration", + "showEnvVarBlockForObject": true, + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "title": "Enables OpenID 2.0 Method", + "default": false + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "base_redirect_uri": { + "type": "string", + "title": "Base URL for OpenID 2.0 Redirect URIs", + "description": "Can be used to modify the base URL for OpenID 2.0 Redirect URLs. If unset, the Public Base URL will be used.", + "format": "uri", + "examples": [ + "https://auth.myexample.org/" + ] + }, + "providers": { + "title": "OpenID 2.0 Providers", + "description": "A list and configuration of OpenID 2.0 providers Ory Kratos should integrate with.", + "type": "array", + "items": { + "$ref": "#/definitions/selfServiceOid2Provider" + } + } + } + } + } } } } diff --git a/go.mod b/go.mod index 22e8187de37e..1c93457704fc 100644 --- a/go.mod +++ b/go.mod @@ -91,6 +91,7 @@ require ( github.com/tidwall/gjson v1.14.3 github.com/tidwall/sjson v1.2.5 github.com/urfave/negroni v1.0.0 + github.com/yohcop/openid-go v1.0.1 github.com/zmb3/spotify/v2 v2.4.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 go.opentelemetry.io/otel v1.22.0 diff --git a/go.sum b/go.sum index d559b4b6ef35..3be77e026728 100644 --- a/go.sum +++ b/go.sum @@ -1018,6 +1018,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= +github.com/yohcop/openid-go v1.0.1 h1:DPRd3iPO5F6O5zX2e62XpVAbPT6wV51cuucH0z9g3js= +github.com/yohcop/openid-go v1.0.1/go.mod h1:b/AvD03P0KHj4yuihb+VtLD6bYYgsy0zqBzPCRjkCNs= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1109,6 +1111,8 @@ golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4 golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1205,6 +1209,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1321,6 +1327,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20191110171634-ad39bd3f0407/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1335,6 +1343,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/identity/credentials.go b/identity/credentials.go index c18b9df97f5d..063e1464954a 100644 --- a/identity/credentials.go +++ b/identity/credentials.go @@ -81,6 +81,7 @@ type CredentialsType string // Please make sure to add all of these values to the test that ensures they are created during migration const ( CredentialsTypePassword CredentialsType = "password" + CredentialsTypeOID2 CredentialsType = "oid2" CredentialsTypeOIDC CredentialsType = "oidc" CredentialsTypeTOTP CredentialsType = "totp" CredentialsTypeLookup CredentialsType = "lookup_secret" @@ -96,6 +97,8 @@ func (c CredentialsType) ToUiNodeGroup() node.UiNodeGroup { switch c { case CredentialsTypePassword: return node.PasswordGroup + case CredentialsTypeOID2: + return node.OpenID2Group case CredentialsTypeOIDC: return node.OpenIDConnectGroup case CredentialsTypeTOTP: @@ -113,6 +116,7 @@ func (c CredentialsType) ToUiNodeGroup() node.UiNodeGroup { var AllCredentialTypes = []CredentialsType{ CredentialsTypePassword, + CredentialsTypeOID2, CredentialsTypeOIDC, CredentialsTypeTOTP, CredentialsTypeLookup, @@ -131,6 +135,7 @@ const ( func ParseCredentialsType(in string) (CredentialsType, bool) { for _, t := range []CredentialsType{ CredentialsTypePassword, + CredentialsTypeOID2, CredentialsTypeOIDC, CredentialsTypeTOTP, CredentialsTypeLookup, diff --git a/identity/credentials_oid2.go b/identity/credentials_oid2.go new file mode 100644 index 000000000000..8726ad816aa7 --- /dev/null +++ b/identity/credentials_oid2.go @@ -0,0 +1,62 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package identity + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/pkg/errors" + + "github.com/ory/kratos/x" +) + +// CredentialsOid2 contains the configuration for credentials of the type oidc. +// +// swagger:model identityCredentialsOidc +type CredentialsOid2 struct { + Providers []CredentialsOid2Provider `json:"providers"` +} + +// CredentialsOid2 Provider contains a specific OpenID 2.0 credential for a particular connection (e.g. Steam). +// +// swagger:model identityCredentialsOid2Provider +type CredentialsOid2Provider struct { + ClaimedId string `json:"claimed_id"` + Provider string `json:"provider"` +} + +// NewCredentialsOid2 creates a new Open ID 2.0 credential. +func NewCredentialsOid2(claimedId, provider string) (*Credentials, error) { + if provider == "" { + return nil, errors.New("received empty provider in oid2 credentials") + } + + if claimedId == "" { + return nil, errors.New("received empty claimed ID in oid2 credentials") + } + + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(CredentialsOid2{ + Providers: []CredentialsOid2Provider{ + { + ClaimedId: claimedId, + Provider: provider, + }}, + }); err != nil { + return nil, errors.WithStack(x.PseudoPanic. + WithDebugf("Unable to encode password options to JSON: %s", err)) + } + + return &Credentials{ + Type: CredentialsTypeOID2, + Identifiers: []string{Oid2UniqueID(provider, claimedId)}, + Config: b.Bytes(), + }, nil +} + +func Oid2UniqueID(provider, subject string) string { + return fmt.Sprintf("%s:%s", provider, subject) +} diff --git a/identity/credentials_oidc.go b/identity/credentials_oidc.go index 09b5d0aecad0..e70b16ca38d8 100644 --- a/identity/credentials_oidc.go +++ b/identity/credentials_oidc.go @@ -39,7 +39,7 @@ func NewCredentialsOIDC(idToken, accessToken, refreshToken, provider, subject, o } if subject == "" { - return nil, errors.New("received empty provider in oidc credentials") + return nil, errors.New("received empty subject in oidc credentials") } var b bytes.Buffer diff --git a/selfservice/strategy/oid2/.schema/link.schema.json b/selfservice/strategy/oid2/.schema/link.schema.json new file mode 100644 index 000000000000..3b3bf8b6dfcd --- /dev/null +++ b/selfservice/strategy/oid2/.schema/link.schema.json @@ -0,0 +1,20 @@ +{ + "$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/login.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "csrf_token": { + "type": "string" + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "traits": { + "description": "DO NOT DELETE THIS FIELD. This field will be overwritten in login.go's and registration.go's decoder() method. Do not add anything to this field as it has no effect." + }, + "method": { + "type": "string" + } + } +} diff --git a/selfservice/strategy/oid2/provider.go b/selfservice/strategy/oid2/provider.go new file mode 100644 index 000000000000..58d72ea6430d --- /dev/null +++ b/selfservice/strategy/oid2/provider.go @@ -0,0 +1,21 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +import ( + "context" + + "github.com/ory/x/urlx" + "net/url" + "strings" +) + +type Provider interface { + Config() *Configuration + GetRedirectUrl(ctx context.Context) string +} + +func (providerConfig Configuration) Redir(public *url.URL) string { + return urlx.AppendPaths(public, strings.Replace(RouteCallback, ":provider", providerConfig.ID, 1)).String() +} diff --git a/selfservice/strategy/oid2/provider_config.go b/selfservice/strategy/oid2/provider_config.go new file mode 100644 index 000000000000..3fa89e0bddd4 --- /dev/null +++ b/selfservice/strategy/oid2/provider_config.go @@ -0,0 +1,51 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +import ( + "github.com/ory/herodot" + "github.com/pkg/errors" + "golang.org/x/exp/maps" +) + +type Configuration struct { + // ID is the provider's ID + ID string `json:"id"` + + // Provider is either "generic" for a generic OpenID 2.0 Provider or one of: + // - generic + // - steam + Provider string `json:"provider"` + + // Label represents an optional label which can be used in the UI generation. + Label string `json:"label"` + + // DiscoveryUrl is the URL of the Open ID 2.0 discovery document, typically something like: + // https://example.org/openid. Should only be used and when `provider` is set to `generic`. + DiscoveryUrl string `json:"discovery_url"` +} + +type ConfigurationCollection struct { + BaseRedirectURI string `json:"base_redirect_uri"` + Providers []Configuration `json:"providers"` +} + +var supportedProviders = map[string]func(config *Configuration, reg Dependencies) Provider{ + "generic": NewProviderGenericOid2, + "steam": NewProviderSteam, +} + +func (c ConfigurationCollection) Provider(id string, reg Dependencies) (Provider, error) { + for k := range c.Providers { + p := c.Providers[k] + if p.ID == id { + if f, ok := supportedProviders[p.Provider]; ok { + return f(&p, reg), nil + } + + return nil, errors.Errorf("provider type %s is not supported, supported are: %v", p.Provider, maps.Keys(supportedProviders)) + } + } + return nil, errors.WithStack(herodot.ErrNotFound.WithReasonf(`OpenID 2.0 Provider "%s" is unknown or has not been configured`, id)) +} diff --git a/selfservice/strategy/oid2/provider_generic_oid2.go b/selfservice/strategy/oid2/provider_generic_oid2.go new file mode 100644 index 000000000000..312f1f849125 --- /dev/null +++ b/selfservice/strategy/oid2/provider_generic_oid2.go @@ -0,0 +1,29 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +import "context" + +type ProviderGenericOid2 struct { + config *Configuration + reg Dependencies +} + +func NewProviderGenericOid2( + config *Configuration, + reg Dependencies, +) Provider { + return &ProviderGenericOid2{ + config: config, + reg: reg, + } +} + +func (g *ProviderGenericOid2) Config() *Configuration { + return g.config +} + +func (g *ProviderGenericOid2) GetRedirectUrl(ctx context.Context) string { + return g.config.Redir(g.reg.Config().Oid2RedirectURIBase(ctx)) +} diff --git a/selfservice/strategy/oid2/provider_steam.go b/selfservice/strategy/oid2/provider_steam.go new file mode 100644 index 000000000000..c5544a4c06c0 --- /dev/null +++ b/selfservice/strategy/oid2/provider_steam.go @@ -0,0 +1,24 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +type ProviderSteam struct { + *ProviderGenericOid2 +} + +const SteamDiscoveryUrl = "https://steamcommunity.com/openid" + +func NewProviderSteam( + config *Configuration, + reg Dependencies, +) Provider { + config.DiscoveryUrl = SteamDiscoveryUrl + + return &ProviderSteam{ + ProviderGenericOid2: &ProviderGenericOid2{ + config: config, + reg: reg, + }, + } +} diff --git a/selfservice/strategy/oid2/schema.go b/selfservice/strategy/oid2/schema.go new file mode 100644 index 000000000000..347e61a26fa8 --- /dev/null +++ b/selfservice/strategy/oid2/schema.go @@ -0,0 +1,11 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +import ( + _ "embed" +) + +//go:embed .schema/link.schema.json +var linkSchema []byte diff --git a/selfservice/strategy/oid2/strategy.go b/selfservice/strategy/oid2/strategy.go new file mode 100644 index 000000000000..13079fa8df78 --- /dev/null +++ b/selfservice/strategy/oid2/strategy.go @@ -0,0 +1,140 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +import ( + "bytes" + "context" + "encoding/json" + "github.com/julienschmidt/httprouter" + "github.com/ory/herodot" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/selfservice/flowhelpers" + "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" + "github.com/ory/x/decoderx" + "github.com/ory/x/jsonx" + "github.com/pkg/errors" + "net/http" +) + +const ( + RouteBase = "/self-service/methods/oid2" + + RouteCallback = RouteBase + "/callback/:provider" +) + +type Dependencies interface { + config.Provider + + identity.PrivilegedPoolProvider + + session.ManagementProvider + + x.CSRFTokenGeneratorProvider + x.LoggingProvider + x.TracingProvider + x.WriterProvider +} + +type Strategy struct { + d Dependencies + dec *decoderx.HTTP +} + +func NewStrategy(d any) *Strategy { + return &Strategy{ + d: d.(Dependencies), + } +} + +func (s *Strategy) ID() identity.CredentialsType { + return identity.CredentialsTypeOID2 +} + +func (s *Strategy) NodeGroup() node.UiNodeGroup { + return node.OpenID2Group +} + +func (s *Strategy) populateMethod(r *http.Request, f flow.Flow, message func(provider string) *text.Message) error { + conf, err := s.Config(r.Context()) + if err != nil { + return err + } + + providers := conf.Providers + + if lf, ok := f.(*login.Flow); ok && lf.IsForced() { + if _, id, c := flowhelpers.GuessForcedLoginIdentifier(r, s.d, lf, s.ID()); id != nil { + if c == nil { + // no OID2 credentials, don't add any providers + providers = nil + } else { + var credentials identity.CredentialsOid2 + if err := json.Unmarshal(c.Config, &credentials); err != nil { + // failed to read OID2 credentials, don't add any providers + providers = nil + } else { + // add only providers that can actually be used to log in as this identity + providers = make([]Configuration, 0, len(conf.Providers)) + for i := range conf.Providers { + for j := range credentials.Providers { + if conf.Providers[i].ID == credentials.Providers[j].Provider { + providers = append(providers, conf.Providers[i]) + break + } + } + } + } + } + } + } + + c := f.GetUI() + c.SetCSRF(s.d.GenerateCSRFToken(r)) + AddProviders(c, providers, message) + + return nil +} + +func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + +} + +func (s *Strategy) Config(ctx context.Context) (*ConfigurationCollection, error) { + var c ConfigurationCollection + + conf := s.d.Config().SelfServiceStrategy(ctx, string(s.ID())).Config + if err := jsonx. + NewStrictDecoder(bytes.NewBuffer(conf)). + Decode(&c); err != nil { + s.d.Logger().WithError(err).WithField("config", conf) + return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to decode OpenID Connect Provider configuration: %s", err)) + } + + return &c, nil +} + +func (s *Strategy) provider(ctx context.Context, id string) (Provider, error) { + if c, err := s.Config(ctx); err != nil { + return nil, err + } else if provider, err := c.Provider(id, s.d); err != nil { + return nil, err + } else { + return provider, nil + } +} + +func (s *Strategy) setRoutes(r *x.RouterPublic) { + wrappedHandleCallback := strategy.IsDisabled(s.d, s.ID().String(), s.HandleCallback) + if handle, _, _ := r.Lookup("GET", RouteCallback); handle == nil { + r.GET(RouteCallback, wrappedHandleCallback) + } +} diff --git a/selfservice/strategy/oid2/strategy_login.go b/selfservice/strategy/oid2/strategy_login.go new file mode 100644 index 000000000000..92af74378a30 --- /dev/null +++ b/selfservice/strategy/oid2/strategy_login.go @@ -0,0 +1,34 @@ +package oid2 + +import ( + "context" + "github.com/gofrs/uuid" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow/login" + "github.com/ory/kratos/session" + "github.com/ory/kratos/x" + "net/http" +) + +var _ login.Strategy = new(Strategy) + +// TODO #3631 implement OID2 login + +func (s *Strategy) RegisterLoginRoutes(publicRouter *x.RouterPublic) { + +} + +func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, sr *login.Flow) error { + return nil +} + +func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, identityID uuid.UUID) (i *identity.Identity, err error) { + return nil, nil +} + +func (s *Strategy) CompletedAuthenticationMethod(ctx context.Context) session.AuthenticationMethod { + return session.AuthenticationMethod{ + Method: s.ID(), + AAL: identity.AuthenticatorAssuranceLevel1, + } +} diff --git a/selfservice/strategy/oid2/strategy_registration.go b/selfservice/strategy/oid2/strategy_registration.go new file mode 100644 index 000000000000..5a50fcdee08e --- /dev/null +++ b/selfservice/strategy/oid2/strategy_registration.go @@ -0,0 +1,126 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +import ( + "encoding/json" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/text" + "github.com/ory/kratos/x" + "github.com/ory/x/decoderx" + "github.com/ory/x/otelx" + "github.com/pkg/errors" + "github.com/tidwall/sjson" + "github.com/yohcop/openid-go" + "net/http" + "strings" +) + +var _ registration.Strategy = new(Strategy) + +func (s *Strategy) RegisterRegistrationRoutes(public *x.RouterPublic) { + s.setRoutes(public) +} + +func (s *Strategy) PopulateRegistrationMethod(r *http.Request, f *registration.Flow) error { + return s.populateMethod(r, f, text.NewInfoRegistrationWith) +} + +// Update Registration Flow with OpenID 2.0 Method +// +// swagger:model updateRegistrationFlowWithOid2Method +type UpdateRegistrationFlowWithOid2Method struct { + // The provider to register with + // + // required: true + Provider string `json:"provider"` + + // The CSRF Token + CSRFToken string `json:"csrf_token"` + + // The identity traits + Traits json.RawMessage `json:"traits"` + + // Method to use + // + // This field must be set to `oid2` when using the oid2 method. + // + // required: true + Method string `json:"method"` +} + +func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) (err error) { + ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.oid2.strategy.Register") + defer otelx.End(span, &err) + + var p UpdateRegistrationFlowWithOid2Method + if err := s.newLinkDecoder(&p, r); err != nil { + return err + } + + pid := p.Provider // this can come from both url query and post body + if pid == "" { + return errors.WithStack(flow.ErrStrategyNotResponsible) + } + + if !strings.EqualFold(strings.ToLower(p.Method), s.ID().String()) && p.Method != "" { + // the user is sending a method that is not oid2, but the payload includes a provider + s.d.Audit(). + WithRequest(r). + WithField("provider", p.Provider). + WithField("method", p.Method). + Warn("The payload includes a `provider` field but is using a method other than `oid2`. Therefore, Open ID 2.0 sign in will not be executed.") + return errors.WithStack(flow.ErrStrategyNotResponsible) + } + + provider, err := s.provider(ctx, pid) + if err != nil { + return err + } + + redirectUrl, err := openid.RedirectURL(provider.Config().DiscoveryUrl, provider.GetRedirectUrl(ctx), s.d.Config().Oid2RedirectURIBase(ctx).String()) + if err != nil { + return err + } + + if x.IsJSONRequest(r) { + s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(redirectUrl)) + } else { + http.Redirect(w, r, redirectUrl, http.StatusSeeOther) + } + + return errors.WithStack(flow.ErrCompletedByStrategy) +} + +// TODO #3631 ...what? +func (s *Strategy) newLinkDecoder(p interface{}, r *http.Request) error { + ds, err := s.d.Config().DefaultIdentityTraitsSchemaURL(r.Context()) + if err != nil { + return err + } + + raw, err := sjson.SetBytes(linkSchema, "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 := s.dec.Decode(r, &p, compiler, + decoderx.HTTPKeepRequestBody(true), + decoderx.HTTPDecoderSetValidatePayloads(false), + decoderx.HTTPDecoderUseQueryAndBody(), + decoderx.HTTPDecoderAllowedMethods("POST", "GET"), + decoderx.HTTPDecoderJSONFollowsFormFormat(), + ); err != nil { + return errors.WithStack(err) + } + + return nil +} diff --git a/selfservice/strategy/oid2/strategy_test.go b/selfservice/strategy/oid2/strategy_test.go new file mode 100644 index 000000000000..7c5e6e7cbfe6 --- /dev/null +++ b/selfservice/strategy/oid2/strategy_test.go @@ -0,0 +1,274 @@ +package oid2_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/gofrs/uuid" + "github.com/ory/kratos/driver" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/selfservice/strategy/oid2" + "github.com/ory/kratos/ui/container" + "github.com/ory/kratos/x" + "github.com/ory/x/urlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestStrategy(t *testing.T) { + ctx := context.Background() + if testing.Short() { + t.Skip() + } + + var ( + conf, reg = internal.NewFastRegistryWithMocks(t) + ) + + returnTS := newReturnTs(t, reg) + conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{returnTS.URL}) + routerP := x.NewRouterPublic() + routerA := x.NewRouterAdmin() + ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, routerP, routerA) + errTS := testhelpers.NewErrorTestServer(t, reg) + uiTS := newUI(t, reg) + viperSetProviderConfig( + t, + conf, + // TODO #3631 start a real server here? + newOid2Provider("valid", "http://localhost:12345"), + ) + + conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") + conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, + identity.CredentialsTypeOID2.String()), []config.SelfServiceHook{{Name: "session"}}) + + //loginAction := func(flowID uuid.UUID) string { + // return ts.URL + login.RouteSubmitFlow + "?flow=" + flowID.String() + //} + //newLoginFlow := func(t *testing.T, requestURL string, exp time.Duration, flowType flow.Type) (req *login.Flow) { + // // Use NewLoginFlow to instantiate the request but change the things we need to control a copy of it. + // req, _, err := reg.LoginHandler().NewLoginFlow(httptest.NewRecorder(), + // &http.Request{URL: urlx.ParseOrPanic(requestURL)}, flowType) + // require.NoError(t, err) + // req.RequestURL = requestURL + // req.ExpiresAt = time.Now().Add(exp) + // require.NoError(t, reg.LoginFlowPersister().UpdateLoginFlow(context.Background(), req)) + // + // // sanity check + // got, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), req.ID) + // require.NoError(t, err) + // + // require.Len(t, got.UI.Nodes, len(req.UI.Nodes), "%+v", got) + // + // return + //} + //newBrowserLoginFlow := func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) { + // return newLoginFlow(t, redirectTo, exp, flow.TypeBrowser) + //} + + registerAction := func(flowID uuid.UUID) string { + return ts.URL + registration.RouteSubmitFlow + "?flow=" + flowID.String() + } + newRegistrationFlow := func(t *testing.T, redirectTo string, exp time.Duration, flowType flow.Type) *registration.Flow { + // Use NewLoginFlow to instantiate the request but change the things we need to control a copy of it. + req, err := reg.RegistrationHandler().NewRegistrationFlow(httptest.NewRecorder(), + &http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flowType) + require.NoError(t, err) + req.RequestURL = redirectTo + req.ExpiresAt = time.Now().Add(exp) + require.NoError(t, reg.RegistrationFlowPersister().UpdateRegistrationFlow(context.Background(), req)) + + // sanity check + got, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), req.ID) + require.NoError(t, err) + require.Len(t, got.UI.Nodes, len(req.UI.Nodes), "%+v", req) + + return req + } + newBrowserRegistrationFlow := func(t *testing.T, redirectTo string, exp time.Duration) *registration.Flow { + return newRegistrationFlow(t, redirectTo, exp, flow.TypeBrowser) + } + + makeRequestWithCookieJar := func(t *testing.T, provider string, action string, fv url.Values, jar *cookiejar.Jar) (*http.Response, []byte) { + fv.Set("provider", provider) + res, err := testhelpers.NewClientWithCookieJar(t, jar, false).PostForm(action, fv) + require.NoError(t, err, action) + + body, err := io.ReadAll(res.Body) + require.NoError(t, res.Body.Close()) + require.NoError(t, err) + + require.Equal(t, 200, res.StatusCode, "%s: %s\n\t%s", action, res.Request.URL.String(), body) + + return res, body + } + makeRequest := func(t *testing.T, provider string, action string, fv url.Values) (*http.Response, []byte) { + return makeRequestWithCookieJar(t, provider, action, fv, nil) + } + + assertFormValues := func(t *testing.T, flowID uuid.UUID, provider string) (action string) { + var config *container.Container + if req, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), flowID); err == nil { + require.EqualValues(t, req.ID, flowID) + config = req.UI + require.NotNil(t, config) + } else if req, err := reg.LoginFlowPersister().GetLoginFlow(context.Background(), flowID); err == nil { + require.EqualValues(t, req.ID, flowID) + config = req.UI + require.NotNil(t, config) + } else { + require.NoError(t, err) + return + } + + assert.Equal(t, "POST", config.Method) + + // TODO #3631 re-enable this once PopulateRegistrationMethod has been implemented + //var found bool + //for _, field := range config.Nodes { + // if strings.Contains(field.ID(), "provider") && field.GetValue() == provider { + // found = true + // break + // } + //} + //require.True(t, found, "%+v", assertx.PrettifyJSONPayload(t, config)) + + return config.Action + } + assertSystemErrorWithMessage := func(t *testing.T, res *http.Response, body []byte, code int, message string) { + require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body) + + assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", body) + assert.Contains(t, gjson.GetBytes(body, "message").String(), message, "%s", body) + } + assertSystemErrorWithReason := func(t *testing.T, res *http.Response, body []byte, code int, reason string) { + require.Contains(t, res.Request.URL.String(), errTS.URL, "%s", body) + + assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", prettyJSON(t, body)) + assert.Contains(t, gjson.GetBytes(body, "reason").String(), reason, "%s", prettyJSON(t, body)) + } + + t.Run("case=should fail because provider does not exist", func(t *testing.T) { + for k, v := range []string{ + //loginAction(newBrowserLoginFlow(t, returnTS.URL, time.Minute).ID), + registerAction(newBrowserRegistrationFlow(t, returnTS.URL, time.Minute).ID), + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + res, body := makeRequest(t, "provider-does-not-exist", v, url.Values{}) + assertSystemErrorWithReason(t, res, body, http.StatusNotFound, "is unknown or has not been configured") + }) + } + }) + + t.Run("case=should fail because flow does not exist", func(t *testing.T) { + for k, v := range []string{ /*loginAction(x.NewUUID()), */ registerAction(x.NewUUID())} { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + res, body := makeRequest(t, "valid", v, url.Values{}) + assertSystemErrorWithMessage(t, res, body, http.StatusNotFound, "Unable to locate the resource") + }) + } + }) + + t.Run("case=should fail because the flow is expired", func(t *testing.T) { + for k, v := range []uuid.UUID{ + //newBrowserLoginFlow(t, returnTS.URL, -time.Minute).ID, + newBrowserRegistrationFlow(t, returnTS.URL, -time.Minute).ID, + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + action := assertFormValues(t, v, "valid") + res, body := makeRequest(t, "valid", action, url.Values{}) + + assert.NotEqual(t, v, gjson.GetBytes(body, "id")) + require.Contains(t, res.Request.URL.String(), uiTS.URL, "%s", body) + assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), "flow expired", "%s", body) + }) + } + }) +} + +func newOid2Provider( + provider string, + discoveryUrl string, +) oid2.Configuration { + return oid2.Configuration{ + Provider: provider, + DiscoveryUrl: discoveryUrl, + } +} + +func newReturnTs(t *testing.T, reg driver.Registry) *httptest.Server { + ctx := context.Background() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/app_code" { + reg.Writer().Write(w, r, "ok") + return + } + sess, err := reg.SessionManager().FetchFromRequest(r.Context(), r) + require.NoError(t, err) + reg.Writer().Write(w, r, sess) + })) + reg.Config().MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, ts.URL) + t.Cleanup(ts.Close) + return ts +} + +func newUI(t *testing.T, reg driver.Registry) *httptest.Server { + ctx := context.Background() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var e interface{} + var err error + if r.URL.Path == "/login" { + e, err = reg.LoginFlowPersister().GetLoginFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("flow"))) + } else if r.URL.Path == "/registration" { + e, err = reg.RegistrationFlowPersister().GetRegistrationFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("flow"))) + } else if r.URL.Path == "/settings" { + e, err = reg.SettingsFlowPersister().GetSettingsFlow(r.Context(), x.ParseUUID(r.URL.Query().Get("flow"))) + } + + require.NoError(t, err) + reg.Writer().Write(w, r, e) + })) + t.Cleanup(ts.Close) + reg.Config().MustSet(ctx, config.ViperKeySelfServiceLoginUI, ts.URL+"/login") + reg.Config().MustSet(ctx, config.ViperKeySelfServiceRegistrationUI, ts.URL+"/registration") + reg.Config().MustSet(ctx, config.ViperKeySelfServiceSettingsURL, ts.URL+"/settings") + return ts +} + +func prettyJSON(t *testing.T, body []byte) string { + var out bytes.Buffer + require.NoError(t, json.Indent(&out, body, "", "\t")) + + return out.String() +} + +func viperSetProviderConfig(t *testing.T, conf *config.Config, providers ...oid2.Configuration) { + ctx := context.Background() + baseKey := fmt.Sprintf("%s.%s", config.ViperKeySelfServiceStrategyConfig, identity.CredentialsTypeOID2) + currentConfig := conf.GetProvider(ctx).Get(baseKey + ".config") + currentEnabled := conf.GetProvider(ctx).Get(baseKey + ".enabled") + + conf.MustSet(ctx, baseKey+".config", &oid2.ConfigurationCollection{Providers: providers}) + conf.MustSet(ctx, baseKey+".enabled", true) + + t.Cleanup(func() { + conf.MustSet(ctx, baseKey+".config", currentConfig) + conf.MustSet(ctx, baseKey+".enabled", currentEnabled) + }) +} diff --git a/selfservice/strategy/oid2/stub/registration.schema.json b/selfservice/strategy/oid2/stub/registration.schema.json new file mode 100644 index 000000000000..1d7adb251ac9 --- /dev/null +++ b/selfservice/strategy/oid2/stub/registration.schema.json @@ -0,0 +1,37 @@ +{ + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "format": "email", + "type": "string", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + } + } + }, + "name": { + "type": "string", + "minLength": 2 + } + } + }, + "metadata_admin": { + "type": "object", + "properties": { + "oid2_steam": { + "type": "string" + } + } + } + }, + "additionalProperties": false +} diff --git a/selfservice/strategy/oid2/types.go b/selfservice/strategy/oid2/types.go new file mode 100644 index 000000000000..4a0c45eedac2 --- /dev/null +++ b/selfservice/strategy/oid2/types.go @@ -0,0 +1,30 @@ +// Copyright © 2024 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oid2 + +import ( + "github.com/ory/kratos/text" + "github.com/ory/x/stringsx" + + "github.com/ory/kratos/ui/container" + + "github.com/ory/kratos/ui/node" +) + +type FlowMethod struct { + *container.Container +} + +func AddProviders(c *container.Container, providers []Configuration, message func(provider string) *text.Message) { + for _, p := range providers { + AddProvider(c, p.ID, message( + stringsx.Coalesce(p.Label, p.ID))) + } +} + +func AddProvider(c *container.Container, providerID string, message *text.Message) { + c.GetNodes().Append( + node.NewInputField("provider", providerID, node.OpenID2Group, node.InputAttributeTypeSubmit).WithMetaLabel(message), + ) +} diff --git a/ui/node/node.go b/ui/node/node.go index c1c2aa64f1c0..2591d7c449d5 100644 --- a/ui/node/node.go +++ b/ui/node/node.go @@ -41,6 +41,7 @@ type UiNodeGroup string const ( DefaultGroup UiNodeGroup = "default" PasswordGroup UiNodeGroup = "password" + OpenID2Group UiNodeGroup = "oid2" OpenIDConnectGroup UiNodeGroup = "oidc" ProfileGroup UiNodeGroup = "profile" LinkGroup UiNodeGroup = "link"