From 28148d30c7643033ad2d7c45e178fa2cabcb0497 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Fri, 17 Mar 2023 15:20:16 +0100 Subject: [PATCH 1/2] feat: support OIDC on API flows --- cmd/clidoc/main.go | 1 + driver/config/config.go | 5 + embedx/config.schema.json | 24 ++ go.mod | 5 +- go.sum | 20 +- ...odel_update_login_flow_with_oidc_method.go | 37 ++ ...date_registration_flow_with_oidc_method.go | 37 ++ ...l_update_settings_flow_with_oidc_method.go | 37 ++ ...odel_update_login_flow_with_oidc_method.go | 37 ++ ...date_registration_flow_with_oidc_method.go | 37 ++ ...l_update_settings_flow_with_oidc_method.go | 37 ++ internal/testhelpers/selfservice_login.go | 5 +- .../testhelpers/selfservice_registration.go | 3 +- selfservice/flow/flow.go | 18 + selfservice/flow/login/hook.go | 24 ++ selfservice/flow/registration/error.go | 36 +- selfservice/hook/session_issuer.go | 26 ++ .../strategy/oidc/.schema/link.schema.json | 2 +- .../strategy/oidc/.schema/login.schema.json | 25 ++ .../oidc/.schema/settings.schema.json | 6 +- ...link_a_connection-browser-flow=fetch.json} | 0 ...k_a_connection-browser-flow=original.json} | 0 ...k_a_connection-browser-flow=response.json} | 0 ...hod=TestPopulateLoginMethod-case=api.json} | 22 ++ ...=TestPopulateLoginMethod-case=browser.json | 84 ++++ ...ategy-method=TestPopulateSignUpMethod.json | 22 ++ selfservice/strategy/oidc/error.go | 27 +- selfservice/strategy/oidc/provider.go | 4 + selfservice/strategy/oidc/provider_config.go | 4 + .../strategy/oidc/provider_generic_oidc.go | 34 +- selfservice/strategy/oidc/provider_google.go | 2 +- .../strategy/oidc/provider_microsoft.go | 28 +- selfservice/strategy/oidc/provider_netid.go | 2 +- selfservice/strategy/oidc/schema.go | 3 + selfservice/strategy/oidc/strategy.go | 12 - .../strategy/oidc/strategy_helper_test.go | 116 +++++- selfservice/strategy/oidc/strategy_login.go | 114 ++++-- .../strategy/oidc/strategy_registration.go | 154 ++++++-- .../strategy/oidc/strategy_settings.go | 44 ++- .../strategy/oidc/strategy_settings_test.go | 124 +++--- selfservice/strategy/oidc/strategy_test.go | 362 ++++++++++++++---- spec/api.json | 12 + spec/swagger.json | 12 + text/id.go | 1 + text/message_validation.go | 9 + 45 files changed, 1376 insertions(+), 238 deletions(-) create mode 100644 selfservice/strategy/oidc/.schema/login.schema.json rename selfservice/strategy/oidc/.snapshots/{TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=fetch.json => TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=fetch.json} (100%) rename selfservice/strategy/oidc/.snapshots/{TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=original.json => TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=original.json} (100%) rename selfservice/strategy/oidc/.snapshots/{TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=response.json => TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=response.json} (100%) rename selfservice/strategy/oidc/.snapshots/{TestStrategy-method=TestPopulateLoginMethod.json => TestStrategy-method=TestPopulateLoginMethod-case=api.json} (72%) create mode 100644 selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod-case=browser.json diff --git a/cmd/clidoc/main.go b/cmd/clidoc/main.go index e405f083207d..fc9956cbcd5e 100644 --- a/cmd/clidoc/main.go +++ b/cmd/clidoc/main.go @@ -142,6 +142,7 @@ func init() { "NewInfoSelfServiceContinueLoginWebAuthn": text.NewInfoSelfServiceContinueLoginWebAuthn(), "NewInfoSelfServiceLoginContinue": text.NewInfoSelfServiceLoginContinue(), "NewErrorValidationSuchNoWebAuthnUser": text.NewErrorValidationSuchNoWebAuthnUser(), + "NewErrorValidationOIDCUserNotFound": text.NewErrorValidationOIDCUserNotFound(), } } diff --git a/driver/config/config.go b/driver/config/config.go index 62a94baed820..13acd57133eb 100644 --- a/driver/config/config.go +++ b/driver/config/config.go @@ -114,6 +114,7 @@ const ( ViperKeySelfServiceStrategyConfig = "selfservice.methods" ViperKeySelfServiceBrowserDefaultReturnTo = "selfservice." + DefaultBrowserReturnURL ViperKeyURLsAllowedReturnToDomains = "selfservice.allowed_return_urls" + ViperKeySelfServiceWebViewRedirectURL = "selfservice.webview_redirect_uri" ViperKeySelfServiceRegistrationEnabled = "selfservice.flows.registration.enabled" ViperKeySelfServiceRegistrationUI = "selfservice.flows.registration.ui_url" ViperKeySelfServiceRegistrationRequestLifespan = "selfservice.flows.registration.lifespan" @@ -803,6 +804,10 @@ func (p *Config) SelfServiceBrowserDefaultReturnTo(ctx context.Context) *url.URL return p.ParseAbsoluteOrRelativeURIOrFail(ctx, ViperKeySelfServiceBrowserDefaultReturnTo) } +func (p *Config) SelfServiceWebViewRedirectURL(ctx context.Context) *url.URL { + return p.GetProvider(ctx).URIF(ViperKeySelfServiceWebViewRedirectURL, nil) +} + func (p *Config) guessBaseURL(ctx context.Context, keyHost, keyPort string, defaultPort int) *url.URL { port := p.GetProvider(ctx).IntF(keyPort, defaultPort) diff --git a/embedx/config.schema.json b/embedx/config.schema.json index f7567a5143bd..e20467c6a86a 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -523,6 +523,21 @@ }, "requested_claims": { "$ref": "#/definitions/OIDCClaims" + }, + "allowed_audiences": { + "title": "List of values to check audience field of ID Token", + "description": "The audience field of ID Token should be equal to one of the items in this list", + "type": "array", + "items": { + "type": "string" + }, + "examples": [ + [ + "com.my-app.app", + "com.my-app.app.bundle.id" + ] + ], + "uniqueItems": true } }, "additionalProperties": false, @@ -1049,6 +1064,15 @@ "default_browser_return_url": { "$ref": "#/definitions/defaultReturnTo" }, + "webview_redirect_uri": { + "type": "string", + "title": "Final webview redirect URI", + "description": "Mobile app should detect this webview redirect and read a session token from the response body.", + "format": "uri", + "examples": [ + "https://auth.myexample.org/oidc/success" + ] + }, "allowed_return_urls": { "title": "Allowed Return To URLs", "description": "List of URLs that are allowed to be redirected to. A redirection request is made by appending `?return_to=...` to Login, Registration, and other self-service flows.", diff --git a/go.mod b/go.mod index c79e618bb37f..c2af16bbeca9 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/bwmarrin/discordgo v0.23.0 github.com/bxcodec/faker/v3 v3.3.1 github.com/cenkalti/backoff v2.2.1+incompatible - github.com/coreos/go-oidc v2.2.1+incompatible + github.com/coreos/go-oidc/v3 v3.5.0 github.com/cortesi/modd v0.0.0-20210323234521-b35eddab86cc github.com/davecgh/go-spew v1.1.1 github.com/davidrjonas/semver-cli v0.0.0-20190116233701-ee19a9a0dda6 @@ -151,6 +151,7 @@ require ( github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fullstorydev/grpcurl v1.8.1 // indirect github.com/fxamacker/cbor/v2 v2.4.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.0 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.21.4 // indirect @@ -249,7 +250,6 @@ require ( github.com/pelletier/go-toml/v2 v2.0.7 // indirect github.com/pkg/profile v1.7.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/pquerna/cachecontrol v0.1.0 // indirect github.com/prometheus/client_golang v1.13.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/common v0.37.0 // indirect @@ -327,7 +327,6 @@ require ( gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 // indirect - gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/sh/v3 v3.3.0-0.dev.0.20210224101809-fb5052e7a010 // indirect diff --git a/go.sum b/go.sum index f28657201b5f..51101b6d5c8d 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,7 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0= cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= @@ -250,8 +251,8 @@ github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= -github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc/v3 v3.5.0 h1:VxKtbccHZxs8juq7RdJntSqtXFtde9YpNpGn0yqgEHw= +github.com/coreos/go-oidc/v3 v3.5.0/go.mod h1:ecXRtV4romGPeO6ieExAsUK9cb/3fp9hXNz1tlv8PIM= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -382,6 +383,8 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= +github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= @@ -593,6 +596,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-github/v27 v27.0.1 h1:sSMFSShNn4VnqCqs+qhab6TS3uQc+uVR6TD1bW6MavM= github.com/google/go-github/v27 v27.0.1/go.mod h1:/0Gr8pJ55COkmv+S/yPKCczSkUPIM/LnFyubufRNIS0= @@ -1151,8 +1155,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= -github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -1543,6 +1545,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191002192127-34f69633bfdc/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -1675,6 +1678,8 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1697,6 +1702,7 @@ golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk= golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M= golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1823,6 +1829,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20191110171634-ad39bd3f0407/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -1832,6 +1839,7 @@ golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1844,6 +1852,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2091,6 +2100,7 @@ google.golang.org/protobuf v1.25.1-0.20200805231151-a709e31e5d12/go.mod h1:9JNX7 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= @@ -2122,8 +2132,6 @@ gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473 h1:6D+BvnJ/j6e222UW gopkg.in/op/go-logging.v1 v1.0.0-20160211212156-b2cb9fa56473/go.mod h1:N1eN2tsCx0Ydtgjl4cqmbRCsY4/+z4cYDeqwZTk6zog= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= -gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= -gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= diff --git a/internal/client-go/model_update_login_flow_with_oidc_method.go b/internal/client-go/model_update_login_flow_with_oidc_method.go index 9c2b92dc3650..1adedef64ce8 100644 --- a/internal/client-go/model_update_login_flow_with_oidc_method.go +++ b/internal/client-go/model_update_login_flow_with_oidc_method.go @@ -19,6 +19,8 @@ import ( type UpdateLoginFlowWithOidcMethod struct { // The CSRF Token CsrfToken *string `json:"csrf_token,omitempty"` + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + IdToken *string `json:"id_token,omitempty"` // Method to use This field must be set to `oidc` when using the oidc method. Method string `json:"method"` // The provider to register with @@ -80,6 +82,38 @@ func (o *UpdateLoginFlowWithOidcMethod) SetCsrfToken(v string) { o.CsrfToken = &v } +// GetIdToken returns the IdToken field value if set, zero value otherwise. +func (o *UpdateLoginFlowWithOidcMethod) GetIdToken() string { + if o == nil || o.IdToken == nil { + var ret string + return ret + } + return *o.IdToken +} + +// GetIdTokenOk returns a tuple with the IdToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithOidcMethod) GetIdTokenOk() (*string, bool) { + if o == nil || o.IdToken == nil { + return nil, false + } + return o.IdToken, true +} + +// HasIdToken returns a boolean if a field has been set. +func (o *UpdateLoginFlowWithOidcMethod) HasIdToken() bool { + if o != nil && o.IdToken != nil { + return true + } + + return false +} + +// SetIdToken gets a reference to the given string and assigns it to the IdToken field. +func (o *UpdateLoginFlowWithOidcMethod) SetIdToken(v string) { + o.IdToken = &v +} + // GetMethod returns the Method field value func (o *UpdateLoginFlowWithOidcMethod) GetMethod() string { if o == nil { @@ -197,6 +231,9 @@ func (o UpdateLoginFlowWithOidcMethod) MarshalJSON() ([]byte, error) { if o.CsrfToken != nil { toSerialize["csrf_token"] = o.CsrfToken } + if o.IdToken != nil { + toSerialize["id_token"] = o.IdToken + } if true { toSerialize["method"] = o.Method } diff --git a/internal/client-go/model_update_registration_flow_with_oidc_method.go b/internal/client-go/model_update_registration_flow_with_oidc_method.go index 187fd57a7f8f..5535dbff75be 100644 --- a/internal/client-go/model_update_registration_flow_with_oidc_method.go +++ b/internal/client-go/model_update_registration_flow_with_oidc_method.go @@ -19,6 +19,8 @@ import ( type UpdateRegistrationFlowWithOidcMethod struct { // The CSRF Token CsrfToken *string `json:"csrf_token,omitempty"` + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + IdToken *string `json:"id_token,omitempty"` // Method to use This field must be set to `oidc` when using the oidc method. Method string `json:"method"` // The provider to register with @@ -82,6 +84,38 @@ func (o *UpdateRegistrationFlowWithOidcMethod) SetCsrfToken(v string) { o.CsrfToken = &v } +// GetIdToken returns the IdToken field value if set, zero value otherwise. +func (o *UpdateRegistrationFlowWithOidcMethod) GetIdToken() string { + if o == nil || o.IdToken == nil { + var ret string + return ret + } + return *o.IdToken +} + +// GetIdTokenOk returns a tuple with the IdToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateRegistrationFlowWithOidcMethod) GetIdTokenOk() (*string, bool) { + if o == nil || o.IdToken == nil { + return nil, false + } + return o.IdToken, true +} + +// HasIdToken returns a boolean if a field has been set. +func (o *UpdateRegistrationFlowWithOidcMethod) HasIdToken() bool { + if o != nil && o.IdToken != nil { + return true + } + + return false +} + +// SetIdToken gets a reference to the given string and assigns it to the IdToken field. +func (o *UpdateRegistrationFlowWithOidcMethod) SetIdToken(v string) { + o.IdToken = &v +} + // GetMethod returns the Method field value func (o *UpdateRegistrationFlowWithOidcMethod) GetMethod() string { if o == nil { @@ -231,6 +265,9 @@ func (o UpdateRegistrationFlowWithOidcMethod) MarshalJSON() ([]byte, error) { if o.CsrfToken != nil { toSerialize["csrf_token"] = o.CsrfToken } + if o.IdToken != nil { + toSerialize["id_token"] = o.IdToken + } if true { toSerialize["method"] = o.Method } diff --git a/internal/client-go/model_update_settings_flow_with_oidc_method.go b/internal/client-go/model_update_settings_flow_with_oidc_method.go index 219f91c3a6b4..e8b8a39f3d1a 100644 --- a/internal/client-go/model_update_settings_flow_with_oidc_method.go +++ b/internal/client-go/model_update_settings_flow_with_oidc_method.go @@ -19,6 +19,8 @@ import ( type UpdateSettingsFlowWithOidcMethod struct { // Flow ID is the flow's ID. in: query Flow *string `json:"flow,omitempty"` + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + IdToken *string `json:"id_token,omitempty"` // Link this provider Either this or `unlink` must be set. type: string in: body Link *string `json:"link,omitempty"` // Method Should be set to profile when trying to update a profile. @@ -81,6 +83,38 @@ func (o *UpdateSettingsFlowWithOidcMethod) SetFlow(v string) { o.Flow = &v } +// GetIdToken returns the IdToken field value if set, zero value otherwise. +func (o *UpdateSettingsFlowWithOidcMethod) GetIdToken() string { + if o == nil || o.IdToken == nil { + var ret string + return ret + } + return *o.IdToken +} + +// GetIdTokenOk returns a tuple with the IdToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateSettingsFlowWithOidcMethod) GetIdTokenOk() (*string, bool) { + if o == nil || o.IdToken == nil { + return nil, false + } + return o.IdToken, true +} + +// HasIdToken returns a boolean if a field has been set. +func (o *UpdateSettingsFlowWithOidcMethod) HasIdToken() bool { + if o != nil && o.IdToken != nil { + return true + } + + return false +} + +// SetIdToken gets a reference to the given string and assigns it to the IdToken field. +func (o *UpdateSettingsFlowWithOidcMethod) SetIdToken(v string) { + o.IdToken = &v +} + // GetLink returns the Link field value if set, zero value otherwise. func (o *UpdateSettingsFlowWithOidcMethod) GetLink() string { if o == nil || o.Link == nil { @@ -238,6 +272,9 @@ func (o UpdateSettingsFlowWithOidcMethod) MarshalJSON() ([]byte, error) { if o.Flow != nil { toSerialize["flow"] = o.Flow } + if o.IdToken != nil { + toSerialize["id_token"] = o.IdToken + } if o.Link != nil { toSerialize["link"] = o.Link } diff --git a/internal/httpclient/model_update_login_flow_with_oidc_method.go b/internal/httpclient/model_update_login_flow_with_oidc_method.go index 9c2b92dc3650..1adedef64ce8 100644 --- a/internal/httpclient/model_update_login_flow_with_oidc_method.go +++ b/internal/httpclient/model_update_login_flow_with_oidc_method.go @@ -19,6 +19,8 @@ import ( type UpdateLoginFlowWithOidcMethod struct { // The CSRF Token CsrfToken *string `json:"csrf_token,omitempty"` + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + IdToken *string `json:"id_token,omitempty"` // Method to use This field must be set to `oidc` when using the oidc method. Method string `json:"method"` // The provider to register with @@ -80,6 +82,38 @@ func (o *UpdateLoginFlowWithOidcMethod) SetCsrfToken(v string) { o.CsrfToken = &v } +// GetIdToken returns the IdToken field value if set, zero value otherwise. +func (o *UpdateLoginFlowWithOidcMethod) GetIdToken() string { + if o == nil || o.IdToken == nil { + var ret string + return ret + } + return *o.IdToken +} + +// GetIdTokenOk returns a tuple with the IdToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateLoginFlowWithOidcMethod) GetIdTokenOk() (*string, bool) { + if o == nil || o.IdToken == nil { + return nil, false + } + return o.IdToken, true +} + +// HasIdToken returns a boolean if a field has been set. +func (o *UpdateLoginFlowWithOidcMethod) HasIdToken() bool { + if o != nil && o.IdToken != nil { + return true + } + + return false +} + +// SetIdToken gets a reference to the given string and assigns it to the IdToken field. +func (o *UpdateLoginFlowWithOidcMethod) SetIdToken(v string) { + o.IdToken = &v +} + // GetMethod returns the Method field value func (o *UpdateLoginFlowWithOidcMethod) GetMethod() string { if o == nil { @@ -197,6 +231,9 @@ func (o UpdateLoginFlowWithOidcMethod) MarshalJSON() ([]byte, error) { if o.CsrfToken != nil { toSerialize["csrf_token"] = o.CsrfToken } + if o.IdToken != nil { + toSerialize["id_token"] = o.IdToken + } if true { toSerialize["method"] = o.Method } diff --git a/internal/httpclient/model_update_registration_flow_with_oidc_method.go b/internal/httpclient/model_update_registration_flow_with_oidc_method.go index 187fd57a7f8f..5535dbff75be 100644 --- a/internal/httpclient/model_update_registration_flow_with_oidc_method.go +++ b/internal/httpclient/model_update_registration_flow_with_oidc_method.go @@ -19,6 +19,8 @@ import ( type UpdateRegistrationFlowWithOidcMethod struct { // The CSRF Token CsrfToken *string `json:"csrf_token,omitempty"` + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + IdToken *string `json:"id_token,omitempty"` // Method to use This field must be set to `oidc` when using the oidc method. Method string `json:"method"` // The provider to register with @@ -82,6 +84,38 @@ func (o *UpdateRegistrationFlowWithOidcMethod) SetCsrfToken(v string) { o.CsrfToken = &v } +// GetIdToken returns the IdToken field value if set, zero value otherwise. +func (o *UpdateRegistrationFlowWithOidcMethod) GetIdToken() string { + if o == nil || o.IdToken == nil { + var ret string + return ret + } + return *o.IdToken +} + +// GetIdTokenOk returns a tuple with the IdToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateRegistrationFlowWithOidcMethod) GetIdTokenOk() (*string, bool) { + if o == nil || o.IdToken == nil { + return nil, false + } + return o.IdToken, true +} + +// HasIdToken returns a boolean if a field has been set. +func (o *UpdateRegistrationFlowWithOidcMethod) HasIdToken() bool { + if o != nil && o.IdToken != nil { + return true + } + + return false +} + +// SetIdToken gets a reference to the given string and assigns it to the IdToken field. +func (o *UpdateRegistrationFlowWithOidcMethod) SetIdToken(v string) { + o.IdToken = &v +} + // GetMethod returns the Method field value func (o *UpdateRegistrationFlowWithOidcMethod) GetMethod() string { if o == nil { @@ -231,6 +265,9 @@ func (o UpdateRegistrationFlowWithOidcMethod) MarshalJSON() ([]byte, error) { if o.CsrfToken != nil { toSerialize["csrf_token"] = o.CsrfToken } + if o.IdToken != nil { + toSerialize["id_token"] = o.IdToken + } if true { toSerialize["method"] = o.Method } diff --git a/internal/httpclient/model_update_settings_flow_with_oidc_method.go b/internal/httpclient/model_update_settings_flow_with_oidc_method.go index 219f91c3a6b4..e8b8a39f3d1a 100644 --- a/internal/httpclient/model_update_settings_flow_with_oidc_method.go +++ b/internal/httpclient/model_update_settings_flow_with_oidc_method.go @@ -19,6 +19,8 @@ import ( type UpdateSettingsFlowWithOidcMethod struct { // Flow ID is the flow's ID. in: query Flow *string `json:"flow,omitempty"` + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + IdToken *string `json:"id_token,omitempty"` // Link this provider Either this or `unlink` must be set. type: string in: body Link *string `json:"link,omitempty"` // Method Should be set to profile when trying to update a profile. @@ -81,6 +83,38 @@ func (o *UpdateSettingsFlowWithOidcMethod) SetFlow(v string) { o.Flow = &v } +// GetIdToken returns the IdToken field value if set, zero value otherwise. +func (o *UpdateSettingsFlowWithOidcMethod) GetIdToken() string { + if o == nil || o.IdToken == nil { + var ret string + return ret + } + return *o.IdToken +} + +// GetIdTokenOk returns a tuple with the IdToken field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *UpdateSettingsFlowWithOidcMethod) GetIdTokenOk() (*string, bool) { + if o == nil || o.IdToken == nil { + return nil, false + } + return o.IdToken, true +} + +// HasIdToken returns a boolean if a field has been set. +func (o *UpdateSettingsFlowWithOidcMethod) HasIdToken() bool { + if o != nil && o.IdToken != nil { + return true + } + + return false +} + +// SetIdToken gets a reference to the given string and assigns it to the IdToken field. +func (o *UpdateSettingsFlowWithOidcMethod) SetIdToken(v string) { + o.IdToken = &v +} + // GetLink returns the Link field value if set, zero value otherwise. func (o *UpdateSettingsFlowWithOidcMethod) GetLink() string { if o == nil || o.Link == nil { @@ -238,6 +272,9 @@ func (o UpdateSettingsFlowWithOidcMethod) MarshalJSON() ([]byte, error) { if o.Flow != nil { toSerialize["flow"] = o.Flow } + if o.IdToken != nil { + toSerialize["id_token"] = o.IdToken + } if o.Link != nil { toSerialize["link"] = o.Link } diff --git a/internal/testhelpers/selfservice_login.go b/internal/testhelpers/selfservice_login.go index f9766d7bb905..d5315e1a6e27 100644 --- a/internal/testhelpers/selfservice_login.go +++ b/internal/testhelpers/selfservice_login.go @@ -206,6 +206,7 @@ func SubmitLoginForm( forced bool, expectedStatusCode int, expectedURL string, + opts ...InitFlowWithOption, ) string { if hc == nil { hc = new(http.Client) @@ -217,9 +218,9 @@ func SubmitLoginForm( hc.Transport = NewTransportWithLogger(hc.Transport, t) var f *kratos.LoginFlow if isAPI { - f = InitializeLoginFlowViaAPI(t, hc, publicTS, forced) + f = InitializeLoginFlowViaAPI(t, hc, publicTS, forced, opts...) } else { - f = InitializeLoginFlowViaBrowser(t, hc, publicTS, forced, isSPA, false, false) + f = InitializeLoginFlowViaBrowser(t, hc, publicTS, forced, isSPA, false, false, opts...) } time.Sleep(time.Millisecond) // add a bit of delay to allow `1ns` to time out. diff --git a/internal/testhelpers/selfservice_registration.go b/internal/testhelpers/selfservice_registration.go index e285f8f8b2bd..7a8399a81ddb 100644 --- a/internal/testhelpers/selfservice_registration.go +++ b/internal/testhelpers/selfservice_registration.go @@ -117,6 +117,7 @@ func SubmitRegistrationForm( isSPA bool, expectedStatusCode int, expectedURL string, + opts ...InitFlowWithOption, ) string { if hc == nil { hc = new(http.Client) @@ -127,7 +128,7 @@ func SubmitRegistrationForm( if isAPI { payload = InitializeRegistrationFlowViaAPI(t, hc, publicTS) } else { - payload = InitializeRegistrationFlowViaBrowser(t, hc, publicTS, isSPA, false, false) + payload = InitializeRegistrationFlowViaBrowser(t, hc, publicTS, isSPA, false, false, opts...) } time.Sleep(time.Millisecond) // add a bit of delay to allow `1ns` to time out. diff --git a/selfservice/flow/flow.go b/selfservice/flow/flow.go index e8e095bcc44c..4e2d67f79a4d 100644 --- a/selfservice/flow/flow.go +++ b/selfservice/flow/flow.go @@ -4,9 +4,12 @@ package flow import ( + "context" "net/http" "net/url" + "github.com/ory/kratos/driver/config" + "github.com/pkg/errors" "github.com/ory/kratos/ui/container" @@ -38,3 +41,18 @@ type Flow interface { AppendTo(*url.URL) *url.URL GetUI() *container.Container } + +func IsWebViewFlow(ctx context.Context, conf *config.Config, f Flow) (bool, error) { + if f.GetType() != TypeBrowser { + return false, nil + } + requestURL, err := url.Parse(f.GetRequestURL()) + if err != nil { + return false, err + } + redirectURL := conf.SelfServiceWebViewRedirectURL(ctx) + if redirectURL == nil { + return false, nil + } + return requestURL.Query().Get("return_to") == redirectURL.String(), nil +} diff --git a/selfservice/flow/login/hook.go b/selfservice/flow/login/hook.go index 7517a0107f64..9dca69200a58 100644 --- a/selfservice/flow/login/hook.go +++ b/selfservice/flow/login/hook.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "path" "time" "github.com/pkg/errors" @@ -248,6 +249,29 @@ func (e *HookExecutor) PostLoginHook(w http.ResponseWriter, r *http.Request, g n finalReturnTo = rt } + isWebView, err := flow.IsWebViewFlow(r.Context(), e.d.Config(), a) + if err != nil { + return err + } + if isWebView { + response := &APIFlowResponse{Session: s, Token: s.Token} + required, err := e.requiresAAL2(r, s, a) + if err != nil { + return err + } + if required { + // If AAL is not satisfied, we omit the identity to preserve the user's privacy in case of a phishing attack. + response.Session.Identity = nil + } + w.Header().Set("Content-Type", "application/json") + returnTo.Path = path.Join(returnTo.Path, "success") + query := returnTo.Query() + query.Set("session_token", s.Token) + returnTo.RawQuery = query.Encode() + w.Header().Set("Location", returnTo.String()) + e.d.Writer().WriteCode(w, r, http.StatusSeeOther, response) + return nil + } x.ContentNegotiationRedirection(w, r, s, e.d.Writer(), finalReturnTo) return nil } diff --git a/selfservice/flow/registration/error.go b/selfservice/flow/registration/error.go index ae01ca2850b4..2daeafc5eeb6 100644 --- a/selfservice/flow/registration/error.go +++ b/selfservice/flow/registration/error.go @@ -5,6 +5,8 @@ package registration import ( "net/http" + "path" + "strconv" "github.com/ory/kratos/schema" "github.com/ory/kratos/ui/node" @@ -126,7 +128,39 @@ func (s *ErrorHandler) WriteFlowError( } if f.Type == flow.TypeBrowser && !x.IsJSONRequest(r) { - http.Redirect(w, r, f.AppendTo(s.d.Config().SelfServiceFlowRegistrationUI(r.Context())).String(), http.StatusFound) + isWebView, innerErr := flow.IsWebViewFlow(r.Context(), s.d.Config(), f) + if innerErr != nil { + s.forward(w, r, f, innerErr) + return + } + + var redirectLocation = "" + if isWebView { + c := s.d.Config() + returnTo, innerErr := x.SecureRedirectTo(r, c.SelfServiceBrowserDefaultReturnTo(r.Context()), + x.SecureRedirectUseSourceURL(f.RequestURL), + x.SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(r.Context())), + x.SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(r.Context())), + x.SecureRedirectOverrideDefaultReturnTo(s.d.Config().SelfServiceFlowLoginReturnTo(r.Context(), f.Active.String())), + ) + if innerErr != nil { + s.forward(w, r, f, innerErr) + return + } + + if len(f.UI.Messages) > 0 { + query := returnTo.Query() + query.Set("code", strconv.Itoa(int(f.UI.Messages[0].ID))) + query.Set("message", f.UI.Messages[0].Text) + returnTo.RawQuery = query.Encode() + } + returnTo.Path = path.Join(returnTo.Path, "error") + redirectLocation = returnTo.String() + + } else { + redirectLocation = f.AppendTo(s.d.Config().SelfServiceFlowRegistrationUI(r.Context())).String() + } + http.Redirect(w, r, redirectLocation, http.StatusFound) return } diff --git a/selfservice/hook/session_issuer.go b/selfservice/hook/session_issuer.go index 4aed85284cb8..e7047abdea81 100644 --- a/selfservice/hook/session_issuer.go +++ b/selfservice/hook/session_issuer.go @@ -6,10 +6,13 @@ package hook import ( "context" "net/http" + "net/url" + "path" "time" "github.com/pkg/errors" + "github.com/ory/kratos/driver/config" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/session" @@ -23,6 +26,7 @@ var ( type ( sessionIssuerDependencies interface { + config.Provider session.ManagementProvider session.PersistenceProvider x.WriterProvider @@ -62,6 +66,28 @@ func (e *SessionIssuer) executePostRegistrationPostPersistHook(w http.ResponseWr return errors.WithStack(registration.ErrHookAbortFlow) } + isWebView, err := flow.IsWebViewFlow(r.Context(), e.r.Config(), a) + if err != nil { + return err + } + if isWebView { + response := ®istration.APIFlowResponse{Session: s, Token: s.Token} + + w.Header().Set("Content-Type", "application/json") + returnTo, err := url.Parse(a.ReturnTo) + if err != nil { + return err + } + returnTo.Path = path.Join(returnTo.Path, "success") + query := returnTo.Query() + query.Set("session_token", s.Token) + returnTo.RawQuery = query.Encode() + w.Header().Set("Location", returnTo.String()) + e.r.Writer().WriteCode(w, r, http.StatusSeeOther, response) + + return errors.WithStack(registration.ErrHookAbortFlow) + } + // cookie is issued both for browser and for SPA flows if err := e.r.SessionManager().IssueCookie(r.Context(), w, r, s); err != nil { return err diff --git a/selfservice/strategy/oidc/.schema/link.schema.json b/selfservice/strategy/oidc/.schema/link.schema.json index b8ebde396f3d..3c3e14e53d06 100644 --- a/selfservice/strategy/oidc/.schema/link.schema.json +++ b/selfservice/strategy/oidc/.schema/link.schema.json @@ -1,5 +1,5 @@ { - "$id": "https://schemas.ory.sh/kratos/selfservice/strategy/password/login.schema.json", + "$id": "https://schemas.ory.sh/kratos/selfservice/strategy/oidc/.schema/link.schema.json", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { diff --git a/selfservice/strategy/oidc/.schema/login.schema.json b/selfservice/strategy/oidc/.schema/login.schema.json new file mode 100644 index 000000000000..c51749dd43d1 --- /dev/null +++ b/selfservice/strategy/oidc/.schema/login.schema.json @@ -0,0 +1,25 @@ +{ + "$id": "https://schemas.ory.sh/kratos/selfservice/strategy/oidc/.schema/login.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "provider", + "method" + ], + "properties": { + "csrf_token": { + "type": "string" + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "id_token": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string" + } + } +} diff --git a/selfservice/strategy/oidc/.schema/settings.schema.json b/selfservice/strategy/oidc/.schema/settings.schema.json index 61f2bb087653..6c5ba9f4e359 100644 --- a/selfservice/strategy/oidc/.schema/settings.schema.json +++ b/selfservice/strategy/oidc/.schema/settings.schema.json @@ -29,6 +29,10 @@ }, "additionalProperties": false } + }, + "id_token": { + "type": "string", + "minLength": 1 } } -} \ No newline at end of file +} diff --git a/selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=fetch.json b/selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=fetch.json similarity index 100% rename from selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=fetch.json rename to selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=fetch.json diff --git a/selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=original.json b/selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=original.json similarity index 100% rename from selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=original.json rename to selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=original.json diff --git a/selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=response.json b/selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=response.json similarity index 100% rename from selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-flow=response.json rename to selfservice/strategy/oidc/.snapshots/TestSettingsStrategy-suite=link-case=should_link_a_connection-browser-flow=response.json diff --git a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod-case=api.json similarity index 72% rename from selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json rename to selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod-case=api.json index 9a93294a6040..a46b9f6cd103 100644 --- a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod.json +++ b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod-case=api.json @@ -57,6 +57,28 @@ } } } + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "check_audience", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with check_audience", + "type": "info", + "context": { + "provider": "check_audience" + } + } + } } ] } diff --git a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod-case=browser.json b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod-case=browser.json new file mode 100644 index 000000000000..a46b9f6cd103 --- /dev/null +++ b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateLoginMethod-case=browser.json @@ -0,0 +1,84 @@ +{ + "method": "POST", + "nodes": [ + { + "type": "input", + "group": "default", + "attributes": { + "name": "csrf_token", + "type": "hidden", + "required": true, + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": {} + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "valid", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with valid", + "type": "info", + "context": { + "provider": "valid" + } + } + } + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "invalid-issuer", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with invalid-issuer", + "type": "info", + "context": { + "provider": "invalid-issuer" + } + } + } + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "check_audience", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1010002, + "text": "Sign in with check_audience", + "type": "info", + "context": { + "provider": "check_audience" + } + } + } + } + ] +} diff --git a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json index 699d8d10ec62..3b71b6f5c9ad 100644 --- a/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json +++ b/selfservice/strategy/oidc/.snapshots/TestStrategy-method=TestPopulateSignUpMethod.json @@ -57,6 +57,28 @@ } } } + }, + { + "type": "input", + "group": "oidc", + "attributes": { + "name": "provider", + "type": "submit", + "value": "check_audience", + "disabled": false, + "node_type": "input" + }, + "messages": [], + "meta": { + "label": { + "id": 1040002, + "text": "Sign up with check_audience", + "type": "info", + "context": { + "provider": "check_audience" + } + } + } } ] } diff --git a/selfservice/strategy/oidc/error.go b/selfservice/strategy/oidc/error.go index d901d474194d..e102709abe86 100644 --- a/selfservice/strategy/oidc/error.go +++ b/selfservice/strategy/oidc/error.go @@ -10,6 +10,9 @@ import ( "github.com/pkg/errors" "github.com/ory/herodot" + "github.com/ory/jsonschema/v3" + "github.com/ory/kratos/schema" + "github.com/ory/kratos/text" "github.com/ory/x/logrusx" ) @@ -22,8 +25,9 @@ var ( WithError("authentication failed because id_token is missing"). WithReasonf(`Authentication failed because no id_token was returned. Please accept the "openid" permission and try again.`) - ErrAPIFlowNotSupported = herodot.ErrBadRequest.WithError("API-based flows are not supported for this method"). - WithReasonf("Social Sign In and OpenID Connect are only supported for flows initiated using the Browser endpoint.") + ErrProviderNoAPISupport = herodot.ErrBadRequest. + WithError("request failed because oidc provider does not implement API flows"). + WithReasonf(`Request failed because oidc provider does not implement API flows.`) ) func logUpstreamError(l *logrusx.Logger, resp *http.Response) error { @@ -39,3 +43,22 @@ func logUpstreamError(l *logrusx.Logger, resp *http.Response) error { l.WithField("response_code", resp.StatusCode).WithField("response_body", string(body)).Error("The upstream OIDC provider returned a non 200 status code.") return errors.WithStack(herodot.ErrInternalServerError.WithReasonf("OpenID Connect provider returned a %d status code but 200 is expected.", resp.StatusCode)) } + +type ValidationErrorContextOIDCPolicyViolation struct { + Reason string +} + +func (r *ValidationErrorContextOIDCPolicyViolation) AddContext(_, _ string) {} + +func (r *ValidationErrorContextOIDCPolicyViolation) FinishInstanceContext() {} + +func NewUserNotFoundError() error { + return errors.WithStack(&schema.ValidationError{ + ValidationError: &jsonschema.ValidationError{ + Message: `user with the provided credentials not found`, + InstancePtr: "#/", + Context: &ValidationErrorContextOIDCPolicyViolation{}, + }, + Messages: new(text.Messages).Add(text.NewErrorValidationOIDCUserNotFound()), + }) +} diff --git a/selfservice/strategy/oidc/provider.go b/selfservice/strategy/oidc/provider.go index 9f6633228b18..9f9a6f434f18 100644 --- a/selfservice/strategy/oidc/provider.go +++ b/selfservice/strategy/oidc/provider.go @@ -23,6 +23,10 @@ type Provider interface { AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption } +type APIFlowProvider interface { + ClaimsFromIDToken(ctx context.Context, rawIDToken string) (*Claims, error) +} + type TokenExchanger interface { Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) } diff --git a/selfservice/strategy/oidc/provider_config.go b/selfservice/strategy/oidc/provider_config.go index 88f8296a66a7..04599b21af7e 100644 --- a/selfservice/strategy/oidc/provider_config.go +++ b/selfservice/strategy/oidc/provider_config.go @@ -103,6 +103,10 @@ type Configuration struct { // // More information: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter RequestedClaims json.RawMessage `json:"requested_claims"` + + // List of values to check audience field of ID Token. + // The audience field of ID Token should be equal to one of the items in this list. + AllowedAudiences []string `json:"allowed_audiences"` } func (p Configuration) Redir(public *url.URL) string { diff --git a/selfservice/strategy/oidc/provider_generic_oidc.go b/selfservice/strategy/oidc/provider_generic_oidc.go index c09185575969..66d6e9171bdd 100644 --- a/selfservice/strategy/oidc/provider_generic_oidc.go +++ b/selfservice/strategy/oidc/provider_generic_oidc.go @@ -13,7 +13,7 @@ import ( "github.com/ory/herodot" "github.com/ory/x/stringslice" - gooidc "github.com/coreos/go-oidc" + gooidc "github.com/coreos/go-oidc/v3/oidc" ) var _ Provider = new(ProviderGenericOIDC) @@ -87,11 +87,22 @@ func (g *ProviderGenericOIDC) AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption return options } -func (g *ProviderGenericOIDC) verifyAndDecodeClaimsWithProvider(ctx context.Context, provider *gooidc.Provider, raw string) (*Claims, error) { - token, err := provider.Verifier(&gooidc.Config{ClientID: g.config.ClientID}).Verify(ctx, raw) +func (g *ProviderGenericOIDC) verifyAndDecodeClaimsWithProvider(ctx context.Context, provider *gooidc.Provider, rawIDToken string) (*Claims, error) { + skipClientIDCheck := g.config.AllowedAudiences != nil && len(g.config.AllowedAudiences) > 0 + token, err := provider. + Verifier(&gooidc.Config{ + ClientID: g.config.ClientID, + SkipClientIDCheck: skipClientIDCheck, + }). + Verify(ctx, rawIDToken) if err != nil { return nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf("%s", err)) } + if skipClientIDCheck { + if err := verifyAudience(token.Audience, g.config.AllowedAudiences); err != nil { + return nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf("%s", err)) + } + } var claims Claims if err := token.Claims(&claims); err != nil { @@ -107,16 +118,31 @@ func (g *ProviderGenericOIDC) verifyAndDecodeClaimsWithProvider(ctx context.Cont return &claims, nil } +func verifyAudience(received []string, expected []string) error { + for _, r := range received { + for _, e := range expected { + if r == e { + return nil + } + } + } + return errors.Errorf("oidc: audience not valid: %v", received) +} + func (g *ProviderGenericOIDC) Claims(ctx context.Context, exchange *oauth2.Token, query url.Values) (*Claims, error) { raw, ok := exchange.Extra("id_token").(string) if !ok || len(raw) == 0 { return nil, errors.WithStack(ErrIDTokenMissing) } + return g.ClaimsFromIDToken(ctx, raw) +} + +func (g *ProviderGenericOIDC) ClaimsFromIDToken(ctx context.Context, rawIDToken string) (*Claims, error) { p, err := g.provider(ctx) if err != nil { return nil, err } - return g.verifyAndDecodeClaimsWithProvider(ctx, p, raw) + return g.verifyAndDecodeClaimsWithProvider(ctx, p, rawIDToken) } diff --git a/selfservice/strategy/oidc/provider_google.go b/selfservice/strategy/oidc/provider_google.go index cb98696484ca..5eac428633e7 100644 --- a/selfservice/strategy/oidc/provider_google.go +++ b/selfservice/strategy/oidc/provider_google.go @@ -6,7 +6,7 @@ package oidc import ( "context" - gooidc "github.com/coreos/go-oidc" + gooidc "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "github.com/ory/x/stringslice" diff --git a/selfservice/strategy/oidc/provider_microsoft.go b/selfservice/strategy/oidc/provider_microsoft.go index e5c4a8ec68ff..de917d3431ec 100644 --- a/selfservice/strategy/oidc/provider_microsoft.go +++ b/selfservice/strategy/oidc/provider_microsoft.go @@ -16,7 +16,7 @@ import ( "github.com/gofrs/uuid" "github.com/golang-jwt/jwt/v4" - gooidc "github.com/coreos/go-oidc" + gooidc "github.com/coreos/go-oidc/v3/oidc" "github.com/pkg/errors" "golang.org/x/oauth2" @@ -31,6 +31,8 @@ func NewProviderMicrosoft( config *Configuration, reg dependencies, ) *ProviderMicrosoft { + config.IssuerURL = microsoftRootUrl + config.Tenant + "/v2.0" + return &ProviderMicrosoft{ ProviderGenericOIDC: &ProviderGenericOIDC{ config: config, @@ -39,12 +41,14 @@ func NewProviderMicrosoft( } } +const microsoftRootUrl = "https://login.microsoftonline.com/" + func (m *ProviderMicrosoft) OAuth2(ctx context.Context) (*oauth2.Config, error) { if len(strings.TrimSpace(m.config.Tenant)) == 0 { return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("No Tenant specified for the `microsoft` oidc provider %s", m.config.ID)) } - endpointPrefix := "https://login.microsoftonline.com/" + m.config.Tenant + endpointPrefix := microsoftRootUrl + m.config.Tenant endpoint := oauth2.Endpoint{ AuthURL: endpointPrefix + "/oauth2/v2.0/authorize", TokenURL: endpointPrefix + "/oauth2/v2.0/token", @@ -59,9 +63,18 @@ func (m *ProviderMicrosoft) Claims(ctx context.Context, exchange *oauth2.Token, return nil, errors.WithStack(ErrIDTokenMissing) } + claims, err := m.ClaimsFromIDToken(ctx, raw) + if err != nil { + return nil, err + } + + return m.updateSubject(ctx, claims, exchange) +} + +func (m *ProviderMicrosoft) ClaimsFromIDToken(ctx context.Context, rawIDToken string) (*Claims, error) { parser := new(jwt.Parser) unverifiedClaims := microsoftUnverifiedClaims{} - if _, _, err := parser.ParseUnverified(raw, &unverifiedClaims); err != nil { + if _, _, err := parser.ParseUnverified(rawIDToken, &unverifiedClaims); err != nil { return nil, err } @@ -69,18 +82,13 @@ func (m *ProviderMicrosoft) Claims(ctx context.Context, exchange *oauth2.Token, return nil, errors.WithStack(herodot.ErrBadRequest.WithReasonf("TenantID claim is not a valid UUID: %s", err)) } - issuer := "https://login.microsoftonline.com/" + unverifiedClaims.TenantID + "/v2.0" + issuer := microsoftRootUrl + unverifiedClaims.TenantID + "/v2.0" p, err := gooidc.NewProvider(ctx, issuer) if err != nil { return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to initialize OpenID Connect Provider: %s", err)) } - claims, err := m.verifyAndDecodeClaimsWithProvider(ctx, p, raw) - if err != nil { - return nil, err - } - - return m.updateSubject(ctx, claims, exchange) + return m.verifyAndDecodeClaimsWithProvider(ctx, p, rawIDToken) } func (m *ProviderMicrosoft) updateSubject(ctx context.Context, claims *Claims, exchange *oauth2.Token) (*Claims, error) { diff --git a/selfservice/strategy/oidc/provider_netid.go b/selfservice/strategy/oidc/provider_netid.go index 26456a01705e..d99cb05e6e77 100644 --- a/selfservice/strategy/oidc/provider_netid.go +++ b/selfservice/strategy/oidc/provider_netid.go @@ -9,7 +9,7 @@ import ( "fmt" "net/url" - gooidc "github.com/coreos/go-oidc" + gooidc "github.com/coreos/go-oidc/v3/oidc" "github.com/ory/x/stringslice" diff --git a/selfservice/strategy/oidc/schema.go b/selfservice/strategy/oidc/schema.go index b62d9f26870f..bf8707603009 100644 --- a/selfservice/strategy/oidc/schema.go +++ b/selfservice/strategy/oidc/schema.go @@ -9,3 +9,6 @@ import ( //go:embed .schema/link.schema.json var linkSchema []byte + +//go:embed .schema/login.schema.json +var loginSchema []byte diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index b7be44f03f55..87f41417baf2 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -215,10 +215,6 @@ func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.U } if ar, err := s.d.RegistrationFlowPersister().GetRegistrationFlow(ctx, rid); err == nil { - if ar.Type != flow.TypeBrowser { - return ar, ErrAPIFlowNotSupported - } - if err := ar.Valid(); err != nil { return ar, err } @@ -226,10 +222,6 @@ func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.U } if ar, err := s.d.LoginFlowPersister().GetLoginFlow(ctx, rid); err == nil { - if ar.Type != flow.TypeBrowser { - return ar, ErrAPIFlowNotSupported - } - if err := ar.Valid(); err != nil { return ar, err } @@ -238,10 +230,6 @@ func (s *Strategy) validateFlow(ctx context.Context, r *http.Request, rid uuid.U ar, err := s.d.SettingsFlowPersister().GetSettingsFlow(ctx, rid) if err == nil { - if ar.Type != flow.TypeBrowser { - return ar, ErrAPIFlowNotSupported - } - sess, err := s.d.SessionManager().FetchFromRequest(ctx, r) if err != nil { return ar, err diff --git a/selfservice/strategy/oidc/strategy_helper_test.go b/selfservice/strategy/oidc/strategy_helper_test.go index 16089bed8599..a37c67fa7145 100644 --- a/selfservice/strategy/oidc/strategy_helper_test.go +++ b/selfservice/strategy/oidc/strategy_helper_test.go @@ -17,6 +17,8 @@ import ( "testing" "time" + "github.com/ory/kratos/internal/testhelpers" + "github.com/julienschmidt/httprouter" "github.com/phayes/freeport" "github.com/pkg/errors" @@ -73,7 +75,7 @@ func (token *idTokenClaims) MarshalJSON() ([]byte, error) { }) } -func createClient(t *testing.T, remote string, redir string) (id, secret string) { +func createClient(t *testing.T, remote string, redir []string) (id, secret string) { require.NoError(t, resilience.Retry(logrusx.New("", ""), time.Second*10, time.Minute*2, func() error { var b bytes.Buffer require.NoError(t, json.NewEncoder(&b).Encode(&struct { @@ -85,7 +87,7 @@ func createClient(t *testing.T, remote string, redir string) (id, secret string) GrantTypes: []string{"authorization_code", "refresh_token"}, ResponseTypes: []string{"code"}, Scope: "offline offline_access openid", - RedirectURIs: []string{redir}, + RedirectURIs: redir, })) res, err := http.Post(remote+"/admin/clients", "application/json", &b) @@ -281,19 +283,40 @@ func newOIDCProvider( hydraPublic string, hydraAdmin string, id string, + testCallbackUrl string, + allowedAudiences []string, ) oidc.Configuration { - clientID, secret := createClient(t, hydraAdmin, kratos.URL+oidc.RouteBase+"/callback/"+id) + redir := []string{ + kratos.URL + oidc.RouteBase + "/callback/" + id, + } + if testCallbackUrl != "" { + redir = append(redir, testCallbackUrl) + } + clientID, secret := createClient(t, hydraAdmin, redir) return oidc.Configuration{ - Provider: "generic", - ID: id, - ClientID: clientID, - ClientSecret: secret, - IssuerURL: hydraPublic + "/", - Mapper: "file://./stub/oidc.hydra.jsonnet", + Provider: "generic", + ID: id, + ClientID: clientID, + ClientSecret: secret, + IssuerURL: hydraPublic + "/", + Mapper: "file://./stub/oidc.hydra.jsonnet", + AllowedAudiences: allowedAudiences, } } +func newTestCallback(t *testing.T, reg driver.Registry) string { + router := httprouter.New() + path := "/testcallback" + router.GET(path, func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + reg.Writer().Write(w, r, map[string]string{"code": r.URL.Query().Get("code")}) + }) + + server := httptest.NewServer(router) + t.Cleanup(server.Close) + return server.URL + path +} + func viperSetProviderConfig(t *testing.T, conf *config.Config, providers ...oidc.Configuration) { ctx := context.Background() conf.MustSet(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypeOIDC)+".config", &oidc.ConfigurationCollection{Providers: providers}) @@ -307,3 +330,78 @@ func AssertSystemError(t *testing.T, errTS *httptest.Server, res *http.Response, assert.Equal(t, int64(code), gjson.GetBytes(body, "code").Int(), "%s", body) assert.Contains(t, gjson.GetBytes(body, "reason").String(), reason, "%s", body) } + +type oauthTokens struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` +} + +func getOauthTokens( + t *testing.T, + hydraPublic string, + clientId string, + clientSecret string, + testCallbackUrl string, +) (error, *oauthTokens) { + hydra := testhelpers.NewClientWithCookieJar(t, nil, true) + var ( + res *http.Response + req *http.Request + err error + ) + + if req, err = http.NewRequest("GET", hydraPublic+"/oauth2/auth", nil); err != nil { + return err, nil + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + q := req.URL.Query() + q.Add("response_type", "code") + q.Add("client_id", clientId) + q.Add("redirect_uri", testCallbackUrl) + q.Add("state", x.NewUUID().String()) + req.URL.RawQuery = q.Encode() + + if res, err = hydra.Do(req); err != nil { + return err, nil + } + defer res.Body.Close() + var body = ioutilx.MustReadAll(res.Body) + require.Equal(t, http.StatusOK, res.StatusCode, "%s", body) + var responseAuth struct { + Code string `json:"code"` + } + require.NoError(t, json.NewDecoder(bytes.NewBuffer(body)).Decode(&responseAuth)) + require.NotNil(t, responseAuth.Code, "%s", body) + + params := url.Values{ + "grant_type": {"authorization_code"}, + "redirect_uri": {testCallbackUrl}, + "client_id": {clientId}, + "code": {responseAuth.Code}, + } + if req, err = http.NewRequest( + "POST", + hydraPublic+"/oauth2/token", + bytes.NewBufferString(params.Encode()), + ); err != nil { + return err, nil + } + req.SetBasicAuth(clientId, clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + if res, err = hydra.Do(req); err != nil { + return err, nil + } + defer res.Body.Close() + body = ioutilx.MustReadAll(res.Body) + require.Equal(t, http.StatusOK, res.StatusCode, "%s", body) + + var tokens oauthTokens + + require.NoError(t, json.NewDecoder(bytes.NewBuffer(body)).Decode(&tokens)) + require.NotNil(t, tokens.IDToken, "%s", body) + + return nil, &tokens +} diff --git a/selfservice/strategy/oidc/strategy_login.go b/selfservice/strategy/oidc/strategy_login.go index ebf1b11cb23d..ac67ff2c96e4 100644 --- a/selfservice/strategy/oidc/strategy_login.go +++ b/selfservice/strategy/oidc/strategy_login.go @@ -6,6 +6,7 @@ package oidc import ( "bytes" "encoding/json" + "fmt" "net/http" "time" @@ -16,6 +17,7 @@ import ( "github.com/ory/kratos/session" "github.com/ory/kratos/ui/node" + "github.com/ory/x/decoderx" "github.com/ory/x/sqlcon" "github.com/ory/kratos/selfservice/flow/registration" @@ -41,10 +43,6 @@ func (s *Strategy) RegisterLoginRoutes(r *x.RouterPublic) { } func (s *Strategy) PopulateLoginMethod(r *http.Request, requestedAAL identity.AuthenticatorAssuranceLevel, l *login.Flow) error { - if l.Type != flow.TypeBrowser { - return nil - } - // This strategy can only solve AAL1 if requestedAAL > identity.AuthenticatorAssuranceLevel1 { return nil @@ -84,6 +82,11 @@ type UpdateLoginFlowWithOidcMethod struct { // // required: false UpstreamParameters json.RawMessage `json:"upstream_parameters"` + + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + // + // required: false + IDToken string `json:"id_token"` } func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login.Flow, token *oauth2.Token, claims *Claims, provider Provider, container *authCodeContainer) (*registration.Flow, error) { @@ -109,6 +112,9 @@ func (s *Strategy) processLogin(w http.ResponseWriter, r *http.Request, a *login } // This flow only works for browsers anyways. + query := r.URL.Query() + query.Set("return_to", a.ReturnTo) + r.URL.RawQuery = query.Encode() aa, err := s.d.RegistrationHandler().NewRegistrationFlow(w, r, flow.TypeBrowser, opts...) if err != nil { return nil, s.handleError(w, r, a, provider.Config().ID, nil, err) @@ -153,12 +159,31 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, return nil, err } + var pid = "" + var idToken = "" + var p UpdateLoginFlowWithOidcMethod - if err := s.newLinkDecoder(&p, r); err != nil { - return nil, s.handleError(w, r, f, "", nil, errors.WithStack(herodot.ErrBadRequest.WithDebug(err.Error()).WithReasonf("Unable to parse HTTP form request: %s", err.Error()))) + if f.Type == flow.TypeBrowser { + if err := s.newLinkDecoder(&p, r); err != nil { + return nil, s.handleError(w, r, f, "", nil, errors.WithStack(herodot.ErrBadRequest.WithDebug(err.Error()).WithReasonf("Unable to parse HTTP form request: %s", err.Error()))) + } + pid = p.Provider // this can come from both url query and post body + } else { + if err := flow.MethodEnabledAndAllowedFromRequest(r, s.ID().String(), s.d); err != nil { + return nil, err + } + + if err := s.dec.Decode(r, &p, + decoderx.HTTPDecoderSetValidatePayloads(true), + decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema), + decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil { + return nil, s.handleError(w, r, f, "", nil, err) + } + + idToken = p.IDToken + pid = p.Provider } - var pid = p.Provider // this can come from both url query and post body if pid == "" { return nil, errors.WithStack(flow.ErrStrategyNotResponsible) } @@ -187,32 +212,61 @@ func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, } state := generateState(f.ID.String()) - if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, - continuity.WithPayload(&authCodeContainer{ - State: state, - FlowID: f.ID.String(), - Traits: p.Traits, - }), - continuity.WithLifespan(time.Minute*30)); err != nil { - return nil, s.handleError(w, r, f, pid, nil, err) - } + if f.Type == flow.TypeBrowser { + if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, + continuity.WithPayload(&authCodeContainer{ + State: state, + FlowID: f.ID.String(), + Traits: p.Traits, + }), + continuity.WithLifespan(time.Minute*30)); err != nil { + return nil, s.handleError(w, r, f, pid, nil, err) + } - f.Active = s.ID() - if err = s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil { - return nil, s.handleError(w, r, f, pid, nil, errors.WithStack(herodot.ErrInternalServerError.WithReason("Could not update flow").WithDebug(err.Error()))) - } + f.Active = s.ID() + if err = s.d.LoginFlowPersister().UpdateLoginFlow(r.Context(), f); err != nil { + return nil, s.handleError(w, r, f, pid, nil, errors.WithStack(herodot.ErrInternalServerError.WithReason("Could not update flow").WithDebug(err.Error()))) + } - var up map[string]string - if err := json.NewDecoder(bytes.NewBuffer(p.UpstreamParameters)).Decode(&up); err != nil { - return nil, err - } + var up map[string]string + if err := json.NewDecoder(bytes.NewBuffer(p.UpstreamParameters)).Decode(&up); err != nil { + return nil, err + } + + codeURL := c.AuthCodeURL(state, append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) + if x.IsJSONRequest(r) { + s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) + } else { + http.Redirect(w, r, codeURL, http.StatusSeeOther) + } + + return nil, errors.WithStack(flow.ErrCompletedByStrategy) + } else if f.Type == flow.TypeAPI { + var claims *Claims + if apiFlowProvider, ok := provider.(APIFlowProvider); ok { + if len(idToken) > 0 { + claims, err = apiFlowProvider.ClaimsFromIDToken(r.Context(), idToken) + if err != nil { + return nil, errors.WithStack(err) + } + } else { + return nil, s.handleError(w, r, f, p.Provider, nil, ErrIDTokenMissing) + } + } else { + return nil, s.handleError(w, r, f, p.Provider, nil, ErrProviderNoAPISupport) + } - codeURL := c.AuthCodeURL(state, append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) - if x.IsJSONRequest(r) { - s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) + i, _, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(r.Context(), + identity.CredentialsTypeOIDC, + identity.OIDCUniqueID(provider.Config().ID, claims.Subject)) + if err != nil { + if errors.Is(err, sqlcon.ErrNoRows) { + return nil, s.handleError(w, r, f, p.Provider, nil, NewUserNotFoundError()) + } + return nil, err + } + return i, nil } else { - http.Redirect(w, r, codeURL, http.StatusSeeOther) + return nil, errors.WithStack(errors.New(fmt.Sprintf("Not supported flow type: %s", f.Type))) } - - return nil, errors.WithStack(flow.ErrCompletedByStrategy) } diff --git a/selfservice/strategy/oidc/strategy_registration.go b/selfservice/strategy/oidc/strategy_registration.go index d571cab7fd8f..0f0d44392839 100644 --- a/selfservice/strategy/oidc/strategy_registration.go +++ b/selfservice/strategy/oidc/strategy_registration.go @@ -6,9 +6,12 @@ package oidc import ( "bytes" "encoding/json" + "fmt" "net/http" "time" + "github.com/google/go-jsonnet" + "github.com/ory/herodot" "github.com/ory/x/fetcher" @@ -48,10 +51,6 @@ func (s *Strategy) RegisterRegistrationRoutes(r *x.RouterPublic) { } func (s *Strategy) PopulateRegistrationMethod(r *http.Request, f *registration.Flow) error { - if f.Type != flow.TypeBrowser { - return nil - } - return s.populateMethod(r, f.UI, text.NewInfoRegistrationWith) } @@ -91,6 +90,11 @@ type UpdateRegistrationFlowWithOidcMethod struct { // // required: false UpstreamParameters json.RawMessage `json:"upstream_parameters"` + + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + // + // required: false + IDToken string `json:"id_token"` } func (s *Strategy) newLinkDecoder(p interface{}, r *http.Request) error { @@ -123,17 +127,34 @@ func (s *Strategy) newLinkDecoder(p interface{}, r *http.Request) error { } func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) (err error) { + var pid = "" + var idToken = "" var p UpdateRegistrationFlowWithOidcMethod - if err := s.newLinkDecoder(&p, r); err != nil { - return s.handleError(w, r, f, "", nil, err) - } + if f.Type == flow.TypeBrowser { + if err := s.newLinkDecoder(&p, r); err != nil { + return s.handleError(w, r, f, "", nil, err) + } - f.TransientPayload = p.TransientPayload + pid = p.Provider // this can come from both url query and post body + } else { + if err := flow.MethodEnabledAndAllowedFromRequest(r, s.ID().String(), s.d); err != nil { + return err + } + + if err := s.dec.Decode(r, &p, + decoderx.HTTPDecoderSetValidatePayloads(true), + decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema), + decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil { + return s.handleError(w, r, f, "", nil, err) + } - var pid = p.Provider // this can come from both url query and post body + idToken = p.IDToken + pid = p.Provider + } if pid == "" { return errors.WithStack(flow.ErrStrategyNotResponsible) } + f.TransientPayload = p.TransientPayload if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil { return s.handleError(w, r, f, pid, nil, err) @@ -159,30 +180,105 @@ func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registrat } state := generateState(f.ID.String()) - if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, - continuity.WithPayload(&authCodeContainer{ - State: state, - FlowID: f.ID.String(), - Traits: p.Traits, - TransientPayload: f.TransientPayload, - }), - continuity.WithLifespan(time.Minute*30)); err != nil { - return s.handleError(w, r, f, pid, nil, err) - } + if f.Type == flow.TypeBrowser { + if err := s.d.ContinuityManager().Pause(r.Context(), w, r, sessionName, + continuity.WithPayload(&authCodeContainer{ + State: state, + FlowID: f.ID.String(), + Traits: p.Traits, + TransientPayload: f.TransientPayload, + }), + continuity.WithLifespan(time.Minute*30)); err != nil { + return s.handleError(w, r, f, pid, nil, err) + } - var up map[string]string - if err := json.NewDecoder(bytes.NewBuffer(p.UpstreamParameters)).Decode(&up); err != nil { - return err - } + var up map[string]string + if err := json.NewDecoder(bytes.NewBuffer(p.UpstreamParameters)).Decode(&up); err != nil { + return err + } - codeURL := c.AuthCodeURL(state, append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) - if x.IsJSONRequest(r) { - s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(codeURL)) + codeURL := c.AuthCodeURL(state, append(provider.AuthCodeURLOptions(req), UpstreamParameters(provider, up)...)...) + 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) + } else if f.Type == flow.TypeAPI { + var claims *Claims + if apiFlowProvider, ok := provider.(APIFlowProvider); ok { + if len(idToken) > 0 { + claims, err = apiFlowProvider.ClaimsFromIDToken(r.Context(), idToken) + if err != nil { + return errors.WithStack(err) + } + } else { + return s.handleError(w, r, f, p.Provider, nil, ErrIDTokenMissing) + } + } else { + return s.handleError(w, r, f, p.Provider, nil, ErrProviderNoAPISupport) + } + + fetch := fetcher.NewFetcher(fetcher.WithClient(s.d.HTTPClient(r.Context()))) + jn, err := fetch.Fetch(provider.Config().Mapper) + if err != nil { + return s.handleError(w, r, f, provider.Config().ID, nil, err) + } + + var jsonClaims bytes.Buffer + if err := json.NewEncoder(&jsonClaims).Encode(claims); err != nil { + return s.handleError(w, r, f, provider.Config().ID, nil, err) + } + + vm := jsonnet.MakeVM() + vm.ExtCode("claims", jsonClaims.String()) + evaluated, err := vm.EvaluateAnonymousSnippet(provider.Config().Mapper, jn.String()) + if err != nil { + return s.handleError(w, r, f, provider.Config().ID, nil, err) + } else if traits := gjson.Get(evaluated, "identity.traits"); !traits.IsObject() { + i.Traits = []byte{'{', '}'} + s.d.Logger(). + WithRequest(r). + WithField("oidc_provider", provider.Config().ID). + WithSensitiveField("oidc_claims", claims). + WithField("mapper_jsonnet_output", evaluated). + WithField("mapper_jsonnet_url", provider.Config().Mapper). + Error("OpenID Connect Jsonnet mapper did not return an object for key identity.traits. Please check your Jsonnet code!") + } else { + i.Traits = []byte(traits.Raw) + } + + s.d.Logger(). + WithRequest(r). + WithField("oidc_provider", provider.Config().ID). + WithSensitiveField("oidc_claims", claims). + WithField("mapper_jsonnet_output", evaluated). + WithField("mapper_jsonnet_url", provider.Config().Mapper). + Debug("OpenID Connect Jsonnet mapper completed.") + + // Validate the identity itself + if err := s.d.IdentityValidator().Validate(r.Context(), i); err != nil { + return s.handleError(w, r, f, provider.Config().ID, i.Traits, err) + } + + creds, err := identity.NewCredentialsOIDC( + idToken, + "", + "", + provider.Config().ID, + claims.Subject) + if err != nil { + return s.handleError(w, r, f, provider.Config().ID, i.Traits, err) + } + + i.SetCredentials(s.ID(), *creds) + + return nil } else { - http.Redirect(w, r, codeURL, http.StatusSeeOther) - } + return errors.WithStack(errors.New(fmt.Sprintf("Not supported flow type: %s", f.Type))) - return errors.WithStack(flow.ErrCompletedByStrategy) + } } func (s *Strategy) registrationToLogin(w http.ResponseWriter, r *http.Request, rf *registration.Flow, providerID string) (*login.Flow, error) { diff --git a/selfservice/strategy/oidc/strategy_settings.go b/selfservice/strategy/oidc/strategy_settings.go index a7703bcf6136..bf32f1af8f75 100644 --- a/selfservice/strategy/oidc/strategy_settings.go +++ b/selfservice/strategy/oidc/strategy_settings.go @@ -8,6 +8,7 @@ import ( "context" _ "embed" "encoding/json" + "fmt" "net/http" "time" @@ -135,10 +136,6 @@ func (s *Strategy) linkableProviders(ctx context.Context, r *http.Request, conf } func (s *Strategy) PopulateSettingsMethod(r *http.Request, id *identity.Identity, sr *settings.Flow) error { - if sr.Type != flow.TypeBrowser { - return nil - } - conf, err := s.Config(r.Context()) if err != nil { return err @@ -230,6 +227,11 @@ type updateSettingsFlowWithOidcMethod struct { // // required: false UpstreamParameters json.RawMessage `json:"upstream_parameters"` + + // Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider. + // + // required: false + IDToken string `json:"id_token"` } func (p *updateSettingsFlowWithOidcMethod) GetFlowID() uuid.UUID { @@ -285,9 +287,39 @@ func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings. InstancePtr: "#/", })) } else if l > 0 { - if err := s.initLinkProvider(w, r, ctxUpdate, &p); err != nil { - return nil, err + switch f.Type { + case flow.TypeAPI: + provider, err := s.provider(r.Context(), r, p.Link) + if err != nil { + return nil, s.handleSettingsError(w, r, ctxUpdate, &p, err) + } + var claims *Claims + token := &oauth2.Token{} + if apiFlowProvider, ok := provider.(APIFlowProvider); ok { + if len(p.IDToken) > 0 { + token = token.WithExtra(map[string]string{"id_token": p.IDToken}) + claims, err = apiFlowProvider.ClaimsFromIDToken(r.Context(), p.IDToken) + if err != nil { + return nil, errors.WithStack(err) + } + } else { + return nil, ErrIDTokenMissing + } + } else { + return nil, ErrProviderNoAPISupport + } + + if err := s.linkProvider(w, r, ctxUpdate, token, claims, provider); err != nil { + return nil, err + } + case flow.TypeBrowser: + if err := s.initLinkProvider(w, r, ctxUpdate, &p); err != nil { + return nil, err + } + default: + return nil, errors.WithStack(errors.New(fmt.Sprintf("Not supported flow type: %s", f.Type))) } + return ctxUpdate, nil } else if u > 0 { if err := s.unlinkProvider(w, r, ctxUpdate, &p); err != nil { diff --git a/selfservice/strategy/oidc/strategy_settings_test.go b/selfservice/strategy/oidc/strategy_settings_test.go index 64dcb4cf9503..595b2ed73d41 100644 --- a/selfservice/strategy/oidc/strategy_settings_test.go +++ b/selfservice/strategy/oidc/strategy_settings_test.go @@ -62,12 +62,15 @@ func TestSettingsStrategy(t *testing.T) { errTS := testhelpers.NewErrorTestServer(t, reg) publicTS, adminTS := testhelpers.NewKratosServers(t) + testCallbackUrl := newTestCallback(t, reg) + googleProviderConfig := newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "google", testCallbackUrl, nil) viperSetProviderConfig( t, conf, - newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "ory"), - newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "google"), - newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "github"), + newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "ory", "", nil), + googleProviderConfig, + newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "github", "", nil), + //newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "valid", clientID, clientSecret, testCallbackUrl), ) testhelpers.InitKratosServers(t, reg, publicTS, adminTS) testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/settings.schema.json") @@ -416,30 +419,59 @@ func TestSettingsStrategy(t *testing.T) { }) t.Run("case=should link a connection", func(t *testing.T) { - t.Cleanup(reset(t)) + t.Run("api", func(t *testing.T) { + t.Cleanup(reset(t)) - subject = "hackerman+new-connection+" + testID - scope = []string{"openid", "offline"} + subject = "ory+new-connection+" + testID + scope = []string{"openid", "offline"} - agent, provider := "githuber", "google" - updatedFlow, res, originalFlow := link(t, agent, provider) - assert.Contains(t, res.Request.URL.String(), uiTS.URL) + agent, provider := "githuber", "google" + f := testhelpers.InitializeSettingsFlowViaAPI(t, agents[agent], publicTS) - updatedFlowSDK, _, err := testhelpers.NewSDKCustomClient(publicTS, agents[agent]).FrontendApi.GetSettingsFlow(context.Background()).Id(originalFlow.Id).Execute() - require.NoError(t, err) - require.EqualValues(t, settings.StateSuccess, updatedFlowSDK.State) + err, tokens := getOauthTokens(t, remotePublic, + googleProviderConfig.ClientID, + googleProviderConfig.ClientSecret, + testCallbackUrl) + require.NoError(t, err) - t.Run("flow=original", func(t *testing.T) { - snapshotx.SnapshotTExcept(t, originalFlow.Ui.Nodes, []string{"0.attributes.value", "1.attributes.value"}) - }) - t.Run("flow=response", func(t *testing.T) { - snapshotx.SnapshotTExcept(t, json.RawMessage(gjson.GetBytes(updatedFlow, "ui.nodes").Raw), []string{"0.attributes.value", "1.attributes.value"}) + values := url.Values{} + values.Add("csrf_token", x.FakeCSRFToken) + values.Add("link", provider) + values.Add("id_token", tokens.IDToken) + + testhelpers.SettingsMakeRequest(t, true, false, f, agents[agent], + testhelpers.EncodeFormAsJSON(t, true, values)) + + checkCredentials(t, true, users[agent].ID, provider, subject, false) }) - t.Run("flow=fetch", func(t *testing.T) { - snapshotx.SnapshotTExcept(t, updatedFlowSDK.Ui.Nodes, []string{"0.attributes.value", "1.attributes.value"}) + + t.Run("browser", func(t *testing.T) { + t.Cleanup(reset(t)) + + subject = "hackerman+new-connection+" + testID + scope = []string{"openid", "offline"} + + agent, provider := "githuber", "google" + updatedFlow, res, originalFlow := link(t, agent, provider) + assert.Contains(t, res.Request.URL.String(), uiTS.URL) + + updatedFlowSDK, _, err := testhelpers.NewSDKCustomClient(publicTS, agents[agent]).FrontendApi.GetSettingsFlow(context.Background()).Id(originalFlow.Id).Execute() + require.NoError(t, err) + require.EqualValues(t, settings.StateSuccess, updatedFlowSDK.State) + + t.Run("flow=original", func(t *testing.T) { + snapshotx.SnapshotTExcept(t, originalFlow.Ui.Nodes, []string{"0.attributes.value", "1.attributes.value"}) + }) + t.Run("flow=response", func(t *testing.T) { + snapshotx.SnapshotTExcept(t, json.RawMessage(gjson.GetBytes(updatedFlow, "ui.nodes").Raw), []string{"0.attributes.value", "1.attributes.value"}) + }) + t.Run("flow=fetch", func(t *testing.T) { + snapshotx.SnapshotTExcept(t, updatedFlowSDK.Ui.Nodes, []string{"0.attributes.value", "1.attributes.value"}) + }) + + checkCredentials(t, true, users[agent].ID, provider, subject, true) }) - checkCredentials(t, true, users[agent].ID, provider, subject, true) }) t.Run("case=should link a connection even if user does not have oidc credentials yet", func(t *testing.T) { @@ -585,8 +617,8 @@ func TestPopulateSettingsMethod(t *testing.T) { return ss.(*oidc.Strategy) } - nr := func() *settings.Flow { - return &settings.Flow{Type: flow.TypeBrowser, ID: x.NewUUID(), UI: container.New("")} + nr := func(t flow.Type) *settings.Flow { + return &settings.Flow{Type: t, ID: x.NewUUID(), UI: container.New("")} } populate := func(t *testing.T, reg *driver.RegistryDefault, i *identity.Identity, req *settings.Flow) *container.Container { @@ -604,15 +636,6 @@ func TestPopulateSettingsMethod(t *testing.T) { {Provider: "generic", ID: "github"}, } - t.Run("case=should not populate non-browser flow", func(t *testing.T) { - reg := nreg(t, &oidc.ConfigurationCollection{Providers: []oidc.Configuration{{Provider: "generic", ID: "github"}}}) - i := &identity.Identity{Traits: []byte(`{"subject":"foo@bar.com"}`)} - require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) - req := &settings.Flow{Type: flow.TypeAPI, ID: x.NewUUID(), UI: container.New("")} - require.NoError(t, ns(t, reg).PopulateSettingsMethod(new(http.Request), i, req)) - require.Empty(t, req.UI.Nodes) - }) - for k, tc := range []struct { c []oidc.Configuration i *identity.Credentials @@ -694,23 +717,30 @@ func TestPopulateSettingsMethod(t *testing.T) { }, } { t.Run("iteration="+strconv.Itoa(k), func(t *testing.T) { - reg := nreg(t, &oidc.ConfigurationCollection{Providers: tc.c}) - i := &identity.Identity{ - Traits: []byte(`{"subject":"foo@bar.com"}`), - Credentials: make(map[identity.CredentialsType]identity.Credentials, 2), - } - if tc.i != nil { - i.Credentials[identity.CredentialsTypeOIDC] = *tc.i - } - if tc.withpw { - i.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ - Type: identity.CredentialsTypePassword, - Identifiers: []string{"foo@bar.com"}, - Config: []byte(`{"hashed_password":"$argon2id$..."}`), - } + for runName, f := range map[string]*settings.Flow{ + "browser": nr(flow.TypeBrowser), + "api": nr(flow.TypeAPI), + } { + t.Run(runName, func(t *testing.T) { + reg := nreg(t, &oidc.ConfigurationCollection{Providers: tc.c}) + i := &identity.Identity{ + Traits: []byte(`{"subject":"foo@bar.com"}`), + Credentials: make(map[identity.CredentialsType]identity.Credentials, 2), + } + if tc.i != nil { + i.Credentials[identity.CredentialsTypeOIDC] = *tc.i + } + if tc.withpw { + i.Credentials[identity.CredentialsTypePassword] = identity.Credentials{ + Type: identity.CredentialsTypePassword, + Identifiers: []string{"foo@bar.com"}, + Config: []byte(`{"hashed_password":"$argon2id$..."}`), + } + } + actual := populate(t, reg, i, f) + assert.EqualValues(t, tc.e, actual.Nodes) + }) } - actual := populate(t, reg, i, nr()) - assert.EqualValues(t, tc.e, actual.Nodes) }) } } diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 8fda01141e45..8a6a03005280 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -13,10 +13,13 @@ import ( "net/http/cookiejar" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" + "github.com/ory/x/sqlxx" + "github.com/ory/x/snapshotx" "github.com/ory/kratos/text" @@ -66,11 +69,14 @@ func TestStrategy(t *testing.T) { routerP := x.NewRouterPublic() routerA := x.NewRouterAdmin() ts, _ := testhelpers.NewKratosServerWithRouters(t, reg, routerP, routerA) - invalid := newOIDCProvider(t, ts, remotePublic, remoteAdmin, "invalid-issuer") + invalid := newOIDCProvider(t, ts, remotePublic, remoteAdmin, "invalid-issuer", "", nil) + testCallbackUrl := newTestCallback(t, reg) + webviewRedirectURI := ts.URL + "/self-service/oidc/webview" + validProviderConfig := newOIDCProvider(t, ts, remotePublic, remoteAdmin, "valid", testCallbackUrl, nil) viperSetProviderConfig( t, conf, - newOIDCProvider(t, ts, remotePublic, remoteAdmin, "valid"), + validProviderConfig, oidc.Configuration{ Provider: "generic", ID: "invalid-issuer", @@ -80,12 +86,14 @@ func TestStrategy(t *testing.T) { IssuerURL: strings.Replace(remotePublic, "localhost", "127.0.0.1", 1) + "/", Mapper: "file://./stub/oidc.hydra.jsonnet", }, + newOIDCProvider(t, ts, remotePublic, remoteAdmin, "check_audience", testCallbackUrl, []string{"test.audience"}), ) conf.MustSet(ctx, config.ViperKeySelfServiceRegistrationEnabled, true) testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") conf.MustSet(ctx, config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeOIDC.String()), []config.SelfServiceHook{{Name: "session"}}) + conf.MustSet(ctx, config.ViperKeySelfServiceWebViewRedirectURL, webviewRedirectURI) t.Logf("Kratos Public URL: %s", ts.URL) t.Logf("Kratos Error URL: %s", errTS.URL) @@ -137,6 +145,7 @@ func TestStrategy(t *testing.T) { var makeRequestWithCookieJar = func(t *testing.T, provider string, action string, fv url.Values, jar *cookiejar.Jar) (*http.Response, []byte) { fv.Set("provider", provider) + fv.Set("method", "oidc") res, err := testhelpers.NewClientWithCookieJar(t, jar, false).PostForm(action, fv) require.NoError(t, err, action) @@ -182,10 +191,10 @@ func TestStrategy(t *testing.T) { assert.Equal(t, claims.metadataPublic.picture, gjson.GetBytes(body, "identity.metadata_public.picture").String(), "%s", body) } - var newLoginFlow = func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) { + var newLoginFlow = func(t *testing.T, redirectTo 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(redirectTo)}, flow.TypeBrowser) + &http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flowType) require.NoError(t, err) req.RequestURL = redirectTo req.ExpiresAt = time.Now().Add(exp) @@ -200,10 +209,14 @@ func TestStrategy(t *testing.T) { return } - var newRegistrationFlow = func(t *testing.T, redirectTo string, exp time.Duration) *registration.Flow { + var newLoginFlowBrowser = func(t *testing.T, redirectTo string, exp time.Duration) (req *login.Flow) { + return newLoginFlow(t, redirectTo, exp, flow.TypeBrowser) + } + + var 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)}, flow.TypeBrowser) + &http.Request{URL: urlx.ParseOrPanic(redirectTo)}, flowType) require.NoError(t, err) req.RequestURL = redirectTo req.ExpiresAt = time.Now().Add(exp) @@ -217,10 +230,14 @@ func TestStrategy(t *testing.T) { return req } + var newRegistrationFlowBrowser = func(t *testing.T, redirectTo string, exp time.Duration) *registration.Flow { + return newRegistrationFlow(t, redirectTo, exp, flow.TypeBrowser) + } + t.Run("case=should fail because provider does not exist", func(t *testing.T) { for k, v := range []string{ - loginAction(newLoginFlow(t, returnTS.URL, time.Minute).ID), - registerAction(newRegistrationFlow(t, returnTS.URL, time.Minute).ID), + loginAction(newLoginFlowBrowser(t, returnTS.URL, time.Minute).ID), + registerAction(newRegistrationFlowBrowser(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{}) @@ -229,11 +246,55 @@ func TestStrategy(t *testing.T) { } }) + var expectValidationError = func(t *testing.T, isAPI, forced, isSPA bool, values func(url.Values)) string { + return testhelpers.SubmitLoginForm(t, isAPI, nil, ts, values, + isSPA, forced, + testhelpers.ExpectStatusCode(isAPI || isSPA, http.StatusBadRequest, http.StatusOK), + testhelpers.ExpectURL(isAPI || isSPA, ts.URL+login.RouteSubmitFlow, conf.SelfServiceFlowLoginUI(ctx).String())) + } + + t.Run("case=api should fail because id_token was not provided", func(t *testing.T) { + var values = func(v url.Values) { + v.Set("method", "oidc") + v.Set("provider", "valid") + } + body := expectValidationError(t, true, false, false, values) + assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "ui.action").String(), ts.URL+login.RouteSubmitFlow, "%s", body) + + assert.Contains(t, + gjson.Get(body, "ui.messages.0.text").String(), + "no id_token", + "%s", body) + //assert.Len(t, gjson.Get(body, "ui.nodes").Array(), 4) //TODO Get rid of password fields + }) + + t.Run("case=api should fail because id_token is invalid", func(t *testing.T) { + var check = func(t *testing.T, body string) { + assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body) + assert.Contains(t, gjson.Get(body, "ui.action").String(), ts.URL+login.RouteSubmitFlow, "%s", body) + + assert.Contains(t, + gjson.Get(body, "ui.messages.0.text").String(), + "malformed jwt", + "%s", body) + //assert.Len(t, gjson.Get(body, "ui.nodes").Array(), 4) //TODO Get rid of password fields + } + + var values = func(v url.Values) { + v.Set("method", "oidc") + v.Set("provider", "valid") + v.Set("id_token", "invalid-token") + } + + check(t, expectValidationError(t, true, false, false, values)) + }) + t.Run("case=should fail because the issuer is mismatching", func(t *testing.T) { scope = []string{"openid"} for k, v := range []string{ - loginAction(newLoginFlow(t, returnTS.URL, time.Minute).ID), - registerAction(newRegistrationFlow(t, returnTS.URL, time.Minute).ID), + loginAction(newLoginFlowBrowser(t, returnTS.URL, time.Minute).ID), + registerAction(newRegistrationFlowBrowser(t, returnTS.URL, time.Minute).ID), } { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { res, body := makeRequest(t, "invalid-issuer", v, url.Values{}) @@ -253,8 +314,8 @@ func TestStrategy(t *testing.T) { t.Run("case=should fail because the flow is expired", func(t *testing.T) { for k, v := range []uuid.UUID{ - newLoginFlow(t, returnTS.URL, -time.Minute).ID, - newRegistrationFlow(t, returnTS.URL, -time.Minute).ID} { + newLoginFlowBrowser(t, returnTS.URL, -time.Minute).ID, + newRegistrationFlowBrowser(t, returnTS.URL, -time.Minute).ID} { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { action := afv(t, v, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) @@ -271,8 +332,8 @@ func TestStrategy(t *testing.T) { scope = []string{} for k, v := range []uuid.UUID{ - newLoginFlow(t, returnTS.URL, time.Minute).ID, - newRegistrationFlow(t, returnTS.URL, time.Minute).ID} { + newLoginFlowBrowser(t, returnTS.URL, time.Minute).ID, + newRegistrationFlowBrowser(t, returnTS.URL, time.Minute).ID} { t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { action := afv(t, v, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) @@ -305,7 +366,7 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should fail login because scope was not provided", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) + r := newLoginFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) aue(t, res, body, "no id_token was returned") @@ -315,7 +376,7 @@ func TestStrategy(t *testing.T) { subject = "not-an-email" scope = []string{"openid"} - r := newRegistrationFlow(t, returnTS.URL, time.Minute) + r := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) @@ -323,6 +384,16 @@ func TestStrategy(t *testing.T) { assert.Contains(t, gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.subject).messages.0.text").String(), "is not valid", "%s\n%s", gjson.GetBytes(body, "ui.nodes.#(attributes.name==traits.subject)").Raw, body) }) + t.Run("case=should fail because the audience is mismatching", func(t *testing.T) { + subject = "foo@bar.com" + scope = []string{"openid"} + + r := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) + action := afv(t, r.ID, "check_audience") + _, body := makeRequest(t, "check_audience", action, url.Values{}) + assert.Contains(t, gjson.GetBytes(body, "ui.messages.0.text").String(), "audience not valid", "%s", body) + }) + t.Run("case=cannot register multiple accounts with the same OIDC account", func(t *testing.T) { subject = "oidc-register-then-login@ory.sh" scope = []string{"openid", "offline"} @@ -341,7 +412,7 @@ func TestStrategy(t *testing.T) { } t.Run("case=should pass registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) + r := newRegistrationFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) ai(t, res, body) @@ -350,7 +421,7 @@ func TestStrategy(t *testing.T) { t.Run("case=try another registration", func(t *testing.T) { returnTo := fmt.Sprintf("%s/home?query=true", returnTS.URL) - r := newRegistrationFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, url.QueryEscape(returnTo)), time.Minute) + r := newRegistrationFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, url.QueryEscape(returnTo)), time.Minute, flow.TypeBrowser) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) assert.Equal(t, returnTo, res.Request.URL.String()) @@ -360,7 +431,6 @@ func TestStrategy(t *testing.T) { }) t.Run("case=register and then login", func(t *testing.T) { - subject = "register-then-login@ory.sh" scope = []string{"openid", "offline"} expectTokens := func(t *testing.T, provider string, body []byte) { @@ -376,20 +446,93 @@ func TestStrategy(t *testing.T) { ) } - t.Run("case=should pass registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") - res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) - expectTokens(t, "valid", body) + t.Run("case=api", func(t *testing.T) { + subject = "register-then-login-api@ory.sh" + + err, tokens := getOauthTokens(t, remotePublic, + validProviderConfig.ClientID, + validProviderConfig.ClientSecret, + testCallbackUrl) + require.NoError(t, err) + + var values = func(v url.Values) { + v.Set("method", "oidc") + v.Set("provider", "valid") + v.Set("id_token", tokens.IDToken) + } + + t.Run("case=should pass registration", func(t *testing.T) { + body := testhelpers.SubmitRegistrationForm(t, true, nil, ts, values, + false, http.StatusOK, ts.URL+registration.RouteSubmitFlow) + assert.Equal(t, subject, gjson.Get(body, "identity.traits.subject").String(), "%s", body) + }) + + t.Run("case=should pass login", func(t *testing.T) { + body := testhelpers.SubmitLoginForm(t, true, nil, ts, values, + false, false, http.StatusOK, ts.URL+login.RouteSubmitFlow) + + assert.NotEmpty(t, gjson.Get(body, "session"), "%s", body) + }) }) - t.Run("case=should pass login", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") - res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) - expectTokens(t, "valid", body) + t.Run("case=webview", func(t *testing.T) { + subject = "register-then-login-webview@ory.sh" + + cj, err := cookiejar.New(&cookiejar.Options{}) + require.NoError(t, err) + c := &http.Client{ + Jar: cj, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if strings.HasSuffix(req.URL.Path, "/success") { + assert.True(t, req.URL.Query().Has("session_token")) + return http.ErrUseLastResponse + } + return nil + }, + } + + var values = func(v url.Values) { + v.Set("method", "oidc") + v.Set("provider", "valid") + } + + t.Run("case=should pass registration", func(t *testing.T) { + body := testhelpers.SubmitRegistrationForm(t, false, c, ts, values, + false, http.StatusSeeOther, ts.URL+"/self-service/methods/oidc/callback/valid", + testhelpers.InitFlowWithReturnTo(webviewRedirectURI)) + + assert.NotEmpty(t, gjson.Get(body, "session_token"), "%s", body) + assert.NotEmpty(t, gjson.Get(body, "session"), "%s", body) + }) + + t.Run("case=should pass login", func(t *testing.T) { + body := testhelpers.SubmitLoginForm(t, false, c, ts, values, + false, false, http.StatusSeeOther, ts.URL+"/self-service/methods/oidc/callback/valid", + testhelpers.InitFlowWithReturnTo(webviewRedirectURI)) + + assert.NotEmpty(t, gjson.Get(body, "session_token"), "%s", body) + assert.NotEmpty(t, gjson.Get(body, "session"), "%s", body) + }) + }) + + t.Run("case=browser", func(t *testing.T) { + subject = "register-then-login-browser@ory.sh" + + t.Run("case=should pass registration", func(t *testing.T) { + r := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) + action := afv(t, r.ID, "valid") + res, body := makeRequest(t, "valid", action, url.Values{}) + ai(t, res, body) + expectTokens(t, "valid", body) + }) + + t.Run("case=should pass login", func(t *testing.T) { + r := newLoginFlowBrowser(t, returnTS.URL, time.Minute) + action := afv(t, r.ID, "valid") + res, body := makeRequest(t, "valid", action, url.Values{}) + ai(t, res, body) + expectTokens(t, "valid", body) + }) }) }) @@ -397,11 +540,32 @@ func TestStrategy(t *testing.T) { subject = "login-without-register@ory.sh" scope = []string{"openid"} + t.Run("case=api should fail as user is not registered", func(t *testing.T) { + err, tokens := getOauthTokens(t, remotePublic, + validProviderConfig.ClientID, + validProviderConfig.ClientSecret, + testCallbackUrl) + require.NoError(t, err) + + var values = func(v url.Values) { + v.Set("method", "oidc") + v.Set("provider", "valid") + v.Set("id_token", tokens.IDToken) + } + + body := testhelpers.SubmitLoginForm(t, true, nil, ts, values, + false, false, http.StatusBadRequest, ts.URL+login.RouteSubmitFlow) + + assert.Equal(t, int(text.ErrorValidationOIDCUserNotFound), int(gjson.Get(body, "ui.messages.0.id").Int()), "%s", body) + }) + t.Run("case=should pass login", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") - res, body := makeRequest(t, "valid", action, url.Values{}) - ai(t, res, body) + t.Run("case=browser", func(t *testing.T) { + flowId := newLoginFlowBrowser(t, returnTS.URL, time.Minute).ID + action := afv(t, flowId, "valid") + res, body := makeRequest(t, "valid", action, url.Values{}) + ai(t, res, body) + }) }) }) @@ -411,7 +575,7 @@ func TestStrategy(t *testing.T) { returnTo := "/foo" t.Run("case=should pass login", func(t *testing.T) { - r := newLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute) + r := newLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute, flow.TypeBrowser) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) assert.True(t, strings.HasSuffix(res.Request.URL.String(), returnTo)) @@ -424,14 +588,14 @@ func TestStrategy(t *testing.T) { scope = []string{"openid"} t.Run("case=should pass registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) + r := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) ai(t, res, body) }) t.Run("case=should pass second time registration", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) + r := newLoginFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) ai(t, res, body) @@ -439,7 +603,7 @@ func TestStrategy(t *testing.T) { t.Run("case=should pass third time registration with return to", func(t *testing.T) { returnTo := "/foo" - r := newLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute) + r := newLoginFlow(t, fmt.Sprintf("%s?return_to=%s", returnTS.URL, returnTo), time.Minute, flow.TypeBrowser) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{}) assert.True(t, strings.HasSuffix(res.Request.URL.String(), returnTo)) @@ -457,7 +621,7 @@ func TestStrategy(t *testing.T) { claims.metadataAdmin.phoneNumber = "911" t.Run("case=should fail registration on first attempt", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) + r := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{"traits.name": {"i"}}) require.Contains(t, res.Request.URL.String(), uiTS.URL, "%s", body) @@ -469,7 +633,7 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should pass registration with valid data", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) + r := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, r.ID, "valid") res, body := makeRequest(t, "valid", action, url.Values{"traits.name": {"valid-name"}}) ai(t, res, body) @@ -483,29 +647,70 @@ func TestStrategy(t *testing.T) { subject = "email-exist-with-password-strategy@ory.sh" scope = []string{"openid"} - t.Run("case=create password identity", func(t *testing.T) { - i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) - i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ - Identifiers: []string{subject}, - }) - i.Traits = identity.Traits(`{"subject":"` + subject + `"}`) + cj, err := cookiejar.New(&cookiejar.Options{}) + require.NoError(t, err) + webViewHTTPClient := &http.Client{ + Jar: cj, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if strings.HasSuffix(req.URL.Path, "/error") { + assert.Equal(t, strconv.Itoa(int(text.ErrorValidationDuplicateCredentials)), + req.URL.Query().Get("code"), "%s", req.URL.String()) + assert.Contains(t, req.URL.Query().Get("message"), + "account with the same", "%s", req.URL.String()) + return http.ErrUseLastResponse + } + return nil + }, + } + + var values = func(v url.Values) { + v.Set("method", "oidc") + v.Set("provider", "valid") + } - require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) + i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) + i.SetCredentials(identity.CredentialsTypePassword, identity.Credentials{ + Identifiers: []string{subject}, }) + i.Traits = identity.Traits(`{"subject":"` + subject + `"}`) + + require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) t.Run("case=should fail registration", func(t *testing.T) { - r := newRegistrationFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") - res, body := makeRequest(t, "valid", action, url.Values{}) - aue(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already. Please sign in to your existing account and link your social profile in the settings page.") - require.Contains(t, gjson.GetBytes(body, "ui.action").String(), "/self-service/login") + t.Run("case=browser", func(t *testing.T) { + r := newRegistrationFlow(t, returnTS.URL, time.Minute, flow.TypeBrowser) + action := afv(t, r.ID, "valid") + res, body := makeRequest(t, "valid", action, url.Values{}) + aue(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already. Please sign in to your existing account and link your social profile in the settings page.") + require.Contains(t, gjson.GetBytes(body, "ui.action").String(), "/self-service/login") + }) + + t.Run("case=webview", func(t *testing.T) { + body := testhelpers.SubmitRegistrationForm(t, false, webViewHTTPClient, ts, values, + false, http.StatusFound, ts.URL+"/self-service/methods/oidc/callback/valid", + testhelpers.InitFlowWithReturnTo(webviewRedirectURI)) + + assert.Empty(t, gjson.Get(body, "session_token"), "%s", body) + assert.Empty(t, gjson.Get(body, "session"), "%s", body) + }) }) t.Run("case=should fail login", func(t *testing.T) { - r := newLoginFlow(t, returnTS.URL, time.Minute) - action := afv(t, r.ID, "valid") - res, body := makeRequest(t, "valid", action, url.Values{}) - aue(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already.") + t.Run("case=browser", func(t *testing.T) { + r := newLoginFlowBrowser(t, returnTS.URL, time.Minute) + action := afv(t, r.ID, "valid") + res, body := makeRequest(t, "valid", action, url.Values{}) + aue(t, res, body, "An account with the same identifier (email, phone, username, ...) exists already.") + }) + + t.Run("case=webview", func(t *testing.T) { + body := testhelpers.SubmitLoginForm(t, false, webViewHTTPClient, ts, values, + false, false, http.StatusFound, ts.URL+"/self-service/methods/oidc/callback/valid", + testhelpers.InitFlowWithReturnTo(webviewRedirectURI)) + + assert.Empty(t, gjson.Get(body, "session_token"), "%s", body) + assert.Empty(t, gjson.Get(body, "session"), "%s", body) + }) }) }) @@ -515,10 +720,10 @@ func TestStrategy(t *testing.T) { fv := url.Values{"traits.name": {"valid-name"}} jar, _ := cookiejar.New(nil) - r1 := newLoginFlow(t, returnTS.URL, time.Minute) + r1 := newLoginFlowBrowser(t, returnTS.URL, time.Minute) res1, body1 := makeRequestWithCookieJar(t, "valid", afv(t, r1.ID, "valid"), fv, jar) ai(t, res1, body1) - r2 := newLoginFlow(t, returnTS.URL, time.Minute) + r2 := newLoginFlowBrowser(t, returnTS.URL, time.Minute) res2, body2 := makeRequestWithCookieJar(t, "valid", afv(t, r2.ID, "valid"), fv, jar) ai(t, res2, body2) assert.Equal(t, body1, body2) @@ -530,10 +735,10 @@ func TestStrategy(t *testing.T) { fv := url.Values{"traits.name": {"valid-name"}} jar, _ := cookiejar.New(nil) - r1 := newLoginFlow(t, returnTS.URL, time.Minute) + r1 := newLoginFlowBrowser(t, returnTS.URL, time.Minute) res1, body1 := makeRequestWithCookieJar(t, "valid", afv(t, r1.ID, "valid"), fv, jar) ai(t, res1, body1) - r2 := newLoginFlow(t, returnTS.URL, time.Minute) + r2 := newLoginFlowBrowser(t, returnTS.URL, time.Minute) require.NoError(t, reg.LoginFlowPersister().ForceLoginFlow(context.Background(), r2.ID)) res2, body2 := makeRequestWithCookieJar(t, "valid", afv(t, r2.ID, "valid"), fv, jar) ai(t, res2, body2) @@ -558,7 +763,7 @@ func TestStrategy(t *testing.T) { } t.Run("case=should pass when registering", func(t *testing.T) { - f := newRegistrationFlow(t, returnTS.URL, time.Minute) + f := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, f.ID, "valid") fv := url.Values{} @@ -579,7 +784,7 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should pass when logging in", func(t *testing.T) { - f := newLoginFlow(t, returnTS.URL, time.Minute) + f := newLoginFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, f.ID, "valid") @@ -601,7 +806,7 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should ignore invalid parameters when logging in", func(t *testing.T) { - f := newLoginFlow(t, returnTS.URL, time.Minute) + f := newLoginFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, f.ID, "valid") fv := url.Values{} @@ -619,7 +824,7 @@ func TestStrategy(t *testing.T) { }) t.Run("case=should ignore invalid parameters when registering", func(t *testing.T) { - f := newRegistrationFlow(t, returnTS.URL, time.Minute) + f := newRegistrationFlowBrowser(t, returnTS.URL, time.Minute) action := afv(t, f.ID, "valid") fv := url.Values{} @@ -649,12 +854,19 @@ func TestStrategy(t *testing.T) { t.Run("method=TestPopulateLoginMethod", func(t *testing.T) { conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://foo/") + for k, flowType := range map[string]flow.Type{ + "api": flow.TypeAPI, + "browser": flow.TypeBrowser, + } { + t.Run(fmt.Sprintf("case=%s", k), func(t *testing.T) { - sr, err := login.NewFlow(conf, time.Minute, "nosurf", &http.Request{URL: urlx.ParseOrPanic("/")}, flow.TypeBrowser) - require.NoError(t, err) - require.NoError(t, reg.LoginStrategies(context.Background()).MustStrategy(identity.CredentialsTypeOIDC).(*oidc.Strategy).PopulateLoginMethod(&http.Request{}, identity.AuthenticatorAssuranceLevel1, sr)) + sr, err := login.NewFlow(conf, time.Minute, "nosurf", &http.Request{URL: urlx.ParseOrPanic("/")}, flowType) + require.NoError(t, err) + require.NoError(t, reg.LoginStrategies(context.Background()).MustStrategy(identity.CredentialsTypeOIDC).(*oidc.Strategy).PopulateLoginMethod(&http.Request{}, identity.AuthenticatorAssuranceLevel1, sr)) - snapshotx.SnapshotTExcept(t, sr.UI, []string{"action", "nodes.0.attributes.value"}) + snapshotx.SnapshotTExcept(t, sr.UI, []string{"action", "nodes.0.attributes.value"}) + }) + } }) } @@ -788,7 +1000,10 @@ func TestDisabledEndpoint(t *testing.T) { t.Run("flow=login", func(t *testing.T) { f := testhelpers.InitializeLoginFlowViaAPI(t, c, publicTS, false) - res, err := c.PostForm(f.Ui.Action, url.Values{"provider": {"oidc"}}) + res, err := c.PostForm(f.Ui.Action, url.Values{ + "method": {"oidc"}, + "provider": {"valid"}, + "id_token": {"fake_token"}}) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, res.StatusCode) @@ -798,7 +1013,10 @@ func TestDisabledEndpoint(t *testing.T) { t.Run("flow=registration", func(t *testing.T) { f := testhelpers.InitializeRegistrationFlowViaAPI(t, c, publicTS) - res, err := c.PostForm(f.Ui.Action, url.Values{"provider": {"oidc"}}) + res, err := c.PostForm(f.Ui.Action, url.Values{ + "method": {"oidc"}, + "provider": {"oidc"}, + "id_token": {"fake_token"}}) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, res.StatusCode) @@ -824,7 +1042,7 @@ func TestPostEndpointRedirect(t *testing.T) { viperSetProviderConfig( t, conf, - newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "apple"), + newOIDCProvider(t, publicTS, remotePublic, remoteAdmin, "apple", "", nil), ) testhelpers.InitKratosServers(t, reg, publicTS, adminTS) diff --git a/spec/api.json b/spec/api.json index 5bce534c7c1c..c653da63bc5a 100755 --- a/spec/api.json +++ b/spec/api.json @@ -2553,6 +2553,10 @@ "description": "The CSRF Token", "type": "string" }, + "id_token": { + "description": "Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider.", + "type": "string" + }, "method": { "description": "Method to use\n\nThis field must be set to `oidc` when using the oidc method.", "type": "string" @@ -2749,6 +2753,10 @@ "description": "The CSRF Token", "type": "string" }, + "id_token": { + "description": "Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider.", + "type": "string" + }, "method": { "description": "Method to use\n\nThis field must be set to `oidc` when using the oidc method.", "type": "string" @@ -2918,6 +2926,10 @@ "description": "Flow ID is the flow's ID.\n\nin: query", "type": "string" }, + "id_token": { + "description": "Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider.", + "type": "string" + }, "link": { "description": "Link this provider\n\nEither this or `unlink` must be set.\n\ntype: string\nin: body", "type": "string" diff --git a/spec/swagger.json b/spec/swagger.json index 6b214c2d325c..814d0097c9dc 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -5315,6 +5315,10 @@ "description": "The CSRF Token", "type": "string" }, + "id_token": { + "description": "Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider.", + "type": "string" + }, "method": { "description": "Method to use\n\nThis field must be set to `oidc` when using the oidc method.", "type": "string" @@ -5479,6 +5483,10 @@ "description": "The CSRF Token", "type": "string" }, + "id_token": { + "description": "Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider.", + "type": "string" + }, "method": { "description": "Method to use\n\nThis field must be set to `oidc` when using the oidc method.", "type": "string" @@ -5614,6 +5622,10 @@ "description": "Flow ID is the flow's ID.\n\nin: query", "type": "string" }, + "id_token": { + "description": "Only used in API-type flows, when an id token has been received by mobile app directly from oidc provider.", + "type": "string" + }, "link": { "description": "Link this provider\n\nEither this or `unlink` must be set.\n\ntype: string\nin: body", "type": "string" diff --git a/text/id.go b/text/id.go index 336eeb56b9ae..64bdb0233128 100644 --- a/text/id.go +++ b/text/id.go @@ -119,6 +119,7 @@ const ( ErrorValidationUniqueItems ErrorValidationWrongType ErrorValidationDuplicateCredentialsOnOIDCLink + ErrorValidationOIDCUserNotFound ) const ( diff --git a/text/message_validation.go b/text/message_validation.go index 0c5cd85b15c0..b74047d52d4c 100644 --- a/text/message_validation.go +++ b/text/message_validation.go @@ -251,3 +251,12 @@ func NewErrorValidationSuchNoWebAuthnUser() *Message { Context: context(nil), } } + +func NewErrorValidationOIDCUserNotFound() *Message { + return &Message{ + ID: ErrorValidationOIDCUserNotFound, + Text: "User with the provided identifier not found.", + Type: Error, + Context: context(nil), + } +} From d51ba70d2baddcf5e4804484788aedd80d78c398 Mon Sep 17 00:00:00 2001 From: Jonas Hungershausen Date: Tue, 21 Mar 2023 18:00:48 +0100 Subject: [PATCH 2/2] chore: fix tests --- internal/testhelpers/selfservice_login.go | 1 + .../testhelpers/selfservice_registration.go | 5 +++-- selfservice/strategy/oidc/strategy_test.go | 19 +++++++++++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/testhelpers/selfservice_login.go b/internal/testhelpers/selfservice_login.go index d5315e1a6e27..0953ce199f65 100644 --- a/internal/testhelpers/selfservice_login.go +++ b/internal/testhelpers/selfservice_login.go @@ -208,6 +208,7 @@ func SubmitLoginForm( expectedURL string, opts ...InitFlowWithOption, ) string { + t.Helper() if hc == nil { hc = new(http.Client) if !isAPI { diff --git a/internal/testhelpers/selfservice_registration.go b/internal/testhelpers/selfservice_registration.go index 7a8399a81ddb..43eb8e056894 100644 --- a/internal/testhelpers/selfservice_registration.go +++ b/internal/testhelpers/selfservice_registration.go @@ -65,12 +65,12 @@ func InitializeRegistrationFlowViaBrowser(t *testing.T, client *http.Client, ts flowID = gjson.GetBytes(body, "id").String() } - rs, _, err := NewSDKCustomClient(ts, client).FrontendApi.GetRegistrationFlow(context.Background()).Id(flowID).Execute() + rs, res, err := NewSDKCustomClient(ts, client).FrontendApi.GetRegistrationFlow(context.Background()).Id(flowID).Execute() if expectGetError { require.Error(t, err) require.Nil(t, rs) } else { - require.NoError(t, err) + require.NoError(t, err, "%#v", string(ioutilx.MustReadAll(res.Body))) assert.Empty(t, rs.Active) } return rs @@ -119,6 +119,7 @@ func SubmitRegistrationForm( expectedURL string, opts ...InitFlowWithOption, ) string { + t.Helper() if hc == nil { hc = new(http.Client) } diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 8a6a03005280..21fbb8d6388e 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -18,9 +18,8 @@ import ( "testing" "time" - "github.com/ory/x/sqlxx" - "github.com/ory/x/snapshotx" + "github.com/ory/x/sqlxx" "github.com/ory/kratos/text" @@ -233,6 +232,12 @@ func TestStrategy(t *testing.T) { var newRegistrationFlowBrowser = func(t *testing.T, redirectTo string, exp time.Duration) *registration.Flow { return newRegistrationFlow(t, redirectTo, exp, flow.TypeBrowser) } + // _ = assertSystemError + // _ = asem + // _ = ai + // _ = newLoginFlowBrowser + // _ = registerAction + // _ = loginAction t.Run("case=should fail because provider does not exist", func(t *testing.T) { for k, v := range []string{ @@ -687,11 +692,14 @@ func TestStrategy(t *testing.T) { t.Run("case=webview", func(t *testing.T) { body := testhelpers.SubmitRegistrationForm(t, false, webViewHTTPClient, ts, values, - false, http.StatusFound, ts.URL+"/self-service/methods/oidc/callback/valid", + false, http.StatusOK, uiTS.URL, testhelpers.InitFlowWithReturnTo(webviewRedirectURI)) assert.Empty(t, gjson.Get(body, "session_token"), "%s", body) assert.Empty(t, gjson.Get(body, "session"), "%s", body) + assert.Len(t, gjson.Get(body, "ui.messages").Array(), 1, "%s", body) + assert.EqualValues(t, gjson.Get(body, "ui.messages.0.id").Int(), text.ErrorValidationDuplicateCredentialsOnOIDCLink, "%s", body) + assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "An account with the same identifier", "%s", body) }) }) @@ -705,11 +713,14 @@ func TestStrategy(t *testing.T) { t.Run("case=webview", func(t *testing.T) { body := testhelpers.SubmitLoginForm(t, false, webViewHTTPClient, ts, values, - false, false, http.StatusFound, ts.URL+"/self-service/methods/oidc/callback/valid", + false, false, http.StatusOK, uiTS.URL, testhelpers.InitFlowWithReturnTo(webviewRedirectURI)) assert.Empty(t, gjson.Get(body, "session_token"), "%s", body) assert.Empty(t, gjson.Get(body, "session"), "%s", body) + assert.Len(t, gjson.Get(body, "ui.messages").Array(), 1, "%s", body) + assert.EqualValues(t, gjson.Get(body, "ui.messages.0.id").Int(), text.ErrorValidationDuplicateCredentialsOnOIDCLink, "%s", body) + assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "An account with the same identifier", "%s", body) }) }) })