snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"useful": "admin metadata"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "updated-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"useful": "metadata"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@other-example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@other-example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "inactive",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@other-example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@other-example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@other-example.org",
"verified": false,
"via": "email",
"status": "",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,51 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

View File

@@ -0,0 +1,53 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"external_id": "original-external-id",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

View File

@@ -0,0 +1,52 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"credentials": {
"password": {
"type": "password",
"identifiers": [
"test"
],
"config": {
"hashed_password": "$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"
},
"version": 0,
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
},
"schema_id": "default",
"schema_url": "file://stub/default.schema.json",
"state": "active",
"traits": {
"email": "some@example.org"
},
"verifiable_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"verified": false,
"via": "email",
"status": "pending",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"recovery_addresses": [
{
"id": "00000000-0000-0000-0000-000000000000",
"value": "some@example.org",
"via": "email",
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}
],
"metadata_public": {
"public": "data"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z",
"organization_id": null
}

101
selfservice/hook/error.go Normal file
View File

@@ -0,0 +1,101 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"encoding/json"
"errors"
"net/http"
"github.com/tidwall/gjson"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/session"
)
var (
_ registration.PostHookPrePersistExecutor = new(Error)
_ registration.PostHookPostPersistExecutor = new(Error)
_ registration.PreHookExecutor = new(Error)
_ login.PreHookExecutor = new(Error)
_ login.PostHookExecutor = new(Error)
_ login.PreHookExecutor = new(Error)
_ settings.PostHookPostPersistExecutor = new(Error)
_ settings.PostHookPrePersistExecutor = new(Error)
_ settings.PreHookExecutor = new(Error)
_ verification.PreHookExecutor = new(Error)
_ recovery.PreHookExecutor = new(Error)
)
type Error struct {
Config json.RawMessage
}
func (e Error) err(path string, abort error) error {
switch gjson.GetBytes(e.Config, path).String() {
case "err":
return errors.New("err")
case "abort":
return abort
}
return nil
}
func (e Error) ExecuteSettingsPreHook(w http.ResponseWriter, r *http.Request, _ settings.PreHookExecutorParams) error {
return e.err("ExecuteSettingsPreHook", settings.ErrHookAbortFlow)
}
func (e Error) ExecuteSettingsPrePersistHook(w http.ResponseWriter, r *http.Request, _ settings.PostHookPrePersistExecutorParams) error {
return e.err("ExecuteSettingsPrePersistHook", settings.ErrHookAbortFlow)
}
func (e Error) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, _ settings.PostHookPostPersistExecutorParams) error {
return e.err("ExecuteSettingsPostPersistHook", settings.ErrHookAbortFlow)
}
func (e Error) ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, g node.UiNodeGroup, a *login.Flow, s *session.Session) error {
return e.err("ExecuteLoginPostHook", login.ErrHookAbortFlow)
}
func (e Error) ExecuteLoginPreHook(w http.ResponseWriter, r *http.Request, a *login.Flow) error {
return e.err("ExecuteLoginPreHook", login.ErrHookAbortFlow)
}
func (e Error) ExecuteRegistrationPreHook(w http.ResponseWriter, r *http.Request, a *registration.Flow) error {
return e.err("ExecuteRegistrationPreHook", registration.ErrHookAbortFlow)
}
func (e Error) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, a *registration.Flow, s *session.Session) error {
return e.err("ExecutePostRegistrationPostPersistHook", registration.ErrHookAbortFlow)
}
func (e Error) ExecutePostRegistrationPrePersistHook(w http.ResponseWriter, r *http.Request, a *registration.Flow, i *identity.Identity) error {
return e.err("ExecutePostRegistrationPrePersistHook", registration.ErrHookAbortFlow)
}
func (e Error) ExecuteRecoveryPreHook(w http.ResponseWriter, r *http.Request, a *recovery.Flow) error {
return e.err("ExecuteRecoveryPreHook", recovery.ErrHookAbortFlow)
}
func (e Error) ExecutePostRecoveryHook(w http.ResponseWriter, r *http.Request, a *recovery.Flow, s *session.Session) error {
return e.err("ExecutePostRecoveryHook", recovery.ErrHookAbortFlow)
}
func (e Error) ExecuteVerificationPreHook(w http.ResponseWriter, r *http.Request, a *verification.Flow) error {
return e.err("ExecuteVerificationPreHook", verification.ErrHookAbortFlow)
}
func (e Error) ExecutePostVerificationHook(w http.ResponseWriter, r *http.Request, a *verification.Flow, i *identity.Identity) error {
return e.err("ExecutePostVerificationHook", verification.ErrHookAbortFlow)
}

15
selfservice/hook/hooks.go Normal file
View File

@@ -0,0 +1,15 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
const (
KeySessionIssuer = "session"
KeySessionDestroyer = "revoke_active_sessions"
KeyWebHook = "web_hook"
KeyRequireVerifiedAddress = "require_verified_address"
KeyVerificationUI = "show_verification_ui"
KeyVerifier = "verification"
KeyVerifyNewAddress = "verify_new_address"
KeyNotifyPreviousAddresses = "notify_previous_addresses"
)

View File

@@ -0,0 +1,74 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hooktest
import (
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/x/configx"
"github.com/ory/x/ioutilx"
)
var jsonnet = base64.StdEncoding.EncodeToString([]byte("function(ctx) ctx"))
type Server struct {
*httptest.Server
LastBody []byte
}
// NewServer returns a new webhook server for testing.
func NewServer() *Server {
s := new(Server)
httptestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.LastBody = ioutilx.MustReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
s.Server = httptestServer
return s
}
// HookConfig returns the hook configuration for calling the webhook server.
func (s *Server) HookConfig() config.SelfServiceHook {
return config.SelfServiceHook{
Name: "web_hook",
Config: []byte(fmt.Sprintf(`
{
"method": "POST",
"url": "%s",
"body": "base64://%s"
}`, s.URL, jsonnet)),
}
}
func (s *Server) AssertTransientPayload(t *testing.T, expected string) {
require.NotEmpty(t, s.LastBody)
actual := gjson.GetBytes(s.LastBody, "flow.transient_payload").String()
assert.JSONEq(t, expected, actual, "%+v", actual)
}
// SetConfig adds the webhook to the list of hooks for the given key and restores
// the original configuration after the test.
func (s *Server) SetConfig(t *testing.T, conf *configx.Provider, key string) {
var newValue []config.SelfServiceHook
original := conf.Get(key)
if originalHooks, ok := original.([]config.SelfServiceHook); ok {
newValue = slices.Clone(originalHooks)
}
require.NoError(t, conf.Set(key, append(newValue, s.HookConfig())))
t.Cleanup(func() {
_ = conf.Set(key, original)
})
}

View File

@@ -0,0 +1,148 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"context"
"net/http"
"slices"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
)
const (
RecipientsRemoved = "removed"
RecipientsAllVerified = "all_verified"
RecipientsAll = "all"
)
type (
NotifyPreviousAddressesConfig struct {
Recipients string `json:"recipients"`
}
notifyPreviousAddressesDependencies interface {
identity.ManagementProvider
config.Provider
logrusx.Provider
otelx.Provider
}
NotifyPreviousAddresses struct {
r notifyPreviousAddressesDependencies
c *NotifyPreviousAddressesConfig
}
)
var _ settings.PostHookPostPersistExecutor = new(NotifyPreviousAddresses)
func NewNotifyPreviousAddresses(r notifyPreviousAddressesDependencies, c *NotifyPreviousAddressesConfig) *NotifyPreviousAddresses {
if c == nil {
c = &NotifyPreviousAddressesConfig{}
}
if c.Recipients == "" {
c.Recipients = RecipientsRemoved
}
return &NotifyPreviousAddresses{r: r, c: c}
}
func (e *NotifyPreviousAddresses) ExecuteSettingsPostPersistHook(
_ http.ResponseWriter, r *http.Request,
params settings.PostHookPostPersistExecutorParams,
) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.NotifyPreviousAddresses.ExecuteSettingsPostPersistHook", func(ctx context.Context) error {
if params.Previous == nil || params.Updated == nil {
return nil
}
targets := computeNotifyTargets(e.c.Recipients, params.Previous, params.Updated)
targets = slices.DeleteFunc(targets, func(a identity.AddressRef) bool {
return a.Via != identity.AddressTypeEmail && a.Via != identity.AddressTypeSMS
})
if len(targets) == 0 {
return nil
}
if err := e.r.IdentityManager().SendVerifiableAddressChangedNotifications(ctx, targets, params.Updated); err != nil {
e.r.Logger().WithError(err).
WithField("count", len(targets)).
Warn("Failed to queue one or more verifiable-address-change notifications.")
// Persist already succeeded — never fail the flow on courier errors.
}
return nil
})
}
func computeNotifyTargets(mode string, original, proposed *identity.Identity) []identity.AddressRef {
if !addressesChanged(original.VerifiableAddresses, proposed.VerifiableAddresses) {
return nil
}
switch mode {
case RecipientsAllVerified:
out := make([]identity.AddressRef, 0, len(original.VerifiableAddresses))
for _, a := range original.VerifiableAddresses {
if a.Verified {
out = append(out, identity.AddressRef{Value: a.Value, Via: a.Via})
}
}
return out
case RecipientsAll:
out := make([]identity.AddressRef, 0, len(original.VerifiableAddresses))
for _, a := range original.VerifiableAddresses {
out = append(out, identity.AddressRef{Value: a.Value, Via: a.Via})
}
return out
default: // RecipientsRemoved
keep := map[string]struct{}{}
for _, a := range proposed.VerifiableAddresses {
keep[a.Via+"|"+a.Value] = struct{}{}
}
out := make([]identity.AddressRef, 0)
for _, a := range original.VerifiableAddresses {
if _, still := keep[a.Via+"|"+a.Value]; still {
continue
}
out = append(out, identity.AddressRef{Value: a.Value, Via: a.Via})
}
return out
}
}
type set struct {
m map[string]struct{}
}
func newSet() *set {
return &set{m: make(map[string]struct{})}
}
func (s *set) add(via, value string) {
s.m[via+"|"+value] = struct{}{}
}
func (s *set) contains(via, value string) bool {
_, ok := s.m[via+"|"+value]
return ok
}
// addressesChanged reports whether two verifiable-address slices represent
// different (via,value) sets. Status or verification-flag flips alone are not
// "address changes" and must not trigger notifications.
func addressesChanged(a, b []identity.VerifiableAddress) bool {
if len(a) != len(b) {
return true
}
seen := newSet()
for _, x := range a {
seen.add(x.Via, x.Value)
}
for _, x := range b {
if !seen.contains(x.Via, x.Value) {
return true
}
}
return false
}

View File

@@ -0,0 +1,195 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
)
func TestNotifyPreviousAddresses_PostPersist(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
mode string
previousAddresses []identity.VerifiableAddress
updatedAddresses []identity.VerifiableAddress
wantRecipients []string
}{
{
name: "removed-only notifies the dropped address",
mode: hook.RecipientsRemoved,
previousAddresses: []identity.VerifiableAddress{
{Value: "old@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
{Value: "keep@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "keep@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
{Value: "new@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: []string{"old@example.com"},
},
{
name: "all-verified notifies every previously verified address",
mode: hook.RecipientsAllVerified,
previousAddresses: []identity.VerifiableAddress{
{Value: "v1@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
{Value: "v2@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
{Value: "unverified@example.com", Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "new@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: []string{"v1@example.com", "v2@example.com"},
},
{
name: "all notifies every prior address regardless of verification",
mode: hook.RecipientsAll,
previousAddresses: []identity.VerifiableAddress{
{Value: "verified@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
{Value: "unverified@example.com", Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "new@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: []string{"verified@example.com", "unverified@example.com"},
},
{
name: "unsupported via is filtered out",
mode: hook.RecipientsAll,
previousAddresses: []identity.VerifiableAddress{
{Value: "ok@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
{Value: "pager://123", Via: "pager", Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "new@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: []string{"ok@example.com"},
},
{
name: "no diff queues no notifications",
mode: hook.RecipientsRemoved,
previousAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: nil,
},
{
name: "all-verified does not notify when only non-address traits changed",
mode: hook.RecipientsAllVerified,
previousAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: nil,
},
{
name: "all does not notify when only non-address traits changed",
mode: hook.RecipientsAll,
previousAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: nil,
},
{
name: "verification status flip alone does not notify",
mode: hook.RecipientsAllVerified,
previousAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: false, Status: identity.VerifiableAddressStatusPending},
},
updatedAddresses: []identity.VerifiableAddress{
{Value: "stable@example.com", Via: identity.AddressTypeEmail, Verified: true, Status: identity.VerifiableAddressStatusCompleted},
},
wantRecipients: nil,
},
} {
tc := tc
t.Run("case="+tc.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/verify_single_email.schema.json")
conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
previous := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
previous.Traits = identity.Traits(`{"email":"new@example.com"}`)
previous.VerifiableAddresses = tc.previousAddresses
updated := *previous
updated.VerifiableAddresses = tc.updatedAddresses
h := hook.NewNotifyPreviousAddresses(reg, &hook.NotifyPreviousAddressesConfig{Recipients: tc.mode})
r := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.NoError(t, h.ExecuteSettingsPostPersistHook(w, r, settings.PostHookPostPersistExecutorParams{
Flow: &settings.Flow{InternalContext: []byte("{}")},
Previous: previous,
Updated: &updated,
Session: &session.Session{Identity: &updated},
}))
messages, err := reg.CourierPersister().NextMessages(ctx, 12)
if len(tc.wantRecipients) == 0 {
require.ErrorIs(t, err, courier.ErrQueueEmpty)
return
}
require.NoError(t, err)
require.Len(t, messages, len(tc.wantRecipients))
gotRecipients := make([]string, len(messages))
for k, m := range messages {
gotRecipients[k] = m.Recipient
assert.Equal(t, courier.MessageTypeEmail, m.Type)
}
assert.ElementsMatch(t, tc.wantRecipients, gotRecipients)
})
}
}
func TestNotifyPreviousAddresses_PostPersist_NilPrevious(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/verify_single_email.schema.json")
conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
updated := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
h := hook.NewNotifyPreviousAddresses(reg, &hook.NotifyPreviousAddressesConfig{Recipients: hook.RecipientsRemoved})
r := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
w := httptest.NewRecorder()
require.NoError(t, h.ExecuteSettingsPostPersistHook(w, r, settings.PostHookPostPersistExecutorParams{
Flow: &settings.Flow{InternalContext: []byte("{}")},
Previous: nil,
Updated: updated,
Session: &session.Session{Identity: updated},
}))
_, err := reg.CourierPersister().NextMessages(ctx, 10)
require.ErrorIs(t, err, courier.ErrQueueEmpty)
}

View File

@@ -0,0 +1,138 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/hashicorp/go-retryablehttp"
"github.com/pkg/errors"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.11.0"
"go.opentelemetry.io/otel/trace"
grpccodes "google.golang.org/grpc/codes"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/request"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/x"
"github.com/ory/x/otelx"
)
type (
PasswordMigration struct {
deps webHookDependencies
conf *request.Config
}
PasswordMigrationRequest struct {
Identifier string `json:"identifier"`
Password string `json:"password"`
Identity *identity.Identity `json:"-"`
}
PasswordMigrationResponse struct {
Status string `json:"status"`
}
)
func NewPasswordMigrationHook(deps webHookDependencies, conf *request.Config) *PasswordMigration {
return &PasswordMigration{deps: deps, conf: conf}
}
func (p *PasswordMigration) Execute(ctx context.Context, req *http.Request, flow flow.Flow, data *PasswordMigrationRequest) (err error) {
var (
httpClient = p.deps.HTTPClient(ctx)
emitEvent = p.conf.EmitAnalyticsEvent == nil || *p.conf.EmitAnalyticsEvent // default true
tracer = trace.SpanFromContext(ctx).TracerProvider().Tracer("kratos-webhooks")
)
ctx, span := tracer.Start(ctx, "selfservice.login.password_migration")
defer otelx.End(span, &err)
if emitEvent {
InstrumentHTTPClientForEvents(ctx, httpClient, x.NewUUID(), "password_migration_hook")
}
builder, err := request.NewBuilder(p.conf, p.deps)
if err != nil {
return errors.WithStack(err)
}
var whReq *retryablehttp.Request
if p.conf.TemplateURI == "" {
whReq, err = builder.BuildRequest(ctx, nil) // passing a nil body here skips Jsonnet
if err != nil {
return err
}
rawData, err := json.Marshal(data)
if err != nil {
return errors.WithStack(err)
}
if err = whReq.SetBody(rawData); err != nil {
return errors.WithStack(err)
}
} else {
type templateContextMerged struct {
templateContext
Password string `json:"password"`
Identifier string `json:"identifier"`
}
whReq, err = builder.BuildRequest(ctx, templateContextMerged{
templateContext: templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: data.Identity,
},
Password: data.Password,
Identifier: data.Identifier,
})
if err != nil {
return err
}
}
p.deps.Logger().WithRequest(whReq.Request).Info("Dispatching password migration hook")
resp, err := httpClient.Do(whReq)
if err != nil {
return (&herodot.DefaultError{
CodeField: http.StatusBadGateway,
StatusField: http.StatusText(http.StatusBadGateway),
GRPCCodeField: grpccodes.Aborted,
ReasonField: "A third-party upstream service could not be reached. Please try again later.",
ErrorField: "calling the password migration hook failed",
}).WithWrap(errors.WithStack(err))
}
defer func() { _ = resp.Body.Close() }()
span.SetAttributes(semconv.HTTPAttributesFromHTTPStatusCode(resp.StatusCode)...)
switch resp.StatusCode {
case http.StatusOK:
// We now check if the response matches `{"status": "password_match" }`.
dec := json.NewDecoder(io.LimitReader(resp.Body, 1024)) // limit the response body to 1KB
var response PasswordMigrationResponse
if err := dec.Decode(&response); err != nil || response.Status != "password_match" {
return errors.WithStack(schema.NewInvalidCredentialsError())
}
return nil
case http.StatusForbidden:
return errors.WithStack(schema.NewInvalidCredentialsError())
default:
span.SetStatus(codes.Error, "Unexpected HTTP status code")
return &herodot.DefaultError{
CodeField: http.StatusBadGateway,
StatusField: http.StatusText(http.StatusBadGateway),
GRPCCodeField: grpccodes.Aborted,
ReasonField: "A third-party upstream service responded improperly. Please try again later.",
ErrorField: fmt.Sprintf("password migration hook failed with status code %v", resp.StatusCode),
}
}
}

View File

@@ -0,0 +1,166 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"net/http"
"github.com/gofrs/uuid"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/otelx"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/sqlxx"
"github.com/pkg/errors"
"github.com/ory/kratos/ui/node"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/session"
)
var _ login.PostHookExecutor = new(AddressVerifier)
type (
addressVerifierDependencies interface {
clock.Provider
config.Provider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
verification.StrategyProvider
verification.FlowPersistenceProvider
identity.PrivilegedPoolProvider
httpx.WriterProvider
otelx.Provider
}
AddressVerifier struct {
r addressVerifierDependencies
}
)
func NewAddressVerifier(r addressVerifierDependencies) *AddressVerifier {
return &AddressVerifier{
r: r,
}
}
func (e *AddressVerifier) ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, _ node.UiNodeGroup, f *login.Flow, s *session.Session) (err error) {
ctx, span := e.r.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.hook.Verifier.do")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
// TODO remove once flag is removed.
if e.r.Config().UseLegacyRequireVerifiedLoginError(ctx) {
if f.Active != identity.CredentialsTypePassword {
span.AddEvent(semconv.NewDeprecatedFeatureUsedEvent(ctx, "legacy_require_verified_login_error"))
return nil
}
}
// END TODO
if len(s.Identity.VerifiableAddresses) == 0 {
return errors.WithStack(herodot.ErrMisconfiguration().WithReason("A misconfiguration prevents login. Expected to find a verification address but this identity does not have one assigned."))
}
for _, va := range s.Identity.VerifiableAddresses {
if va.Verified {
return nil
}
}
strategies, primaryStrategy, err := e.r.GetActiveVerificationStrategies(ctx)
if err != nil {
return err
}
// TODO remove once flag is removed.
if e.r.Config().UseLegacyRequireVerifiedLoginError(ctx) {
span.AddEvent(semconv.NewDeprecatedFeatureUsedEvent(ctx, "legacy_require_verified_login_error"))
return login.ErrAddressNotVerified()
}
// END TODO
i := s.Identity
for k := range i.VerifiableAddresses {
address := &i.VerifiableAddresses[k]
if address.Value == "" {
continue
}
verificationFlow, err := verification.NewPostHookFlow(e.r,
e.r.Config().SelfServiceFlowVerificationRequestLifespan(ctx),
e.r.GenerateCSRFToken(r), r, strategies, f)
if err != nil {
return err
}
verificationFlow.State = flow.StateEmailSent
for _, strategy := range strategies {
if ps, isPrimary := strategy.(verification.PrimaryStrategy); isPrimary {
verificationFlow.Active = sqlxx.NullString(ps.NodeGroup())
}
if err := strategy.PopulateVerificationMethod(r, verificationFlow); err != nil {
return err
}
}
// The code strategy's PopulateVerificationMethod sets the email "code sent"
// message unconditionally; override it with the message that matches the
// channel the code was sent over. The legacy link strategy sends a link,
// not a code, and sets no message — leave it untouched.
if primaryStrategy.NodeGroup() == node.CodeGroup {
verificationFlow.UI.Messages.Set(text.VerificationCodeSentMessage(address.Via))
}
verificationFlow.SessionID = uuid.NullUUID{UUID: s.ID, Valid: true}
verificationFlow.IdentityID = uuid.NullUUID{UUID: s.Identity.ID, Valid: true}
verificationFlow.UI.Nodes.Append(
node.NewInputField(address.Via, address.Value, node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoNodeResendOTP()),
)
if err := e.r.VerificationFlowPersister().CreateVerificationFlow(ctx, verificationFlow); err != nil {
return err
}
if err := primaryStrategy.SendVerificationCode(ctx, verificationFlow, i, address); err != nil {
return err
}
address.Status = identity.VerifiableAddressStatusSent
if err := e.r.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, address, "status"); err != nil {
return err
}
flowURL := verificationFlow.AppendTo(e.r.Config().SelfServiceFlowVerificationUI(ctx)).String()
continueWith := flow.NewContinueWithVerificationUI(verificationFlow.ID, address.Value, flowURL)
f.AddContinueWith(continueWith)
if x.IsJSONRequest(r) {
e.r.Writer().WriteErrorCode(w, r, http.StatusForbidden, flow.ErrorWithContinueWith(login.ErrAddressNotVerified(), continueWith))
return errors.WithStack(login.ErrHookAbortFlow)
}
if x.IsBrowserRequest(r) {
http.Redirect(w, r, flowURL, http.StatusSeeOther)
return errors.WithStack(login.ErrHookAbortFlow)
}
return errors.WithStack(login.ErrHookAbortFlow)
}
return login.ErrAddressNotVerified()
}

View File

@@ -0,0 +1,287 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/hook"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/ui/node"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
)
func TestAddressVerifier(t *testing.T) {
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/code.schema.json")
verifier := hook.NewAddressVerifier(reg)
t.Run("legacy behavior", func(t *testing.T) {
// Mock legacy config
conf.MustSet(context.Background(), config.ViperKeyUseLegacyRequireVerifiedLoginError, true)
for _, tc := range []struct {
flow *login.Flow
neverError bool
}{
{&login.Flow{Active: identity.CredentialsTypePassword}, false},
{&login.Flow{Active: identity.CredentialsTypeOIDC}, true},
} {
t.Run(tc.flow.Active.String()+" flow", func(t *testing.T) {
for _, uc := range []struct {
name string
verifiableAddresses []identity.VerifiableAddress
expectedError error
}{
{
name: "No Verification Address",
verifiableAddresses: []identity.VerifiableAddress{},
expectedError: herodot.ErrMisconfiguration().WithReason("A misconfiguration prevents login. Expected to find a verification address but this identity does not have one assigned."),
},
{
name: "Single Address Not Verified",
verifiableAddresses: []identity.VerifiableAddress{
{ID: uuid.UUID{}, Verified: false},
},
expectedError: login.ErrAddressNotVerified(),
},
{
name: "Single Address Verified",
verifiableAddresses: []identity.VerifiableAddress{
{ID: uuid.UUID{}, Verified: true},
},
},
{
name: "Multiple Addresses Verified",
verifiableAddresses: []identity.VerifiableAddress{
{ID: uuid.UUID{}, Verified: true},
{ID: uuid.UUID{}, Verified: true},
},
},
{
name: "Multiple Addresses Not Verified",
verifiableAddresses: []identity.VerifiableAddress{
{ID: uuid.UUID{}, Verified: false},
{ID: uuid.UUID{}, Verified: false},
},
expectedError: login.ErrAddressNotVerified(),
},
{
name: "One Address Verified And One Not",
verifiableAddresses: []identity.VerifiableAddress{
{ID: uuid.UUID{}, Verified: true},
{ID: uuid.UUID{}, Verified: false},
},
},
} {
t.Run(uc.name, func(t *testing.T) {
sessions := &session.Session{
ID: x.NewUUID(),
Identity: &identity.Identity{ID: x.NewUUID(), VerifiableAddresses: uc.verifiableAddresses},
}
err := verifier.ExecuteLoginPostHook(nil, httptest.NewRequest("GET", "http://example.com", nil), node.DefaultGroup, tc.flow, sessions)
if tc.neverError || uc.expectedError == nil {
assert.NoError(t, err)
} else {
assert.ErrorIs(t, err, uc.expectedError)
}
})
}
})
}
})
t.Run("current behavior", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/require_verified.schema.json")
// Mock non-legacy config
conf.MustSet(context.Background(), config.ViperKeyUseLegacyRequireVerifiedLoginError, false)
mockRequest := httptest.NewRequest("GET", "http://example.com", nil)
// Setup flow with identity addresses needing verification
loginFlow := &login.Flow{
Active: identity.CredentialsTypePassword,
ID: x.NewUUID(),
}
// Must persist the flow first for foreign key constraints
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), loginFlow))
t.Run("json request for unverified address", func(t *testing.T) {
// Mock JSON request
mockJSONReq := httptest.NewRequest("GET", "http://example.com", nil)
mockJSONReq.Header.Set("Content-Type", "application/json")
mockJSONReq.Header.Set("Accept", "application/json")
mockResponse := httptest.NewRecorder()
// Create identity with valid values
identity := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"user@example.com"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{
ID: x.NewUUID(),
Value: "user@example.com",
Verified: false,
Via: identity.AddressTypeEmail,
},
},
}
// Persist identity to satisfy foreign key constraints
require.NoError(t, reg.IdentityManager().Create(context.Background(), identity))
sessions := &session.Session{
ID: x.NewUUID(),
Identity: identity,
}
// Expect verification flow creation and ErrHookAbortFlow
err := verifier.ExecuteLoginPostHook(mockResponse, mockJSONReq, node.DefaultGroup, loginFlow, sessions)
assert.ErrorIs(t, err, login.ErrHookAbortFlow)
// Verify response contains continueWith and ErrAddressNotVerified
resp := mockResponse.Result()
body, _ := io.ReadAll(resp.Body)
var responseErr *herodot.DefaultError
// Check for required JSON fields
require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(body, "error").Raw), &responseErr))
assert.Contains(t, responseErr.ReasonField, login.ErrAddressNotVerified().Reason(), "%s", string(body))
// Verify flow has continueWith added
var continueWith flow.ContinueWithVerificationUI
require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(body, "error.details.continue_with.0").Raw), &continueWith))
// Verify the continueWith fields are properly set
assert.NotEmpty(t, continueWith.Flow.ID)
assert.Contains(t, continueWith.Action, "show_verification_ui")
assert.Contains(t, continueWith.Flow.URL, reg.Config().SelfServiceFlowVerificationUI(context.Background()).String())
assert.Equal(t, continueWith.Flow.VerifiableAddress, "user@example.com")
})
t.Run("browser request for unverified address", func(t *testing.T) {
// Mock browser request
mockBrowserReq := httptest.NewRequest("GET", "http://example.com", nil)
mockBrowserReq.Header.Set("Accept", "text/html")
mockResponse := httptest.NewRecorder()
// Create new flow for this test case
browserFlow := &login.Flow{
Active: identity.CredentialsTypePassword,
ID: x.NewUUID(),
}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), browserFlow))
// Create identity with valid values
identity := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"user2@example.com"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{
ID: x.NewUUID(),
Value: "user2@example.com",
Verified: false,
Via: identity.AddressTypeEmail,
},
},
}
require.NoError(t, reg.IdentityManager().Create(context.Background(), identity))
sessions := &session.Session{
ID: x.NewUUID(),
Identity: identity,
}
// Expect verification flow creation and redirect
err := verifier.ExecuteLoginPostHook(mockResponse, mockBrowserReq, node.DefaultGroup, browserFlow, sessions)
assert.ErrorIs(t, err, login.ErrHookAbortFlow)
// Verify redirect occurred
resp := mockResponse.Result()
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.NotEmpty(t, resp.Header.Get("Location"))
})
t.Run("verified address skips verification", func(t *testing.T) {
// Create new flow for this test case
verifiedFlow := &login.Flow{
Active: identity.CredentialsTypePassword,
ID: x.NewUUID(),
}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), verifiedFlow))
// Create identity with verified address
identity := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{"email":"verified@example.com"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{
ID: x.NewUUID(),
Value: "verified@example.com",
Verified: true,
Via: identity.AddressTypeEmail,
},
},
}
require.NoError(t, reg.IdentityManager().Create(context.Background(), identity))
sessions := &session.Session{
ID: x.NewUUID(),
Identity: identity,
}
err := verifier.ExecuteLoginPostHook(nil, mockRequest, node.DefaultGroup, verifiedFlow, sessions)
assert.NoError(t, err)
})
t.Run("no verifiable address", func(t *testing.T) {
// Create new flow for this test case
noAddressFlow := &login.Flow{
Active: identity.CredentialsTypePassword,
ID: x.NewUUID(),
}
require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(context.Background(), noAddressFlow))
// Create identity with no verifiable addresses
identity := &identity.Identity{
ID: x.NewUUID(),
Traits: identity.Traits(`{}`),
VerifiableAddresses: []identity.VerifiableAddress{},
}
require.NoError(t, reg.IdentityManager().Create(context.Background(), identity))
sessions := &session.Session{
ID: x.NewUUID(),
Identity: identity,
}
err := verifier.ExecuteLoginPostHook(nil, mockRequest, node.DefaultGroup, noAddressFlow, sessions)
assert.ErrorIs(t, err, herodot.ErrMisconfiguration())
})
})
}

View File

@@ -0,0 +1,63 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"context"
"net/http"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/ory/x/otelx"
)
var (
_ login.PostHookExecutor = new(SessionDestroyer)
_ recovery.PostHookExecutor = new(SessionDestroyer)
_ settings.PostHookPostPersistExecutor = new(SessionDestroyer)
)
type (
sessionDestroyerDependencies interface {
session.ManagementProvider
session.PersistenceProvider
}
SessionDestroyer struct {
r sessionDestroyerDependencies
}
)
func NewSessionDestroyer(r sessionDestroyerDependencies) *SessionDestroyer {
return &SessionDestroyer{r: r}
}
func (e *SessionDestroyer) ExecuteLoginPostHook(_ http.ResponseWriter, r *http.Request, _ node.UiNodeGroup, _ *login.Flow, s *session.Session) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.SessionDestroyer.ExecuteLoginPostHook", func(ctx context.Context) error {
if _, err := e.r.SessionPersister().RevokeSessionsIdentityExcept(ctx, s.Identity.ID, s.ID); err != nil {
return err
}
return nil
})
}
func (e *SessionDestroyer) ExecutePostRecoveryHook(_ http.ResponseWriter, r *http.Request, _ *recovery.Flow, s *session.Session) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.SessionDestroyer.ExecutePostRecoveryHook", func(ctx context.Context) error {
if _, err := e.r.SessionPersister().RevokeSessionsIdentityExcept(ctx, s.Identity.ID, s.ID); err != nil {
return err
}
return nil
})
}
func (e *SessionDestroyer) ExecuteSettingsPostPersistHook(_ http.ResponseWriter, r *http.Request, params settings.PostHookPostPersistExecutorParams) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.SessionDestroyer.ExecuteSettingsPostPersistHook", func(ctx context.Context) error {
if _, err := e.r.SessionPersister().RevokeSessionsIdentityExcept(ctx, params.Updated.ID, params.Session.ID); err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,119 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook_test
import (
"context"
"net/http"
"testing"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/corpx"
"github.com/ory/kratos/ui/node"
"github.com/go-faker/faker/v4"
"github.com/gobuffalo/httptest"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
)
func init() {
corpx.RegisterFakes()
}
func TestSessionDestroyer(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "http://localhost/")
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/stub.schema.json")
h := hook.NewSessionDestroyer(reg)
for _, tc := range []struct {
name string
hook func(*identity.Identity) error
}{
{
name: "ExecuteLoginPostHook",
hook: func(i *identity.Identity) error {
return h.ExecuteLoginPostHook(
httptest.NewRecorder(),
new(http.Request),
node.DefaultGroup,
nil,
&session.Session{Identity: i},
)
},
},
{
name: "ExecutePostRecoveryHook",
hook: func(i *identity.Identity) error {
return h.ExecutePostRecoveryHook(
httptest.NewRecorder(),
new(http.Request),
nil,
&session.Session{Identity: i},
)
},
},
{
name: "ExecuteSettingsPostPersistHook",
hook: func(i *identity.Identity) error {
return h.ExecuteSettingsPostPersistHook(
httptest.NewRecorder(),
new(http.Request),
settings.PostHookPostPersistExecutorParams{
Flow: nil,
Previous: i,
Updated: i,
Session: &session.Session{Identity: i},
},
)
},
},
} {
t.Run(tc.name, func(t *testing.T) {
var i identity.Identity
require.NoError(t, faker.FakeData(&i))
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), &i))
sessions := make([]session.Session, 5)
for k := range sessions {
s := sessions[k] // keep this for pointers' sake ;)
require.NoError(t, faker.FakeData(&s))
s.IdentityID = uuid.Nil
s.Identity = &i
s.Active = true
require.NoError(t, reg.SessionPersister().UpsertSession(context.Background(), &s))
sessions[k] = s
}
for k := range sessions {
sess, err := reg.SessionPersister().GetSession(context.Background(), sessions[k].ID, session.ExpandNothing)
require.NoError(t, err)
assert.True(t, sess.IsActive())
}
// Should revoke all the sessions.
require.NoError(t, tc.hook(&i))
for k := range sessions {
sess, err := reg.SessionPersister().GetSession(context.Background(), sessions[k].ID, session.ExpandNothing)
require.NoError(t, err)
assert.False(t, sess.IsActive())
}
})
}
}

View File

@@ -0,0 +1,160 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"context"
"net/http"
"go.opentelemetry.io/otel/trace"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/events"
"github.com/pkg/errors"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/sessiontokenexchange"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
"github.com/ory/x/httpx"
"github.com/ory/x/otelx"
)
var _ registration.PostHookPostPersistExecutor = new(SessionIssuer)
type (
sessionIssuerDependencies interface {
session.ManagementProvider
session.PersistenceProvider
sessiontokenexchange.PersistenceProvider
config.Provider
httpx.WriterProvider
hydra.Provider
}
SessionIssuerProvider interface {
HookSessionIssuer() *SessionIssuer
}
SessionIssuer struct {
r sessionIssuerDependencies
}
)
func NewSessionIssuer(r sessionIssuerDependencies) *SessionIssuer {
return &SessionIssuer{r: r}
}
func (e *SessionIssuer) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, a *registration.Flow, s *session.Session) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.SessionIssuer.ExecutePostRegistrationPostPersistHook", func(ctx context.Context) error {
return e.executePostRegistrationPostPersistHook(w, r.WithContext(ctx), a, s)
})
}
func (e *SessionIssuer) executePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, a *registration.Flow, s *session.Session) error {
if a.Type == flow.TypeAPI {
// We don't want to redirect with the code, if the flow was submitted with an ID token.
// This is the case for Sign in with native Apple SDK or Google SDK.
if s.AuthenticatedVia(identity.CredentialsTypeOIDC) && a.IDToken == "" {
if handled, err := e.r.SessionManager().MaybeRedirectAPICodeFlow(w, r, a, s.ID, node.OpenIDConnectGroup); err != nil {
return errors.WithStack(err)
} else if handled {
// The redirect response has already been written. Abort the flow
// so the executor does not run its tail block, which would call
// MaybeRedirectAPICodeFlow a second time and issue a redundant
// (and slow) write to the GLOBAL session_token_exchanges table.
return errors.WithStack(registration.ErrHookAbortFlow)
}
}
a.AddContinueWith(flow.NewContinueWithSetToken(s.Token))
e.r.Writer().Write(w, r, &registration.APIFlowResponse{
Session: s,
Token: s.Token,
Identity: s.Identity,
ContinueWith: a.ContinueWithItems,
})
trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginSucceeded(r.Context(), &events.LoginSucceededOpts{
SessionID: s.ID,
IdentityID: s.Identity.ID,
FlowID: a.ID,
FlowType: string(a.Type),
Method: a.Active.String(),
}))
return errors.WithStack(registration.ErrHookAbortFlow)
}
// cookie is issued both for browser and for SPA flows
if err := e.r.SessionManager().IssueCookie(r.Context(), w, r, s); err != nil {
return err
}
trace.SpanFromContext(r.Context()).AddEvent(events.NewLoginSucceeded(r.Context(), &events.LoginSucceededOpts{
SessionID: s.ID,
IdentityID: s.Identity.ID,
FlowID: a.ID,
FlowType: string(a.Type),
Method: a.Active.String(),
}))
// SPA flows additionally send the session
if x.IsJSONRequest(r) {
if err := e.acceptLoginChallenge(r.Context(), a, s, s.Identity); err != nil {
return err
}
e.r.Writer().Write(w, r, &registration.APIFlowResponse{
Session: s,
Identity: s.Identity,
ContinueWith: a.ContinueWithItems,
})
return errors.WithStack(registration.ErrHookAbortFlow)
}
return nil
}
func (e *SessionIssuer) acceptLoginChallenge(ctx context.Context, registrationFlow *registration.Flow, s *session.Session, i *identity.Identity) error {
// If Kratos is used as a Hydra login provider, we need to redirect back to Hydra by using the continue_with items
// with the post login challenge URL as the body.
// We only do this if the flow did not create a verification flow (e.g. verification is disabled or not active due to it being a code flow).
// Since the session issuer hook must be the last hook in the flow, we can safely assume that the verification flow was already added (if it was)
if registrationFlow.OAuth2LoginChallenge != "" && !willVerificationFollow(registrationFlow) {
postChallengeURL, err := e.r.Hydra().AcceptLoginRequest(ctx,
hydra.AcceptLoginRequestParams{
LoginChallenge: string(registrationFlow.OAuth2LoginChallenge),
IdentityID: i.ID.String(),
SessionID: s.ID.String(),
AuthenticationMethods: s.AMR,
})
if err != nil {
return err
}
cw := []flow.ContinueWith{}
for _, i := range registrationFlow.ContinueWithItems {
// Filter any continueWithRedirectBrowserTo items out of the list
// We will add a new one at the end of the flow
// as the OAuth2 login challenge should be the last step in the flow
if i.GetAction() != string(flow.ContinueWithActionRedirectBrowserToString) {
cw = append(cw, i)
}
}
registrationFlow.ContinueWithItems = append(cw, flow.NewContinueWithRedirectBrowserTo(postChallengeURL))
}
return nil
}
// willVerificationFollow returns true if the flow's continue with items contain a verification UI.
func willVerificationFollow(f *registration.Flow) bool {
for _, i := range f.ContinueWithItems {
if i.GetAction() == string(flow.ContinueWithActionShowVerificationUIString) {
return true
}
}
return false
}

View File

@@ -0,0 +1,355 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/ory/kratos/hydra"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
"github.com/ory/kratos/x"
"github.com/ory/x/randx"
)
func TestSessionIssuer(t *testing.T) {
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
reg.SetHydra(hydra.NewFake())
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "http://localhost/")
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/stub.schema.json")
var r http.Request
h := hook.NewSessionIssuer(reg)
t.Run("method=sign-up", func(t *testing.T) {
t.Run("flow=browser", func(t *testing.T) {
w := httptest.NewRecorder()
s := testhelpers.CreateSession(t, reg)
f := &registration.Flow{Type: flow.TypeBrowser}
require.NoError(t, h.ExecutePostRegistrationPostPersistHook(w, &r,
f, &session.Session{ID: s.ID, Identity: s.Identity, Token: randx.MustString(12, randx.AlphaLowerNum)}))
require.Empty(t, f.ContinueWithItems)
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID, got.ID)
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.Contains(t, w.Header().Get("Set-Cookie"), config.DefaultSessionCookieName)
})
t.Run("flow=api", func(t *testing.T) {
w := httptest.NewRecorder()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
s := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now().UTC(),
}
f := &registration.Flow{Type: flow.TypeAPI}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
err := h.ExecutePostRegistrationPostPersistHook(w, &http.Request{Header: http.Header{"Accept": {"application/json"}}}, f, s)
require.ErrorIs(t, err, registration.ErrHookAbortFlow, "%+v", err)
require.Len(t, f.ContinueWithItems, 1)
st := f.ContinueWithItems[0]
require.IsType(t, &flow.ContinueWithSetOrySessionToken{}, st)
assert.NotEmpty(t, st.(*flow.ContinueWithSetOrySessionToken).OrySessionToken)
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID.String(), got.ID.String())
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.Empty(t, w.Header().Get("Set-Cookie"))
body := w.Body.Bytes()
assert.Equal(t, i.ID.String(), gjson.GetBytes(body, "identity.id").String())
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "session.id").String())
assert.Equal(t, got.Token, gjson.GetBytes(body, "session_token").String())
})
t.Run("flow=spa", func(t *testing.T) {
w := httptest.NewRecorder()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
s := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now().UTC(),
}
f := &registration.Flow{Type: flow.TypeBrowser}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
err := h.ExecutePostRegistrationPostPersistHook(w, &http.Request{Header: http.Header{"Accept": {"application/json"}}}, f, s)
require.ErrorIs(t, err, registration.ErrHookAbortFlow, "%+v", err)
require.Empty(t, f.ContinueWithItems)
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID.String(), got.ID.String())
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.NotEmpty(t, w.Header().Get("Set-Cookie"))
body := w.Body.Bytes()
assert.Equal(t, i.ID.String(), gjson.GetBytes(body, "identity.id").String())
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "session.id").String())
assert.Empty(t, gjson.GetBytes(body, "session_token").String())
})
t.Run("flow=api with oidc code exchange", func(t *testing.T) {
// A native API OIDC flow with a session token exchange code redirects
// via MaybeRedirectAPICodeFlow. Once the redirect is written, the hook
// must abort the flow (ErrHookAbortFlow) so the executor's tail block
// does not call MaybeRedirectAPICodeFlow a second time and issue a
// redundant write to the GLOBAL session_token_exchanges table.
w := httptest.NewRecorder()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
s := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now().UTC(),
}
s.CompletedLoginFor(identity.CredentialsTypeOIDC, identity.AuthenticatorAssuranceLevel1)
f := &registration.Flow{ID: x.NewUUID(), Type: flow.TypeAPI}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
ex, err := reg.SessionTokenExchangePersister().CreateSessionTokenExchanger(ctx, f.ID)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "https://localhost/self-service/methods/oidc/callback/test", nil).WithContext(ctx)
req.Header.Set("Accept", "application/json")
err = h.ExecutePostRegistrationPostPersistHook(w, req, f, s)
require.ErrorIs(t, err, registration.ErrHookAbortFlow, "%+v", err)
// The redirect carries the return-to code, proving the OIDC handled
// branch ran before aborting.
assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Contains(t, w.Header().Get("Location"), "code="+ex.ReturnToCode)
// The exchanger was bound to the session exactly once.
got, _, err := reg.SessionTokenExchangePersister().CodeForFlow(ctx, f.ID)
require.NoError(t, err)
assert.Equal(t, ex.ReturnToCode, got.ReturnToCode)
exchanged, err := reg.SessionTokenExchangePersister().GetExchangerFromCode(ctx, ex.InitCode, ex.ReturnToCode)
require.NoError(t, err)
assert.Equal(t, s.ID, exchanged.SessionID.UUID)
})
})
t.Run("method=sign-up with oauth2", func(t *testing.T) {
t.Run("flow=browser", func(t *testing.T) {
w := httptest.NewRecorder()
s := testhelpers.CreateSession(t, reg)
f := &registration.Flow{
Type: flow.TypeBrowser,
OAuth2LoginChallenge: hydra.FakeValidLoginChallenge,
}
require.NoError(t, h.ExecutePostRegistrationPostPersistHook(w, &r,
f, &session.Session{ID: s.ID, Identity: s.Identity, Token: randx.MustString(12, randx.AlphaLowerNum)}))
require.Empty(t, f.ContinueWithItems)
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID, got.ID)
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.Contains(t, w.Header().Get("Set-Cookie"), config.DefaultSessionCookieName)
})
t.Run("flow=api", func(t *testing.T) {
w := httptest.NewRecorder()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
s := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now().UTC(),
}
f := &registration.Flow{
Type: flow.TypeAPI,
OAuth2LoginChallenge: hydra.FakeValidLoginChallenge,
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
err := h.ExecutePostRegistrationPostPersistHook(w, &http.Request{Header: http.Header{"Accept": {"application/json"}}}, f, s)
require.ErrorIs(t, err, registration.ErrHookAbortFlow, "%+v", err)
require.Len(t, f.ContinueWithItems, 1)
st := f.ContinueWithItems[0]
require.IsType(t, &flow.ContinueWithSetOrySessionToken{}, st)
assert.NotEmpty(t, st.(*flow.ContinueWithSetOrySessionToken).OrySessionToken)
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID.String(), got.ID.String())
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.Empty(t, w.Header().Get("Set-Cookie"))
body := w.Body.Bytes()
assert.Equal(t, i.ID.String(), gjson.GetBytes(body, "identity.id").String())
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "session.id").String())
assert.Equal(t, got.Token, gjson.GetBytes(body, "session_token").String())
})
t.Run("flow=spa", func(t *testing.T) {
w := httptest.NewRecorder()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
s := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now().UTC(),
}
f := &registration.Flow{
Type: flow.TypeBrowser,
OAuth2LoginChallenge: hydra.FakeValidLoginChallenge,
ContinueWithItems: []flow.ContinueWith{flow.NewContinueWithRedirectBrowserTo("https://ory.sh")},
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
err := h.ExecutePostRegistrationPostPersistHook(w, &http.Request{Header: http.Header{"Accept": {"application/json"}}}, f, s)
require.ErrorIs(t, err, registration.ErrHookAbortFlow, "%+v", err)
require.Len(t, f.ContinueWithItems, 1)
require.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, f.ContinueWithItems[0].GetAction())
require.EqualValues(t, hydra.FakePostLoginURL, f.ContinueWithItems[0].(*flow.ContinueWithRedirectBrowserTo).RedirectTo)
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID.String(), got.ID.String())
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.NotEmpty(t, w.Header().Get("Set-Cookie"))
body := w.Body.Bytes()
assert.Equal(t, i.ID.String(), gjson.GetBytes(body, "identity.id").String())
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "session.id").String())
assert.Empty(t, gjson.GetBytes(body, "session_token").String())
})
})
t.Run("method=sign-up with oauth2 and verification enabled", func(t *testing.T) {
w := httptest.NewRecorder()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
s := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now().UTC(),
}
vf := &verification.Flow{
ID: x.NewUUID(),
}
f := &registration.Flow{
Type: flow.TypeBrowser,
OAuth2LoginChallenge: hydra.FakeValidLoginChallenge,
ContinueWithItems: []flow.ContinueWith{flow.NewContinueWithVerificationUI(vf.ID, "some@ory.sh", "")},
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
err := h.ExecutePostRegistrationPostPersistHook(w, &http.Request{Header: http.Header{"Accept": {"application/json"}}}, f, s)
require.ErrorIs(t, err, registration.ErrHookAbortFlow, "%+v", err)
require.Len(t, f.ContinueWithItems, 1)
require.EqualValues(t, flow.ContinueWithActionShowVerificationUIString, f.ContinueWithItems[0].GetAction())
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID.String(), got.ID.String())
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.NotEmpty(t, w.Header().Get("Set-Cookie"))
body := w.Body.Bytes()
assert.Equal(t, i.ID.String(), gjson.GetBytes(body, "identity.id").String())
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "session.id").String())
assert.Empty(t, gjson.GetBytes(body, "session_token").String())
})
t.Run("method=sign-up with oauth2 and some other continue with item", func(t *testing.T) {
w := httptest.NewRecorder()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
s := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now().UTC(),
}
vf := &recovery.Flow{
ID: x.NewUUID(),
}
f := &registration.Flow{
Type: flow.TypeBrowser,
OAuth2LoginChallenge: hydra.FakeValidLoginChallenge,
ContinueWithItems: []flow.ContinueWith{flow.NewContinueWithRecoveryUI(vf)},
}
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(context.Background(), i))
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, s))
err := h.ExecutePostRegistrationPostPersistHook(w, &http.Request{Header: http.Header{"Accept": {"application/json"}}}, f, s)
require.ErrorIs(t, err, registration.ErrHookAbortFlow, "%+v", err)
require.Len(t, f.ContinueWithItems, 2)
require.EqualValues(t, flow.ContinueWithActionShowRecoveryUIString, f.ContinueWithItems[0].GetAction())
require.EqualValues(t, hydra.FakePostLoginURL, f.ContinueWithItems[1].(*flow.ContinueWithRedirectBrowserTo).RedirectTo)
got, err := reg.SessionPersister().GetSession(context.Background(), s.ID, session.ExpandNothing)
require.NoError(t, err)
assert.Equal(t, s.ID.String(), got.ID.String())
assert.True(t, got.AuthenticatedAt.After(time.Now().Add(-time.Minute)))
assert.NotEmpty(t, w.Header().Get("Set-Cookie"))
body := w.Body.Bytes()
assert.Equal(t, i.ID.String(), gjson.GetBytes(body, "identity.id").String())
assert.Equal(t, s.ID.String(), gjson.GetBytes(body, "session.id").String())
assert.Empty(t, gjson.GetBytes(body, "session_token").String())
})
}

View File

@@ -0,0 +1,115 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"encoding/json"
"net/http"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/gofrs/uuid"
"github.com/tidwall/gjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/session"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
)
var (
_ registration.PostHookPostPersistExecutor = new(ShowVerificationUIHook)
_ login.PostHookExecutor = new(ShowVerificationUIHook)
_ settings.PostHookPostPersistExecutor = new(ShowVerificationUIHook)
)
type (
showVerificationUIDependencies interface {
httpx.WriterProvider
config.Provider
otelx.Provider
logrusx.Provider
}
ShowVerfificationUIProvider interface {
HookShowVerificationUI() *ShowVerificationUIHook
}
// ShowVerificationUIHook is a post registration hook that redirects browser clients to the verification UI.
ShowVerificationUIHook struct {
d showVerificationUIDependencies
}
)
func NewShowVerificationUIHook(d showVerificationUIDependencies) *ShowVerificationUIHook {
return &ShowVerificationUIHook{d: d}
}
// ExecutePostRegistrationPostPersistHook 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) ExecutePostRegistrationPostPersistHook(_ http.ResponseWriter, r *http.Request, f *registration.Flow, _ *session.Session) error {
return e.execute(r, f)
}
// 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 e.execute(r, f)
}
func (e *ShowVerificationUIHook) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, params settings.PostHookPostPersistExecutorParams) error {
return e.execute(r, params.Flow)
}
type verificationUIFlow interface {
flow.InternalContexter
flow.FlowWithContinueWith
}
type loginOrRegistrationFlow interface {
SetReturnToVerification(string)
}
func (e *ShowVerificationUIHook) execute(r *http.Request, f verificationUIFlow) (err error) {
ctx, span := e.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.hook.ShowVerificationUIHook.Do")
defer otelx.End(span, &err)
var cw flow.ContinueWithVerificationUIFlow
verificationFlow := gjson.GetBytes(f.GetInternalContext(), InternalContextRegistrationVerificationFlow).Raw
if verificationFlow == "" {
// TODO This is a fallback for flows that do not have the appropriate internalContext yet.
// Remove this once we have released this change.
for _, c := range f.ContinueWith() {
if item, ok := c.(*flow.ContinueWithVerificationUI); ok {
cw = item.Flow
}
}
} else {
if err := json.Unmarshal([]byte(verificationFlow), &cw); err != nil {
return err
}
}
if cw.ID == uuid.Nil {
e.d.Logger().WithRequest(r).Warn("Ignoring hook `show_verification_ui` because no verification flow ID was found in the registration flow. Cannot show verification UI. This is likely a configuration issue or a bug.")
return nil
}
vf := flow.NewContinueWithVerificationUI(cw.ID, cw.VerifiableAddress, cw.URL)
f.AddContinueWith(vf)
if returnToVerification, ok := f.(loginOrRegistrationFlow); ok && x.IsBrowserRequest(r) {
verificationUI := e.d.Config().SelfServiceFlowVerificationUI(ctx)
returnToVerification.SetReturnToVerification(vf.AppendTo(verificationUI).String())
}
return nil
}

View File

@@ -0,0 +1,227 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook_test
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/selfservice/hook"
)
func TestExecutePostRegistrationPostPersistHook(t *testing.T) {
t.Run("flow=registration", func(t *testing.T) {
t.Run("case=no continue with items returns 200 OK", func(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
f := &registration.Flow{}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecutePostRegistrationPostPersistHook(rec, browserRequest, f, nil))
require.Equal(t, 200, rec.Code)
})
t.Run("case=not a browser request returns 200 OK", func(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
browserRequest.Header.Add("Accept", "application/json")
f := &registration.Flow{}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecutePostRegistrationPostPersistHook(rec, browserRequest, f, nil))
require.Equal(t, 200, rec.Code)
})
t.Run("case=verification ui in continue with item returns redirect", func(t *testing.T) {
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationUI, "/verification")
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
vf := &verification.Flow{
ID: uuid.Must(uuid.NewV4()),
}
rf := &registration.Flow{}
rf.ContinueWithItems = []flow.ContinueWith{
flow.NewContinueWithVerificationUI(vf.ID, "some@ory.sh", ""),
}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecutePostRegistrationPostPersistHook(rec, browserRequest, rf, nil))
assert.Equal(t, 200, rec.Code)
assert.Equal(t, "/verification?flow="+vf.ID.String(), rf.ReturnToVerification)
})
t.Run("case=no verification ui in continue with item returns 200 OK", func(t *testing.T) {
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationUI, "/verification")
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
rf := &registration.Flow{}
rf.ContinueWithItems = []flow.ContinueWith{
flow.NewContinueWithSetToken("token"),
}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecutePostRegistrationPostPersistHook(rec, browserRequest, rf, nil))
assert.Equal(t, 200, rec.Code)
})
})
t.Run("flow=login", func(t *testing.T) {
t.Run("case=no continue with items returns 200 OK", func(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
f := &login.Flow{}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecuteLoginPostHook(rec, browserRequest, "", f, nil))
require.Equal(t, 200, rec.Code)
})
t.Run("case=not a browser request returns 200 OK", func(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
browserRequest.Header.Add("Accept", "application/json")
f := &login.Flow{}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecuteLoginPostHook(rec, browserRequest, "", f, nil))
require.Equal(t, 200, rec.Code)
})
t.Run("case=verification ui in continue with item returns redirect", func(t *testing.T) {
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationUI, "/verification")
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
vf := &verification.Flow{
ID: uuid.Must(uuid.NewV4()),
}
rf := &login.Flow{}
rf.ContinueWithItems = []flow.ContinueWith{
flow.NewContinueWithVerificationUI(vf.ID, "some@ory.sh", ""),
}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecuteLoginPostHook(rec, browserRequest, "", rf, nil))
assert.Equal(t, 200, rec.Code)
assert.Equal(t, "/verification?flow="+vf.ID.String(), rf.ReturnToVerification)
})
t.Run("case=no verification ui in continue with item returns 200 OK", func(t *testing.T) {
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationUI, "/verification")
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
rf := &login.Flow{}
rf.ContinueWithItems = []flow.ContinueWith{
flow.NewContinueWithSetToken("token"),
}
rec := httptest.NewRecorder()
require.NoError(t, h.ExecuteLoginPostHook(rec, browserRequest, "", rf, nil))
assert.Equal(t, 200, rec.Code)
})
})
t.Run("internal_context=registration", func(t *testing.T) {
t.Run("case=verification flow from internal context returns redirect", func(t *testing.T) {
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationUI, "/verification")
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
vfID := uuid.Must(uuid.NewV4())
cw := flow.ContinueWithVerificationUIFlow{
ID: vfID,
VerifiableAddress: "test@ory.sh",
}
internalContext, err := json.Marshal(map[string]interface{}{
hook.InternalContextRegistrationVerificationFlow: cw,
})
require.NoError(t, err)
rf := &registration.Flow{}
rf.InternalContext = internalContext
rec := httptest.NewRecorder()
require.NoError(t, h.ExecutePostRegistrationPostPersistHook(rec, browserRequest, rf, nil))
assert.Equal(t, 200, rec.Code)
assert.Equal(t, "/verification?flow="+vfID.String(), rf.ReturnToVerification)
})
t.Run("case=invalid json in internal context returns error", func(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
internalContext, err := json.Marshal(map[string]interface{}{
hook.InternalContextRegistrationVerificationFlow: "invalid json",
})
require.NoError(t, err)
rf := &registration.Flow{}
rf.InternalContext = internalContext
rec := httptest.NewRecorder()
err = h.ExecutePostRegistrationPostPersistHook(rec, browserRequest, rf, nil)
require.Error(t, err)
})
})
t.Run("internal_context=login", func(t *testing.T) {
t.Run("case=verification flow from internal context returns redirect", func(t *testing.T) {
conf, reg := pkg.NewVeryFastRegistryWithoutDB(t)
conf.MustSet(context.Background(), config.ViperKeySelfServiceVerificationUI, "/verification")
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
vfID := uuid.Must(uuid.NewV4())
cw := flow.ContinueWithVerificationUIFlow{
ID: vfID,
VerifiableAddress: "test@ory.sh",
}
internalContext, err := json.Marshal(map[string]interface{}{
hook.InternalContextRegistrationVerificationFlow: cw,
})
require.NoError(t, err)
lf := &login.Flow{}
lf.InternalContext = internalContext
rec := httptest.NewRecorder()
require.NoError(t, h.ExecuteLoginPostHook(rec, browserRequest, "", lf, nil))
assert.Equal(t, 200, rec.Code)
assert.Equal(t, "/verification?flow="+vfID.String(), lf.ReturnToVerification)
})
t.Run("case=invalid json in internal context returns error", func(t *testing.T) {
_, reg := pkg.NewVeryFastRegistryWithoutDB(t)
h := hook.NewShowVerificationUIHook(reg)
browserRequest := httptest.NewRequest("GET", "/", nil)
internalContext, err := json.Marshal(map[string]interface{}{
hook.InternalContextRegistrationVerificationFlow: "invalid json",
})
require.NoError(t, err)
lf := &login.Flow{}
lf.InternalContext = internalContext
rec := httptest.NewRecorder()
err = h.ExecuteLoginPostHook(rec, browserRequest, "", lf, nil)
require.Error(t, err)
})
})
}

View File

@@ -0,0 +1,3 @@
function(ctx) {
flow_id: ctx.foo,
}

View File

@@ -0,0 +1 @@
error "cancel"

View File

@@ -0,0 +1,27 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"code": {
"identifier": true,
"via": "email"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"verification": {
"via": "email"
}
}
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"bar": {
"type": "string"
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
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,
cookies: ctx.request_cookies,
transient_payload: if std.objectHas(ctx.flow, "transient_payload") then ctx.flow.transient_payload,
})

View File

@@ -0,0 +1,24 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"emails": {
"type": "array",
"items": {
"type": "string",
"ory.sh/kratos": {
"verification": {
"via": "email"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,31 @@
{
"$id": "verify_email_and_phone.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": { "password": { "identifier": true } },
"verification": { "via": "email" }
}
},
"phone": {
"type": "string",
"format": "no-validate",
"ory.sh/kratos": {
"verification": { "via": "sms" }
}
},
"name": { "type": "string" }
},
"required": ["email"],
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,31 @@
{
"$id": "https://example.com/verify-single-email.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": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
}
}
},
"name": {
"type": "string"
}
},
"required": ["email"]
}
}
}

View File

@@ -0,0 +1,31 @@
{
"$id": "verify_two_emails.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Two Email Schema",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": { "password": { "identifier": true } },
"verification": { "via": "email" }
}
},
"recovery_email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"verification": { "via": "email" }
}
},
"name": { "type": "string" }
},
"required": ["email"],
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,220 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"context"
"net/http"
"github.com/tidwall/sjson"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/otelx/semconv"
"github.com/gofrs/uuid"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/otelx"
)
var (
_ registration.PostHookPostPersistExecutor = new(Verifier)
_ settings.PostHookPostPersistExecutor = new(Verifier)
_ login.PostHookExecutor = new(Verifier)
)
type (
verifierDependencies interface {
clock.Provider
config.Provider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
verification.StrategyProvider
verification.FlowPersistenceProvider
identity.PrivilegedPoolProvider
httpx.WriterProvider
otelx.Provider
}
Verifier struct {
r verifierDependencies
}
)
func NewVerifier(r verifierDependencies) *Verifier {
return &Verifier{r: r}
}
func (e *Verifier) ExecutePostRegistrationPostPersistHook(w http.ResponseWriter, r *http.Request, f *registration.Flow, s *session.Session) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.Verifier.ExecutePostRegistrationPostPersistHook", func(ctx context.Context) error {
return e.do(w, r.WithContext(ctx), s.Identity, f, func(v *verification.Flow) {
v.OAuth2LoginChallenge = f.OAuth2LoginChallenge
v.SessionID = uuid.NullUUID{UUID: s.ID, Valid: true}
v.IdentityID = uuid.NullUUID{UUID: s.Identity.ID, Valid: true}
v.AMR = s.AMR
})
})
}
func (e *Verifier) ExecuteSettingsPostPersistHook(w http.ResponseWriter, r *http.Request, params settings.PostHookPostPersistExecutorParams) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.Verifier.ExecuteSettingsPostPersistHook", func(ctx context.Context) error {
return e.do(w, r.WithContext(ctx), params.Updated, params.Flow, nil)
})
}
func (e *Verifier) ExecuteLoginPostHook(w http.ResponseWriter, r *http.Request, g node.UiNodeGroup, f *login.Flow, s *session.Session) (err error) {
ctx, span := e.r.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.hook.Verifier.ExecuteLoginPostHook")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
if f.RequestedAAL != identity.AuthenticatorAssuranceLevel1 {
span.SetAttributes(attribute.String("skip_reason", "skipping verification hook because AAL is not 1"))
return nil
}
return e.do(w, r.WithContext(ctx), s.Identity, f, func(vf *verification.Flow) {
vf.OAuth2LoginChallenge = f.OAuth2LoginChallenge
var sID uuid.UUID
if s != nil {
sID = s.ID
}
vf.SessionID = uuid.NullUUID{UUID: sID, Valid: sID != uuid.Nil}
var iID uuid.UUID
if s != nil && s.Identity != nil {
iID = s.Identity.ID
}
vf.IdentityID = uuid.NullUUID{UUID: iID, Valid: iID != uuid.Nil}
vf.AMR = s.AMR
})
}
const InternalContextRegistrationVerificationFlow = "registration_verification_flow_continue_with"
func (e *Verifier) do(
w http.ResponseWriter,
r *http.Request,
i *identity.Identity,
f interface {
flow.FlowWithContinueWith
flow.InternalContexter
},
flowCallback func(*verification.Flow),
) (err error) {
ctx, span := e.r.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.hook.Verifier.do")
r = r.WithContext(ctx)
defer otelx.End(span, &err)
// This is called after the identity has been created so we can safely assume that all addresses are available
// already.
strategies, primaryStrategy, err := e.r.GetActiveVerificationStrategies(ctx)
if err != nil {
return err
}
isBrowserFlow := f.GetType() == flow.TypeBrowser
isRegistrationOrLoginFlow := f.GetFlowName() == flow.RegistrationFlow || f.GetFlowName() == flow.LoginFlow
for k := range i.VerifiableAddresses {
address := &i.VerifiableAddresses[k]
if isRegistrationOrLoginFlow && address.Verified {
continue
} else if !isRegistrationOrLoginFlow && address.Status != identity.VerifiableAddressStatusPending {
// In case of the settings flow, we only want to create a new verification flow if there is no pending
// verification flow for the address. Otherwise, we would create a new verification flow for each setting,
// even if the address did not change.
continue
}
if address.Value == "" {
continue
}
var csrf string
// TODO: this is pretty ugly, we should probably have a better way to handle CSRF tokens here.
if isBrowserFlow {
if isRegistrationOrLoginFlow {
// If this hook is executed from a registration flow, we need to regenerate the CSRF token.
csrf = e.r.CSRFHandler().RegenerateToken(w, r)
} else {
// If it came from a settings flow, there already is a CSRF token, so we can just use that.
csrf = e.r.GenerateCSRFToken(r)
}
}
verificationFlow, err := verification.NewPostHookFlow(e.r,
e.r.Config().SelfServiceFlowVerificationRequestLifespan(ctx),
csrf, r, strategies, f)
if err != nil {
return err
}
if flowCallback != nil {
flowCallback(verificationFlow)
}
verificationFlow.State = flow.StateEmailSent
if err := primaryStrategy.PopulateVerificationMethod(r, verificationFlow); err != nil {
return err
}
// The code strategy's PopulateVerificationMethod sets the email "code sent"
// message unconditionally; override it with the message that matches the
// channel the code was sent over. The legacy link strategy sends a link,
// not a code, and sets no message — leave it untouched.
if primaryStrategy.NodeGroup() == node.CodeGroup {
verificationFlow.UI.Messages.Set(text.VerificationCodeSentMessage(address.Via))
}
verificationFlow.UI.Nodes.Append(
node.NewInputField(address.Via, address.Value, node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoNodeResendOTP()),
)
if err := e.r.VerificationFlowPersister().CreateVerificationFlow(ctx, verificationFlow); err != nil {
return err
}
if err := primaryStrategy.SendVerificationCode(ctx, verificationFlow, i, address); err != nil {
return err
}
address.Status = identity.VerifiableAddressStatusSent
if err := e.r.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, address, "status"); err != nil {
return err
}
flowURL := ""
if verificationFlow.Type == flow.TypeBrowser {
flowURL = verificationFlow.AppendTo(e.r.Config().SelfServiceFlowVerificationUI(ctx)).String()
}
continueWith := flow.NewContinueWithVerificationUI(verificationFlow.ID, address.Value, flowURL)
internalContext, err := sjson.SetBytes(f.GetInternalContext(), InternalContextRegistrationVerificationFlow, continueWith.Flow)
if err != nil {
return err
}
f.SetInternalContext(internalContext)
if e.r.Config().UseLegacyShowVerificationUI(ctx) {
span.AddEvent(semconv.NewDeprecatedFeatureUsedEvent(ctx, "legacy_continue_with_verification_ui"))
f.AddContinueWith(continueWith)
continue // Legacy behavior
}
break // We only do this for the first address we find as we can't redirect to multiple flows at once.
}
return nil
}

View File

@@ -0,0 +1,280 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/tidwall/gjson"
"github.com/ory/kratos/courier"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/ui/node"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/x"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
func TestVerifier(t *testing.T) {
ctx := context.Background()
u := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.com/")}
for _, tc := range []struct {
name string
execHook func(h *hook.Verifier, i *identity.Identity, f flow.Flow) error
originalFlow func() interface {
flow.InternalContexter
flow.FlowWithContinueWith
}
}{
{
name: "login",
execHook: func(h *hook.Verifier, i *identity.Identity, f flow.Flow) error {
return h.ExecuteLoginPostHook(
httptest.NewRecorder(), u, node.CodeGroup, f.(*login.Flow), &session.Session{ID: x.NewUUID(), Identity: i})
},
originalFlow: func() interface {
flow.InternalContexter
flow.FlowWithContinueWith
} {
return &login.Flow{RequestURL: "http://foo.com/login", RequestedAAL: "aal1"}
},
},
{
name: "registration",
execHook: func(h *hook.Verifier, i *identity.Identity, f flow.Flow) error {
return h.ExecutePostRegistrationPostPersistHook(
httptest.NewRecorder(), u, f.(*registration.Flow), &session.Session{ID: x.NewUUID(), Identity: i})
},
originalFlow: func() interface {
flow.InternalContexter
flow.FlowWithContinueWith
} {
return &registration.Flow{RequestURL: "http://foo.com/registration?after_verification_return_to=verification_callback"}
},
},
} {
t.Run("flow="+tc.name, func(t *testing.T) {
for _, enabled := range []bool{true, false} {
t.Run(fmt.Sprintf("legacy flag=%v", enabled), func(t *testing.T) {
t.Run("case=should send out emails for unverified addresses", func(t *testing.T) {
originalFlow := tc.originalFlow()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/verify.schema.json")
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.com/")
conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
conf.MustSet(ctx, config.ViperKeyUseLegacyShowVerificationUI, enabled)
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"emails":["foo@ory.sh","bar@ory.sh"]}`)
require.NoError(t, reg.IdentityManager().Create(context.Background(), i))
h := hook.NewVerifier(reg)
require.NoError(t, tc.execHook(h, i, originalFlow))
assert.Lenf(t, gjson.GetBytes(originalFlow.GetInternalContext(), hook.InternalContextRegistrationVerificationFlow).Array(), 1, "%s", originalFlow.GetInternalContext())
var verificationID uuid.UUID
if enabled {
assert.Lenf(t, originalFlow.ContinueWith(), 2, "%#v", originalFlow.ContinueWith())
assertContinueWithAddresses(t, originalFlow.ContinueWith(), []string{"foo@ory.sh", "bar@ory.sh"})
vf := originalFlow.ContinueWith()[0]
assert.IsType(t, &flow.ContinueWithVerificationUI{}, vf)
verificationID = vf.(*flow.ContinueWithVerificationUI).Flow.ID
} else {
assert.Lenf(t, originalFlow.ContinueWith(), 0, "%#v", originalFlow.ContinueWith())
verificationID = uuid.FromStringOrNil(gjson.GetBytes(originalFlow.GetInternalContext(), hook.InternalContextRegistrationVerificationFlow+".id").String())
require.NotEqual(t, uuid.Nil, verificationID, "%s", originalFlow.GetInternalContext())
}
expectedVerificationFlow, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, verificationID)
require.NoError(t, err)
require.Equal(t, expectedVerificationFlow.State, flow.StateEmailSent)
require.NotEmpty(t, expectedVerificationFlow.SessionID.UUID)
require.NotEmpty(t, expectedVerificationFlow.IdentityID.UUID)
require.NotNil(t, expectedVerificationFlow.UI.Nodes.Find("email"))
require.Len(t, expectedVerificationFlow.UI.Messages, 1)
require.Equal(t, text.InfoSelfServiceVerificationEmailWithCodeSent, expectedVerificationFlow.UI.Messages[0].ID)
messages, err := reg.CourierPersister().NextMessages(context.Background(), 12)
require.NoError(t, err)
if enabled {
require.Len(t, messages, 2)
} else {
require.Len(t, messages, 1)
}
})
})
}
t.Run("case should skip already verified addresses", func(t *testing.T) {
t.Parallel()
originalFlow := tc.originalFlow()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/verify.schema.json")
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.com/")
conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
h := hook.NewVerifier(reg)
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"emails":["foo@ory.sh","bar@ory.sh"]}`)
require.NoError(t, reg.IdentityManager().Create(context.Background(), i))
for _, address := range i.VerifiableAddresses {
address.Verified = true
address.VerifiedAt = new(sqlxx.NullTime(time.Now()))
address.Status = identity.VerifiableAddressStatusCompleted
require.NoError(t, reg.Persister().UpdateVerifiableAddress(context.Background(), &address))
}
i, err := reg.PrivilegedIdentityPool().GetIdentity(ctx, i.ID, identity.ExpandDefault)
require.NoError(t, err)
require.NoError(t, tc.execHook(h, i, originalFlow))
assert.Empty(t, originalFlow.ContinueWith(), "%#ßv", originalFlow.ContinueWith())
})
})
}
t.Run("flow=login/case=does not run if aal is not 1", func(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
h := hook.NewVerifier(reg)
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
f := &login.Flow{RequestedAAL: "aal2"}
require.NoError(t, h.ExecuteLoginPostHook(httptest.NewRecorder(), u, node.CodeGroup, f, &session.Session{ID: x.NewUUID(), Identity: i}))
messages, err := reg.CourierPersister().NextMessages(context.Background(), 12)
require.EqualError(t, err, "queue is empty")
require.Len(t, messages, 0)
})
t.Run("name=settings", func(t *testing.T) {
t.Parallel()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/verify.schema.json")
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.com/")
conf.MustSet(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
conf.MustSet(ctx, config.ViperKeyUseLegacyShowVerificationUI, true)
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"emails":["foo@ory.sh","bar@ory.sh","baz@ory.sh"]}`)
require.NoError(t, reg.IdentityManager().Create(context.Background(), i))
actual, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.AddressTypeEmail, "foo@ory.sh")
require.NoError(t, err)
assert.EqualValues(t, "foo@ory.sh", actual.Value)
actual, err = reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.AddressTypeEmail, "bar@ory.sh")
require.NoError(t, err)
assert.EqualValues(t, "bar@ory.sh", actual.Value)
actual, err = reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.AddressTypeEmail, "baz@ory.sh")
require.NoError(t, err)
assert.EqualValues(t, "baz@ory.sh", actual.Value)
verifiedAt := sqlxx.NullTime(time.Now())
actual.Status = identity.VerifiableAddressStatusCompleted
actual.Verified = true
actual.VerifiedAt = &verifiedAt
require.NoError(t, reg.PrivilegedIdentityPool().UpdateVerifiableAddress(context.Background(), actual))
i, err = reg.IdentityPool().GetIdentity(context.Background(), i.ID, identity.ExpandDefault)
require.NoError(t, err)
originalFlow := &settings.Flow{RequestURL: "http://foo.com/settings?after_verification_return_to=verification_callback"}
h := hook.NewVerifier(reg)
require.NoError(t, h.ExecuteSettingsPostPersistHook(
httptest.NewRecorder(), u, settings.PostHookPostPersistExecutorParams{
Flow: originalFlow,
Updated: i,
Previous: i,
Session: &session.Session{ID: x.NewUUID(), Identity: i},
}))
assert.Lenf(t, originalFlow.ContinueWith(), 2, "%#ßv", originalFlow.ContinueWith())
assertContinueWithAddresses(t, originalFlow.ContinueWith(), []string{"foo@ory.sh", "bar@ory.sh"})
vf := originalFlow.ContinueWith()[0]
assert.IsType(t, &flow.ContinueWithVerificationUI{}, vf)
fView := vf.(*flow.ContinueWithVerificationUI).Flow
expectedVerificationFlow, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, fView.ID)
require.NoError(t, err)
require.Equal(t, expectedVerificationFlow.State, flow.StateEmailSent)
messages, err := reg.CourierPersister().NextMessages(context.Background(), 12)
require.NoError(t, err)
require.Len(t, messages, 2)
recipients := make([]string, len(messages))
for k, m := range messages {
recipients[k] = m.Recipient
}
assert.Contains(t, recipients, "foo@ory.sh")
assert.Contains(t, recipients, "bar@ory.sh")
// Email to baz@ory.sh is skipped because it is verified already.
assert.NotContains(t, recipients, "baz@ory.sh")
// these addresses will be marked as sent and won't be sent again by the settings hook
address1, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.AddressTypeEmail, "foo@ory.sh")
require.NoError(t, err)
assert.EqualValues(t, identity.VerifiableAddressStatusSent, address1.Status)
address2, err := reg.IdentityPool().FindVerifiableAddressByValue(context.Background(), identity.AddressTypeEmail, "bar@ory.sh")
require.NoError(t, err)
assert.EqualValues(t, identity.VerifiableAddressStatusSent, address2.Status)
originalFlow = &settings.Flow{RequestURL: "http://foo.com/settings?after_verification_return_to=verification_callback"}
require.NoError(t, h.ExecuteSettingsPostPersistHook(
httptest.NewRecorder(), u, settings.PostHookPostPersistExecutorParams{
Flow: originalFlow,
Updated: i,
Previous: i,
Session: &session.Session{ID: x.NewUUID(), Identity: i},
}))
assert.Emptyf(t, originalFlow.ContinueWith(), "%+v", originalFlow.ContinueWith())
require.NoError(t, err)
messages, err = reg.CourierPersister().NextMessages(context.Background(), 12)
require.EqualError(t, err, courier.ErrQueueEmpty.Error())
assert.Len(t, messages, 0)
})
}
func assertContinueWithAddresses(t *testing.T, cs []flow.ContinueWith, addresses []string) {
t.Helper()
require.Len(t, cs, len(addresses))
e := make([]string, len(cs))
for i, c := range cs {
require.IsType(t, &flow.ContinueWithVerificationUI{}, c)
fView := c.(*flow.ContinueWithVerificationUI).Flow
e[i] = fView.VerifiableAddress
}
require.ElementsMatch(t, addresses, e)
}

View File

@@ -0,0 +1,253 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"context"
"encoding/json"
"net/http"
"slices"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/tidwall/sjson"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/clock"
"github.com/ory/x/httpx"
"github.com/ory/x/otelx"
"github.com/ory/x/sqlcon"
)
var _ settings.PostHookPrePersistExecutor = new(VerifyNewAddress)
type (
verifyNewAddressDependencies interface {
clock.Provider
config.Provider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
verification.StrategyProvider
verification.FlowPersistenceProvider
identity.PrivilegedPoolProvider
identity.PendingTraitsChangePersistenceProvider
settings.FlowPersistenceProvider
httpx.WriterProvider
otelx.Provider
session.ManagementProvider
}
VerifyNewAddressProvider interface {
HookVerifyNewAddress() *VerifyNewAddress
}
VerifyNewAddress struct {
r verifyNewAddressDependencies
}
)
func NewVerifyNewAddress(r verifyNewAddressDependencies) *VerifyNewAddress {
return &VerifyNewAddress{r: r}
}
func (e *VerifyNewAddress) ExecuteSettingsPrePersistHook(
w http.ResponseWriter, r *http.Request,
params settings.PostHookPrePersistExecutorParams,
) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.VerifyNewAddress.ExecuteSettingsPrePersistHook", func(ctx context.Context) error {
return e.execute(ctx, w, r, params.Flow, params.Identity, params.Session)
})
}
func (e *VerifyNewAddress) execute(
ctx context.Context, w http.ResponseWriter, r *http.Request,
f *settings.Flow, proposed *identity.Identity, s *session.Session,
) error {
original := s.Identity
if original == nil {
return nil
}
// Find verifiable addresses that changed between original and proposed.
changed := findChangedAddresses(original.VerifiableAddresses, proposed.VerifiableAddresses)
if len(changed) == 0 {
return nil // No address changes — let the flow proceed normally.
}
// At this point, we know the user is trying to change an address. We need to check that the session is currently privileged.
if !e.r.SessionManager().IsPrivileged(ctx, s) {
return errors.WithStack(settings.NewFlowNeedsReAuth())
}
// Only one address change can be verified at a time. Reject if multiple
// addresses changed to prevent unverified address changes from being applied.
if len(changed) > 1 {
f.UI.Messages.Clear()
f.UI.Messages.Add(text.NewErrorValidationSettingsTooManyAddressChanges())
if err := e.r.SettingsFlowPersister().UpdateSettingsFlow(ctx, f); err != nil {
return err
}
if x.IsJSONRequest(r) {
e.r.Writer().Write(w, r, f)
return errors.WithStack(settings.ErrHookAbortFlow)
}
http.Redirect(w, r, f.AppendTo(e.r.Config().SelfServiceFlowSettingsUI(ctx)).String(), http.StatusSeeOther)
return errors.WithStack(settings.ErrHookAbortFlow)
}
// Process the first changed address (we can only redirect to one verification flow).
addr := changed[0]
// Reject if the new address already belongs to a different identity
existingAddr, err := e.r.PrivilegedIdentityPool().FindVerifiableAddressByValue(ctx, addr.Via, addr.Value)
if err != nil && !errors.Is(err, sqlcon.ErrNoRows()) {
return err
}
if err == nil && existingAddr.IdentityID != original.ID {
f.UI.Messages.Clear()
f.UI.Messages.Add(text.NewErrorValidationDuplicateCredentials())
if err := e.r.SettingsFlowPersister().UpdateSettingsFlow(ctx, f); err != nil {
return err
}
if x.IsJSONRequest(r) {
e.r.Writer().WriteCode(w, r, http.StatusBadRequest, f)
return errors.WithStack(settings.ErrHookAbortFlow)
}
http.Redirect(w, r, f.AppendTo(e.r.Config().SelfServiceFlowSettingsUI(ctx)).String(), http.StatusSeeOther)
return errors.WithStack(settings.ErrHookAbortFlow)
}
// Delete any existing pending changes for this identity.
if err := e.r.PendingTraitsChangePersister().DeletePendingTraitsChangesByIdentity(ctx, original.ID); err != nil {
return err
}
// Create the verification flow.
strategies, primaryStrategy, err := e.r.GetActiveVerificationStrategies(ctx)
if err != nil {
return err
}
var csrf string
if f.Type == flow.TypeBrowser {
csrf = e.r.GenerateCSRFToken(r)
}
verificationFlow, err := verification.NewPostHookFlow(e.r,
e.r.Config().SelfServiceFlowVerificationRequestLifespan(ctx),
csrf, r, strategies, f)
if err != nil {
return err
}
verificationFlow.State = flow.StateEmailSent
if err := primaryStrategy.PopulateVerificationMethod(r, verificationFlow); err != nil {
return err
}
// The code strategy's PopulateVerificationMethod sets the email "code sent"
// message unconditionally; override it with the message that matches the
// channel the code was sent over. The legacy link strategy sends a link,
// not a code, and sets no message — leave it untouched.
if primaryStrategy.NodeGroup() == node.CodeGroup {
verificationFlow.UI.Messages.Set(text.VerificationCodeSentMessage(addr.Via))
}
verificationFlow.UI.Nodes.Append(
node.NewInputField(addr.Via, addr.Value, node.CodeGroup, node.InputAttributeTypeSubmit).
WithMetaLabel(text.NewInfoNodeResendOTP()),
)
if err := e.r.VerificationFlowPersister().CreateVerificationFlow(ctx, verificationFlow); err != nil {
return err
}
// Create PendingTraitsChange record.
sessionID := s.ID
originFlowID := f.ID
ptc := &identity.PendingTraitsChange{
IdentityID: original.ID,
IdentityRegion: original.Region,
SessionID: uuid.NullUUID{UUID: sessionID, Valid: true},
OriginSettingsFlowID: uuid.NullUUID{UUID: originFlowID, Valid: true},
NewAddressValue: addr.Value,
NewAddressVia: addr.Via,
OriginalTraitsHash: identity.HashTraits(json.RawMessage(original.Traits)),
ProposedTraits: json.RawMessage(proposed.Traits),
VerificationFlowID: verificationFlow.ID,
Status: identity.PendingTraitsChangeStatusPending,
}
if err := e.r.PendingTraitsChangePersister().CreatePendingTraitsChange(ctx, ptc); err != nil {
return err
}
// Send verification code to the new address.
if err := primaryStrategy.SendVerificationCode(ctx, verificationFlow, proposed, ptc); err != nil {
return err
}
// Store verification flow reference in InternalContext (for show_verification_ui).
flowURL := ""
if verificationFlow.Type == flow.TypeBrowser {
flowURL = verificationFlow.AppendTo(e.r.Config().SelfServiceFlowVerificationUI(ctx)).String()
}
continueWith := flow.NewContinueWithVerificationUI(verificationFlow.ID, addr.Value, flowURL)
internalContext, err := sjson.SetBytes(f.GetInternalContext(), InternalContextRegistrationVerificationFlow, continueWith.Flow)
if err != nil {
return err
}
f.SetInternalContext(internalContext)
f.AddContinueWith(continueWith)
// Persist the updated settings flow (with InternalContext/ContinueWith).
if err := e.r.SettingsFlowPersister().UpdateSettingsFlow(ctx, f); err != nil {
return err
}
// Write the HTTP response and abort the flow.
if x.IsJSONRequest(r) || verificationFlow.Type == flow.TypeAPI {
e.r.Writer().Write(w, r, f)
return errors.WithStack(settings.ErrHookAbortFlow)
}
if flowURL != "" {
http.Redirect(w, r, flowURL, http.StatusSeeOther)
return errors.WithStack(settings.ErrHookAbortFlow)
}
// This should not happen: we only end up here if, the flow is an API flow, but
return errors.WithStack(settings.ErrHookAbortFlow)
}
type changedAddress struct {
Value string
Via string
}
// findChangedAddresses compares original and proposed verifiable addresses and
// returns addresses that are new or have a different value.
func findChangedAddresses(original, proposed []identity.VerifiableAddress) []changedAddress {
origByVia := make(map[string][]string, len(original))
for _, a := range original {
origByVia[a.Via] = append(origByVia[a.Via], a.Value)
}
var changed []changedAddress
for _, p := range proposed {
if !slices.Contains(origByVia[p.Via], p.Value) {
changed = append(changed, changedAddress{Value: p.Value, Via: p.Via})
}
}
return changed
}

View File

@@ -0,0 +1,606 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/hook"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/x"
"github.com/ory/x/contextx"
"github.com/ory/x/randx"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
)
func TestVerifyNewAddress(t *testing.T) {
t.Parallel()
ctx := context.Background()
setup := func(t *testing.T) (context.Context, *hook.VerifyNewAddress, *driver.RegistryDefault) {
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx := testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_single_email.schema.json")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPublicBaseURL, "https://www.ory.sh/")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyCourierSMTPURL, "smtp://foo@bar@dev.null/")
h := hook.NewVerifyNewAddress(reg)
return ctx, h, reg
}
newSettingsFlow := func(t *testing.T, i identity.Identity) *settings.Flow {
t.Helper()
now := time.Now().UTC()
return &settings.Flow{
ID: x.NewUUID(),
ExpiresAt: now.Add(time.Hour),
IssuedAt: now,
RequestURL: "http://foo.com/settings",
IdentityID: i.ID,
Identity: &i,
Type: flow.TypeBrowser,
State: flow.StateShowForm,
UI: &container.Container{Method: "POST"},
InternalContext: []byte("{}"),
}
}
verifyAllAddresses := func(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, i *identity.Identity) *identity.Identity {
t.Helper()
for idx := range i.VerifiableAddresses {
i.VerifiableAddresses[idx].Verified = true
verifiedAt := sqlxx.NullTime(time.Now())
i.VerifiableAddresses[idx].VerifiedAt = &verifiedAt
i.VerifiableAddresses[idx].Status = identity.VerifiableAddressStatusCompleted
require.NoError(t, reg.PrivilegedIdentityPool().UpdateVerifiableAddress(ctx, &i.VerifiableAddresses[idx]))
}
var err error
i, err = reg.PrivilegedIdentityPool().GetIdentity(ctx, i.ID, identity.ExpandDefault)
require.NoError(t, err)
return i
}
createIdentity := func(t *testing.T, ctx context.Context, reg *driver.RegistryDefault, email string, verified bool) *identity.Identity {
t.Helper()
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"email":"` + email + `","name":"Test"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, i))
if verified {
i = verifyAllAddresses(t, ctx, reg, i)
}
return i
}
t.Run("case=no-op when address does not change", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
original := createIdentity(t, ctx, reg, "old@example.com", true)
f := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
proposed := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"old@example.com","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "old@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
},
}
sess := &session.Session{ID: x.NewUUID(), Identity: original, AuthenticatedAt: time.Now()}
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.NoError(t, err, "hook should be a no-op when address did not change")
})
t.Run("case=aborts flow when address changes", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
original := createIdentity(t, ctx, reg, "old@example.com", true)
f := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
proposed := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"new@example.com","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "new@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
},
}
sess := &session.Session{
ID: x.NewUUID(),
Identity: original,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now(),
}
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.Error(t, err)
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
// Settings flow should have a ContinueWith entry pointing to the verification flow.
require.NotEmpty(t, f.ContinueWith(), "settings flow should have ContinueWith items")
cw := f.ContinueWith()[0]
assert.IsType(t, &flow.ContinueWithVerificationUI{}, cw)
vfID := cw.(*flow.ContinueWithVerificationUI).Flow.ID
// A PendingTraitsChange record should exist.
ptc, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID)
require.NoError(t, err)
assert.Equal(t, identity.PendingTraitsChangeStatusPending, ptc.Status)
assert.Equal(t, "new@example.com", ptc.NewAddressValue)
assert.Equal(t, original.ID, ptc.IdentityID)
assert.Equal(t, identity.HashTraits(json.RawMessage(original.Traits)), ptc.OriginalTraitsHash)
require.True(t, ptc.SessionID.Valid, "pending change should record the session id")
assert.Equal(t, sess.ID, ptc.SessionID.UUID)
require.True(t, ptc.OriginSettingsFlowID.Valid, "pending traits change should record the originating settings flow ID")
assert.Equal(t, f.ID, ptc.OriginSettingsFlowID.UUID, "originating settings flow ID should match the flow the hook ran in")
// Verification flow should exist and be in StateEmailSent.
vf, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, vfID)
require.NoError(t, err)
assert.Equal(t, flow.StateEmailSent, vf.State)
// A verification code message should have been sent.
messages, err := reg.CourierPersister().NextMessages(ctx, 12)
require.NoError(t, err)
require.Len(t, messages, 1)
assert.Equal(t, "new@example.com", messages[0].Recipient)
})
t.Run("case=previous pending changes are deleted on new submission", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
original := createIdentity(t, ctx, reg, "old@example.com", true)
// First change: old@example.com -> new1@example.com
f1 := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f1))
proposed1 := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"new1@example.com","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "new1@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
},
}
sess := &session.Session{
ID: x.NewUUID(),
Identity: original,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now(),
}
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f1,
Identity: proposed1,
Session: sess,
})
require.True(t, errors.Is(err, settings.ErrHookAbortFlow))
vfID1 := f1.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
ptc1, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID1)
require.NoError(t, err)
assert.Equal(t, "new1@example.com", ptc1.NewAddressValue)
// Drain courier messages from first change.
_, _ = reg.CourierPersister().NextMessages(ctx, 12)
// Second change: old@example.com -> new2@example.com
f2 := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f2))
proposed2 := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"new2@example.com","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "new2@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
},
}
w2 := httptest.NewRecorder()
err = h.ExecuteSettingsPrePersistHook(w2, r, settings.PostHookPrePersistExecutorParams{
Flow: f2,
Identity: proposed2,
Session: sess,
})
require.True(t, errors.Is(err, settings.ErrHookAbortFlow))
vfID2 := f2.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
ptc2, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID2)
require.NoError(t, err)
assert.Equal(t, "new2@example.com", ptc2.NewAddressValue)
// The first pending change should have been deleted.
_, err = reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID1)
require.Error(t, err, "first pending change should have been deleted")
})
t.Run("case=correctly detects change with two email addresses", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_two_emails.schema.json")
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"email":"primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, i))
// Verify both addresses.
i = verifyAllAddresses(t, ctx, reg, i)
require.Len(t, i.VerifiableAddresses, 2, "identity should have two verifiable addresses")
// Change only the primary email; recovery email stays the same.
f := newSettingsFlow(t, *i)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
proposed := &identity.Identity{
ID: i.ID,
Traits: identity.Traits(`{"email":"new-primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "new-primary@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
{Value: "recovery@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
},
}
sess := &session.Session{
ID: x.NewUUID(),
Identity: i,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now(),
}
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
// Should abort with exactly one changed address (not two, not zero).
require.Error(t, err)
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
// Verify only one verification was triggered (for the changed address).
assert.Len(t, f.ContinueWith(), 1)
cw := f.ContinueWith()[0]
assert.IsType(t, &flow.ContinueWithVerificationUI{}, cw)
vfID := cw.(*flow.ContinueWithVerificationUI).Flow.ID
ptc, err := reg.PendingTraitsChangePersister().GetPendingTraitsChangeByVerificationFlow(ctx, vfID)
require.NoError(t, err)
assert.Equal(t, "new-primary@example.com", ptc.NewAddressValue, "pending change should be for the changed address only")
})
t.Run("case=no-op with two unchanged email addresses", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_two_emails.schema.json")
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"email":"primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, i))
i = verifyAllAddresses(t, ctx, reg, i)
f := newSettingsFlow(t, *i)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
proposed := &identity.Identity{
ID: i.ID,
Traits: identity.Traits(`{"email":"primary@example.com","recovery_email":"recovery@example.com","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "primary@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
{Value: "recovery@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
},
}
sess := &session.Session{ID: x.NewUUID(), Identity: i, AuthenticatedAt: time.Now()}
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.NoError(t, err, "hook should be a no-op when no addresses changed")
assert.Empty(t, f.ContinueWith())
})
t.Run("case=rejects change when new address is already owned by another identity", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
_ = createIdentity(t, ctx, reg, "existing@example.com", true)
original := createIdentity(t, ctx, reg, "other@example.com", true)
f := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
proposed := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"existing@example.com","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "existing@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
},
}
sess := &session.Session{
ID: x.NewUUID(),
Identity: original,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now(),
}
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
// Flow should carry the duplicate credentials error — no redirect to verification.
assert.Empty(t, f.ContinueWith(), "should not start a verification flow")
require.NotEmpty(t, f.UI.Messages)
assert.Contains(t, f.UI.Messages[0].Text, "exists already")
})
t.Run("case=rejects change when new phone number is already owned by another identity", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_email_and_phone.schema.json")
existing := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
existing.Traits = identity.Traits(`{"email":"phone-existing@example.com","phone":"+19999999999","name":"Existing"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, existing))
verifyAllAddresses(t, ctx, reg, existing)
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
original.Traits = identity.Traits(`{"email":"phone-original@example.com","phone":"+10000000000","name":"Original"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, original))
original = verifyAllAddresses(t, ctx, reg, original)
f := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
// Proposed: email unchanged, phone changed to the existing's number.
proposed := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"phone-original@example.com","phone":"+19999999999","name":"Original"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "phone-original@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
{Value: "+19999999999", Via: identity.AddressTypeSMS, IdentityID: original.ID},
},
}
sess := &session.Session{
ID: x.NewUUID(),
Identity: original,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now(),
}
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
// Flow should carry the duplicate credentials error — no redirect to verification.
assert.Empty(t, f.ContinueWith(), "should not start a verification flow")
require.NotEmpty(t, f.UI.Messages)
assert.Contains(t, f.UI.Messages[0].Text, "exists already")
})
t.Run("case=returns error when multiple addresses change at once", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_email_and_phone.schema.json")
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
i.Traits = identity.Traits(`{"email":"old@example.com","phone":"+12345678901","name":"Test"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, i))
i = verifyAllAddresses(t, ctx, reg, i)
f := newSettingsFlow(t, *i)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
proposed := &identity.Identity{
ID: i.ID,
Traits: identity.Traits(`{"email":"new@example.com","phone":"+19876543210","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "new@example.com", Via: identity.AddressTypeEmail, IdentityID: i.ID},
{Value: "+19876543210", Via: "sms", IdentityID: i.ID},
},
}
sess := &session.Session{ID: x.NewUUID(), Identity: i, AuthenticatedAt: time.Now()}
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.Error(t, err)
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
// The flow should contain the too-many-address-changes error message.
require.NotEmpty(t, f.UI.Messages)
})
t.Run("case=SMS verification flow shows the phone code-sent message, not the email one", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://./stub/verify_email_and_phone.schema.json")
// Configure an SMS courier channel so the verification code can be queued for a phone number.
ctx = contextx.WithConfigValue(ctx, config.ViperKeyCourierChannels, []map[string]any{
{"id": "sms", "type": "http", "request_config": map[string]any{
"url": "http://localhost:1234/sms",
"method": "POST",
"body": "base64://ZnVuY3Rpb24oY3R4KSBjdHg=",
}},
})
original := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
original.Traits = identity.Traits(`{"email":"sms-msg@example.com","phone":"+10000000000","name":"Test"}`)
require.NoError(t, reg.IdentityManager().Create(ctx, original))
original = verifyAllAddresses(t, ctx, reg, original)
f := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
// Proposed: email unchanged, phone changed to a new, unowned number.
const newPhone = "+19876543210"
proposed := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"sms-msg@example.com","phone":"` + newPhone + `","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: "sms-msg@example.com", Via: identity.AddressTypeEmail, IdentityID: original.ID},
{Value: newPhone, Via: identity.AddressTypeSMS, IdentityID: original.ID},
},
}
sess := &session.Session{
ID: x.NewUUID(),
Identity: original,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now(),
}
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
require.NotEmpty(t, f.ContinueWith(), "settings flow should redirect to verification")
vfID := f.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
vf, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, vfID)
require.NoError(t, err)
// An SMS verification must show only the phone "code has been sent" message,
// never the email wording.
require.Len(t, vf.UI.Messages, 1, "expected exactly one code-sent message")
assert.Equal(t, text.InfoSelfServiceVerificationPhoneWithCodeSent, vf.UI.Messages[0].ID)
})
t.Run("case=link strategy verification flow does not claim a code was sent", func(t *testing.T) {
t.Parallel()
ctx, h, reg := setup(t)
// Use the legacy link strategy as the primary verification method. It
// delivers a link, not a code, and sets no "code sent" message.
ctx = contextx.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+".link.enabled", true)
ctx = contextx.WithConfigValue(ctx, config.ViperKeySelfServiceVerificationUse, "link")
original := createIdentity(t, ctx, reg, "link-msg@example.com", true)
f := newSettingsFlow(t, *original)
require.NoError(t, reg.SettingsFlowPersister().CreateSettingsFlow(ctx, f))
const newEmail = "link-msg-new@example.com"
proposed := &identity.Identity{
ID: original.ID,
Traits: identity.Traits(`{"email":"` + newEmail + `","name":"Test"}`),
VerifiableAddresses: []identity.VerifiableAddress{
{Value: newEmail, Via: identity.AddressTypeEmail, IdentityID: original.ID},
},
}
sess := &session.Session{
ID: x.NewUUID(),
Identity: original,
Token: randx.MustString(12, randx.AlphaLowerNum),
LogoutToken: randx.MustString(12, randx.AlphaLowerNum),
AuthenticatedAt: time.Now(),
}
require.NoError(t, reg.SessionPersister().UpsertSession(ctx, sess))
r := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.sh/")}
r = r.WithContext(ctx)
w := httptest.NewRecorder()
err := h.ExecuteSettingsPrePersistHook(w, r, settings.PostHookPrePersistExecutorParams{
Flow: f,
Identity: proposed,
Session: sess,
})
require.True(t, errors.Is(err, settings.ErrHookAbortFlow), "expected ErrHookAbortFlow, got: %v", err)
require.NotEmpty(t, f.ContinueWith(), "settings flow should redirect to verification")
vfID := f.ContinueWith()[0].(*flow.ContinueWithVerificationUI).Flow.ID
vf, err := reg.VerificationFlowPersister().GetVerificationFlow(ctx, vfID)
require.NoError(t, err)
// A link was sent, not a code — the flow must not carry a "code has
// been sent" message.
require.Empty(t, vf.UI.Messages, "link strategy flows must not claim a code was sent")
})
}

View File

@@ -0,0 +1,578 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package hook
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/textproto"
"time"
"github.com/dgraph-io/ristretto/v2"
"github.com/gofrs/uuid"
"github.com/hashicorp/go-retryablehttp"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.11.0"
"go.opentelemetry.io/otel/trace"
grpccodes "google.golang.org/grpc/codes"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/request"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/recovery"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/selfservice/flow/verification"
"github.com/ory/kratos/session"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/events"
"github.com/ory/x/httpx"
"github.com/ory/x/jsonnetsecure"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/reqlog"
)
var _ interface {
login.PreHookExecutor
login.PostHookExecutor
registration.PostHookPostPersistExecutor
registration.PostHookPrePersistExecutor
registration.PreHookExecutor
verification.PreHookExecutor
verification.PostHookExecutor
recovery.PreHookExecutor
recovery.PostHookExecutor
settings.PreHookExecutor
settings.PostHookPrePersistExecutor
settings.PostHookPostPersistExecutor
} = (*WebHook)(nil)
var jsonnetCache, _ = ristretto.NewCache(&ristretto.Config[[]byte, []byte]{
MaxCost: 100 << 20, // 100MB,
NumCounters: 1_000_000, // 1kB per snippet -> 100k snippets -> 1M counters
BufferItems: 64,
})
type (
webHookDependencies interface {
logrusx.Provider
httpx.ClientProvider
otelx.Provider
jsonnetsecure.VMProvider
config.Provider
}
templateContext struct {
Flow flow.Flow `json:"flow"`
RequestHeaders http.Header `json:"request_headers"`
RequestMethod string `json:"request_method"`
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 {
deps webHookDependencies
conf *request.Config
}
detailedMessage struct {
ID int `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Context json.RawMessage `json:"context,omitempty"`
}
errorMessage struct {
InstancePtr string `json:"instance_ptr"`
DetailedMessages []detailedMessage `json:"messages"`
}
rawHookResponse struct {
Messages []errorMessage `json:"messages"`
}
)
func cookies(req *http.Request) map[string]string {
cookies := make(map[string]string)
for _, c := range req.Cookies() {
if c.Name != "" {
cookies[c.Name] = c.Value
}
}
return cookies
}
func NewWebHook(r webHookDependencies, c *request.Config) *WebHook {
return &WebHook{deps: r, conf: c}
}
func (e *WebHook) ExecuteLoginPreHook(_ http.ResponseWriter, req *http.Request, flow *login.Flow) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteLoginPreHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
})
})
}
func (e *WebHook) ExecuteLoginPostHook(_ http.ResponseWriter, req *http.Request, _ node.UiNodeGroup, flow *login.Flow, session *session.Session) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteLoginPostHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: session.Identity,
Session: session,
})
})
}
func (e *WebHook) ExecuteVerificationPreHook(_ http.ResponseWriter, req *http.Request, flow *verification.Flow) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteVerificationPreHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
})
})
}
func (e *WebHook) ExecutePostVerificationHook(_ http.ResponseWriter, req *http.Request, flow *verification.Flow, id *identity.Identity) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecutePostVerificationHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: id,
})
})
}
func (e *WebHook) ExecuteRecoveryPreHook(_ http.ResponseWriter, req *http.Request, flow *recovery.Flow) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteRecoveryPreHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestCookies: cookies(req),
RequestURL: x.RequestURL(req).String(),
})
})
}
func (e *WebHook) ExecutePostRecoveryHook(_ http.ResponseWriter, req *http.Request, flow *recovery.Flow, session *session.Session) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecutePostRecoveryHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: session.Identity,
})
})
}
func (e *WebHook) ExecuteRegistrationPreHook(_ http.ResponseWriter, req *http.Request, flow *registration.Flow) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteRegistrationPreHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
})
})
}
func (e *WebHook) ExecutePostRegistrationPrePersistHook(_ http.ResponseWriter, req *http.Request, flow *registration.Flow, id *identity.Identity) error {
if !e.conf.CanInterrupt && !e.conf.Response.Parse {
return nil
}
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecutePostRegistrationPrePersistHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: id,
})
})
}
func (e *WebHook) ExecutePostRegistrationPostPersistHook(_ http.ResponseWriter, req *http.Request, flow *registration.Flow, session *session.Session) error {
if e.conf.CanInterrupt || e.conf.Response.Parse {
return nil
}
// We want to decouple the request from the hook execution, so that the hooks still execute even
// if the request is canceled.
ctx := context.WithoutCancel(req.Context())
return otelx.WithSpan(ctx, "selfservice.hook.WebHook.ExecutePostRegistrationPostPersistHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: session.Identity,
})
})
}
func (e *WebHook) ExecuteSettingsPreHook(_ http.ResponseWriter, req *http.Request, params settings.PreHookExecutorParams) error {
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteSettingsPreHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: params.Flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Session: params.Session,
})
})
}
func (e *WebHook) ExecuteSettingsPostPersistHook(_ http.ResponseWriter, req *http.Request, params settings.PostHookPostPersistExecutorParams) error {
if e.conf.CanInterrupt || e.conf.Response.Parse {
return nil
}
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteSettingsPostPersistHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: params.Flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: params.Updated,
Session: params.Session,
})
})
}
func (e *WebHook) ExecuteSettingsPrePersistHook(_ http.ResponseWriter, req *http.Request, params settings.PostHookPrePersistExecutorParams) error {
if !e.conf.CanInterrupt && !e.conf.Response.Parse {
return nil
}
return otelx.WithSpan(req.Context(), "selfservice.hook.WebHook.ExecuteSettingsPrePersistHook", func(ctx context.Context) error {
return e.execute(ctx, &templateContext{
Flow: params.Flow,
RequestHeaders: req.Header,
RequestMethod: req.Method,
RequestURL: x.RequestURL(req).String(),
RequestCookies: cookies(req),
Identity: params.Identity,
Session: params.Session,
})
})
}
func (e *WebHook) execute(ctx context.Context, data *templateContext) error {
var (
httpClient = e.deps.HTTPClient(ctx)
ignoreResponse = e.conf.Response.Ignore
canInterrupt = e.conf.CanInterrupt
parseResponse = e.conf.Response.Parse
emitEvent = e.conf.EmitAnalyticsEvent == nil || *e.conf.EmitAnalyticsEvent // default true
webhookID = e.conf.ID
// The trigger ID is a random ID. It can be used to correlate webhook requests across retries.
triggerID = x.NewUUID()
tracer = trace.SpanFromContext(ctx).TracerProvider().Tracer("kratos-webhooks")
)
if ignoreResponse && (parseResponse || canInterrupt) {
return errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("A webhook is configured to ignore the response but also to parse the response. This is not possible."))
}
makeRequest := func() (finalErr error) {
if ignoreResponse {
// This means we want to run this closure asynchronously and not be
// canceled when the parent context is canceled.
//
// The webhook will still cancel after 30 seconds as that is the
// configured timeout for the HTTP client.
ctx = context.WithoutCancel(ctx)
}
ctx, span := tracer.Start(ctx, "selfservice.webhook")
defer otelx.End(span, &finalErr)
if emitEvent {
InstrumentHTTPClientForEvents(ctx, httpClient, triggerID, webhookID)
}
defer func(startTime time.Time) {
traceID, spanID := span.SpanContext().TraceID(), span.SpanContext().SpanID()
logger := e.deps.Logger().WithField("otel", map[string]string{
"trace_id": traceID.String(),
"span_id": spanID.String(),
}).WithField("duration", time.Since(startTime))
if finalErr != nil {
if emitEvent && !errors.Is(finalErr, context.Canceled) {
span.AddEvent(events.NewWebhookFailed(ctx, finalErr, triggerID, webhookID))
}
if ignoreResponse {
logger.WithError(finalErr).Warning("Webhook request failed but the error was ignored because the configuration indicated that the upstream response should be ignored")
} else {
logger.WithError(finalErr).Error("Webhook request failed")
}
} else {
logger.Info("Webhook request succeeded")
if emitEvent {
span.AddEvent(events.NewWebhookSucceeded(ctx, triggerID, webhookID))
}
}
}(time.Now())
builder, err := request.NewBuilder(e.conf, e.deps, request.WithCache(jsonnetCache))
if err != nil {
return err
}
span.SetAttributes(
attribute.String("webhook.jsonnet.template-uri", builder.Config.TemplateURI),
attribute.Bool("webhook.can_interrupt", canInterrupt),
attribute.Bool("webhook.response.ignore", ignoreResponse),
attribute.Bool("webhook.response.parse", parseResponse),
)
data.RequestHeaders = RemoveDisallowedHeaders(data.RequestHeaders, e.deps.Config().WebhookHeaderAllowlist(ctx))
req, err := builder.BuildRequest(ctx, data)
if errors.Is(err, request.ErrCancel) {
span.SetAttributes(attribute.Bool("webhook.jsonnet.canceled", true))
return nil
} else if err != nil {
return err
}
if data.Identity != nil {
span.SetAttributes(
attribute.String("webhook.identity.id", data.Identity.ID.String()),
attribute.String("webhook.identity.nid", data.Identity.NID.String()),
)
}
e.deps.Logger().WithRequest(req.Request).Info("Dispatching webhook")
resp, err := httpClient.Do(req)
if err != nil {
if IsTimeoutError(err) {
return (&herodot.DefaultError{
CodeField: http.StatusGatewayTimeout,
StatusField: http.StatusText(http.StatusGatewayTimeout),
GRPCCodeField: grpccodes.DeadlineExceeded,
ErrorField: err.Error(),
ReasonField: "A third-party upstream service could not be reached. Please try again later.",
}).WithWrap(errors.WithStack(err))
}
return errors.WithStack(err)
}
// Close the original body, not the NopCloser below: closing it returns
// the connection to the pool and ends the otelhttp client span.
defer func(body io.ReadCloser) { _ = body.Close() }(resp.Body)
resp.Body = io.NopCloser(io.LimitReader(resp.Body, 5<<20)) // read at most 5 MB from the response
span.SetAttributes(semconv.HTTPAttributesFromHTTPStatusCode(resp.StatusCode)...)
if resp.StatusCode >= http.StatusBadRequest {
span.SetStatus(codes.Error, "HTTP status code >= 400")
if canInterrupt || parseResponse {
if err := parseWebhookResponse(resp, data.Identity); err != nil {
return err
}
}
return &herodot.DefaultError{
CodeField: http.StatusBadGateway,
StatusField: http.StatusText(http.StatusBadGateway),
GRPCCodeField: grpccodes.Aborted,
ReasonField: "A third-party upstream service responded improperly. Please try again later.",
ErrorField: fmt.Sprintf("webhook failed with status code %v", resp.StatusCode),
}
}
if parseResponse {
return parseWebhookResponse(resp, data.Identity)
}
return nil
}
if !ignoreResponse {
t0 := time.Now()
err := makeRequest()
reqlog.AccumulateExternalLatency(ctx, time.Since(t0))
return err
}
go func() {
// we cannot handle the error as we are running async, and it is logged anyway
_ = makeRequest()
}()
return nil
}
// RemoveDisallowedHeaders removes all headers from httpHeaders that are not in
// headerAllowlist.
func RemoveDisallowedHeaders(httpHeaders http.Header, headerAllowlist []string) http.Header {
res := make(http.Header, len(headerAllowlist))
for _, allowed := range headerAllowlist {
allowed = textproto.CanonicalMIMEHeaderKey(allowed)
if h, ok := httpHeaders[allowed]; ok {
res[allowed] = h
}
}
return res
}
func parseWebhookResponse(resp *http.Response, id *identity.Identity) (err error) {
if resp == nil {
return errors.Errorf("empty response provided from the webhook")
}
if resp.StatusCode == http.StatusOK {
type localIdentity identity.Identity
var hookResponse struct {
Identity *localIdentity `json:"identity"`
}
// io.ReadAll is safe, because resp.Body is already a limited reader.
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "webhook response body could not be read")
}
if err = json.Unmarshal(body, &hookResponse); err != nil {
return errors.Wrap(err, "webhook response could not be unmarshalled properly from JSON")
}
if hookResponse.Identity == nil {
return nil
}
if len(hookResponse.Identity.Traits) > 0 {
id.Traits = hookResponse.Identity.Traits
}
if len(hookResponse.Identity.SchemaID) > 0 {
id.SchemaID = hookResponse.Identity.SchemaID
}
if len(hookResponse.Identity.State) > 0 {
id.State = hookResponse.Identity.State
}
if len(hookResponse.Identity.VerifiableAddresses) > 0 {
id.VerifiableAddresses = hookResponse.Identity.VerifiableAddresses
}
if len(hookResponse.Identity.RecoveryAddresses) > 0 {
id.RecoveryAddresses = hookResponse.Identity.RecoveryAddresses
}
if len(hookResponse.Identity.MetadataPublic) > 0 {
id.MetadataPublic = hookResponse.Identity.MetadataPublic
}
if len(hookResponse.Identity.MetadataAdmin) > 0 {
id.MetadataAdmin = hookResponse.Identity.MetadataAdmin
}
if gjson.GetBytes(body, "identity.external_id").Exists() {
id.ExternalID = hookResponse.Identity.ExternalID
}
return nil
} else if resp.StatusCode == http.StatusNoContent {
return nil
} else if resp.StatusCode >= http.StatusBadRequest {
var hookResponse rawHookResponse
if err := json.NewDecoder(resp.Body).Decode(&hookResponse); err != nil {
return errors.Wrap(err, "webhook response could not be unmarshalled properly from JSON")
}
var validationErrs []*schema.ValidationError
for _, msg := range hookResponse.Messages {
messages := text.Messages{}
for _, detail := range msg.DetailedMessages {
var msgType text.UITextType
if detail.Type == "error" {
msgType = text.Error
} else {
msgType = text.Info
}
messages.Add(&text.Message{
ID: text.ID(detail.ID),
Text: detail.Text,
Type: msgType,
Context: detail.Context,
})
}
validationErrs = append(validationErrs, schema.NewHookValidationError(msg.InstancePtr, "a webhook target returned an error", messages))
}
if len(validationErrs) == 0 {
return errors.New("error while parsing webhook response: got no validation errors")
}
return schema.NewValidationListError(validationErrs)
}
return nil
}
func IsTimeoutError(err error) bool {
var te interface{ Timeout() bool }
return errors.As(err, &te) && te.Timeout() || errors.Is(err, context.DeadlineExceeded)
}
func InstrumentHTTPClientForEvents(ctx context.Context, httpClient *retryablehttp.Client, triggerID uuid.UUID, webhookID string) {
// TODO(@alnr): improve this implementation to redact sensitive data
var (
attempt = 0
requestID uuid.UUID
reqBody []byte
)
httpClient.RequestLogHook = func(_ retryablehttp.Logger, req *http.Request, retryNumber int) {
attempt = retryNumber + 1
requestID = x.NewUUID()
req.Header.Set("Ory-Webhook-Request-ID", requestID.String())
req.Header.Set("Ory-Webhook-Trigger-ID", triggerID.String())
// TODO(@alnr): redact sensitive data
// reqBody, _ = httputil.DumpRequestOut(req, true)
reqBody = []byte("<redacted>")
}
httpClient.ResponseLogHook = func(_ retryablehttp.Logger, res *http.Response) {
// res.Body = io.NopCloser(io.LimitReader(res.Body, 5<<20)) // read at most 5 MB from the response
// resBody, _ := httputil.DumpResponse(res, true)
// resBody = resBody[:min(len(resBody), 2<<10)] // truncate response body to 2 kB for event
// TODO(@alnr): redact sensitive data
resBody := []byte("<redacted>")
trace.SpanFromContext(ctx).AddEvent(events.NewWebhookDelivered(ctx, res.Request.URL, reqBody, res.StatusCode, resBody, attempt, requestID, triggerID, webhookID))
}
}

File diff suppressed because it is too large Load Diff