diff --git a/embedx/config.schema.json b/embedx/config.schema.json index df65ed3d1b67..a6b18489b7c3 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -571,7 +571,7 @@ } } }, - "selfServiceAfterPasswordLoginMethod": { + "selfServiceAfterDefaultLoginMethod": { "type": "object", "additionalProperties": false, "properties": { @@ -691,7 +691,10 @@ "$ref": "#/definitions/defaultReturnTo" }, "password": { - "$ref": "#/definitions/selfServiceAfterPasswordLoginMethod" + "$ref": "#/definitions/selfServiceAfterDefaultLoginMethod" + }, + "webauthn": { + "$ref": "#/definitions/selfServiceAfterDefaultLoginMethod" }, "oidc": { "$ref": "#/definitions/selfServiceAfterOIDCLoginMethod" diff --git a/internal/registrationhelpers/helpers.go b/internal/registrationhelpers/helpers.go index 2ab2e8a92e69..613edbeb0120 100644 --- a/internal/registrationhelpers/helpers.go +++ b/internal/registrationhelpers/helpers.go @@ -1,10 +1,22 @@ package registrationhelpers import ( + "bytes" "context" _ "embed" "encoding/json" "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + kratos "github.com/ory/kratos-client-go" "github.com/ory/kratos/driver" "github.com/ory/kratos/driver/config" @@ -16,15 +28,7 @@ import ( "github.com/ory/x/assertx" "github.com/ory/x/httpx" "github.com/ory/x/ioutilx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" + "github.com/ory/x/stringslice" ) func setupServer(t *testing.T, reg *driver.RegistryDefault) *httptest.Server { @@ -78,8 +82,166 @@ var basicSchema []byte //go:embed stub/multifield.schema.json var multifieldSchema []byte -func AssertRegistrationRespectsValidation(t *testing.T, flows []string, payload func(url.Values)) { - conf, reg := internal.NewFastRegistryWithMocks(t) +var skipIfNotEnabled = func(t *testing.T, flows []string, flow string) { + if !stringslice.Has(flows, flow) { + t.Skipf("Skipping for %s flow because it was not included in the list of flows to be executed.", flow) + } +} + +func AssertSchemDoesNotExist(t *testing.T, reg *driver.RegistryDefault, flows []string, payload func(v url.Values)) { + conf := reg.Config(context.Background()) + _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) + publicTS := setupServer(t, reg) + apiClient := testhelpers.NewDebugClient(t) + errTS := testhelpers.NewErrorTestServer(t, reg) + + reset := func() { + testhelpers.SetDefaultIdentitySchemaFromRaw(conf, basicSchema) + } + reset() + + t.Run("case=should fail because schema does not exist", func(t *testing.T) { + var check = func(t *testing.T, actual string) { + assert.Equal(t, int64(http.StatusInternalServerError), gjson.Get(actual, "code").Int(), "%s", actual) + assert.Equal(t, "Internal Server Error", gjson.Get(actual, "status").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "reason").String(), "no such file or directory", "%s", actual) + } + + values := url.Values{ + "traits.username": {testhelpers.RandomEmail()}, + "traits.foobar": {"bar"}, + "csrf_token": {x.FakeCSRFToken}, + } + payload(values) + + t.Run("type=api", func(t *testing.T) { + skipIfNotEnabled(t, flows, "api") + f := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, publicTS) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/i-do-not-exist.schema.json") + t.Cleanup(reset) + + body, res := testhelpers.RegistrationMakeRequest(t, false, false, f, apiClient, values.Encode()) + assert.Contains(t, res.Request.URL.String(), publicTS.URL) + check(t, gjson.Get(body, "error").Raw) + }) + + t.Run("type=spa", func(t *testing.T) { + skipIfNotEnabled(t, flows, "spa") + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, true) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/i-do-not-exist.schema.json") + t.Cleanup(reset) + + body, res := testhelpers.RegistrationMakeRequest(t, false, true, f, apiClient, values.Encode()) + assert.Contains(t, res.Request.URL.String(), publicTS.URL) + check(t, gjson.Get(body, "error").Raw) + }) + + t.Run("type=browser", func(t *testing.T) { + skipIfNotEnabled(t, flows, "browser") + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, false) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/i-do-not-exist.schema.json") + t.Cleanup(reset) + + body, res := testhelpers.RegistrationMakeRequest(t, false, false, f, apiClient, values.Encode()) + assert.Contains(t, res.Request.URL.String(), errTS.URL) + check(t, body) + }) + }) +} + +func AssertCSRFFailures(t *testing.T, reg *driver.RegistryDefault, flows []string, payload func(v url.Values)) { + conf := reg.Config(context.Background()) + testhelpers.SetDefaultIdentitySchemaFromRaw(conf, multifieldSchema) + _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) + publicTS := setupServer(t, reg) + apiClient := testhelpers.NewDebugClient(t) + _ = testhelpers.NewErrorTestServer(t, reg) + + var values = url.Values{ + "csrf_token": {"invalid_token"}, + "traits.username": {testhelpers.RandomEmail()}, + "traits.foobar": {"bar"}, + } + + payload(values) + + t.Run("case=should fail because of missing CSRF token/type=browser", func(t *testing.T) { + skipIfNotEnabled(t, flows, "browser") + + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, false) + + actual, res := testhelpers.RegistrationMakeRequest(t, false, false, f, browserClient, values.Encode()) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assertx.EqualAsJSON(t, x.ErrInvalidCSRFToken, + json.RawMessage(actual), "%s", actual) + }) + + t.Run("case=should fail because of missing CSRF token/type=spa", func(t *testing.T) { + skipIfNotEnabled(t, flows, "spa") + + browserClient := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, browserClient, publicTS, true) + + actual, res := testhelpers.RegistrationMakeRequest(t, false, true, f, browserClient, values.Encode()) + assert.EqualValues(t, http.StatusForbidden, res.StatusCode) + assertx.EqualAsJSON(t, x.ErrInvalidCSRFToken, + json.RawMessage(gjson.Get(actual, "error").Raw), "%s", actual) + }) + + t.Run("case=should pass even without CSRF token/type=api", func(t *testing.T) { + skipIfNotEnabled(t, flows, "api") + + f := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, publicTS) + + actual, res := testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values)) + assert.EqualValues(t, http.StatusOK, res.StatusCode) + assert.NotEmpty(t, gjson.Get(actual, "identity.id").Raw, "%s", actual) // registration successful + }) + + t.Run("case=should fail with correct CSRF error cause/type=api", func(t *testing.T) { + skipIfNotEnabled(t, flows, "api") + + for k, tc := range []struct { + mod func(http.Header) + exp string + }{ + { + mod: func(h http.Header) { + h.Add("Cookie", "name=bar") + }, + exp: "The HTTP Request Header included the \\\"Cookie\\\" key", + }, + { + mod: func(h http.Header) { + h.Add("Origin", "www.bar.com") + }, + exp: "The HTTP Request Header included the \\\"Origin\\\" key", + }, + } { + t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) { + f := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, publicTS) + c := f.Ui + + req := testhelpers.NewRequest(t, true, "POST", c.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values))) + tc.mod(req.Header) + + res, err := apiClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + + actual := string(ioutilx.MustReadAll(res.Body)) + assert.EqualValues(t, http.StatusBadRequest, res.StatusCode) + assert.Contains(t, actual, tc.exp) + }) + } + }) +} + +func AssertRegistrationRespectsValidation(t *testing.T, reg *driver.RegistryDefault, flows []string, payload func(url.Values)) { + conf := reg.Config(context.Background()) testhelpers.SetDefaultIdentitySchemaFromRaw(conf, multifieldSchema) _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) publicTS := setupServer(t, reg) @@ -108,7 +270,7 @@ func AssertRegistrationRespectsValidation(t *testing.T, flows []string, payload }) } -func AssertCommonErrorCases(t *testing.T, flows []string) { +func AssertCommonErrorCases(t *testing.T, reg *driver.RegistryDefault, flows []string) { conf, reg := internal.NewFastRegistryWithMocks(t) testhelpers.SetDefaultIdentitySchemaFromRaw(conf, basicSchema) uiTS := testhelpers.NewRegistrationUIFlowEchoServer(t, reg) diff --git a/selfservice/flow/registration/decoder.go b/selfservice/flow/registration/decoder.go new file mode 100644 index 000000000000..fbe201dd4c85 --- /dev/null +++ b/selfservice/flow/registration/decoder.go @@ -0,0 +1,30 @@ +package registration + +import ( + "net/http" + + "github.com/pkg/errors" + "github.com/tidwall/sjson" + + "github.com/ory/kratos/driver/config" + "github.com/ory/x/decoderx" +) + +func DecodeBody(p interface{}, r *http.Request, dec *decoderx.HTTP, conf *config.Config, schema []byte) error { + ds, err := conf.DefaultIdentityTraitsSchemaURL() + if err != nil { + return err + } + raw, err := sjson.SetBytes(schema, + "properties.traits.$ref", ds.String()+"#/properties/traits") + if err != nil { + return errors.WithStack(err) + } + + compiler, err := decoderx.HTTPRawJSONSchemaCompiler(raw) + if err != nil { + return errors.WithStack(err) + } + + return dec.Decode(r, p, compiler, decoderx.HTTPDecoderSetValidatePayloads(true), decoderx.HTTPDecoderJSONFollowsFormFormat()) +} diff --git a/selfservice/strategy/webauthn/.schema/registration.schema.json b/selfservice/strategy/webauthn/.schema/registration.schema.json index 74ec9c25b9f3..68624249fc7a 100644 --- a/selfservice/strategy/webauthn/.schema/registration.schema.json +++ b/selfservice/strategy/webauthn/.schema/registration.schema.json @@ -26,6 +26,9 @@ ] }, { + "method": { + "const": "webauthn" + }, "required": [ "identifier", "method" diff --git a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_does_not_exist_when_passwordless_is_disabled-browser.json b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_does_not_exist_when_passwordless_is_disabled-browser.json new file mode 100644 index 000000000000..39afd9d8e8eb --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_does_not_exist_when_passwordless_is_disabled-browser.json @@ -0,0 +1,80 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "traits.foobar", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "password", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "password", + "node_type": "input", + "required": true, + "type": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "traits.username", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "password", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "context": {}, + "id": 1040001, + "text": "Sign up", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_does_not_exist_when_passwordless_is_disabled-spa.json b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_does_not_exist_when_passwordless_is_disabled-spa.json new file mode 100644 index 000000000000..39afd9d8e8eb --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_does_not_exist_when_passwordless_is_disabled-spa.json @@ -0,0 +1,80 @@ +[ + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "traits.foobar", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "password", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "password", + "node_type": "input", + "required": true, + "type": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "traits.username", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "password", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "context": {}, + "id": 1040001, + "text": "Sign up", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-browser.json b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-browser.json new file mode 100644 index 000000000000..1f5d2e17af95 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-browser.json @@ -0,0 +1,146 @@ +[ + { + "attributes": { + "disabled": false, + "name": "traits.foobar", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "traits.username", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_register_displayname", + "node_type": "input", + "type": "text", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1050013, + "text": "Name of the security key", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_register", + "node_type": "input", + "type": "hidden", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_register_trigger", + "node_type": "input", + "type": "button", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1040004, + "text": "Sign up with security key", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "async": true, + "crossorigin": "anonymous", + "id": "webauthn_script", + "integrity": "sha512-E3ctShTQEYTkfWrjztRCbP77lN7L0jJC2IOd6j8vqUKslvqhX/Ho3QxlQJIeTI78krzAWUQlDXd9JQ0PZlKhzQ==", + "node_type": "script", + "referrerpolicy": "no-referrer", + "type": "text/javascript" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "password", + "node_type": "input", + "required": true, + "type": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "context": {}, + "id": 1040001, + "text": "Sign up", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-spa.json b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-spa.json new file mode 100644 index 000000000000..1f5d2e17af95 --- /dev/null +++ b/selfservice/strategy/webauthn/.snapshots/TestRegistration-case=webauthn_button_exists-spa.json @@ -0,0 +1,146 @@ +[ + { + "attributes": { + "disabled": false, + "name": "traits.foobar", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "traits.username", + "node_type": "input", + "required": true, + "type": "text" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "csrf_token", + "node_type": "input", + "required": true, + "type": "hidden" + }, + "group": "default", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_register_displayname", + "node_type": "input", + "type": "text", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1050013, + "text": "Name of the security key", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_register", + "node_type": "input", + "type": "hidden", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "webauthn_register_trigger", + "node_type": "input", + "type": "button", + "value": "" + }, + "group": "webauthn", + "messages": [], + "meta": { + "label": { + "id": 1040004, + "text": "Sign up with security key", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "async": true, + "crossorigin": "anonymous", + "id": "webauthn_script", + "integrity": "sha512-E3ctShTQEYTkfWrjztRCbP77lN7L0jJC2IOd6j8vqUKslvqhX/Ho3QxlQJIeTI78krzAWUQlDXd9JQ0PZlKhzQ==", + "node_type": "script", + "referrerpolicy": "no-referrer", + "type": "text/javascript" + }, + "group": "webauthn", + "messages": [], + "meta": {}, + "type": "script" + }, + { + "attributes": { + "disabled": false, + "name": "password", + "node_type": "input", + "required": true, + "type": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "id": 1070001, + "text": "Password", + "type": "info" + } + }, + "type": "input" + }, + { + "attributes": { + "disabled": false, + "name": "method", + "node_type": "input", + "type": "submit", + "value": "password" + }, + "group": "password", + "messages": [], + "meta": { + "label": { + "context": {}, + "id": 1040001, + "text": "Sign up", + "type": "info" + } + }, + "type": "input" + } +] diff --git a/selfservice/strategy/webauthn/credentials.go b/selfservice/strategy/webauthn/credentials.go index 85e4f3086bce..960e328918d1 100644 --- a/selfservice/strategy/webauthn/credentials.go +++ b/selfservice/strategy/webauthn/credentials.go @@ -1,9 +1,10 @@ package webauthn import ( - "github.com/ory/kratos/identity" "time" + "github.com/ory/kratos/identity" + "github.com/duo-labs/webauthn/webauthn" ) diff --git a/selfservice/strategy/webauthn/credentials_test.go b/selfservice/strategy/webauthn/credentials_test.go index 8e1efe5c9f01..0697c5269546 100644 --- a/selfservice/strategy/webauthn/credentials_test.go +++ b/selfservice/strategy/webauthn/credentials_test.go @@ -1,9 +1,10 @@ package webauthn import ( - "github.com/ory/kratos/identity" "testing" + "github.com/ory/kratos/identity" + "github.com/duo-labs/webauthn/webauthn" "github.com/stretchr/testify/assert" ) diff --git a/selfservice/strategy/webauthn/errors.go b/selfservice/strategy/webauthn/errors.go index 14a5d49b1fc0..ef1819ed40ac 100644 --- a/selfservice/strategy/webauthn/errors.go +++ b/selfservice/strategy/webauthn/errors.go @@ -1,8 +1,9 @@ package webauthn import ( - "github.com/ory/jsonschema/v3" "github.com/pkg/errors" + + "github.com/ory/jsonschema/v3" ) var ErrNotEnoughCredentials = &jsonschema.ValidationError{ diff --git a/selfservice/strategy/webauthn/fixtures/registration/success/identity.json b/selfservice/strategy/webauthn/fixtures/registration/success/identity.json new file mode 100644 index 000000000000..5aa6d11cae1c --- /dev/null +++ b/selfservice/strategy/webauthn/fixtures/registration/success/identity.json @@ -0,0 +1,14 @@ +{ + "id": "6e11a9a7-62fd-4c88-871a-097f18f0306f", + "schema_id": "default", + "schema_url": "http://localhost:4455/schemas/default", + "state": "active", + "state_changed_at": "2021-08-17T11:15:59.232051+02:00", + "traits": { + "email": "foo@bar.com", + "website": "https://www.ory.sh" + }, + "created_at": "2021-08-17T11:15:59.232288+02:00", + "updated_at": "2021-08-17T11:15:59.232288+02:00" +} + diff --git a/selfservice/strategy/webauthn/fixtures/registration/success/internal_context.json b/selfservice/strategy/webauthn/fixtures/registration/success/internal_context.json new file mode 100644 index 000000000000..b986cdab0dee --- /dev/null +++ b/selfservice/strategy/webauthn/fixtures/registration/success/internal_context.json @@ -0,0 +1,8 @@ +{ + "totp_url": "otpauth://totp/issuer.ory.sh:6e11a9a7-62fd-4c88-871a-097f18f0306f?algorithm=SHA1&digits=6&issuer=issuer.ory.sh&period=30&secret=2F43HRJNMUW67EDMRR7AKQYRZP3AI6IG", + "webauthn_session_data": { + "challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY", + "user_id": "bhGpp2L9TIiHGgl/GPAwbw==", + "userVerification": "" + } +} diff --git a/selfservice/strategy/webauthn/fixtures/registration/success/response.json b/selfservice/strategy/webauthn/fixtures/registration/success/response.json new file mode 100644 index 000000000000..7d2b3819927d --- /dev/null +++ b/selfservice/strategy/webauthn/fixtures/registration/success/response.json @@ -0,0 +1,9 @@ +{ + "id": "L1yOrxHy5Lq72lAPahaWdl0q9gsXRzV2BJ4xJmkTVH_8uuVKU-FVbJlVRwYGzPNc1IjCWUYAK0H0YSpd5hz-Pg", + "rawId": "L1yOrxHy5Lq72lAPahaWdl0q9gsXRzV2BJ4xJmkTVH_8uuVKU-FVbJlVRwYGzPNc1IjCWUYAK0H0YSpd5hz-Pg", + "type": "public-key", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAABAAAAAAAAAAAAAAAAAAAAAAAQC9cjq8R8uS6u9pQD2oWlnZdKvYLF0c1dgSeMSZpE1R__LrlSlPhVWyZVUcGBszzXNSIwllGACtB9GEqXeYc_j6lAQIDJiABIVggFFzdor6hBMgrpYLCds8Uu2JtPaaaxKU6LEAUT6QRZ5UiWCA24TI4vED6rrTUjykchoAln67u5GT1nwmzjvrk79HhlQ", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVWx4SFNUa3VNdnRWRG9WOXk1bGh1OU95TlVQOFA3TVAwUllBVDZJbV9yWSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDQ1NSIsImNyb3NzT3JpZ2luIjpmYWxzZX0" + } +} diff --git a/selfservice/strategy/webauthn/fixtures/settings/has_webauth.json b/selfservice/strategy/webauthn/fixtures/settings/has_webauth.json deleted file mode 100644 index 9f039ec76548..000000000000 --- a/selfservice/strategy/webauthn/fixtures/settings/has_webauth.json +++ /dev/null @@ -1,109 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "required": true, - "type": "hidden", - "value": "aWJlY3F1bHp1aXN2YnFvY2NzdHpjNnJ0YnkxNnI2Mzk=" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_remove", - "type": "submit", - "value": "626172626172" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "context": { - "added_at": "0001-01-01T00:00:00Z", - "display_name": "bar" - }, - "id": 1050012, - "text": "Remove security key \"bar\"", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_remove", - "type": "submit", - "value": "666f6f666f6f" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "context": { - "added_at": "0001-01-01T00:00:00Z", - "display_name": "foo" - }, - "id": 1050012, - "text": "Remove security key \"foo\"", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_register_displayname", - "type": "text", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1050013, - "text": "Name of the security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_register_trigger", - "onclick": "window.__oryWebAuthnRegistration({\"publicKey\":{\"challenge\":\"h7BkjEGXvBnOPDrDsBUiSRB90QamqOtWbprYhcaBwro=\",\"rp\":{\"name\":\"Ory Corp\",\"id\":\"localhost\"},\"user\":{\"name\":\"placeholder\",\"icon\":\"https://via.placeholder.com/128\",\"displayName\":\"placeholder\",\"id\":\"uJTun1EFRNurSuKVCLV9ZA==\"},\"pubKeyCredParams\":[{\"type\":\"public-key\",\"alg\":-7},{\"type\":\"public-key\",\"alg\":-35},{\"type\":\"public-key\",\"alg\":-36},{\"type\":\"public-key\",\"alg\":-257},{\"type\":\"public-key\",\"alg\":-258},{\"type\":\"public-key\",\"alg\":-259},{\"type\":\"public-key\",\"alg\":-37},{\"type\":\"public-key\",\"alg\":-38},{\"type\":\"public-key\",\"alg\":-39},{\"type\":\"public-key\",\"alg\":-8}],\"authenticatorSelection\":{\"requireResidentKey\":false,\"userVerification\":\"preferred\"},\"timeout\":60000}})", - "onload": "if (\n (window \u0026\u0026 window.__oryWebAuthnLogin \u0026\u0026 window.__oryWebAuthnRegistration) ||\n (!window \u0026\u0026 __oryWebAuthnLogin \u0026\u0026 __oryWebAuthnRegistration)\n) {\n // Already registered these functions, do nothing.\n} else {\n function __oryWebAuthnBufferDecode(value) {\n return Uint8Array.from(atob(value), function (c) {\n return c.charCodeAt(0)\n });\n }\n\n function __oryWebAuthnBufferEncode(value) {\n return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=/g, '');\n }\n\n function __oryWebAuthnLogin(opt, resultQuerySelector = '*[name=\"webauthn_login\"]', triggerQuerySelector = '*[name=\"webauthn_login_trigger\"]') {\n if (!window.PublicKeyCredential) {\n alert('This browser does not support WebAuthn!');\n }\n\n opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge);\n opt.publicKey.allowCredentials = opt.publicKey.allowCredentials.map(function (value) {\n return {\n ...value,\n id: __oryWebAuthnBufferDecode(value.id)\n }\n });\n\n navigator.credentials.get(opt).then(function (credential) {\n document.querySelector(resultQuerySelector).value = JSON.stringify({\n id: credential.id,\n rawId: __oryWebAuthnBufferEncode(credential.rawId),\n type: credential.type,\n response: {\n authenticatorData: __oryWebAuthnBufferEncode(credential.response.authenticatorData),\n clientDataJSON: __oryWebAuthnBufferEncode(credential.response.clientDataJSON),\n signature: __oryWebAuthnBufferEncode(credential.response.signature),\n userHandle: __oryWebAuthnBufferEncode(credential.response.userHandle),\n },\n })\n\n document.querySelector(triggerQuerySelector).closest('form').submit()\n }).catch((err) =\u003e {\n alert(err)\n })\n }\n\n function __oryWebAuthnRegistration(opt, resultQuerySelector = '*[name=\"webauthn_register\"]', triggerQuerySelector = '*[name=\"webauthn_register_trigger\"]') {\n if (!window.PublicKeyCredential) {\n alert('This browser does not support WebAuthn!');\n }\n\n opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id);\n opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge);\n\n if (opt.publicKey.excludeCredentials) {\n opt.publicKey.excludeCredentials = opt.publicKey.excludeCredentials.map(function (value) {\n return {\n ...value,\n id: __oryWebAuthnBufferDecode(value.id)\n }\n })\n }\n\n navigator.credentials.create(opt).then(function (credential) {\n document.querySelector(resultQuerySelector).value = JSON.stringify({\n id: credential.id,\n rawId: __oryWebAuthnBufferEncode(credential.rawId),\n type: credential.type,\n response: {\n attestationObject: __oryWebAuthnBufferEncode(credential.response.attestationObject),\n clientDataJSON: __oryWebAuthnBufferEncode(credential.response.clientDataJSON),\n },\n })\n\n document.querySelector(triggerQuerySelector).closest('form').submit()\n }).catch((err) =\u003e {\n alert(err)\n })\n }\n\n if (window) {\n window.__oryWebAuthnLogin = __oryWebAuthnLogin\n window.__oryWebAuthnRegistration = __oryWebAuthnRegistration\n }\n}\n", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1050012, - "text": "Add security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_register", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - } -] diff --git a/selfservice/strategy/webauthn/fixtures/settings/no_webauth.json b/selfservice/strategy/webauthn/fixtures/settings/no_webauth.json deleted file mode 100644 index e4f99e44ae28..000000000000 --- a/selfservice/strategy/webauthn/fixtures/settings/no_webauth.json +++ /dev/null @@ -1,64 +0,0 @@ -[ - { - "attributes": { - "disabled": false, - "name": "csrf_token", - "required": true, - "type": "hidden", - "value": "Yms2cmo1NTA2OWFub3F3dHI2NHcxNnd2MTNsNzhocTk=" - }, - "group": "default", - "messages": [], - "meta": {}, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_register_displayname", - "type": "text", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1050013, - "text": "Name of the security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_register_trigger", - "onclick": "// noinspection JSAnnotator\nif (!window.PublicKeyCredential) {\n alert('This browser does not support WebAuthn!');\n} else {\n function bufferDecode(value) {\n return Uint8Array.from(atob(value), c =\u003e c.charCodeAt(0));\n }\n\n function bufferEncode(value) {\n return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=/g, '');\n }\n\n const opt = {\"publicKey\":{\"challenge\":\"jL11UA6hT+HxhilPeOinWmaYVUj3CbW0BWFecAOtXfI=\",\"rp\":{\"name\":\"Ory Corp\",\"id\":\"localhost\"},\"user\":{\"name\":\"placeholder\",\"icon\":\"https://via.placeholder.com/128\",\"displayName\":\"placeholder\",\"id\":\"yUL/larKSTabHb6K6Lk9ug==\"},\"pubKeyCredParams\":[{\"type\":\"public-key\",\"alg\":-7},{\"type\":\"public-key\",\"alg\":-35},{\"type\":\"public-key\",\"alg\":-36},{\"type\":\"public-key\",\"alg\":-257},{\"type\":\"public-key\",\"alg\":-258},{\"type\":\"public-key\",\"alg\":-259},{\"type\":\"public-key\",\"alg\":-37},{\"type\":\"public-key\",\"alg\":-38},{\"type\":\"public-key\",\"alg\":-39},{\"type\":\"public-key\",\"alg\":-8}],\"authenticatorSelection\":{\"requireResidentKey\":false,\"userVerification\":\"preferred\"},\"timeout\":60000}}\n opt.publicKey.user.id = bufferDecode(opt.publicKey.user.id);\n opt.publicKey.challenge = bufferDecode(opt.publicKey.challenge);\n\n if (opt.publicKey.excludeCredentials) {\n opt.publicKey.excludeCredentials = opt.publicKey.excludeCredentials.map(function (value) {\n return {\n ...value,\n id: bufferDecode(value.id)\n }\n })\n }\n\n navigator.credentials.create(opt).then(function (credential) {\n document.querySelector('*[name=\"webauthn_register\"]').value = JSON.stringify({\n id: credential.id,\n rawId: bufferEncode(credential.rawId),\n type: credential.type,\n response: {\n attestationObject: bufferEncode(credential.response.attestationObject),\n clientDataJSON: bufferEncode(credential.response.clientDataJSON),\n },\n })\n\n document.querySelector('*[name=\"webauthn_register_trigger\"]').closest('form').submit()\n }).catch((err) =\u003e {\n alert(err)\n })\n}\n", - "type": "button", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": { - "label": { - "id": 1050012, - "text": "Add security key", - "type": "info" - } - }, - "type": "input" - }, - { - "attributes": { - "disabled": false, - "name": "webauthn_register", - "type": "hidden", - "value": "" - }, - "group": "webauthn", - "messages": [], - "meta": {}, - "type": "input" - } -] diff --git a/selfservice/strategy/webauthn/login.go b/selfservice/strategy/webauthn/login.go index 96c2fb082d31..8403d76720d6 100644 --- a/selfservice/strategy/webauthn/login.go +++ b/selfservice/strategy/webauthn/login.go @@ -2,11 +2,12 @@ package webauthn import ( "encoding/json" - "github.com/ory/kratos/selfservice/flowhelpers" "net/http" "strings" "time" + "github.com/ory/kratos/selfservice/flowhelpers" + "github.com/gofrs/uuid" "github.com/ory/kratos/text" diff --git a/selfservice/strategy/webauthn/login_test.go b/selfservice/strategy/webauthn/login_test.go index 74b6eb4bc6b8..0b79b30d0b06 100644 --- a/selfservice/strategy/webauthn/login_test.go +++ b/selfservice/strategy/webauthn/login_test.go @@ -4,13 +4,15 @@ import ( "context" _ "embed" "encoding/json" + "net/http" + "net/url" + "testing" + "github.com/duo-labs/webauthn/protocol" + kratos "github.com/ory/kratos-client-go" "github.com/ory/kratos/text" "github.com/ory/x/snapshotx" - "net/http" - "net/url" - "testing" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/strategy/webauthn" diff --git a/selfservice/strategy/webauthn/registration.go b/selfservice/strategy/webauthn/registration.go new file mode 100644 index 000000000000..47860e991576 --- /dev/null +++ b/selfservice/strategy/webauthn/registration.go @@ -0,0 +1,217 @@ +package webauthn + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/duo-labs/webauthn/protocol" + "github.com/duo-labs/webauthn/webauthn" + "github.com/pkg/errors" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "github.com/ory/herodot" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/text" + "github.com/ory/kratos/ui/container" + "github.com/ory/kratos/ui/node" + "github.com/ory/kratos/x" + "github.com/ory/x/urlx" +) + +// swagger:model submitSelfServiceRegistrationFlowWithWebAuthnMethodBody +type submitSelfServiceRegistrationFlowWithWebAuthnMethodBody struct { + // Register a WebAuthn Security Key + // + // It is expected that the JSON returned by the WebAuthn registration process + // is included here. + Register string `json:"webauthn_register"` + + // Name of the WebAuthn Security Key to be Added + // + // A human-readable name for the security key which will be added. + RegisterDisplayName string `json:"webauthn_register_displayname"` + + // CSRFToken is the anti-CSRF token + CSRFToken string `json:"csrf_token"` + + // The identity's traits + // + // required: true + Traits json.RawMessage `json:"traits"` + + // Method + // + // Should be set to "webauthn" when trying to add, update, or remove a webAuthn pairing. + // + // required: true + Method string `json:"method"` + + // Flow is flow ID. + // + // swagger:ignore + Flow string `json:"flow"` +} + +func (s *Strategy) RegisterRegistrationRoutes(_ *x.RouterPublic) { +} + +func (s *Strategy) handleRegistrationError(_ http.ResponseWriter, r *http.Request, f *registration.Flow, p *submitSelfServiceRegistrationFlowWithWebAuthnMethodBody, err error) error { + if f != nil { + if p != nil { + for _, n := range container.NewFromJSON("", node.DefaultGroup, p.Traits, "traits").Nodes { + // we only set the value and not the whole field because we want to keep types from the initial form generation + f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue()) + } + } + + f.UI.Nodes.SetValueAttribute(node.WebAuthnRegisterDisplayName, p.RegisterDisplayName) + if f.Type == flow.TypeBrowser { + f.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + } + } + + return err +} + +func (s *Strategy) decode(p *submitSelfServiceRegistrationFlowWithWebAuthnMethodBody, r *http.Request) error { + return registration.DecodeBody(p, r, s.hd, s.d.Config(r.Context()), registrationSchema) +} + +func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, f *registration.Flow, i *identity.Identity) (err error) { + if f.Type != flow.TypeBrowser || !s.d.Config(r.Context()).WebAuthnForPasswordless() { + return flow.ErrStrategyNotResponsible + } + + var p submitSelfServiceRegistrationFlowWithWebAuthnMethodBody + if err := s.decode(&p, r); err != nil { + return s.handleRegistrationError(w, r, f, &p, err) + } + + if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config(r.Context()).DisableAPIFlowEnforcement(), s.d.GenerateCSRFToken, p.CSRFToken); err != nil { + return s.handleRegistrationError(w, r, f, &p, err) + } + + if len(p.Register) == 0 { + return flow.ErrStrategyNotResponsible + } + + p.Method = s.SettingsStrategyID() + if err := flow.MethodEnabledAndAllowed(r.Context(), s.SettingsStrategyID(), p.Method, s.d); err != nil { + return s.handleRegistrationError(w, r, f, &p, err) + } + + if len(p.Traits) == 0 { + p.Traits = json.RawMessage("{}") + } + i.Traits = identity.Traits(p.Traits) + + webAuthnSession := gjson.GetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData)) + if !webAuthnSession.IsObject() { + return s.handleRegistrationError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Expected WebAuthN in internal context to be an object."))) + } + + var webAuthnSess webauthn.SessionData + if err := json.Unmarshal([]byte(gjson.GetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData)).Raw), &webAuthnSess); err != nil { + return s.handleRegistrationError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Expected WebAuthN in internal context to be an object but got: %s", err))) + } + + webAuthnResponse, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(p.Register)) + if err != nil { + return s.handleRegistrationError(w, r, f, &p, errors.WithStack(herodot.ErrBadRequest.WithReasonf("Unable to parse WebAuthn response: %s", err))) + } + + web, err := s.newWebAuthn(r.Context()) + if err != nil { + return s.handleRegistrationError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to get webAuthn config.").WithDebug(err.Error()))) + } + + credential, err := web.CreateCredential(&wrappedUser{id: webAuthnSess.UserID}, webAuthnSess, webAuthnResponse) + if err != nil { + return s.handleRegistrationError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to create WebAuthn credential: %s", err))) + } + + var cc CredentialsConfig + wc := CredentialFromWebAuthn(credential, true) + wc.AddedAt = time.Now().UTC().Round(time.Second) + wc.DisplayName = p.RegisterDisplayName + wc.IsPasswordless = s.d.Config(r.Context()).WebAuthnForPasswordless() + cc.UserHandle = webAuthnSess.UserID + + cc.Credentials = append(cc.Credentials, *wc) + co, err := json.Marshal(cc) + if err != nil { + return s.handleRegistrationError(w, r, f, &p, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to encode identity credentials.").WithDebug(err.Error()))) + } + + i.UpsertCredentialsConfig(s.ID(), co) + if err := s.validateCredentials(r.Context(), i); err != nil { + return s.handleRegistrationError(w, r, f, &p, err) + } + + // Remove the WebAuthn URL from the internal context now that it is set! + f.InternalContext, err = sjson.DeleteBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData)) + if err != nil { + return s.handleRegistrationError(w, r, f, &p, err) + } + + if err := s.d.RegistrationFlowPersister().UpdateRegistrationFlow(r.Context(), f); err != nil { + return s.handleRegistrationError(w, r, f, &p, err) + } + + return nil +} + +func (s *Strategy) PopulateRegistrationMethod(r *http.Request, f *registration.Flow) error { + if f.Type != flow.TypeBrowser || !s.d.Config(r.Context()).WebAuthnForPasswordless() { + return nil + } + + ds, err := s.d.Config(r.Context()).DefaultIdentityTraitsSchemaURL() + if err != nil { + return err + } + + nodes, err := container.NodesFromJSONSchema(r.Context(), node.DefaultGroup, ds.String(), "", nil) + if err != nil { + return err + } + + for _, n := range nodes { + f.UI.SetNode(n) + } + + web, err := s.newWebAuthn(r.Context()) + if err != nil { + return err + } + + webauthID := x.NewUUID() + option, sessionData, err := web.BeginRegistration(&wrappedUser{id: webauthID[:]}) + if err != nil { + return errors.WithStack(err) + } + + f.InternalContext, err = sjson.SetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData), sessionData) + if err != nil { + return errors.WithStack(err) + } + + injectWebAuthnOptions, err := json.Marshal(option) + if err != nil { + return errors.WithStack(err) + } + + f.UI.Nodes.Upsert(NewWebAuthnScript(urlx.AppendPaths(s.d.Config(r.Context()).SelfPublicURL(), webAuthnRoute).String(), jsOnLoad)) + f.UI.Nodes.Upsert(NewWebAuthnConnectionName()) + f.UI.Nodes.Upsert(NewWebAuthnConnectionInput()) + f.UI.Nodes.Upsert(NewWebAuthnConnectionTrigger(string(injectWebAuthnOptions)). + WithMetaLabel(text.NewInfoSelfServiceRegistrationRegisterWebAuthn())) + + f.UI.SetCSRF(s.d.GenerateCSRFToken(r)) + return nil +} diff --git a/selfservice/strategy/webauthn/registration_test.go b/selfservice/strategy/webauthn/registration_test.go new file mode 100644 index 000000000000..b077e834531a --- /dev/null +++ b/selfservice/strategy/webauthn/registration_test.go @@ -0,0 +1,373 @@ +package webauthn_test + +import ( + "context" + _ "embed" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + kratos "github.com/ory/kratos-client-go" + "github.com/ory/kratos/driver" + "github.com/ory/kratos/driver/config" + "github.com/ory/kratos/identity" + "github.com/ory/kratos/internal" + "github.com/ory/kratos/internal/registrationhelpers" + "github.com/ory/kratos/internal/testhelpers" + "github.com/ory/kratos/selfservice/flow" + "github.com/ory/kratos/selfservice/flow/registration" + "github.com/ory/kratos/selfservice/strategy/webauthn" + "github.com/ory/kratos/text" + "github.com/ory/kratos/ui/node" + "github.com/ory/kratos/x" + "github.com/ory/x/assertx" +) + +var ( + flows = []string{"spa", "browser"} + //go:embed fixtures/registration/success/identity.json + registrationFixtureSuccessIdentity []byte + //go:embed fixtures/registration/success/response.json + registrationFixtureSuccessResponse []byte + //go:embed fixtures/registration/success/internal_context.json + registrationFixtureSuccessInternalContext []byte +) + +func flowToIsSPA(flow string) bool { + return flow == "spa" +} + +func newRegistrationRegistry(t *testing.T) *driver.RegistryDefault { + conf, reg := internal.NewFastRegistryWithMocks(t) + conf.MustSet(config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePassword)+".enabled", true) + enableWebAuthn(conf) + conf.MustSet(config.ViperKeyWebAuthnPasswordless, true) + return reg +} + +func TestRegistration(t *testing.T) { + reg := newRegistrationRegistry(t) + conf := reg.Config(context.Background()) + + router := x.NewRouterPublic() + publicTS, _ := testhelpers.NewKratosServerWithRouters(t, reg, router, x.NewRouterAdmin()) + + _ = testhelpers.NewErrorTestServer(t, reg) + _ = testhelpers.NewRegistrationUIFlowEchoServer(t, reg) + _ = testhelpers.NewRedirSessionEchoTS(t, reg) + + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") + conf.MustSet(config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}) + + redirTS := testhelpers.NewRedirSessionEchoTS(t, reg) + redirNoSessionTS := testhelpers.NewRedirNoSessionTS(t, reg) + + // set the "return to" server, which will assert the session state + // (redirTS: enforce that a session exists, redirNoSessionTS: enforce that no session exists) + var useReturnToFromTS = func(ts *httptest.Server) { + conf.MustSet(config.ViperKeySelfServiceBrowserDefaultReturnTo, ts.URL+"/default-return-to") + conf.MustSet(config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, ts.URL+"/registration-return-ts") + } + useReturnToFromTS(redirTS) + + //checkURL := func(t *testing.T, shouldRedirect bool, res *http.Response) { + // if shouldRedirect { + // assert.Contains(t, res.Request.URL.String(), uiTS.URL+"/registration-ts") + // } else { + // assert.Contains(t, res.Request.URL.String(), publicTS.URL+registration.RouteSubmitFlow) + // } + //} + + t.Run("AssertCommonErrorCases", func(t *testing.T) { + reg := newRegistrationRegistry(t) + registrationhelpers.AssertCommonErrorCases(t, reg, flows) + }) + + t.Run("AssertRegistrationRespectsValidation", func(t *testing.T) { + reg := newRegistrationRegistry(t) + registrationhelpers.AssertRegistrationRespectsValidation(t, reg, flows, func(v url.Values) { + v.Del("traits.foobar") + v.Set(node.WebAuthnRegister, "{}") + v.Del("method") + }) + }) + + t.Run("AssertCSRFFailures", func(t *testing.T) { + reg := newRegistrationRegistry(t) + registrationhelpers.AssertCSRFFailures(t, reg, flows, func(v url.Values) { + v.Set(node.WebAuthnRegister, "{}") + v.Del("method") + }) + }) + + t.Run("AssertSchemDoesNotExist", func(t *testing.T) { + reg := newRegistrationRegistry(t) + registrationhelpers.AssertSchemDoesNotExist(t, reg, flows, func(v url.Values) { + v.Set(node.WebAuthnRegister, "{}") + v.Del("method") + }) + }) + + t.Run("case=webauthn button does not exist when passwordless is disabled", func(t *testing.T) { + conf.MustSet(config.ViperKeyWebAuthnPasswordless, false) + t.Cleanup(func() { + conf.MustSet(config.ViperKeyWebAuthnPasswordless, true) + }) + for _, f := range flows { + t.Run(f, func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, publicTS, flowToIsSPA(f)) + testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ + "0.attributes.value", + }) + }) + } + }) + + t.Run("case=webauthn button exists", func(t *testing.T) { + for _, f := range flows { + t.Run(f, func(t *testing.T) { + client := testhelpers.NewClientWithCookies(t) + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, publicTS, flowToIsSPA(f)) + testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{ + "2.attributes.value", + "5.attributes.onclick", + "6.attributes.nonce", + "6.attributes.src", + }) + }) + } + }) + + t.Run("case=should return an error because not passing validation", func(t *testing.T) { + email := testhelpers.RandomEmail() + + var values = func(v url.Values) { + v.Set("traits.username", email) + v.Del("traits.foobar") + v.Set(node.WebAuthnRegister, "{}") + v.Del("method") + } + + for _, f := range flows { + t.Run("type="+f, func(t *testing.T) { + actual := registrationhelpers.ExpectValidationError(t, publicTS, conf, f, values) + + assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual) + registrationhelpers.CheckFormContent(t, []byte(actual), node.WebAuthnRegisterTrigger, "csrf_token", "traits.username", "traits.foobar") + assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual) + assert.Equal(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual) + }) + } + }) + + t.Run("case=should return an error because webauthn response is invalid", func(t *testing.T) { + email := testhelpers.RandomEmail() + var values = func(v url.Values) { + v.Set("traits.username", email) + v.Set("traits.foobar", "bazbar") + v.Set(node.WebAuthnRegister, "{}") + v.Del("method") + } + + for _, f := range flows { + t.Run("type="+f, func(t *testing.T) { + actual := registrationhelpers.ExpectValidationError(t, publicTS, conf, f, values) + assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual) + registrationhelpers.CheckFormContent(t, []byte(actual), node.WebAuthnRegisterTrigger, "csrf_token", "traits.username", "traits.foobar") + assert.Equal(t, "bazbar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).attributes.value").String(), "%s", actual) + assert.Equal(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual) + assert.Contains(t, gjson.Get(actual, "ui.messages.0").String(), `Unable to parse WebAuthn response: Parse error for Registration`, "%s", actual) + }) + } + }) + + submitWebAuthnRegistrationWithClient := func(t *testing.T, flow string, contextFixture []byte, client *http.Client, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.SelfServiceRegistrationFlow) { + isSPA := flow == "spa" + f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, publicTS, isSPA, opts...) + + // We inject the session to replay + interim, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), uuid.FromStringOrNil(f.Id)) + require.NoError(t, err) + interim.InternalContext = contextFixture + require.NoError(t, reg.RegistrationFlowPersister().UpdateRegistrationFlow(context.Background(), interim)) + + values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes) + cb(values) + + // We use the response replay + body, res := testhelpers.RegistrationMakeRequest(t, false, isSPA, f, client, values.Encode()) + return body, res, f + } + + t.Run("case=should fail to create identity if schema is missing the identifier", func(t *testing.T) { + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/noid.schema.json") + t.Cleanup(func() { + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") + }) + + email := testhelpers.RandomEmail() + + var values = func(v url.Values) { + v.Set("traits.email", email) + v.Set(node.WebAuthnRegister, string(registrationFixtureSuccessResponse)) + v.Del("method") + } + + for _, f := range flows { + t.Run("type="+f, func(t *testing.T) { + actual, _, _ := submitWebAuthnRegistrationWithClient(t, f, registrationFixtureSuccessInternalContext, testhelpers.NewClientWithCookies(t), values) + + assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual) + registrationhelpers.CheckFormContent(t, []byte(actual), node.WebAuthnRegisterTrigger, "csrf_token", "traits.email") + assert.Equal(t, text.NewErrorValidationIdentifierMissing().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual) + }) + } + }) + + makeRegistration := func(t *testing.T, f string, values func(v url.Values)) (actual string, res *http.Response, fetchedFlow *registration.Flow) { + actual, res, actualFlow := submitWebAuthnRegistrationWithClient(t, f, registrationFixtureSuccessInternalContext, testhelpers.NewClientWithCookies(t), values) + fetchedFlow, err := reg.RegistrationFlowPersister().GetRegistrationFlow(context.Background(), uuid.FromStringOrNil(actualFlow.Id)) + require.NoError(t, err) + + return actual, res, fetchedFlow + } + + makeSuccessfulRegistration := func(t *testing.T, f string, expectReturnTo string, values func(v url.Values)) (actual string) { + actual, res, fetchedFlow := makeRegistration(t, f, values) + assert.Empty(t, gjson.GetBytes(fetchedFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypeWebAuthn, webauthn.InternalContextKeySessionData)), "has cleaned up the internal context after success") + if f == "spa" { + expectReturnTo = publicTS.URL + } + assert.Contains(t, res.Request.URL.String(), expectReturnTo, "%+v\n\t%s", res.Request, assertx.PrettifyJSONPayload(t, actual)) + return actual + } + + getPrefix := func(f string) (prefix string) { + if f == "spa" { + prefix = "session." + } + return + } + t.Run("successful registration", func(t *testing.T) { + t.Cleanup(func() { + conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeWebAuthn.String()), nil) + }) + + var values = func(email string) func(v url.Values) { + return func(v url.Values) { + v.Set("traits.username", email) + v.Set("traits.foobar", "bazbar") + v.Set(node.WebAuthnRegister, string(registrationFixtureSuccessResponse)) + v.Del("method") + } + } + + t.Run("case=should create the identity but not a session", func(t *testing.T) { + useReturnToFromTS(redirNoSessionTS) + t.Cleanup(func() { + useReturnToFromTS(redirTS) + }) + conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypePassword.String()), nil) + + for _, f := range flows { + t.Run("type="+f, func(t *testing.T) { + email := testhelpers.RandomEmail() + actual := makeSuccessfulRegistration(t, f, redirNoSessionTS.URL+"/registration-return-ts", values(email)) + + if f == "spa" { + assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual) + assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual) + } else { + assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual) + } + + i, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeWebAuthn, email) + require.NoError(t, err) + assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual) + }) + } + }) + + t.Run("case=should create the identity and a session and use the correct schema", func(t *testing.T) { + conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeWebAuthn.String()), []config.SelfServiceHook{{Name: "session"}}) + conf.MustSet(config.ViperKeyDefaultIdentitySchemaID, "advanced-user") + conf.MustSet(config.ViperKeyIdentitySchemas, config.Schemas{ + {ID: "does-not-exist", URL: "file://./stub/profile.schema.json"}, + {ID: "advanced-user", URL: "file://./stub/registration.schema.json"}, + }) + + for _, f := range flows { + t.Run("type="+f, func(t *testing.T) { + email := testhelpers.RandomEmail() + actual := makeSuccessfulRegistration(t, f, redirTS.URL+"/registration-return-ts", values(email)) + + prefix := getPrefix(f) + + assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual) + assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual) + + i, _, err := reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(context.Background(), identity.CredentialsTypeWebAuthn, email) + require.NoError(t, err) + assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual) + }) + } + }) + + t.Run("case=not able to create the same account twice", func(t *testing.T) { + conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeWebAuthn.String()), []config.SelfServiceHook{{Name: "session"}}) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") + + for _, f := range flows { + t.Run("type="+f, func(t *testing.T) { + email := testhelpers.RandomEmail() + actual := makeSuccessfulRegistration(t, f, redirTS.URL+"/registration-return-ts", values(email)) + assert.True(t, gjson.Get(actual, getPrefix(f)+"active").Bool(), "%s", actual) + + actual, _, _ = makeRegistration(t, f, values(email)) + assert.Contains(t, gjson.Get(actual, "ui.action").String(), publicTS.URL+registration.RouteSubmitFlow, "%s", actual) + registrationhelpers.CheckFormContent(t, []byte(actual), node.WebAuthnRegisterTrigger, "csrf_token", "traits.username") + assert.Equal(t, text.NewErrorValidationDuplicateCredentials().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual) + }) + } + }) + + t.Run("case=reset previous form errors", func(t *testing.T) { + conf.MustSet(config.HookStrategyKey(config.ViperKeySelfServiceRegistrationAfter, identity.CredentialsTypeWebAuthn.String()), []config.SelfServiceHook{{Name: "session"}}) + testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration.schema.json") + + for _, f := range flows { + t.Run("type="+f, func(t *testing.T) { + email := testhelpers.RandomEmail() + actual, _, _ := makeRegistration(t, f, func(v url.Values) { + v.Del("traits.username") + v.Set("traits.foobar", "bazbar") + v.Set(node.WebAuthnRegister, string(registrationFixtureSuccessResponse)) + v.Del("method") + }) + registrationhelpers.CheckFormContent(t, []byte(actual), node.WebAuthnRegisterTrigger, "csrf_token", "traits.username", "traits.foobar") + assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages.0").String(), `Property username is missing`, "%s", actual) + + actual, _, _ = makeRegistration(t, f, func(v url.Values) { + v.Set("traits.username", email) + v.Del("traits.foobar") + v.Set(node.WebAuthnRegister, string(registrationFixtureSuccessResponse)) + v.Del("method") + }) + registrationhelpers.CheckFormContent(t, []byte(actual), node.WebAuthnRegisterTrigger, "csrf_token", "traits.username", "traits.foobar") + assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual) + assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages").Array()) + assert.Empty(t, gjson.Get(actual, "ui.nodes.messages").Array()) + }) + } + }) + }) +} diff --git a/selfservice/strategy/webauthn/settings_test.go b/selfservice/strategy/webauthn/settings_test.go index b22d5b1c87a6..f73c6fffae21 100644 --- a/selfservice/strategy/webauthn/settings_test.go +++ b/selfservice/strategy/webauthn/settings_test.go @@ -5,12 +5,13 @@ import ( _ "embed" "encoding/json" "fmt" - "github.com/ory/x/snapshotx" "net/http" "net/url" "testing" "time" + "github.com/ory/x/snapshotx" + "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/strategy/webauthn" @@ -36,12 +37,6 @@ import ( "github.com/ory/kratos/x" ) -//go:embed fixtures/settings/has_webauth.json -var settingsFixtureHasWebAuthn []byte - -//go:embed fixtures/settings/no_webauth.json -var settingsFixtureNoWebauthn []byte - //go:embed fixtures/settings/success/identity.json var settingsFixtureSuccessIdentity []byte @@ -60,7 +55,7 @@ func createIdentityWithoutWebAuthn(t *testing.T, reg driver.Registry) *identity. return id } -func createIdentity(t *testing.T, reg driver.Registry) *identity.Identity { +func createIdentityAndReturnIdentifier(t *testing.T, reg driver.Registry, conf []byte) (*identity.Identity, string) { identifier := x.NewUUID().String() + "@ory.sh" password := x.NewUUID().String() p, err := reg.Hasher().Generate(context.Background(), []byte(password)) @@ -75,6 +70,9 @@ func createIdentity(t *testing.T, reg driver.Registry) *identity.Identity { }, }, } + if conf == nil { + conf = []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo"},{"id":"YmFyYmFy","display_name":"bar"}]}`) + } require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i)) i.Credentials = map[identity.CredentialsType]identity.Credentials{ identity.CredentialsTypePassword: { @@ -84,12 +82,17 @@ func createIdentity(t *testing.T, reg driver.Registry) *identity.Identity { }, identity.CredentialsTypeWebAuthn: { Type: identity.CredentialsTypeWebAuthn, - Identifiers: []string{i.ID.String()}, - Config: sqlxx.JSONRawMessage(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo"},{"id":"YmFyYmFy","display_name":"bar"}]}`), + Identifiers: []string{identifier}, + Config: conf, }, } require.NoError(t, reg.PrivilegedIdentityPool().UpdateIdentity(context.Background(), i)) - return i + return i, identifier +} + +func createIdentity(t *testing.T, reg driver.Registry) *identity.Identity { + id, _ := createIdentityAndReturnIdentifier(t, reg, nil) + return id } func enableWebAuthn(conf *config.Config) { diff --git a/selfservice/strategy/webauthn/stub/noid.schema.json b/selfservice/strategy/webauthn/stub/noid.schema.json new file mode 100644 index 000000000000..d1dcaa77d138 --- /dev/null +++ b/selfservice/strategy/webauthn/stub/noid.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + }, + "additionalProperties": false +} diff --git a/selfservice/strategy/webauthn/stub/profile.schema.json b/selfservice/strategy/webauthn/stub/profile.schema.json new file mode 100644 index 000000000000..2eae6b68f1aa --- /dev/null +++ b/selfservice/strategy/webauthn/stub/profile.schema.json @@ -0,0 +1,25 @@ +{ + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "ory.sh/kratos": { + "credentials": { + "webauthn": { + "identifier": true + } + } + } + } + } + } + }, + "additionalProperties": false +} diff --git a/selfservice/strategy/webauthn/stub/registration.schema.json b/selfservice/strategy/webauthn/stub/registration.schema.json new file mode 100644 index 000000000000..2f355fb746d8 --- /dev/null +++ b/selfservice/strategy/webauthn/stub/registration.schema.json @@ -0,0 +1,35 @@ +{ + "$id": "https://example.com/person.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "foobar": { + "type": "string", + "minLength": 2 + }, + "username": { + "type": "string", + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "webauthn": { + "identifier": true + } + } + } + } + }, + "required": [ + "foobar", + "username" + ] + } + }, + "additionalProperties": false +} diff --git a/selfservice/strategy/webauthn/validate.go b/selfservice/strategy/webauthn/validate.go new file mode 100644 index 000000000000..90829c05ab81 --- /dev/null +++ b/selfservice/strategy/webauthn/validate.go @@ -0,0 +1,21 @@ +package webauthn + +import ( + "context" + + "github.com/ory/kratos/identity" + "github.com/ory/kratos/schema" +) + +func (s *Strategy) validateCredentials(ctx context.Context, i *identity.Identity) error { + if err := s.d.IdentityValidator().Validate(ctx, i); err != nil { + return err + } + + c := i.GetCredentialsOr(identity.CredentialsTypeWebAuthn, &identity.Credentials{}) + if len(c.Identifiers) == 0 { + return schema.NewMissingIdentifierError() + } + + return nil +} diff --git a/text/message_validation.go b/text/message_validation.go index da048273911c..3a5270d522cb 100644 --- a/text/message_validation.go +++ b/text/message_validation.go @@ -107,7 +107,7 @@ func NewErrorValidationLookupInvalid() *Message { func NewErrorValidationIdentifierMissing() *Message { return &Message{ ID: ErrorValidationIdentifierMissing, - Text: "Could not find any login identifiers. Did you forget to set them?", + Text: "Could not find any login identifiers. Did you forget to set them? This could also be caused by a server misconfiguration.", Type: Error, } } diff --git a/ui/node/identifiers.go b/ui/node/identifiers.go index 2f71afef3eac..591dcea60679 100644 --- a/ui/node/identifiers.go +++ b/ui/node/identifiers.go @@ -19,7 +19,6 @@ const ( const ( WebAuthnRegisterTrigger = "webauthn_register_trigger" WebAuthnRegister = "webauthn_register" - WebAuthnIdentifier = "webauthn_identifier" WebAuthnLogin = "webauthn_login" WebAuthnLoginTrigger = "webauthn_login_trigger" WebAuthnRegisterDisplayName = "webauthn_register_displayname"