diff --git a/.docker/Dockerfile-build b/.docker/Dockerfile-build index 6ee16085cf84..bd619930f0a9 100644 --- a/.docker/Dockerfile-build +++ b/.docker/Dockerfile-build @@ -1,6 +1,5 @@ # syntax = docker/dockerfile:1-experimental -# Workaround for https://github.com/GoogleContainerTools/distroless/issues/1342 -FROM golang:1.21 AS builder +FROM golang:1.22-bullseye AS builder RUN apt-get update && apt-get upgrade -y &&\ mkdir -p /var/lib/sqlite diff --git a/.docker/Dockerfile-debug b/.docker/Dockerfile-debug index 9ed036daebe7..a309b5ad92bb 100644 --- a/.docker/Dockerfile-debug +++ b/.docker/Dockerfile-debug @@ -1,4 +1,4 @@ -FROM golang:1.21 +FROM golang:1.22-bullseye ENV CGO_ENABLED 1 RUN apt-get update && apt-get install -y --no-install-recommends inotify-tools psmisc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6d4ed3dc155b..2d4e28d070dc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -79,7 +79,7 @@ jobs: fetch-depth: 2 - uses: actions/setup-go@v4 with: - go-version: "1.21" + go-version: "1.22" - run: go list -json > go.list - name: Run nancy uses: sonatype-nexus-community/nancy-github-action@v1.0.2 @@ -170,7 +170,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: "1.21" + go-version: "1.22" - name: Install selfservice-ui-react-native uses: actions/checkout@v3 @@ -274,7 +274,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v4 with: - go-version: "1.21" + go-version: "1.22" - run: go build -tags sqlite,json1 . - name: Install selfservice-ui-react-native diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index ce6695943cd8..7e243923b8ca 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 with: - go-version: "1.21" + go-version: "1.22" - run: make format - name: Indicate formatting issues run: git diff HEAD --exit-code --color diff --git a/.github/workflows/licenses.yml b/.github/workflows/licenses.yml index 8871ccb2c542..8a86486031de 100644 --- a/.github/workflows/licenses.yml +++ b/.github/workflows/licenses.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-go@v2 with: - go-version: "1.21" + go-version: "1.22" - uses: actions/setup-node@v2 with: node-version: "18" diff --git a/.grype.yaml b/.grype.yaml index 1ba341fccad5..57438622ad00 100644 --- a/.grype.yaml +++ b/.grype.yaml @@ -1,5 +1,6 @@ #only-fixed: true ignore: + - vulnerability: GHSA-c5pj-mqfh-rvc3 # https://github.com/advisories/GHSA-c5pj-mqfh-rvc3 - vulnerability: CVE-2015-5237 - vulnerability: CVE-2022-30065 - vulnerability: CVE-2023-2650 diff --git a/.trivyignore b/.trivyignore index a142ea336cff..4a01119a556c 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,2 +1,3 @@ CVE-2022-30065 +CVE-2024-2961 CVE-2023-2650 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5dabc7b428..e9a081e3be7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ **Table of Contents** -- [ (2024-03-26)](#2024-03-26) +- [ (2024-04-26)](#2024-04-26) - [Breaking Changes](#breaking-changes) - [Bug Fixes](#bug-fixes) - [Features](#features) @@ -322,7 +322,7 @@ -# [](https://github.com/ory/kratos/compare/v1.1.0...v) (2024-03-26) +# [](https://github.com/ory/kratos/compare/v1.1.0...v) (2024-04-26) ## Breaking Changes @@ -337,6 +337,12 @@ defaults to `false`. - Add login succeeded event to post registration hook ([#3739](https://github.com/ory/kratos/issues/3739)) ([b685fa5](https://github.com/ory/kratos/commit/b685fa5477be2ba099fd2420b27b2411fafc7e51)) +- Add missing env vars to set up guide + ([#3855](https://github.com/ory/kratos/issues/3855)) + ([da90502](https://github.com/ory/kratos/commit/da90502dc3bf8e3d34fb4ecc531834b1919989ad)): + + Closes https://github.com/ory/kratos/issues/3828 + - Add missing indexes and remove unused index ([6d7372e](https://github.com/ory/kratos/commit/6d7372ee3d88ee4fc552b969dd0ff338dcc0544c)) - Add missing indexes and remove unused index @@ -345,11 +351,46 @@ defaults to `false`. - Add sms mfa via parameter to spec ([#3766](https://github.com/ory/kratos/issues/3766)) ([b291c95](https://github.com/ory/kratos/commit/b291c959c18c72f5edc55607ab23b4592faf8d53)) +- Allow updating just the verified_at timestamp of addresses + ([#3880](https://github.com/ory/kratos/issues/3880)) + ([696cc1b](https://github.com/ory/kratos/commit/696cc1b59b18627fec63915070f4d8c5b3e3250d)) +- Always issue session last ([#3876](https://github.com/ory/kratos/issues/3876)) + ([e942507](https://github.com/ory/kratos/commit/e94250705e999567e2ed58cebdb3f6a9d589e3ef)): + + In post persist hooks, the session issuance hook always needs to come last. + This fixes the getHooks function to ensure this. + - Audit issues ([#3797](https://github.com/ory/kratos/issues/3797)) ([7017490](https://github.com/ory/kratos/commit/7017490caa9c70e22d5c626773c0266521813ff5)) +- Close res body ([#3870](https://github.com/ory/kratos/issues/3870)) + ([cc39f8d](https://github.com/ory/kratos/commit/cc39f8df7c235af0df616432bc4f88681896ad85)) +- Db index and duplicate credentials error + ([#3896](https://github.com/ory/kratos/issues/3896)) + ([9f34a21](https://github.com/ory/kratos/commit/9f34a21ea2035a5d33edd96753023a3c8c6c054c)): + + - fix: don't return password cred type if empty + - fix: better index for config.user_handle on identity_credentials + +- Do not require method to be passkey in settings schema + ([#3862](https://github.com/ory/kratos/issues/3862)) + ([660f330](https://github.com/ory/kratos/commit/660f330ab69ef0e6fd21501fbc9dfed693d4a715)) +- Don't require connection_uri in SMTP + ([#3861](https://github.com/ory/kratos/issues/3861)) + ([800f8f1](https://github.com/ory/kratos/commit/800f8f1036ef46a561d24dcdec45dd48803978d7)) +- Don't treat passkeys as AAL2 + ([#3853](https://github.com/ory/kratos/issues/3853)) + ([8eee972](https://github.com/ory/kratos/commit/8eee972d89accb02b3caa053fca2f16ed2c876f1)) +- Drop index if exists ([#3846](https://github.com/ory/kratos/issues/3846)) + ([ad0619d](https://github.com/ory/kratos/commit/ad0619d803cd2842a67c56a545ec5ab252501b0f)) - Drop trigram index on identifiers ([#3827](https://github.com/ory/kratos/issues/3827)) ([8f8fd90](https://github.com/ory/kratos/commit/8f8fd90304886ecd689a85fc60c4712e47526cdd)) +- Enum type of session expandables + ([#3891](https://github.com/ory/kratos/issues/3891)) + ([63d785e](https://github.com/ory/kratos/commit/63d785e5e73ff067ec804ecc2107fac1525d3688)) +- Enum type of session expandables + ([#3895](https://github.com/ory/kratos/issues/3895)) + ([c435727](https://github.com/ory/kratos/commit/c435727c1e3c70c040b7fc7648ce621b136e5fc2)) - Execute verification & verification_ui properly in login flows ([#3847](https://github.com/ory/kratos/issues/3847)) ([5aad1c1](https://github.com/ory/kratos/commit/5aad1c1e6cc92f72af56511dacb9812edb600813)) @@ -359,6 +400,11 @@ defaults to `false`. - Improve SDK discriminators ([#3844](https://github.com/ory/kratos/issues/3844)) ([c08b3ad](https://github.com/ory/kratos/commit/c08b3ad76c5adb712c945cdbd92a9a51832e94b9)) +- Include all creds in duplicate credential err + ([#3881](https://github.com/ory/kratos/issues/3881)) + ([e06c241](https://github.com/ory/kratos/commit/e06c241ffe3f0e696bb1cbc1d1080f9d4e09fbd2)) +- Linkedin issuer override ([#3875](https://github.com/ory/kratos/issues/3875)) + ([11d221a](https://github.com/ory/kratos/commit/11d221a4d33878930ca7025ae1b5c18b25dd1add)) - Make sure emails can still be sent with SMS enabled ([#3795](https://github.com/ory/kratos/issues/3795)) ([7c68c5a](https://github.com/ory/kratos/commit/7c68c5aa69ed76a84a37a37a3555277ddc772cf8)) @@ -371,6 +417,25 @@ defaults to `false`. - Prevent SMTP URL leak on unparsable URL ([#3770](https://github.com/ory/kratos/issues/3770)) ([c5f39f4](https://github.com/ory/kratos/commit/c5f39f4bc481e400f736ede7f8f0be546a55eebf)) +- Respect return_to in OIDC API flow error case + ([#3893](https://github.com/ory/kratos/issues/3893)) + ([e8f1bcb](https://github.com/ory/kratos/commit/e8f1bcb1342af994b8e08282aa4066ee00ffe7d4)): + + - fix: respect return_to in OIDC API flow error case + + This fix ensures that we redirect the user to the return_to URL when an error + occurs during the OIDC login for native flows. + + Native flows are initialized through the API, and the browser URL is retrieved + from a 422 response after a POST to submit the login flow. Successful OIDC + flows already returned the `code` to the `return_to` URL. Now, unsuccessful + flows return the `flow` with the current flow ID (which might have changed), + so that the caller can retrieve the full flow and act accordingly. + + - fix: ignore trivvy CVE report + + Bump in distroless is still open + - **sdk:** Expand identity in session extension ([#3843](https://github.com/ory/kratos/issues/3843)) ([04f0231](https://github.com/ory/kratos/commit/04f02318d4de5290cbf100e9b301284d5ee40fe7)), @@ -397,11 +462,27 @@ defaults to `false`. user-controlled and these endpoints could not be used fully due to the backend ignoring any value other than `true` (all lowercase). +- Tweaks to UpsertSessions ([#3878](https://github.com/ory/kratos/issues/3878)) + ([da51dcd](https://github.com/ory/kratos/commit/da51dcdb8c82a5dbd290ab2f48ad74a1c6dd18f0)) +- Use correct post-verification identity state in post-hooks + ([#3863](https://github.com/ory/kratos/issues/3863)) + ([6e63d06](https://github.com/ory/kratos/commit/6e63d06db1cd1ab62f8a2d0b202ec74572420204)) +- Webhook transient payload in OIDC login flows + ([#3857](https://github.com/ory/kratos/issues/3857)) + ([2cdfc70](https://github.com/ory/kratos/commit/2cdfc70c726a166790b98d419895f0396d13176f)): + + - fix: transient payload with OIDC login + ### Features - Add `include_credential` query param to `/admin/identities` list call ([#3343](https://github.com/ory/kratos/issues/3343)) ([d94530a](https://github.com/ory/kratos/commit/d94530a716358895b01b65babd77226fab69f494)) +- Add headers to web hooks ([#3849](https://github.com/ory/kratos/issues/3849)) + ([4642de0](https://github.com/ory/kratos/commit/4642de0cfd1fb15bc48c7093be9449abd488755c)) +- Add session to post login webhook + ([#3877](https://github.com/ory/kratos/issues/3877)) + ([386078e](https://github.com/ory/kratos/commit/386078e0b5c74c54ce2c7dc6fd12fd865817b87a)) - Add transient payloads to all flows ([#3738](https://github.com/ory/kratos/issues/3738)) ([b8b747b](https://github.com/ory/kratos/commit/b8b747b2adc59c8cf938a0ee30accdb4135634b8)) @@ -434,6 +515,8 @@ defaults to `false`. ### Tests +- Deflake session test ([#3864](https://github.com/ory/kratos/issues/3864)) + ([6b275f3](https://github.com/ory/kratos/commit/6b275f35a0732ffb723d47df5b6afbdc06eaf71f)) - Resolve failing test for empty tokens ([#3775](https://github.com/ory/kratos/issues/3775)) ([7277368](https://github.com/ory/kratos/commit/7277368bc28df8f0badffc7e739cef20f05e9a02)) diff --git a/cmd/clidoc/main.go b/cmd/clidoc/main.go index 5c2555eaddb2..6ed8df8d1748 100644 --- a/cmd/clidoc/main.go +++ b/cmd/clidoc/main.go @@ -199,7 +199,7 @@ func main() { } } - if err := writeMessages(filepath.Join(os.Args[2], "concepts/ui-user-interface.mdx"), sortedMessages); err != nil { + if err := writeMessages(filepath.Join(os.Args[2], "concepts/ui-messages.md"), sortedMessages); err != nil { _, _ = fmt.Fprintf(os.Stderr, "Unable to generate message table: %+v\n", err) os.Exit(1) } diff --git a/driver/registry.go b/driver/registry.go index e87fdd076078..dc31f7305633 100644 --- a/driver/registry.go +++ b/driver/registry.go @@ -106,7 +106,7 @@ type Registry interface { courier.PersistenceProvider schema.HandlerProvider - schema.IdentityTraitsProvider + schema.IdentitySchemaProvider password2.ValidationProvider @@ -180,15 +180,16 @@ func NewRegistryFromDSN(ctx context.Context, c *config.Config, l *logrusx.Logger } type options struct { - skipNetworkInit bool - config *config.Config - replaceTracer func(*otelx.Tracer) *otelx.Tracer - inspect func(Registry) error - extraMigrations []fs.FS - replacementStrategies []NewStrategy - extraHooks map[string]func(config.SelfServiceHook) any - disableMigrationLogging bool - jsonnetPool jsonnetsecure.Pool + skipNetworkInit bool + config *config.Config + replaceTracer func(*otelx.Tracer) *otelx.Tracer + replaceIdentitySchemaProvider func(Registry) schema.IdentitySchemaProvider + inspect func(Registry) error + extraMigrations []fs.FS + replacementStrategies []NewStrategy + extraHooks map[string]func(config.SelfServiceHook) any + disableMigrationLogging bool + jsonnetPool jsonnetsecure.Pool } type RegistryOption func(*options) @@ -209,6 +210,12 @@ func WithConfig(config *config.Config) RegistryOption { } } +func WithIdentitySchemaProvider(f func(r Registry) schema.IdentitySchemaProvider) RegistryOption { + return func(o *options) { + o.replaceIdentitySchemaProvider = f + } +} + func ReplaceTracer(f func(*otelx.Tracer) *otelx.Tracer) RegistryOption { return func(o *options) { o.replaceTracer = f diff --git a/driver/registry_default.go b/driver/registry_default.go index 1ab63c0af561..eab63a120981 100644 --- a/driver/registry_default.go +++ b/driver/registry_default.go @@ -93,9 +93,10 @@ type RegistryDefault struct { hookCodeAddressVerifier *hook.CodeAddressVerifier hookTwoStepRegistration *hook.TwoStepRegistration - identityHandler *identity.Handler - identityValidator *identity.Validator - identityManager *identity.Manager + identityHandler *identity.Handler + identityValidator *identity.Validator + identityManager *identity.Manager + identitySchemaProvider schema.IdentitySchemaProvider courierHandler *courier.Handler @@ -621,6 +622,7 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize instrumentedsql.WithOmitArgs(), // don't risk leaking PII or secrets } } + if o.replaceTracer != nil { m.trc = o.replaceTracer(m.trc) } @@ -633,6 +635,10 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize m.WithHooks(o.extraHooks) } + if o.replaceIdentitySchemaProvider != nil { + m.identitySchemaProvider = o.replaceIdentitySchemaProvider(m) + } + bc := backoff.NewExponentialBackOff() bc.MaxElapsedTime = time.Minute * 5 bc.Reset() diff --git a/driver/registry_default_hooks.go b/driver/registry_default_hooks.go index 05b9a10f3d98..73a855daadc5 100644 --- a/driver/registry_default_hooks.go +++ b/driver/registry_default_hooks.go @@ -62,10 +62,12 @@ func (m *RegistryDefault) WithHooks(hooks map[string]func(config.SelfServiceHook } func (m *RegistryDefault) getHooks(credentialsType string, configs []config.SelfServiceHook) (i []interface{}) { + var addSessionIssuer bool for _, h := range configs { switch h.Name { case hook.KeySessionIssuer: - i = append(i, m.HookSessionIssuer()) + // The session issuer hook always needs to come last. + addSessionIssuer = true case hook.KeySessionDestroyer: i = append(i, m.HookSessionDestroyer()) case hook.KeyWebHook: @@ -96,6 +98,9 @@ func (m *RegistryDefault) getHooks(credentialsType string, configs []config.Self Errorf("A unknown hook was requested and can therefore not be used") } } + if addSessionIssuer { + i = append(i, m.HookSessionIssuer()) + } return i } diff --git a/driver/registry_default_schemas.go b/driver/registry_default_schemas.go index 9f68fbd2d86e..d3d61a3e7e35 100644 --- a/driver/registry_default_schemas.go +++ b/driver/registry_default_schemas.go @@ -5,32 +5,13 @@ package driver import ( "context" - "net/url" - - "github.com/pkg/errors" "github.com/ory/kratos/schema" ) -func (m *RegistryDefault) IdentityTraitsSchemas(ctx context.Context) (schema.Schemas, error) { - ms, err := m.Config().IdentityTraitsSchemas(ctx) - if err != nil { - return nil, err +func (m *RegistryDefault) IdentityTraitsSchemas(ctx context.Context) (schema.IdentitySchemaList, error) { + if m.identitySchemaProvider == nil { + m.identitySchemaProvider = schema.NewDefaultIdentityTraitsProvider(m) } - - var ss schema.Schemas - for _, s := range ms { - surl, err := url.Parse(s.URL) - if err != nil { - return nil, errors.WithStack(err) - } - - ss = append(ss, schema.Schema{ - ID: s.ID, - URL: surl, - RawURL: s.URL, - }) - } - - return ss, nil + return m.identitySchemaProvider.IdentityTraitsSchemas(ctx) } diff --git a/driver/registry_default_schemas_test.go b/driver/registry_default_schemas_test.go index 8d76c22bf491..aed6819a383b 100644 --- a/driver/registry_default_schemas_test.go +++ b/driver/registry_default_schemas_test.go @@ -39,7 +39,7 @@ func TestRegistryDefault_IdentityTraitsSchemas(t *testing.T) { ss, err := reg.IdentityTraitsSchemas(context.Background()) require.NoError(t, err) - assert.Equal(t, 2, len(ss)) + assert.Equal(t, 2, ss.Total()) assert.Contains(t, ss, defaultSchema) assert.Contains(t, ss, altSchema) } diff --git a/embedx/config.schema.json b/embedx/config.schema.json index b99c90d73897..b501558edbc3 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -268,8 +268,8 @@ "description": "The HTTP headers that must be applied to the Web-Hook", "additionalProperties": { "type": "string" - } - }, + } + }, "body": { "type": "string", "oneOf": [ @@ -2066,7 +2066,6 @@ "default": "localhost" } }, - "required": ["connection_uri"], "additionalProperties": false }, "sms": { @@ -2869,6 +2868,19 @@ "description": "Secifies which organizations are available. Only effective in the Ory Network.", "type": "array", "default": [] + }, + "enterprise": { + "title": "Enterprise features", + "description": "Specifies enterprise features. Only effective in the Ory Network or with a valid license.", + "type": "object", + "properties": { + "identity_schema_fallback_url_template": { + "type": "string", + "title": "Fallback URL template for identity schemas", + "description": "A fallback URL template used when looking up identity schemas." + } + }, + "additionalProperties": false } }, "allOf": [ diff --git a/go.mod b/go.mod index 655a07bbf9e2..8f8d3c1e60ec 100644 --- a/go.mod +++ b/go.mod @@ -99,9 +99,9 @@ require ( go.opentelemetry.io/otel v1.22.0 go.opentelemetry.io/otel/sdk v1.21.0 go.opentelemetry.io/otel/trace v1.22.0 - golang.org/x/crypto v0.21.0 + golang.org/x/crypto v0.22.0 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa - golang.org/x/net v0.21.0 + golang.org/x/net v0.24.0 golang.org/x/oauth2 v0.16.0 golang.org/x/sync v0.5.0 golang.org/x/text v0.14.0 @@ -135,7 +135,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/docker/cli v20.10.21+incompatible // indirect github.com/docker/distribution v2.8.2+incompatible // indirect - github.com/docker/docker v20.10.24+incompatible // indirect + github.com/docker/docker v20.10.27+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.0 // indirect @@ -308,8 +308,8 @@ require ( go.opentelemetry.io/otel/metric v1.22.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect golang.org/x/mod v0.14.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/term v0.18.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/term v0.19.0 // indirect golang.org/x/tools v0.15.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/go.sum b/go.sum index f344856176b4..964e696c2c4b 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/docker/cli v20.10.21+incompatible h1:qVkgyYUnOLQ98LtXBrwd/duVqPT2X4SH github.com/docker/cli v20.10.21+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v20.10.24+incompatible h1:Ugvxm7a8+Gz6vqQYQQ2W7GYq5EUPaAiuPgIfVyI3dYE= -github.com/docker/docker v20.10.24+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v20.10.27+incompatible h1:Id/ZooynV4ZlD6xX20RCd3SR0Ikn7r4QZDa2ECK2TgA= +github.com/docker/docker v20.10.27+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -1116,8 +1116,9 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1212,8 +1213,9 @@ golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1328,8 +1330,9 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20191110171634-ad39bd3f0407/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1342,8 +1345,9 @@ golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/identity/handler.go b/identity/handler.go index 8622a2e76d8e..3ac641e09377 100644 --- a/identity/handler.go +++ b/identity/handler.go @@ -550,9 +550,9 @@ func (h *Handler) identityFromCreateIdentityBody(ctx context.Context, cr *Create // swagger:route PATCH /admin/identities identity batchPatchIdentities // -// # Create and deletes multiple identities +// # Create multiple identities // -// Creates or delete multiple +// Creates multiple // [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model). // This endpoint can also be used to [import // credentials](https://www.ory.sh/docs/kratos/manage-identities/import-user-accounts-identities) @@ -896,18 +896,18 @@ func (h *Handler) patch(w http.ResponseWriter, r *http.Request, ps httprouter.Pa patchedIdentity.StateChangedAt = &stateChangedAt } - updatedIdenty := Identity(patchedIdentity) + updatedIdentity := Identity(patchedIdentity) if err := h.r.IdentityManager().Update( r.Context(), - &updatedIdenty, + &updatedIdentity, ManagerAllowWriteProtectedTraits, ); err != nil { h.r.Writer().WriteError(w, r, err) return } - h.r.Writer().Write(w, r, WithCredentialsMetadataAndAdminMetadataInJSON(updatedIdenty)) + h.r.Writer().Write(w, r, WithCredentialsMetadataAndAdminMetadataInJSON(updatedIdentity)) } func deletCredentialWebAuthFromIdentity(identity *Identity) (*Identity, error) { diff --git a/identity/handler_test.go b/identity/handler_test.go index bfdf1a86dfc3..ab2ed0c0cbdc 100644 --- a/identity/handler_test.go +++ b/identity/handler_test.go @@ -928,6 +928,98 @@ func TestHandler(t *testing.T) { } }) + t.Run("case=PATCH should update verified_at timestamp", func(t *testing.T) { + for name, ts := range map[string]*httptest.Server{"public": publicTS, "admin": adminTS} { + t.Run("endpoint="+name, func(t *testing.T) { + email := x.NewUUID().String() + "@ory.sh" + var cr identity.CreateIdentityBody + cr.SchemaID = "employee" + cr.Traits = []byte(`{"email":"` + email + `"}`) + res := send(t, ts, "POST", "/identities", http.StatusCreated, &cr) + assert.EqualValues(t, email, res.Get("recovery_addresses.0.value").String(), "%s", res.Raw) + assert.EqualValues(t, email, res.Get("verifiable_addresses.0.value").String(), "%s", res.Raw) + assert.Falsef(t, res.Get("verifiable_addresses.0.verified").Bool(), "%s", res.Raw) + assert.Falsef(t, res.Get("verifiable_addresses.0.verified_at").Exists(), "%s", res.Raw) + identityID := res.Get("id").String() + + // set to verified, should also update verified_at timestamp + patch1 := []patch{ + { + "op": "replace", + "path": "/verifiable_addresses/0/verified", + "value": true, + }, + } + + now := time.Now() + + res = send(t, ts, "PATCH", "/identities/"+identityID, http.StatusOK, &patch1) + assert.EqualValues(t, email, res.Get("recovery_addresses.0.value").String(), "%s", res.Raw) + assert.EqualValues(t, email, res.Get("verifiable_addresses.0.value").String(), "%s", res.Raw) + assert.Truef(t, res.Get("verifiable_addresses.0.verified").Bool(), "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.updated_at").Time(), 5*time.Second, "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.verified_at").Time(), 5*time.Second, "%s", res.Raw) + + res = get(t, ts, "/identities/"+identityID, http.StatusOK) + assert.EqualValues(t, email, res.Get("recovery_addresses.0.value").String(), "%s", res.Raw) + assert.EqualValues(t, email, res.Get("verifiable_addresses.0.value").String(), "%s", res.Raw) + assert.Truef(t, res.Get("verifiable_addresses.0.verified").Bool(), "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.updated_at").Time(), 5*time.Second, "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.verified_at").Time(), 5*time.Second, "%s", res.Raw) + + // update only verified_at timestamp + verifiedAt := time.Date(1999, 1, 7, 8, 23, 19, 0, time.UTC) + patch2 := []patch{ + { + "op": "replace", + "path": "/verifiable_addresses/0/verified_at", + "value": verifiedAt.Format(time.RFC3339), + }, + } + + now = time.Now() + res = send(t, ts, "PATCH", "/identities/"+identityID, http.StatusOK, &patch2) + assert.EqualValues(t, email, res.Get("recovery_addresses.0.value").String(), "%s", res.Raw) + assert.EqualValues(t, email, res.Get("verifiable_addresses.0.value").String(), "%s", res.Raw) + assert.Truef(t, res.Get("verifiable_addresses.0.verified").Bool(), "%s", res.Raw) + assert.Equalf(t, verifiedAt, res.Get("verifiable_addresses.0.verified_at").Time(), "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.updated_at").Time(), 5*time.Second, "%s", res.Raw) + + res = get(t, ts, "/identities/"+identityID, http.StatusOK) + assert.EqualValues(t, email, res.Get("recovery_addresses.0.value").String(), "%s", res.Raw) + assert.EqualValues(t, email, res.Get("verifiable_addresses.0.value").String(), "%s", res.Raw) + assert.Truef(t, res.Get("verifiable_addresses.0.verified").Bool(), "%s", res.Raw) + assert.Equalf(t, verifiedAt, res.Get("verifiable_addresses.0.verified_at").Time(), "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.updated_at").Time(), 5*time.Second, "%s", res.Raw) + + // remove verified status + patch3 := []patch{ + { + "op": "replace", + "path": "/verifiable_addresses/0/verified", + "value": false, + }, + } + + now = time.Now() + + res = send(t, ts, "PATCH", "/identities/"+identityID, http.StatusOK, &patch3) + assert.EqualValues(t, email, res.Get("recovery_addresses.0.value").String(), "%s", res.Raw) + assert.EqualValues(t, email, res.Get("verifiable_addresses.0.value").String(), "%s", res.Raw) + assert.Falsef(t, res.Get("verifiable_addresses.0.verified").Bool(), "%s", res.Raw) + assert.Falsef(t, res.Get("verifiable_addresses.0.verified_at").Exists(), "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.updated_at").Time(), 5*time.Second, "%s", res.Raw) + + res = get(t, ts, "/identities/"+identityID, http.StatusOK) + assert.EqualValues(t, email, res.Get("recovery_addresses.0.value").String(), "%s", res.Raw) + assert.EqualValues(t, email, res.Get("verifiable_addresses.0.value").String(), "%s", res.Raw) + assert.Falsef(t, res.Get("verifiable_addresses.0.verified").Bool(), "%s", res.Raw) + assert.Falsef(t, res.Get("verifiable_addresses.0.verified_at").Exists(), "%s", res.Raw) + assert.WithinDurationf(t, now, res.Get("verifiable_addresses.0.updated_at").Time(), 5*time.Second, "%s", res.Raw) + }) + } + }) + t.Run("case=PATCH update should not persist if schema id is invalid", func(t *testing.T) { uuid := x.NewUUID().String() i := &identity.Identity{Traits: identity.Traits(fmt.Sprintf(`{"subject":"%s"}`, uuid))} diff --git a/identity/identity_verification.go b/identity/identity_verification.go index 54ac435ec9fd..251fee019d3b 100644 --- a/identity/identity_verification.go +++ b/identity/identity_verification.go @@ -118,5 +118,5 @@ func (a VerifiableAddress) ValidateNID() error { // Hash returns a unique string representation for the recovery address. func (a VerifiableAddress) Hash() string { - return fmt.Sprintf("%v|%v|%v|%v|%v|%v", a.Value, a.Verified, a.Via, a.Status, a.IdentityID, a.NID) + return fmt.Sprintf("%v|%v|%v|%v|%v|%v|%v", a.Value, a.Verified, a.Via, a.Status, a.VerifiedAt, a.IdentityID, a.NID) } diff --git a/identity/identity_verification_test.go b/identity/identity_verification_test.go index 4559a5759dba..6f3ca2c69524 100644 --- a/identity/identity_verification_test.go +++ b/identity/identity_verification_test.go @@ -34,10 +34,10 @@ func TestNewVerifiableEmailAddress(t *testing.T) { } var tagsIgnoredForHashing = map[string]struct{}{ - "id": {}, - "created_at": {}, - "updated_at": {}, - "verified_at": {}, + "id": {}, + "created_at": {}, + "updated_at": {}, + // "verified_at": {}, // we explicitly want to be able to update just this field and nothing else } func reflectiveHash(record any) string { @@ -102,5 +102,4 @@ func TestVerifiableAddress_Hash(t *testing.T) { ) }) } - } diff --git a/identity/manager.go b/identity/manager.go index 9bd02ce7451c..04fb3edae500 100644 --- a/identity/manager.go +++ b/identity/manager.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "reflect" + "slices" "sort" "go.opentelemetry.io/otel/trace" @@ -188,6 +189,8 @@ func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identi return creds[i].Type < creds[j].Type }) + duplicateCredErr := &ErrDuplicateCredentials{error: e} + for _, cred := range creds { if cred.Config == nil { continue @@ -202,11 +205,19 @@ func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identi if len(cred.Identifiers) > 0 { identifierHint = cred.Identifiers[0] } - return &ErrDuplicateCredentials{ - error: e, - availableCredentials: []CredentialsType{cred.Type}, - identifierHint: identifierHint, + duplicateCredErr.SetIdentifierHint(identifierHint) + + var cfg CredentialsPassword + if err := json.Unmarshal(cred.Config, &cfg); err != nil { + // just ignore this credential if the config is invalid + continue } + if cfg.HashedPassword == "" { + // just ignore this credential if the hashed password is empty + continue + } + + duplicateCredErr.AddCredentialsType(cred.Type) case CredentialsTypeOIDC: var cfg CredentialsOIDC if err := json.Unmarshal(cred.Config, &cfg); err != nil { @@ -218,12 +229,9 @@ func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identi available = append(available, provider.Provider) } - return &ErrDuplicateCredentials{ - error: e, - availableCredentials: []CredentialsType{cred.Type}, - availableOIDCProviders: available, - identifierHint: foundConflictAddress, - } + duplicateCredErr.AddCredentialsType(cred.Type) + duplicateCredErr.SetIdentifierHint(foundConflictAddress) + duplicateCredErr.availableOIDCProviders = available case CredentialsTypeWebAuthn: var cfg CredentialsWebAuthnConfig if err := json.Unmarshal(cred.Config, &cfg); err != nil { @@ -237,18 +245,33 @@ func (m *Manager) findExistingAuthMethod(ctx context.Context, e error, i *Identi for _, webauthn := range cfg.Credentials { if webauthn.IsPasswordless { - return &ErrDuplicateCredentials{ - error: e, - availableCredentials: []CredentialsType{cred.Type}, - identifierHint: identifierHint, - } + duplicateCredErr.AddCredentialsType(cred.Type) + duplicateCredErr.SetIdentifierHint(identifierHint) + break + } + } + case CredentialsTypePasskey: + var cfg CredentialsWebAuthnConfig + if err := json.Unmarshal(cred.Config, &cfg); err != nil { + return errors.WithStack(herodot.ErrInternalServerError.WithReasonf("Unable to JSON decode identity credentials %s for identity %s.", cred.Type, found.ID)) + } + + identifierHint := foundConflictAddress + if len(cred.Identifiers) > 0 { + identifierHint = cred.Identifiers[0] + } + + for _, webauthn := range cfg.Credentials { + if webauthn.IsPasswordless { + duplicateCredErr.AddCredentialsType(cred.Type) + duplicateCredErr.SetIdentifierHint(identifierHint) + break } } } } - // Still not found? Return generic error. - return &ErrDuplicateCredentials{error: e} + return duplicateCredErr } type ErrDuplicateCredentials struct { @@ -265,15 +288,28 @@ func (e *ErrDuplicateCredentials) Unwrap() error { return e.error } +func (e *ErrDuplicateCredentials) AddCredentialsType(ct CredentialsType) { + e.availableCredentials = append(e.availableCredentials, ct) +} + +func (e *ErrDuplicateCredentials) SetIdentifierHint(hint string) { + if hint != "" { + e.identifierHint = hint + } +} + func (e *ErrDuplicateCredentials) AvailableCredentials() []string { res := make([]string, len(e.availableCredentials)) for k, v := range e.availableCredentials { res[k] = string(v) } + slices.Sort(res) + return res } func (e *ErrDuplicateCredentials) AvailableOIDCProviders() []string { + slices.Sort(e.availableOIDCProviders) return e.availableOIDCProviders } diff --git a/identity/manager_test.go b/identity/manager_test.go index 81001c9ba9c6..e0346b8ee0c0 100644 --- a/identity/manager_test.go +++ b/identity/manager_test.go @@ -222,6 +222,66 @@ func TestManager(t *testing.T) { assert.Len(t, verr.AvailableOIDCProviders(), 0) assert.Equal(t, verr.IdentifierHint(), email) }) + + t.Run("type=oidc", func(t *testing.T) { + email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" + creds := map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypeOIDC: { + Type: identity.CredentialsTypeOIDC, + // Identifiers in OIDC are not email addresses, but a unique user ID. + Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()}, + Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`), + }, + } + + first := createIdentity(email, "email_creds", creds) + require.NoError(t, reg.IdentityManager().Create(context.Background(), first)) + + second := createIdentity(email, "email_creds", creds) + err := reg.IdentityManager().Create(context.Background(), second) + require.Error(t, err) + + var verr = new(identity.ErrDuplicateCredentials) + assert.ErrorAs(t, err, &verr) + assert.ElementsMatch(t, []string{"oidc"}, verr.AvailableCredentials()) + assert.ElementsMatch(t, []string{"google", "github"}, verr.AvailableOIDCProviders()) + assert.Equal(t, email, verr.IdentifierHint()) + }) + + t.Run("type=password+oidc+webauthn", func(t *testing.T) { + email := uuid.Must(uuid.NewV4()).String() + "@ory.sh" + creds := map[identity.CredentialsType]identity.Credentials{ + identity.CredentialsTypePassword: { + Type: identity.CredentialsTypePassword, + Identifiers: []string{email}, + Config: sqlxx.JSONRawMessage(`{"hashed_password":"$2a$08$.cOYmAd.vCpDOoiVJrO5B.hjTLKQQ6cAK40u8uB.FnZDyPvVvQ9Q."}`), + }, + identity.CredentialsTypeOIDC: { + Type: identity.CredentialsTypeOIDC, + // Identifiers in OIDC are not email addresses, but a unique user ID. + Identifiers: []string{"google:" + uuid.Must(uuid.NewV4()).String()}, + Config: sqlxx.JSONRawMessage(`{"providers":[{"provider": "google"},{"provider": "github"}]}`), + }, + identity.CredentialsTypeWebAuthn: { + Type: identity.CredentialsTypeWebAuthn, + Identifiers: []string{email}, + Config: sqlxx.JSONRawMessage(`{"credentials": [{"is_passwordless":true}]}`), + }, + } + + first := createIdentity(email, "email_creds", creds) + require.NoError(t, reg.IdentityManager().Create(context.Background(), first)) + + second := createIdentity(email, "email_creds", creds) + err := reg.IdentityManager().Create(context.Background(), second) + require.Error(t, err) + + var verr = new(identity.ErrDuplicateCredentials) + assert.ErrorAs(t, err, &verr) + assert.ElementsMatch(t, []string{"password", "oidc", "webauthn"}, verr.AvailableCredentials()) + assert.ElementsMatch(t, []string{"google", "github"}, verr.AvailableOIDCProviders()) + assert.Equal(t, email, verr.IdentifierHint()) + }) }) runAddress := func(t *testing.T, field string) { @@ -278,7 +338,7 @@ func TestManager(t *testing.T) { var verr = new(identity.ErrDuplicateCredentials) assert.ErrorAs(t, err, &verr) assert.EqualValues(t, []string{identity.CredentialsTypeOIDC.String()}, verr.AvailableCredentials()) - assert.EqualValues(t, verr.AvailableOIDCProviders(), []string{"google", "github"}) + assert.EqualValues(t, verr.AvailableOIDCProviders(), []string{"github", "google"}) assert.Equal(t, verr.IdentifierHint(), email) }) } diff --git a/identity/validator.go b/identity/validator.go index 3bd8f9476fa5..b977105f49bd 100644 --- a/identity/validator.go +++ b/identity/validator.go @@ -19,7 +19,7 @@ import ( type ( validatorDependencies interface { - IdentityTraitsSchemas(ctx context.Context) (schema.Schemas, error) + schema.IdentitySchemaProvider config.Provider } Validator struct { diff --git a/internal/client-go/README.md b/internal/client-go/README.md index 33082914bfef..04dd61ab7d1e 100644 --- a/internal/client-go/README.md +++ b/internal/client-go/README.md @@ -111,7 +111,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**UpdateRegistrationFlow**](docs/FrontendApi.md#updateregistrationflow) | **Post** /self-service/registration | Update Registration Flow *FrontendApi* | [**UpdateSettingsFlow**](docs/FrontendApi.md#updatesettingsflow) | **Post** /self-service/settings | Complete Settings Flow *FrontendApi* | [**UpdateVerificationFlow**](docs/FrontendApi.md#updateverificationflow) | **Post** /self-service/verification | Complete Verification Flow -*IdentityApi* | [**BatchPatchIdentities**](docs/IdentityApi.md#batchpatchidentities) | **Patch** /admin/identities | Create and deletes multiple identities +*IdentityApi* | [**BatchPatchIdentities**](docs/IdentityApi.md#batchpatchidentities) | **Patch** /admin/identities | Create multiple identities *IdentityApi* | [**CreateIdentity**](docs/IdentityApi.md#createidentity) | **Post** /admin/identities | Create an Identity *IdentityApi* | [**CreateRecoveryCodeForIdentity**](docs/IdentityApi.md#createrecoverycodeforidentity) | **Post** /admin/recovery/code | Create a Recovery Code *IdentityApi* | [**CreateRecoveryLinkForIdentity**](docs/IdentityApi.md#createrecoverylinkforidentity) | **Post** /admin/recovery/link | Create a Recovery Link diff --git a/internal/client-go/api_identity.go b/internal/client-go/api_identity.go index c898733ecd4b..04774a5b3938 100644 --- a/internal/client-go/api_identity.go +++ b/internal/client-go/api_identity.go @@ -29,8 +29,8 @@ var ( type IdentityApi interface { /* - * BatchPatchIdentities Create and deletes multiple identities - * Creates or delete multiple + * BatchPatchIdentities Create multiple identities + * Creates multiple [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model). This endpoint can also be used to [import credentials](https://www.ory.sh/docs/kratos/manage-identities/import-user-accounts-identities) @@ -327,8 +327,8 @@ func (r IdentityApiApiBatchPatchIdentitiesRequest) Execute() (*BatchPatchIdentit } /* - - BatchPatchIdentities Create and deletes multiple identities - - Creates or delete multiple + - BatchPatchIdentities Create multiple identities + - Creates multiple [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model). This endpoint can also be used to [import diff --git a/internal/httpclient/README.md b/internal/httpclient/README.md index 33082914bfef..04dd61ab7d1e 100644 --- a/internal/httpclient/README.md +++ b/internal/httpclient/README.md @@ -111,7 +111,7 @@ Class | Method | HTTP request | Description *FrontendApi* | [**UpdateRegistrationFlow**](docs/FrontendApi.md#updateregistrationflow) | **Post** /self-service/registration | Update Registration Flow *FrontendApi* | [**UpdateSettingsFlow**](docs/FrontendApi.md#updatesettingsflow) | **Post** /self-service/settings | Complete Settings Flow *FrontendApi* | [**UpdateVerificationFlow**](docs/FrontendApi.md#updateverificationflow) | **Post** /self-service/verification | Complete Verification Flow -*IdentityApi* | [**BatchPatchIdentities**](docs/IdentityApi.md#batchpatchidentities) | **Patch** /admin/identities | Create and deletes multiple identities +*IdentityApi* | [**BatchPatchIdentities**](docs/IdentityApi.md#batchpatchidentities) | **Patch** /admin/identities | Create multiple identities *IdentityApi* | [**CreateIdentity**](docs/IdentityApi.md#createidentity) | **Post** /admin/identities | Create an Identity *IdentityApi* | [**CreateRecoveryCodeForIdentity**](docs/IdentityApi.md#createrecoverycodeforidentity) | **Post** /admin/recovery/code | Create a Recovery Code *IdentityApi* | [**CreateRecoveryLinkForIdentity**](docs/IdentityApi.md#createrecoverylinkforidentity) | **Post** /admin/recovery/link | Create a Recovery Link diff --git a/internal/httpclient/api_identity.go b/internal/httpclient/api_identity.go index c898733ecd4b..04774a5b3938 100644 --- a/internal/httpclient/api_identity.go +++ b/internal/httpclient/api_identity.go @@ -29,8 +29,8 @@ var ( type IdentityApi interface { /* - * BatchPatchIdentities Create and deletes multiple identities - * Creates or delete multiple + * BatchPatchIdentities Create multiple identities + * Creates multiple [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model). This endpoint can also be used to [import credentials](https://www.ory.sh/docs/kratos/manage-identities/import-user-accounts-identities) @@ -327,8 +327,8 @@ func (r IdentityApiApiBatchPatchIdentitiesRequest) Execute() (*BatchPatchIdentit } /* - - BatchPatchIdentities Create and deletes multiple identities - - Creates or delete multiple + - BatchPatchIdentities Create multiple identities + - Creates multiple [identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model). This endpoint can also be used to [import diff --git a/internal/testhelpers/selfservice_verification.go b/internal/testhelpers/selfservice_verification.go index 92bc5b191d43..9786c8210e73 100644 --- a/internal/testhelpers/selfservice_verification.go +++ b/internal/testhelpers/selfservice_verification.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" "net/url" @@ -29,6 +30,32 @@ import ( "github.com/ory/kratos/x" ) +func NewVerifyAfterHookWebHookTarget(ctx context.Context, t *testing.T, conf *config.Config, assert func(t *testing.T, body []byte)) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + msg, err := io.ReadAll(r.Body) + require.NoError(t, err) + + assert(t, msg) + })) + + // A hook to ensure that the verification hook is called with the correct data + conf.MustSet(ctx, config.ViperKeySelfServiceVerificationAfter+".hooks", []map[string]interface{}{ + { + "hook": "web_hook", + "config": map[string]interface{}{ + "url": ts.URL, + "method": "POST", + "body": "base64://ZnVuY3Rpb24oY3R4KSB7CiAgICBpZGVudGl0eTogY3R4LmlkZW50aXR5Cn0=", + }, + }, + }) + + t.Cleanup(ts.Close) + t.Cleanup(func() { + conf.MustSet(ctx, config.ViperKeySelfServiceVerificationAfter+".hooks", []map[string]interface{}{}) + }) +} + func NewRecoveryUIFlowEchoServer(t *testing.T, reg driver.Registry) *httptest.Server { ctx := context.Background() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -57,6 +84,7 @@ func GetRecoveryFlowForType(t *testing.T, client *http.Client, ts *httptest.Serv res, err := client.Get(url) require.NoError(t, err) + defer res.Body.Close() var flowID string switch ft { diff --git a/package-lock.json b/package-lock.json index 8f860f034e72..705526d800d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "kratos", "dependencies": { "@openapitools/openapi-generator-cli": "2.7.0", "yamljs": "0.3.0" diff --git a/persistence/sql/identity/persister_identity.go b/persistence/sql/identity/persister_identity.go index 8c001bc87e6e..984fb0199da2 100644 --- a/persistence/sql/identity/persister_identity.go +++ b/persistence/sql/identity/persister_identity.go @@ -47,7 +47,7 @@ var ( ) type dependencies interface { - schema.IdentityTraitsProvider + schema.IdentitySchemaProvider identity.ValidationProvider x.LoggingProvider config.Provider @@ -267,9 +267,9 @@ INNER JOIN identity_credentials FROM identity_credential_types WHERE name = ? ) -WHERE identity_credentials.config ->> '%s' = ? +WHERE identity_credentials.config ->> '%s' = ? AND identity_credentials.config ->> '%s' IS NOT NULL AND identities.nid = ? -LIMIT 1`, jsonPath), +LIMIT 1`, jsonPath, jsonPath), identity.CredentialsTypeWebAuthn, base64.StdEncoding.EncodeToString(userHandle), p.NetworkID(ctx), @@ -477,6 +477,9 @@ func (p *IdentityPersister) normalizeVerifiableAddresses(ctx context.Context, id if v.Verified && (v.VerifiedAt == nil || time.Time(*v.VerifiedAt).IsZero()) { v.VerifiedAt = pointerx.Ptr(sqlxx.NullTime(time.Now())) } + if !v.Verified { + v.VerifiedAt = nil + } id.VerifiableAddresses[k] = v } diff --git a/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.cockroach.down.sql b/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.cockroach.down.sql new file mode 100644 index 000000000000..dd8f3d45ff55 --- /dev/null +++ b/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.cockroach.down.sql @@ -0,0 +1 @@ +DROP INDEX identity_credentials_config_user_handle_idx; \ No newline at end of file diff --git a/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.cockroach.up.sql b/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.cockroach.up.sql new file mode 100644 index 000000000000..a953dd44b8b1 --- /dev/null +++ b/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.cockroach.up.sql @@ -0,0 +1,4 @@ +CREATE INDEX identity_credentials_config_user_handle_idx + ON identity_credentials ((config ->> 'user_handle')) + WHERE config ->> 'user_handle' IS NOT NULL +; diff --git a/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.down.sql b/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.down.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.up.sql b/persistence/sql/migrations/sql/20240425095000000000_identity_credentials_fix_user_handle_index.up.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.cockroach.down.sql b/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.cockroach.down.sql new file mode 100644 index 000000000000..14c295e29c5d --- /dev/null +++ b/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.cockroach.down.sql @@ -0,0 +1,3 @@ +CREATE INVERTED INDEX identity_credentials_user_handle_idx + ON identity_credentials (config) + WHERE config ->> 'user_handle' IS NOT NULL; \ No newline at end of file diff --git a/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.cockroach.up.sql b/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.cockroach.up.sql new file mode 100644 index 000000000000..91e0c2a6c2c2 --- /dev/null +++ b/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.cockroach.up.sql @@ -0,0 +1 @@ +DROP INDEX identity_credentials_user_handle_idx; diff --git a/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.down.sql b/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.down.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.up.sql b/persistence/sql/migrations/sql/20240425095000000001_identity_credentials_fix_user_handle_index.up.sql new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/persistence/sql/persister.go b/persistence/sql/persister.go index 99990b7ace91..85bcdf7466c8 100644 --- a/persistence/sql/persister.go +++ b/persistence/sql/persister.go @@ -40,7 +40,7 @@ type ( config.Provider contextx.Provider x.TracingProvider - schema.IdentityTraitsProvider + schema.IdentitySchemaProvider identity.ValidationProvider } Persister struct { diff --git a/persistence/sql/persister_hmac_test.go b/persistence/sql/persister_hmac_test.go index 05dcd5985908..c7adcdce3a1e 100644 --- a/persistence/sql/persister_hmac_test.go +++ b/persistence/sql/persister_hmac_test.go @@ -52,7 +52,7 @@ func (l *logRegistryOnly) Audit() *logrusx.Logger { func (l *logRegistryOnly) Tracer(ctx context.Context) *otelx.Tracer { return otelx.NewNoop(l.l, new(otelx.Config)) } -func (l *logRegistryOnly) IdentityTraitsSchemas(ctx context.Context) (schema.Schemas, error) { +func (l *logRegistryOnly) IdentityTraitsSchemas(ctx context.Context) (schema.IdentitySchemaList, error) { panic("implement me") } diff --git a/persistence/sql/persister_session.go b/persistence/sql/persister_session.go index 7cbd968f50a2..c0c1c3d865ba 100644 --- a/persistence/sql/persister_session.go +++ b/persistence/sql/persister_session.go @@ -185,10 +185,21 @@ func (p *Persister) UpsertSession(ctx context.Context, s *session.Session) (err s.NID = p.NetworkID(ctx) - return errors.WithStack(p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) error { + var updated bool + defer func() { + if err != nil { + return + } + if updated { + trace.SpanFromContext(ctx).AddEvent(events.NewSessionChanged(ctx, string(s.AuthenticatorAssuranceLevel), s.ID, s.IdentityID)) + } else { + trace.SpanFromContext(ctx).AddEvent(events.NewSessionIssued(ctx, string(s.AuthenticatorAssuranceLevel), s.ID, s.IdentityID)) + } + }() + return errors.WithStack(p.Transaction(ctx, func(ctx context.Context, tx *pop.Connection) (err error) { + updated = false exists := false if !s.ID.IsNil() { - var err error exists, err = tx.Where("id = ? AND nid = ?", s.ID, s.NID).Exists(new(session.Session)) if err != nil { return sqlcon.HandleError(err) @@ -198,10 +209,10 @@ func (p *Persister) UpsertSession(ctx context.Context, s *session.Session) (err if exists { // This must not be eager or identities will be created / updated // Only update session and not corresponding session device records - if err := tx.Update(s); err != nil { + if err := tx.Update(s, "issued_at", "identity_id", "nid"); err != nil { return sqlcon.HandleError(err) } - trace.SpanFromContext(ctx).AddEvent(events.NewSessionChanged(ctx, string(s.AuthenticatorAssuranceLevel), s.ID, s.IdentityID)) + updated = true return nil } @@ -227,7 +238,6 @@ func (p *Persister) UpsertSession(ctx context.Context, s *session.Session) (err } } - trace.SpanFromContext(ctx).AddEvent(events.NewSessionIssued(ctx, string(s.AuthenticatorAssuranceLevel), s.ID, s.IdentityID)) return nil })) } diff --git a/schema/handler.go b/schema/handler.go index e154ae75d699..ff06bc8c43ef 100644 --- a/schema/handler.go +++ b/schema/handler.go @@ -31,7 +31,7 @@ type ( handlerDependencies interface { x.WriterProvider x.LoggingProvider - IdentityTraitsProvider + IdentitySchemaProvider x.CSRFProvider config.Provider x.TracingProvider diff --git a/schema/schema.go b/schema/schema.go index 69b6bbca7332..40671c94a000 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -4,6 +4,7 @@ package schema import ( + "cmp" "context" "encoding/base64" "io" @@ -21,16 +22,58 @@ import ( "github.com/ory/x/urlx" ) +var _ IdentitySchemaList = (*Schemas)(nil) + type Schemas []Schema -type IdentityTraitsProvider interface { - IdentityTraitsSchemas(ctx context.Context) (Schemas, error) + +type IdentitySchemaProvider interface { + IdentityTraitsSchemas(ctx context.Context) (IdentitySchemaList, error) } -func (s Schemas) GetByID(id string) (*Schema, error) { - if id == "" { - id = config.DefaultIdentityTraitsSchemaID +type deps interface { + config.Provider +} + +type DefaultIdentitySchemaProvider struct { + d deps +} + +func NewDefaultIdentityTraitsProvider(d deps) *DefaultIdentitySchemaProvider { + return &DefaultIdentitySchemaProvider{d: d} +} + +func (d *DefaultIdentitySchemaProvider) IdentityTraitsSchemas(ctx context.Context) (IdentitySchemaList, error) { + ms, err := d.d.Config().IdentityTraitsSchemas(ctx) + if err != nil { + return nil, err + } + + var ss Schemas + for _, s := range ms { + surl, err := url.Parse(s.URL) + if err != nil { + return nil, errors.WithStack(err) + } + + ss = append(ss, Schema{ + ID: s.ID, + URL: surl, + RawURL: s.URL, + }) } + return ss, nil +} + +type IdentitySchemaList interface { + GetByID(id string) (*Schema, error) + Total() int + List(page, perPage int) Schemas +} + +func (s Schemas) GetByID(id string) (*Schema, error) { + id = cmp.Or(id, config.DefaultIdentityTraitsSchemaID) + for _, ss := range s { if ss.ID == id { return &ss, nil @@ -98,11 +141,16 @@ func GetKeysInOrder(ctx context.Context, schemaRef string) ([]string, error) { } type Schema struct { - ID string `json:"id"` - URL *url.URL `json:"-"` - RawURL string `json:"url"` + ID string `json:"id"` + URL *url.URL `json:"-"` + // RawURL contains the raw URL value as it was passed in the configuration. URL parsing can break base64 encoded URLs. + RawURL string `json:"url"` } func (s *Schema) SchemaURL(host *url.URL) *url.URL { - return urlx.AppendPaths(host, SchemasPath, base64.RawURLEncoding.EncodeToString([]byte(s.ID))) + return IDToURL(host, s.ID) +} + +func IDToURL(host *url.URL, id string) *url.URL { + return urlx.AppendPaths(host, SchemasPath, base64.RawURLEncoding.EncodeToString([]byte(id))) } diff --git a/selfservice/flow/login/handler_test.go b/selfservice/flow/login/handler_test.go index 813a0c8ad669..c8d5ac97772e 100644 --- a/selfservice/flow/login/handler_test.go +++ b/selfservice/flow/login/handler_test.go @@ -547,12 +547,19 @@ func TestFlowLifecycle(t *testing.T) { }) t.Run("case=returns session exchange code with any truthy value", func(t *testing.T) { + conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{"https://www.ory.sh", "https://example.com"}) parameters := []string{"true", "True", "1"} - for i := range parameters { - res, body := initFlow(t, url.Values{"return_session_token_exchange_code": {parameters[i]}}, true) - assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow) - assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) + for _, param := range parameters { + t.Run("return_session_token_exchange_code="+param, func(t *testing.T) { + res, body := initFlow(t, url.Values{ + "return_session_token_exchange_code": {param}, + "return_to": {"https://example.com/redirect"}, + }, true) + assert.Contains(t, res.Request.URL.String(), login.RouteInitAPIFlow) + assert.NotEmpty(t, gjson.GetBytes(body, "session_token_exchange_code").String()) + assert.Equal(t, "https://example.com/redirect", gjson.GetBytes(body, "return_to").String()) + }) } }) diff --git a/selfservice/flow/settings/error.go b/selfservice/flow/settings/error.go index 2fd190c888e9..2583cd9dd526 100644 --- a/selfservice/flow/settings/error.go +++ b/selfservice/flow/settings/error.go @@ -4,7 +4,6 @@ package settings import ( - "context" "net/http" "net/url" @@ -43,7 +42,7 @@ type ( HandlerProvider FlowPersistenceProvider - IdentityTraitsSchemas(ctx context.Context) (schema.Schemas, error) + schema.IdentitySchemaProvider } ErrorHandlerProvider interface{ SettingsFlowErrorHandler() *ErrorHandler } diff --git a/selfservice/flow/settings/handler.go b/selfservice/flow/settings/handler.go index 8b96ce85369a..efc1e5a84bc3 100644 --- a/selfservice/flow/settings/handler.go +++ b/selfservice/flow/settings/handler.go @@ -68,7 +68,7 @@ type ( HookExecutorProvider x.CSRFTokenGeneratorProvider - schema.IdentityTraitsProvider + schema.IdentitySchemaProvider login.HandlerProvider } diff --git a/selfservice/hook/show_verification_ui.go b/selfservice/hook/show_verification_ui.go index 566546a028da..65a5935ec7a6 100644 --- a/selfservice/hook/show_verification_ui.go +++ b/selfservice/hook/show_verification_ui.go @@ -50,7 +50,7 @@ func (e *ShowVerificationUIHook) ExecutePostRegistrationPostPersistHook(_ http.R }) } -// ExecutePostRegistrationPostPersistHook adds redirect headers and status code if the request is a browser request. +// ExecuteLoginPostHook adds redirect headers and status code if the request is a browser request. // If the request is not a browser request, this hook does nothing. func (e *ShowVerificationUIHook) ExecuteLoginPostHook(_ http.ResponseWriter, r *http.Request, _ node.UiNodeGroup, f *login.Flow, _ *session.Session) error { return otelx.WithSpan(r.Context(), "selfservice.hook.ShowVerificationUIHook.ExecutePostRegistrationPostPersistHook", func(ctx context.Context) error { diff --git a/selfservice/hook/stub/test_body.jsonnet b/selfservice/hook/stub/test_body.jsonnet index 409fa13695ca..117ecc587707 100644 --- a/selfservice/hook/stub/test_body.jsonnet +++ b/selfservice/hook/stub/test_body.jsonnet @@ -1,6 +1,7 @@ function(ctx) std.prune({ flow_id: ctx.flow.id, identity_id: if std.objectHas(ctx, "identity") then ctx.identity.id, + session_id: if std.objectHas(ctx, "session") then ctx.session.id, headers: ctx.request_headers, url: ctx.request_url, method: ctx.request_method, diff --git a/selfservice/hook/web_hook.go b/selfservice/hook/web_hook.go index cbb9f0a0b0d4..d4c8131e50d5 100644 --- a/selfservice/hook/web_hook.go +++ b/selfservice/hook/web_hook.go @@ -81,6 +81,7 @@ type ( RequestURL string `json:"request_url"` RequestCookies map[string]string `json:"request_cookies"` Identity *identity.Identity `json:"identity,omitempty"` + Session *session.Session `json:"session,omitempty"` } WebHook struct { @@ -140,6 +141,7 @@ func (e *WebHook) ExecuteLoginPostHook(_ http.ResponseWriter, req *http.Request, RequestURL: x.RequestURL(req).String(), RequestCookies: cookies(req), Identity: session.Identity, + Session: session, }) }) } diff --git a/selfservice/hook/web_hook_integration_test.go b/selfservice/hook/web_hook_integration_test.go index c3d8344fc7a8..59159f49b6cf 100644 --- a/selfservice/hook/web_hook_integration_test.go +++ b/selfservice/hook/web_hook_integration_test.go @@ -148,6 +148,24 @@ func TestWebHooks(t *testing.T) { }`, f.GetID(), s.Identity.ID, string(h), req.Method, "http://www.ory.sh/some_end_point", string(tp)) } + bodyWithFlowAndIdentityAndSessionAndTransientPayload := func(req *http.Request, f flow.Flow, s *session.Session, tp json.RawMessage) string { + h, _ := json.Marshal(req.Header) + return fmt.Sprintf(`{ + "flow_id": "%s", + "identity_id": "%s", + "session_id": "%s", + "headers": %s, + "method": "%s", + "url": "%s", + "cookies": { + "Some-Cookie-1": "Some-Cookie-Value", + "Some-Cookie-2": "Some-other-Cookie-Value", + "Some-Cookie-3": "Third-Cookie-Value" + }, + "transient_payload": %s + }`, f.GetID(), s.Identity.ID, s.ID, string(h), req.Method, "http://www.ory.sh/some_end_point", string(tp)) + } + for _, tc := range []struct { uc string callWebHook func(wh *hook.WebHook, req *http.Request, f flow.Flow, s *session.Session) error @@ -171,7 +189,7 @@ func TestWebHooks(t *testing.T) { return wh.ExecuteLoginPostHook(nil, req, node.PasswordGroup, f.(*login.Flow), s) }, expectedBody: func(req *http.Request, f flow.Flow, s *session.Session) string { - return bodyWithFlowAndIdentityAndTransientPayload(req, f, s, transientPayload) + return bodyWithFlowAndIdentityAndSessionAndTransientPayload(req, f, s, transientPayload) }, }, { diff --git a/selfservice/strategy/code/strategy.go b/selfservice/strategy/code/strategy.go index fd8993447744..ee3ce353e4ae 100644 --- a/selfservice/strategy/code/strategy.go +++ b/selfservice/strategy/code/strategy.go @@ -103,7 +103,7 @@ type ( RegistrationCodePersistenceProvider LoginCodePersistenceProvider - schema.IdentityTraitsProvider + schema.IdentitySchemaProvider session.PersistenceProvider sessiontokenexchange.PersistenceProvider diff --git a/selfservice/strategy/code/strategy_verification.go b/selfservice/strategy/code/strategy_verification.go index 2f80a4490982..5b30e4c2f5ec 100644 --- a/selfservice/strategy/code/strategy_verification.go +++ b/selfservice/strategy/code/strategy_verification.go @@ -254,15 +254,6 @@ func (s *Strategy) verificationUseCode(w http.ResponseWriter, r *http.Request, c return s.retryVerificationFlowWithError(w, r, f.Type, err) } - i, err := s.deps.IdentityPool().GetIdentity(r.Context(), code.VerifiableAddress.IdentityID, identity.ExpandDefault) - if err != nil { - return s.retryVerificationFlowWithError(w, r, f.Type, err) - } - - if err := s.deps.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil { - return s.retryVerificationFlowWithError(w, r, f.Type, err) - } - address := code.VerifiableAddress address.Verified = true verifiedAt := sqlxx.NullTime(time.Now().UTC()) @@ -272,6 +263,11 @@ func (s *Strategy) verificationUseCode(w http.ResponseWriter, r *http.Request, c return s.retryVerificationFlowWithError(w, r, f.Type, err) } + i, err := s.deps.IdentityPool().GetIdentity(r.Context(), code.VerifiableAddress.IdentityID, identity.ExpandDefault) + if err != nil { + return s.retryVerificationFlowWithError(w, r, f.Type, err) + } + returnTo := f.ContinueURL(r.Context(), s.deps.Config()) f.UI = &container.Container{ @@ -292,6 +288,10 @@ func (s *Strategy) verificationUseCode(w http.ResponseWriter, r *http.Request, c return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) } + if err := s.deps.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil { + return s.retryVerificationFlowWithError(w, r, f.Type, err) + } + return nil } diff --git a/selfservice/strategy/code/strategy_verification_test.go b/selfservice/strategy/code/strategy_verification_test.go index 6f25e324d8b2..322dc56eabd2 100644 --- a/selfservice/strategy/code/strategy_verification_test.go +++ b/selfservice/strategy/code/strategy_verification_test.go @@ -13,6 +13,7 @@ import ( "net/http/httptest" "net/url" "strings" + "sync" "testing" "time" @@ -297,6 +298,13 @@ func TestVerification(t *testing.T) { }) t.Run("description=should verify an email address", func(t *testing.T) { + var wg sync.WaitGroup + testhelpers.NewVerifyAfterHookWebHookTarget(ctx, t, conf, func(t *testing.T, msg []byte) { + defer wg.Done() + assert.EqualValues(t, true, gjson.GetBytes(msg, "identity.verifiable_addresses.0.verified").Bool(), string(msg)) + assert.EqualValues(t, "completed", gjson.GetBytes(msg, "identity.verifiable_addresses.0.status").String(), string(msg)) + }) + check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.CodeGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, verificationEmail, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual) @@ -342,15 +350,21 @@ func TestVerification(t *testing.T) { } t.Run("type=browser", func(t *testing.T) { + wg.Add(1) check(t, expectSuccess(t, nil, false, false, values)) + wg.Wait() }) t.Run("type=spa", func(t *testing.T) { + wg.Add(1) check(t, expectSuccess(t, nil, false, true, values)) + wg.Wait() }) t.Run("type=api", func(t *testing.T) { + wg.Add(1) check(t, expectSuccess(t, nil, true, false, values)) + wg.Wait() }) }) diff --git a/selfservice/strategy/link/strategy.go b/selfservice/strategy/link/strategy.go index 8188b7e9e873..cdf8356cc4b3 100644 --- a/selfservice/strategy/link/strategy.go +++ b/selfservice/strategy/link/strategy.go @@ -75,7 +75,7 @@ type ( VerificationTokenPersistenceProvider SenderProvider - schema.IdentityTraitsProvider + schema.IdentitySchemaProvider } Strategy struct { diff --git a/selfservice/strategy/link/strategy_verification.go b/selfservice/strategy/link/strategy_verification.go index 6fe054f746fb..a2a72ea9a277 100644 --- a/selfservice/strategy/link/strategy_verification.go +++ b/selfservice/strategy/link/strategy_verification.go @@ -216,15 +216,6 @@ func (s *Strategy) verificationUseToken(w http.ResponseWriter, r *http.Request, return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) } - i, err := s.d.IdentityPool().GetIdentity(r.Context(), token.VerifiableAddress.IdentityID, identity.ExpandDefault) - if err != nil { - return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) - } - - if err := s.d.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil { - return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) - } - address := token.VerifiableAddress address.Verified = true verifiedAt := sqlxx.NullTime(time.Now().UTC()) @@ -234,6 +225,11 @@ func (s *Strategy) verificationUseToken(w http.ResponseWriter, r *http.Request, return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) } + i, err := s.d.IdentityPool().GetIdentity(r.Context(), token.VerifiableAddress.IdentityID, identity.ExpandDefault) + if err != nil { + return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) + } + returnTo := f.ContinueURL(r.Context(), s.d.Config()) f.UI. @@ -259,6 +255,10 @@ func (s *Strategy) verificationUseToken(w http.ResponseWriter, r *http.Request, return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) } + if err := s.d.VerificationExecutor().PostVerificationHook(w, r, f, i); err != nil { + return s.retryVerificationFlowWithError(w, r, flow.TypeBrowser, err) + } + return nil } diff --git a/selfservice/strategy/link/strategy_verification_test.go b/selfservice/strategy/link/strategy_verification_test.go index cab8fec60b99..84ee51ae9dd1 100644 --- a/selfservice/strategy/link/strategy_verification_test.go +++ b/selfservice/strategy/link/strategy_verification_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "sync" "testing" "time" @@ -270,6 +271,12 @@ func TestVerification(t *testing.T) { }) t.Run("description=should verify an email address", func(t *testing.T) { + var wg sync.WaitGroup + testhelpers.NewVerifyAfterHookWebHookTarget(ctx, t, conf, func(t *testing.T, msg []byte) { + defer wg.Done() + assert.EqualValues(t, true, gjson.GetBytes(msg, "identity.verifiable_addresses.0.verified").Bool(), string(msg)) + assert.EqualValues(t, "completed", gjson.GetBytes(msg, "identity.verifiable_addresses.0.status").String(), string(msg)) + }) check := func(t *testing.T, actual string) { assert.EqualValues(t, string(node.LinkGroup), gjson.Get(actual, "active").String(), "%s", actual) assert.EqualValues(t, verificationEmail, gjson.Get(actual, "ui.nodes.#(attributes.name==email).attributes.value").String(), "%s", actual) @@ -310,15 +317,21 @@ func TestVerification(t *testing.T) { } t.Run("type=browser", func(t *testing.T) { + wg.Add(1) check(t, expectSuccess(t, nil, false, false, values)) + wg.Wait() }) t.Run("type=spa", func(t *testing.T) { + wg.Add(1) check(t, expectSuccess(t, nil, false, true, values)) + wg.Wait() }) t.Run("type=api", func(t *testing.T) { + wg.Add(1) check(t, expectSuccess(t, nil, true, false, values)) + wg.Wait() }) }) diff --git a/selfservice/strategy/oidc/provider_linkedin_v2.go b/selfservice/strategy/oidc/provider_linkedin_v2.go index 7ce40239ef46..a71d801c24bd 100644 --- a/selfservice/strategy/oidc/provider_linkedin_v2.go +++ b/selfservice/strategy/oidc/provider_linkedin_v2.go @@ -3,18 +3,6 @@ package oidc -import ( - "context" - "net/url" - - gooidc "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -type ProviderLinkedInV2 struct { - *ProviderGenericOIDC -} - func NewProviderLinkedInV2( config *Configuration, reg Dependencies, @@ -22,26 +10,8 @@ func NewProviderLinkedInV2( config.ClaimsSource = ClaimsSourceUserInfo config.IssuerURL = "https://www.linkedin.com/oauth" - return &ProviderLinkedInV2{ - ProviderGenericOIDC: &ProviderGenericOIDC{ - config: config, - reg: reg, - }, + return &ProviderGenericOIDC{ + config: config, + reg: reg, } } - -func (l *ProviderLinkedInV2) wrapCtx(ctx context.Context) context.Context { - // We need to overwrite the issuer here because the discovery URL is under - // `https://www.linkedin.com/oauth/.well-known/openid-configuration`, wherease - // the issuer is `https://www.linkedin.com` (without the `/oauth`). This is - // not conformant according to the OIDC spec, but needed for LinkedIn. - return gooidc.InsecureIssuerURLContext(ctx, "https://www.linkedin.com") -} - -func (l *ProviderLinkedInV2) OAuth2(ctx context.Context) (*oauth2.Config, error) { - return l.ProviderGenericOIDC.OAuth2(l.wrapCtx(ctx)) -} - -func (l *ProviderLinkedInV2) Claims(ctx context.Context, exchange *oauth2.Token, query url.Values) (*Claims, error) { - return l.ProviderGenericOIDC.Claims(l.wrapCtx(ctx), exchange, query) -} diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index b710d7423c85..449bfece3878 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -370,7 +370,7 @@ func (s *Strategy) alreadyAuthenticated(w http.ResponseWriter, r *http.Request, returnTo := s.d.Config().SelfServiceBrowserDefaultReturnTo(ctx) if redirecter, ok := f.(flow.FlowWithRedirect); ok { r, err := x.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(ctx, s.d)...) - if err != nil { + if err == nil { returnTo = r } } @@ -462,6 +462,9 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request, ps htt case *login.Flow: a.TransientPayload = cntnr.TransientPayload if ff, err := s.processLogin(w, r, a, et, claims, provider, cntnr); err != nil { + if errors.Is(err, flow.ErrCompletedByStrategy) { + return + } if ff != nil { s.forwardError(w, r, ff, err) return @@ -631,7 +634,19 @@ func (s *Strategy) handleError(w http.ResponseWriter, r *http.Request, f flow.Fl return err } // return a new login flow with the error message embedded in the login flow. - redirectURL := lf.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())) + var redirectURL *url.URL + if lf.Type == flow.TypeAPI { + returnTo := s.d.Config().SelfServiceBrowserDefaultReturnTo(r.Context()) + if redirecter, ok := f.(flow.FlowWithRedirect); ok { + secureReturnTo, err := x.SecureRedirectTo(r, returnTo, redirecter.SecureRedirectToOpts(r.Context(), s.d)...) + if err == nil { + returnTo = secureReturnTo + } + } + redirectURL = lf.AppendTo(returnTo) + } else { + redirectURL = lf.AppendTo(s.d.Config().SelfServiceFlowLoginUI(r.Context())) + } if dc, err := flow.DuplicateCredentials(lf); err == nil && dc != nil { redirectURL = urlx.CopyWithQuery(redirectURL, url.Values{"no_org_ui": {"true"}}) diff --git a/selfservice/strategy/oidc/strategy_test.go b/selfservice/strategy/oidc/strategy_test.go index 74ea4e0726d6..b02e4fc45853 100644 --- a/selfservice/strategy/oidc/strategy_test.go +++ b/selfservice/strategy/oidc/strategy_test.go @@ -184,7 +184,7 @@ func TestStrategy(t *testing.T) { return res, body } - makeAPICodeFlowRequest := func(t *testing.T, provider, action string) (returnToCode string) { + makeAPICodeFlowRequest := func(t *testing.T, provider, action string) (returnToURL *url.URL) { res, err := testhelpers.NewDebugClient(t).Post(action, "application/json", strings.NewReader(fmt.Sprintf(`{ "method": "oidc", "provider": %q @@ -197,13 +197,10 @@ func TestStrategy(t *testing.T) { res, err = testhelpers.NewClientWithCookieJar(t, nil, true).Get(changeLocation.RedirectBrowserTo) require.NoError(t, err) - returnToURL := res.Request.URL + returnToURL = res.Request.URL assert.True(t, strings.HasPrefix(returnToURL.String(), returnTS.URL+"/app_code")) - code := returnToURL.Query().Get("code") - assert.NotEmpty(t, code, "code query param was empty in the return_to URL") - - return code + return returnToURL } exchangeCodeForToken := func(t *testing.T, codes sessiontokenexchange.Codes) (codeResponse session.CodeExchangeResponse, err error) { @@ -553,12 +550,15 @@ func TestStrategy(t *testing.T) { t.Run("suite=API with session token exchange code", func(t *testing.T) { scope = []string{"openid"} - loginOrRegister := func(t *testing.T, id uuid.UUID, code string) { + loginOrRegister := func(t *testing.T, flowID uuid.UUID, code string) { _, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{InitCode: code}) require.Error(t, err) - action := assertFormValues(t, id, "valid") - returnToCode := makeAPICodeFlowRequest(t, "valid", action) + action := assertFormValues(t, flowID, "valid") + returnToURL := makeAPICodeFlowRequest(t, "valid", action) + returnToCode := returnToURL.Query().Get("code") + assert.NotEmpty(t, code, "code query param was empty in the return_to URL") + codeResponse, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{ InitCode: code, ReturnToCode: returnToCode, @@ -568,11 +568,11 @@ func TestStrategy(t *testing.T) { assert.NotEmpty(t, codeResponse.Token) assert.Equal(t, subject, gjson.GetBytes(codeResponse.Session.Identity.Traits, "subject").String()) } - register := func(t *testing.T) { + performRegistration := func(t *testing.T) { f := newAPIRegistrationFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute) loginOrRegister(t, f.ID, f.SessionTokenExchangeCode) } - login := func(t *testing.T) { + performLogin := func(t *testing.T) { f := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute) loginOrRegister(t, f.ID, f.SessionTokenExchangeCode) } @@ -582,16 +582,16 @@ func TestStrategy(t *testing.T) { first, then func(*testing.T) }{{ name: "login-twice", - first: login, then: login, + first: performLogin, then: performLogin, }, { name: "login-then-register", - first: login, then: register, + first: performLogin, then: performRegistration, }, { name: "register-then-login", - first: register, then: login, + first: performRegistration, then: performLogin, }, { name: "register-twice", - first: register, then: register, + first: performRegistration, then: performRegistration, }} { t.Run("case="+tc.name, func(t *testing.T) { subject = tc.name + "-api-code-testing@ory.sh" @@ -599,6 +599,31 @@ func TestStrategy(t *testing.T) { tc.then(t) }) } + t.Run("case=should use redirect_to URL on failure", func(t *testing.T) { + ctx := context.Background() + subject = "existing-subject-api-code-testing@ory.sh" + + 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(ctx, i)) + + f := newAPILoginFlow(t, returnTS.URL+"?return_session_token_exchange_code=true&return_to=/app_code", 1*time.Minute) + + _, err := exchangeCodeForToken(t, sessiontokenexchange.Codes{InitCode: f.SessionTokenExchangeCode}) + require.Error(t, err) + + action := assertFormValues(t, f.ID, "valid") + returnToURL := makeAPICodeFlowRequest(t, "valid", action) + returnedFlow := returnToURL.Query().Get("flow") + + require.NotEmpty(t, returnedFlow, "flow query param was empty in the return_to URL") + loginFlow, err := reg.LoginFlowPersister().GetLoginFlow(ctx, uuid.FromStringOrNil(returnedFlow)) + require.NoError(t, err) + assert.Equal(t, text.ErrorValidationDuplicateCredentials, loginFlow.UI.Messages[0].ID) + }) }) t.Run("case=submit id_token during registration or login", func(t *testing.T) { diff --git a/selfservice/strategy/passkey/.schema/settings.schema.json b/selfservice/strategy/passkey/.schema/settings.schema.json index 7753e441d04f..58bf997024fd 100644 --- a/selfservice/strategy/passkey/.schema/settings.schema.json +++ b/selfservice/strategy/passkey/.schema/settings.schema.json @@ -7,7 +7,7 @@ "type": "string" }, "method": { - "const": "passkey" + "type": "string" }, "passkey_settings_register": { "type": "string" diff --git a/selfservice/strategy/passkey/passkey_registration_test.go b/selfservice/strategy/passkey/passkey_registration_test.go index 1a4759dfa09e..d495e8c4dfe4 100644 --- a/selfservice/strategy/passkey/passkey_registration_test.go +++ b/selfservice/strategy/passkey/passkey_registration_test.go @@ -372,7 +372,7 @@ func TestRegistration(t *testing.T) { assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual) registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username") assert.Equal(t, - "You tried signing in with "+email+" which is already in use by another account. You can sign in using your password.", + "You tried signing in with "+email+" which is already in use by another account. You can sign in using your passkey.", gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual) }) } diff --git a/selfservice/strategy/passkey/passkey_settings.go b/selfservice/strategy/passkey/passkey_settings.go index 05d49ab56f63..548a261e442b 100644 --- a/selfservice/strategy/passkey/passkey_settings.go +++ b/selfservice/strategy/passkey/passkey_settings.go @@ -125,7 +125,8 @@ func (s *Strategy) PopulateSettingsMethod(r *http.Request, id *identity.Identity Attributes: &node.InputAttributes{ Name: node.PasskeySettingsRegister, Type: node.InputAttributeTypeHidden, - }}) + }, + }) f.UI.Nodes.Upsert(&node.Node{ Type: node.Input, @@ -135,7 +136,8 @@ func (s *Strategy) PopulateSettingsMethod(r *http.Request, id *identity.Identity Name: node.PasskeyCreateData, Type: node.InputAttributeTypeHidden, FieldValue: string(injectWebAuthnOptions), - }}) + }, + }) return nil } @@ -156,7 +158,7 @@ func (s *Strategy) identityListWebAuthn(id *identity.Identity) (*identity.Creden func (s *Strategy) Settings(w http.ResponseWriter, r *http.Request, f *settings.Flow, ss *session.Session) (*settings.UpdateContext, error) { if f.Type != flow.TypeBrowser { - return nil, flow.ErrStrategyNotResponsible + return nil, errors.WithStack(flow.ErrStrategyNotResponsible) } var p updateSettingsFlowWithPasskeyMethod ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, f, ss, settings.ContinuityKey(s.SettingsStrategyID()), &p) diff --git a/selfservice/strategy/profile/strategy.go b/selfservice/strategy/profile/strategy.go index 0942d738840e..b83e6ad527b2 100644 --- a/selfservice/strategy/profile/strategy.go +++ b/selfservice/strategy/profile/strategy.go @@ -62,7 +62,7 @@ type ( registration.FlowPersistenceProvider - schema.IdentityTraitsProvider + schema.IdentitySchemaProvider } Strategy struct { d strategyDependencies @@ -81,19 +81,19 @@ func (s *Strategy) SettingsStrategyID() string { func (s *Strategy) RegisterSettingsRoutes(public *x.RouterPublic) {} func (s *Strategy) PopulateSettingsMethod(r *http.Request, id *identity.Identity, f *settings.Flow) error { - schemas, err := s.d.Config().IdentityTraitsSchemas(r.Context()) + schemas, err := s.d.IdentityTraitsSchemas(r.Context()) if err != nil { return err } - traitsSchema, err := schemas.FindSchemaByID(id.SchemaID) + traitsSchema, err := schemas.GetByID(id.SchemaID) if err != nil { return err } // use a schema compiler that disables identifiers schemaCompiler := jsonschema.NewCompiler() - nodes, err := container.NodesFromJSONSchema(r.Context(), node.ProfileGroup, traitsSchema.URL, "", schemaCompiler) + nodes, err := container.NodesFromJSONSchema(r.Context(), node.ProfileGroup, traitsSchema.URL.String(), "", schemaCompiler) if err != nil { return err } diff --git a/selfservice/strategy/webauthn/registration_test.go b/selfservice/strategy/webauthn/registration_test.go index 035e7ffd984c..c0503b151ed8 100644 --- a/selfservice/strategy/webauthn/registration_test.go +++ b/selfservice/strategy/webauthn/registration_test.go @@ -438,7 +438,7 @@ func TestRegistration(t *testing.T) { 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, "You tried signing in with "+email+" which is already in use by another account. You can sign in using your password.", gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual) + assert.Equal(t, "You tried signing in with "+email+" which is already in use by another account. You can sign in using your passkey or a security key.", gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual) }) } }) diff --git a/session/handler.go b/session/handler.go index 3d9d0787404f..8953fb37c6c3 100644 --- a/session/handler.go +++ b/session/handler.go @@ -338,11 +338,19 @@ type listSessionsRequest struct { // If no value is provided, the expandable properties are skipped. // // required: false - // enum: identity,devices // in: query - ExpandOptions []string `json:"expand"` + ExpandOptions []SessionExpandable `json:"expand"` } +// Expandable properties of a session +// swagger:enum SessionExpandable +type SessionExpandable string + +const ( + SessionExpandableIdentity SessionExpandable = "identity" + SessionExpandableDevices SessionExpandable = "devices" +) + // Session List Response // // The response given when listing sessions in an administrative context. @@ -432,9 +440,8 @@ type getSession struct { // If no value is provided, the expandable properties are skipped. // // required: false - // enum: identity,devices // in: query - ExpandOptions []string `json:"expand"` + ExpandOptions []SessionExpandable `json:"expand"` // ID is the session's ID. // diff --git a/session/handler_test.go b/session/handler_test.go index cd0a9ddca6c1..3c61b7764832 100644 --- a/session/handler_test.go +++ b/session/handler_test.go @@ -184,11 +184,13 @@ func TestSessionWhoAmI(t *testing.T) { assert.NotEmpty(t, res.Header.Get("X-Kratos-Authenticated-Identity-Id")) if cacheEnabled { + var expectedSeconds int if maxAge > 0 { - assert.Equal(t, fmt.Sprintf("%0.f", maxAge.Seconds()), res.Header.Get("Ory-Session-Cache-For")) + expectedSeconds = int(maxAge.Seconds()) } else { - assert.Equal(t, fmt.Sprintf("%0.f", conf.SessionLifespan(ctx).Seconds()), res.Header.Get("Ory-Session-Cache-For")) + expectedSeconds = int(conf.SessionLifespan(ctx).Seconds()) } + assert.InDelta(t, expectedSeconds, x.Must(strconv.Atoi(res.Header.Get("Ory-Session-Cache-For"))), 5) } else { assert.Empty(t, res.Header.Get("Ory-Session-Cache-For")) } diff --git a/spec/api.json b/spec/api.json index 76036e26595f..48b0d934d382 100644 --- a/spec/api.json +++ b/spec/api.json @@ -3918,7 +3918,7 @@ ] }, "patch": { - "description": "Creates or delete multiple\n[identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model).\nThis endpoint can also be used to [import\ncredentials](https://www.ory.sh/docs/kratos/manage-identities/import-user-accounts-identities)\nfor instance passwords, social sign in configurations or multifactor methods.", + "description": "Creates multiple\n[identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model).\nThis endpoint can also be used to [import\ncredentials](https://www.ory.sh/docs/kratos/manage-identities/import-user-accounts-identities)\nfor instance passwords, social sign in configurations or multifactor methods.", "operationId": "batchPatchIdentities", "requestBody": { "content": { @@ -3977,7 +3977,7 @@ "oryAccessToken": [] } ], - "summary": "Create and deletes multiple identities", + "summary": "Create multiple identities", "tags": [ "identity" ] @@ -4785,11 +4785,11 @@ "in": "query", "name": "expand", "schema": { - "enum": [ - "identity", - "devices" - ], "items": { + "enum": [ + "identity", + "devices" + ], "type": "string" }, "type": "array" @@ -4901,11 +4901,11 @@ "in": "query", "name": "expand", "schema": { - "enum": [ - "identity", - "devices" - ], "items": { + "enum": [ + "identity", + "devices" + ], "type": "string" }, "type": "array" diff --git a/spec/swagger.json b/spec/swagger.json index 7da943e9f0a8..1d548df9a00f 100755 --- a/spec/swagger.json +++ b/spec/swagger.json @@ -339,7 +339,7 @@ "oryAccessToken": [] } ], - "description": "Creates or delete multiple\n[identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model).\nThis endpoint can also be used to [import\ncredentials](https://www.ory.sh/docs/kratos/manage-identities/import-user-accounts-identities)\nfor instance passwords, social sign in configurations or multifactor methods.", + "description": "Creates multiple\n[identities](https://www.ory.sh/docs/kratos/concepts/identity-user-model).\nThis endpoint can also be used to [import\ncredentials](https://www.ory.sh/docs/kratos/manage-identities/import-user-accounts-identities)\nfor instance passwords, social sign in configurations or multifactor methods.", "consumes": [ "application/json" ], @@ -353,7 +353,7 @@ "tags": [ "identity" ], - "summary": "Create and deletes multiple identities", + "summary": "Create multiple identities", "operationId": "batchPatchIdentities", "parameters": [ { @@ -1039,12 +1039,12 @@ "in": "query" }, { - "enum": [ - "identity", - "devices" - ], "type": "array", "items": { + "enum": [ + "identity", + "devices" + ], "type": "string" }, "description": "ExpandOptions is a query parameter encoded list of all properties that must be expanded in the Session.\nIf no value is provided, the expandable properties are skipped.", @@ -1090,12 +1090,12 @@ "operationId": "getSession", "parameters": [ { - "enum": [ - "identity", - "devices" - ], "type": "array", "items": { + "enum": [ + "identity", + "devices" + ], "type": "string" }, "description": "ExpandOptions is a query parameter encoded list of all properties that must be expanded in the Session.\nExample - ?expand=Identity\u0026expand=Devices\nIf no value is provided, the expandable properties are skipped.", @@ -3251,9 +3251,9 @@ "title": "JSONRawMessage represents a json.RawMessage that works well with JSON, SQL, and Swagger." }, "NullTime": { - "description": "NullTime implements the Scanner interface so\nit can be used as a scan destination, similar to NullString.", + "description": "NullTime implements the [Scanner] interface so\nit can be used as a scan destination, similar to [NullString].", "type": "object", - "title": "NullTime represents a time.Time that may be null.", + "title": "NullTime represents a [time.Time] that may be null.", "properties": { "Time": { "type": "string", diff --git a/text/message_validation.go b/text/message_validation.go index 28396180c0c5..c10fddead805 100644 --- a/text/message_validation.go +++ b/text/message_validation.go @@ -9,8 +9,6 @@ import ( "golang.org/x/text/cases" "golang.org/x/text/language" - - "golang.org/x/exp/maps" ) func NewValidationErrorGeneric(reason string) *Message { @@ -279,25 +277,32 @@ func NewErrorValidationDuplicateCredentialsWithHints(availableCredentialTypes [] reason := fmt.Sprintf("You tried signing in with %s which is already in use by another account.", identifier) if len(availableCredentialTypes) > 0 { - humanReadable := make(map[string]struct{}, len(availableCredentialTypes)) + humanReadable := make([]string, 0, len(availableCredentialTypes)) for _, cred := range availableCredentialTypes { switch cred { case "password": - humanReadable["your password"] = struct{}{} + humanReadable = append(humanReadable, "your password") case "oidc": - humanReadable["social sign in"] = struct{}{} + humanReadable = append(humanReadable, "social sign in") case "webauthn": - humanReadable["your PassKey or a security key"] = struct{}{} + humanReadable = append(humanReadable, "your passkey or a security key") + case "passkey": + humanReadable = append(humanReadable, "your passkey") } } if len(humanReadable) == 0 { // show at least some hint // also our example message generation tool runs into this case - for _, cred := range availableCredentialTypes { - humanReadable[cred] = struct{}{} - } + humanReadable = append(humanReadable, availableCredentialTypes...) + } + + // Final format: "You can sign in using foo, bar, or baz." + if len(humanReadable) > 1 { + humanReadable[len(humanReadable)-1] = "or " + humanReadable[len(humanReadable)-1] + } + if len(humanReadable) > 0 { + reason += fmt.Sprintf(" You can sign in using %s.", strings.Join(humanReadable, ", ")) } - reason += fmt.Sprintf(" You can sign in using %s.", strings.Join(maps.Keys(humanReadable), ", ")) } if len(oidcProviders) > 0 { reason += fmt.Sprintf(" You can sign in using one of the following social sign in providers: %s.", strings.Join(oidcProviders, ", "))