feat: webhooks that update identities

Introduces a new configuration `response.parse` in webhooks. This enables updating of identity data during registration, including admin/public metadata, identity traits, enabling/disabling identity, and modifying verified/recovery addresses.

Please note that `can_interrupt` is being deprecated in favor of `response.parse`.

Closes #2161
This commit is contained in:
aeneasr 2023-02-02 19:12:55 +01:00 committed by hackerman
parent 586eaf9e3f
commit 2cbee3e8ee
22 changed files with 851 additions and 63 deletions

View File

@ -174,7 +174,23 @@
"type": "boolean",
"description": "Ignore the response from the web hook. If enabled the request will be made asynchronously which can be useful if you only wish to notify another system but do not parse the response.",
"default": false
},
"parse": {
"type": "boolean",
"default": false,
"description": "If enabled parses the response before saving the flow result. Set this value to true if you would like to modify the identity, for example identity metadata, before saving it during registration. When enabled, you may also abort the registration, verification, login or settings flow due to, for example, a validation flow. Head over to the [web hook documentation](https://www.ory.sh/docs/kratos/hooks/configure-hooks) for more information."
}
},
"not": {
"properties": {
"ignore": {
"const": true
},
"parse": {
"const": true
}
},
"required":["ignore","parse"]
}
},
"url": {
@ -211,7 +227,7 @@
"can_interrupt": {
"type": "boolean",
"default": false,
"description": "If enabled allows the web hook to interrupt / abort the self-service flow. It only applies to certain flows (registration/verification/login/settings) and requires a valid response format."
"description": "Deprecated, please use `response.parse` instead. If enabled allows the web hook to interrupt / abort the self-service flow. It only applies to certain flows (registration/verification/login/settings) and requires a valid response format."
},
"auth": {
"type": "object",
@ -1208,7 +1224,10 @@
"title": "Verification Strategy",
"description": "The strategy to use for verification requests",
"type": "string",
"enum": ["link", "code"],
"enum": [
"link",
"code"
],
"default": "code"
}
}

View File

@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/ory/x/pointerx"
"golang.org/x/sync/errgroup"
"go.opentelemetry.io/otel/attribute"
@ -257,9 +259,26 @@ func (p *Persister) normalizeAllAddressess(ctx context.Context, id *identity.Ide
func (p *Persister) normalizeVerifiableAddresses(ctx context.Context, id *identity.Identity) {
for k := range id.VerifiableAddresses {
id.VerifiableAddresses[k].IdentityID = id.ID
id.VerifiableAddresses[k].NID = p.NetworkID(ctx)
id.VerifiableAddresses[k].Value = stringToLowerTrim(id.VerifiableAddresses[k].Value)
v := id.VerifiableAddresses[k]
v.IdentityID = id.ID
v.NID = p.NetworkID(ctx)
v.Value = stringToLowerTrim(v.Value)
v.Via = x.Coalesce(v.Via, identity.AddressTypeEmail)
if len(v.Status) == 0 {
if v.Verified {
v.Status = identity.VerifiableAddressStatusCompleted
} else {
v.Status = identity.VerifiableAddressStatusPending
}
}
// If verified is true but no timestamp is set, we default to time.Now
if v.Verified && (v.VerifiedAt == nil || time.Time(*v.VerifiedAt).IsZero()) {
v.VerifiedAt = pointerx.Ptr(sqlxx.NullTime(time.Now()))
}
id.VerifiableAddresses[k] = v
}
}
@ -268,6 +287,7 @@ func (p *Persister) normalizeRecoveryAddresses(ctx context.Context, id *identity
id.RecoveryAddresses[k].IdentityID = id.ID
id.RecoveryAddresses[k].NID = p.NetworkID(ctx)
id.RecoveryAddresses[k].Value = stringToLowerTrim(id.RecoveryAddresses[k].Value)
id.RecoveryAddresses[k].Via = x.Coalesce(id.RecoveryAddresses[k].Via, identity.AddressTypeEmail)
}
}

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,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,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": {
"useful": "metadata"
},
"metadata_admin": {
"admin": "data"
},
"created_at": "0001-01-01T00:00:00Z",
"updated_at": "0001-01-01T00:00:00Z"
}

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@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"
}

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": "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"
}

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@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"
}

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@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"
}

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,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

@ -10,6 +10,8 @@ import (
"net/http"
"time"
"github.com/ory/herodot"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel/attribute"
@ -188,7 +190,7 @@ func (e *WebHook) ExecuteRegistrationPreHook(_ http.ResponseWriter, req *http.Re
}
func (e *WebHook) ExecutePostRegistrationPrePersistHook(_ http.ResponseWriter, req *http.Request, flow *registration.Flow, id *identity.Identity) error {
if !gjson.GetBytes(e.conf, "can_interrupt").Bool() {
if !(gjson.GetBytes(e.conf, "can_interrupt").Bool() || gjson.GetBytes(e.conf, "response.parse").Bool()) {
return nil
}
return otelx.WithSpan(req.Context(), "selfservice.hook.ExecutePostRegistrationPrePersistHook", func(ctx context.Context) error {
@ -204,7 +206,7 @@ func (e *WebHook) ExecutePostRegistrationPrePersistHook(_ http.ResponseWriter, r
}
func (e *WebHook) ExecutePostRegistrationPostPersistHook(_ http.ResponseWriter, req *http.Request, flow *registration.Flow, session *session.Session) error {
if gjson.GetBytes(e.conf, "can_interrupt").Bool() {
if gjson.GetBytes(e.conf, "can_interrupt").Bool() || gjson.GetBytes(e.conf, "response.parse").Bool() {
return nil
}
return otelx.WithSpan(req.Context(), "selfservice.hook.ExecutePostRegistrationPostPersistHook", func(ctx context.Context) error {
@ -288,11 +290,16 @@ func (e *WebHook) execute(ctx context.Context, data *templateContext) error {
httpClient = e.deps.HTTPClient(ctx)
ignoreResponse = gjson.GetBytes(e.conf, "response.ignore").Bool()
canInterrupt = gjson.GetBytes(e.conf, "can_interrupt").Bool()
parseResponse = gjson.GetBytes(e.conf, "response.parse").Bool()
tracer = trace.SpanFromContext(ctx).TracerProvider().Tracer("kratos-webhooks")
spanOpts = []trace.SpanStartOption{trace.WithAttributes(attrs...)}
errChan = make(chan error, 1)
)
if ignoreResponse && (parseResponse || canInterrupt) {
return errors.WithStack(herodot.ErrInternalServerError.WithReasonf("A webhook is configured to ignore the response but also to parse the response. This is not possible."))
}
ctx, span := tracer.Start(ctx, "selfservice.webhook", spanOpts...)
e.deps.Logger().WithRequest(req.Request).Info("Dispatching webhook")
@ -323,8 +330,8 @@ func (e *WebHook) execute(ctx context.Context, data *templateContext) error {
if resp.StatusCode >= http.StatusBadRequest {
span.SetStatus(codes.Error, "HTTP status code >= 400")
if canInterrupt {
if err := parseWebhookResponse(resp); err != nil {
if canInterrupt || parseResponse {
if err := parseWebhookResponse(resp, data.Identity); err != nil {
span.SetStatus(codes.Error, err.Error())
errChan <- err
}
@ -333,6 +340,13 @@ func (e *WebHook) execute(ctx context.Context, data *templateContext) error {
return
}
if parseResponse {
if err := parseWebhookResponse(resp, data.Identity); err != nil {
span.SetStatus(codes.Error, err.Error())
errChan <- err
}
}
errChan <- nil
}()
@ -355,38 +369,91 @@ func (e *WebHook) execute(ctx context.Context, data *templateContext) error {
return <-errChan
}
func parseWebhookResponse(resp *http.Response) (err error) {
func parseWebhookResponse(resp *http.Response, id *identity.Identity) (err error) {
if resp == nil {
return errors.Errorf("empty response provided from the webhook")
}
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,
})
if resp.StatusCode == http.StatusOK {
var hookResponse struct {
Identity *identity.Identity `json:"identity"`
}
validationErrs = append(validationErrs, schema.NewHookValidationError(msg.InstancePtr, "a webhook target returned an error", messages))
if err := json.NewDecoder(resp.Body).Decode(&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.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
}
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)
}
if len(validationErrs) == 0 {
return errors.New("error while parsing webhook response: got no validation errors")
}
return schema.NewValidationListError(validationErrs)
return nil
}

View File

@ -19,6 +19,8 @@ import (
"testing"
"time"
"github.com/ory/x/snapshotx"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/require"
@ -607,6 +609,90 @@ func TestWebHooks(t *testing.T) {
})
}
t.Run("update identity fields", func(t *testing.T) {
run := func(t *testing.T, id identity.Identity, responseCode int, response []byte) *identity.WithCredentialsAndAdminMetadataInJSON {
f := &registration.Flow{ID: x.NewUUID()}
req := &http.Request{
Host: "www.ory.sh",
Header: map[string][]string{},
RequestURI: "/some_end_point",
Method: http.MethodPost,
URL: &url.URL{Path: "some_end_point"},
}
ts := newServer(webHookHttpCodeWithBodyEndPoint(t, responseCode, response))
conf := json.RawMessage(fmt.Sprintf(`{"url": "%s", "method": "POST", "body": "%s", "response": {"parse":true}}`, ts.URL+path, "file://./stub/test_body.jsonnet"))
wh := hook.NewWebHook(&whDeps, conf)
in := &id
err := wh.ExecutePostRegistrationPrePersistHook(nil, req, f, in)
require.NoError(t, err)
result := identity.WithCredentialsAndAdminMetadataInJSON(*in)
return &result
}
t.Run("case=update identity fields", func(t *testing.T) {
expected := identity.Identity{
Credentials: map[identity.CredentialsType]identity.Credentials{identity.CredentialsTypePassword: {Type: "password", Identifiers: []string{"test"}, Config: []byte(`{"hashed_password":"$argon2id$v=19$m=65536,t=1,p=1$Z3JlZW5hbmRlcnNlY3JldA$Z3JlZW5hbmRlcnNlY3JldA"}`)}},
SchemaID: "default",
SchemaURL: "file://stub/default.schema.json",
State: identity.StateActive,
Traits: []byte(`{"email":"some@example.org"}`),
VerifiableAddresses: []identity.VerifiableAddress{{
Value: "some@example.org",
Verified: false,
Via: "email",
Status: identity.VerifiableAddressStatusPending,
}},
RecoveryAddresses: []identity.RecoveryAddress{{
Value: "some@example.org",
Via: "email",
}},
MetadataPublic: []byte(`{"public":"data"}`),
MetadataAdmin: []byte(`{"admin":"data"}`),
InternalCredentials: identity.CredentialsCollection{{Type: "password", Identifiers: []string{"test"}, Config: []byte(`{}`)}},
}
t.Run("case=body is empty", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{}`))
snapshotx.SnapshotT(t, &actual)
})
t.Run("case=identity is present but empty", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{"identity":{}}`))
snapshotx.SnapshotT(t, &actual)
})
t.Run("case=identity has updated traits", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"traits":{"email":"some@other-example.org"}}}`))
snapshotx.SnapshotT(t, &actual)
})
t.Run("case=identity has updated state", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"state":"inactive"}}`))
snapshotx.SnapshotT(t, &actual)
})
t.Run("case=identity has updated public metadata", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"metadata_public":{"useful":"metadata"}}}`))
snapshotx.SnapshotT(t, &actual)
})
t.Run("case=identity has updated admin metadata", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"metadata_admin":{"useful":"metadata"}}}`))
snapshotx.SnapshotT(t, &actual)
})
t.Run("case=identity has updated verified addresses", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"traits":{"email":"some@other-example.org"},"verifiable_addresses":[{"value":"some@other-example.org","via":"email"}]}}`))
snapshotx.SnapshotT(t, &actual)
})
t.Run("case=identity has updated recovery addresses", func(t *testing.T) {
actual := run(t, expected, http.StatusOK, []byte(`{"identity":{"traits":{"email":"some@other-example.org"},"recovery_addresses":[{"value":"some@other-example.org","via":"email"}]}}`))
snapshotx.SnapshotT(t, &actual)
})
})
})
t.Run("must error when config is erroneous", func(t *testing.T) {
req := &http.Request{
Header: map[string][]string{"Some-Header": {"Some-Value"}},
@ -624,6 +710,24 @@ func TestWebHooks(t *testing.T) {
assert.Error(t, err)
})
t.Run("cannot have parse and ignore both set", func(t *testing.T) {
ts := newServer(webHookHttpCodeEndPoint(200))
req := &http.Request{
Header: map[string][]string{"Some-Header": {"Some-Value"}},
Host: "www.ory.sh",
TLS: new(tls.ConnectionState),
URL: &url.URL{Path: "/some_end_point"},
Method: http.MethodPost,
}
f := &login.Flow{ID: x.NewUUID()}
conf := json.RawMessage(fmt.Sprintf(`{"url": "%s", "method": "GET", "body": "./stub/test_body.jsonnet", "response": {"ignore": true, "parse": true}}`, ts.URL+path))
wh := hook.NewWebHook(&whDeps, conf)
err := wh.ExecuteLoginPreHook(nil, req, f)
assert.Error(t, err)
})
t.Run("must error when template is erroneous", func(t *testing.T) {
ts := newServer(webHookHttpCodeEndPoint(200))
req := &http.Request{

View File

@ -45,6 +45,68 @@ context("Registration success with email profile with webhooks", () => {
expect(identity.traits.email).to.equal(email)
})
})
it("should sign up and modify the identity", () => {
const email = gen.email()
const password = gen.password()
const updatedEmail = {
identity: {
traits: { email: "updated-" + email },
verifiable_addresses: [
{ via: "email", value: "updated-" + email, verified: true },
{ via: "email", value: "this-email-should-be-ignored" },
{ via: "email", value: "" },
],
recovery_addresses: [
{ via: "email", value: "updated-" + email },
{ via: "email", value: "this-email-should-be-ignored" },
{ via: "email", value: "" },
],
metadata_public: { some: "public fields" },
},
}
cy.setPostPasswordRegistrationHooks([
{ hook: "session" },
{
hook: "web_hook",
config: {
url:
"http://127.0.0.1:4459/webhook/write?response=" +
encodeURIComponent(JSON.stringify(updatedEmail)),
method: "POST",
body: "file://test/e2e/profiles/webhooks/webhook_body.jsonnet",
response: { parse: true },
},
},
])
cy.get(appPrefix(app) + 'input[name="traits"]').should("not.exist")
cy.get('input[name="traits.email"]').type(email)
cy.get('input[name="password"]').type(password)
cy.submitPasswordForm()
cy.getSession().should((session) => {
const { identity } = session
expect(identity.id).to.not.be.empty
expect(identity.schema_id).to.equal("default")
expect(identity.schema_url).to.equal(`${APP_URL}/schemas/ZGVmYXVsdA`)
expect(identity.traits.email).to.equal("updated-" + email)
expect(identity.metadata_public.some).to.equal("public fields")
expect(identity.verifiable_addresses[0].verified).to.equal(true)
expect(identity.verifiable_addresses[0].verified_at).not.to.be.empty
expect(identity.verifiable_addresses[0].via).to.eq("email")
expect(identity.verifiable_addresses[0].value).to.eq(
"updated-" + email,
)
expect(identity.verifiable_addresses).to.have.length(1)
expect(identity.recovery_addresses).to.have.length(1)
expect(identity.recovery_addresses[0].via).to.eq("email")
expect(identity.recovery_addresses[0].value).to.eq("updated-" + email)
})
})
})
})
})

View File

@ -209,6 +209,13 @@ Cypress.Commands.add("enableLoginForVerifiedAddressOnly", () => {
})
})
Cypress.Commands.add("setPostPasswordRegistrationHooks", (hooks) => {
updateConfigFile((config) => {
config.selfservice.flows.registration["after"].password = { hooks }
return config
})
})
Cypress.Commands.add("shortLoginLifespan", ({} = {}) => {
updateConfigFile((config) => {
config.selfservice.flows.login.lifespan = "100ms"

View File

@ -1,8 +1,6 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
import { Session } from "@ory/kratos-client"
export interface MailMessage {
fromAddress: string
toAddresses: Array<string>
@ -151,6 +149,15 @@ declare global {
returnTo?: string
}): Chainable<void>
/**
* Sets the post registration hook.
*
* @param hooks
*/
setPostPasswordRegistrationHooks(
hooks: Array<{ hook: string; config?: any }>,
)
/**
* Submits a verification flow via the Browser
*

View File

@ -7,6 +7,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
@ -97,15 +98,24 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) {
}
payload := struct {
IdentityId string `json:"identity_id,omitempty"`
Email string `json:"email,omitempty"`
FlowId string `json:"flow_id"`
FlowType string `json:"flow_type"`
IdentityId string `json:"identity_id,omitempty"`
Email string `json:"email,omitempty"`
FlowId string `json:"flow_id"`
FlowType string `json:"flow_type"`
Context json.RawMessage `json:"ctx"`
}{}
encoder := json.NewDecoder(r.Body)
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
log.WithError(err).Warn("could not read request body")
b := bytes.NewBufferString(fmt.Sprintf("error while reading request body: %s", err))
_, _ = w.Write(b.Bytes())
return
}
defer r.Body.Close()
if err := encoder.Decode(&payload); err != nil {
if err := json.Unmarshal(body, &payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
log.WithError(err).Warn("could not unmarshal request JSON")
b := bytes.NewBufferString(fmt.Sprintf("error while parsing request JSON: %s", err))
@ -113,7 +123,7 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) {
return
}
log.WithField("payload", payload).Info("unmarshalled request")
log.WithField("payload", string(body)).Info("unmarshalled request")
if !strings.Contains(payload.Email, "_blocked@ory.sh") || payload.FlowType == "api" {
w.WriteHeader(http.StatusOK)
@ -129,7 +139,7 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) {
}
msg := errorMessage{InstancePtr: "#/traits/email", Messages: []detailedMessage{detail}}
resp := rawHookResponse{Messages: []errorMessage{msg}}
err := json.NewEncoder(w).Encode(&resp)
err = json.NewEncoder(w).Encode(&resp)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
buff := bytes.NewBufferString(err.Error())
@ -138,11 +148,22 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) {
}
}
func writeWebhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusBadRequest)
return
}
log.WithFields(log.Fields{"application": "webhooks", "path": r.URL.Path, "response": r.URL.Query().Get("response")}).Info("sending response")
_, _ = w.Write([]byte(r.URL.Query().Get("response")))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", healthCheck)
mux.HandleFunc("/webhook", accessLog(headerAuth(webhookHandler)))
mux.HandleFunc("/webhook/write", accessLog(writeWebhookHandler))
s := http.Server{
Addr: ":4459",

View File

@ -12,7 +12,7 @@
"@types/node": "^16.9.6",
"@types/yamljs": "^0.2.31",
"chrome-remote-interface": "0.31.2",
"cypress": "^10.3.1",
"cypress": "^11.2.0",
"dayjs": "^1.10.4",
"got": "^11.8.2",
"otplib": "^12.0.1",
@ -754,9 +754,9 @@
}
},
"node_modules/cypress": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-10.3.1.tgz",
"integrity": "sha512-As9HrExjAgpgjCnbiQCuPdw5sWKx5HUJcK2EOKziu642akwufr/GUeqL5UnCPYXTyyibvEdWT/pSC2qnGW/e5w==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-11.2.0.tgz",
"integrity": "sha512-u61UGwtu7lpsNWLUma/FKNOsrjcI6wleNmda/TyKHe0dOBcVjbCPlp1N6uwFZ0doXev7f/91YDpU9bqDCFeBLA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@ -779,7 +779,7 @@
"dayjs": "^1.10.4",
"debug": "^4.3.2",
"enquirer": "^2.3.6",
"eventemitter2": "^6.4.3",
"eventemitter2": "6.4.7",
"execa": "4.1.0",
"executable": "^4.1.1",
"extract-zip": "2.0.1",
@ -952,9 +952,9 @@
}
},
"node_modules/eventemitter2": {
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.5.tgz",
"integrity": "sha512-bXE7Dyc1i6oQElDG0jMRZJrRAn9QR2xyyFGmBdZleNmyQX0FqGYmhZIrIrpPfm/w//LTo4tVQGOGQcGCb5q9uw==",
"version": "6.4.7",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz",
"integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==",
"dev": true
},
"node_modules/execa": {
@ -2960,9 +2960,9 @@
}
},
"cypress": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-10.3.1.tgz",
"integrity": "sha512-As9HrExjAgpgjCnbiQCuPdw5sWKx5HUJcK2EOKziu642akwufr/GUeqL5UnCPYXTyyibvEdWT/pSC2qnGW/e5w==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-11.2.0.tgz",
"integrity": "sha512-u61UGwtu7lpsNWLUma/FKNOsrjcI6wleNmda/TyKHe0dOBcVjbCPlp1N6uwFZ0doXev7f/91YDpU9bqDCFeBLA==",
"dev": true,
"requires": {
"@cypress/request": "^2.88.10",
@ -2984,7 +2984,7 @@
"dayjs": "^1.10.4",
"debug": "^4.3.2",
"enquirer": "^2.3.6",
"eventemitter2": "^6.4.3",
"eventemitter2": "6.4.7",
"execa": "4.1.0",
"executable": "^4.1.1",
"extract-zip": "2.0.1",
@ -3117,9 +3117,9 @@
"dev": true
},
"eventemitter2": {
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.5.tgz",
"integrity": "sha512-bXE7Dyc1i6oQElDG0jMRZJrRAn9QR2xyyFGmBdZleNmyQX0FqGYmhZIrIrpPfm/w//LTo4tVQGOGQcGCb5q9uw==",
"version": "6.4.7",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz",
"integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==",
"dev": true
},
"execa": {

View File

@ -13,7 +13,7 @@
"@types/node": "^16.9.6",
"@types/yamljs": "^0.2.31",
"chrome-remote-interface": "0.31.2",
"cypress": "^10.3.1",
"cypress": "^11.2.0",
"dayjs": "^1.10.4",
"got": "^11.8.2",
"otplib": "^12.0.1",

View File

@ -17,6 +17,12 @@
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
},
"recovery": {
"via": "email"
}
}
}

View File

@ -2,5 +2,7 @@ function(ctx) {
identity_id: if std.objectHas(ctx, "identity") then ctx.identity.id else null,
email: if std.objectHas(ctx, "identity") then ctx.identity.traits.email else null,
flow_id: ctx.flow.id,
flow_type: ctx.flow.type
flow_type: ctx.flow.type,
identity: if std.objectHas(ctx, "identity") then ctx.identity else null,
ctx: ctx
}

14
x/coalesce.go Normal file
View File

@ -0,0 +1,14 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package x
// Coalesce returns the first non-empty string value
func Coalesce[T ~string](str ...T) T {
for _, s := range str {
if len(s) > 0 {
return s
}
}
return ""
}