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
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
101
selfservice/hook/error.go
Normal 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
15
selfservice/hook/hooks.go
Normal 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"
|
||||
)
|
||||
74
selfservice/hook/hooktest/web_hook_test_server.go
Normal file
74
selfservice/hook/hooktest/web_hook_test_server.go
Normal 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)
|
||||
})
|
||||
}
|
||||
148
selfservice/hook/notify_previous_addresses.go
Normal file
148
selfservice/hook/notify_previous_addresses.go
Normal 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
|
||||
}
|
||||
195
selfservice/hook/notify_previous_addresses_test.go
Normal file
195
selfservice/hook/notify_previous_addresses_test.go
Normal 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)
|
||||
}
|
||||
138
selfservice/hook/password_migration_hook.go
Normal file
138
selfservice/hook/password_migration_hook.go
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
166
selfservice/hook/require_verified_address.go
Normal file
166
selfservice/hook/require_verified_address.go
Normal 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()
|
||||
}
|
||||
287
selfservice/hook/require_verified_address_test.go
Normal file
287
selfservice/hook/require_verified_address_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
}
|
||||
63
selfservice/hook/session_destroyer.go
Normal file
63
selfservice/hook/session_destroyer.go
Normal 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
|
||||
})
|
||||
}
|
||||
119
selfservice/hook/session_destroyer_test.go
Normal file
119
selfservice/hook/session_destroyer_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
160
selfservice/hook/session_issuer.go
Normal file
160
selfservice/hook/session_issuer.go
Normal 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, ®istration.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, ®istration.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
|
||||
}
|
||||
355
selfservice/hook/session_issuer_test.go
Normal file
355
selfservice/hook/session_issuer_test.go
Normal 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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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())
|
||||
})
|
||||
}
|
||||
115
selfservice/hook/show_verification_ui.go
Normal file
115
selfservice/hook/show_verification_ui.go
Normal 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
|
||||
}
|
||||
227
selfservice/hook/show_verification_ui_test.go
Normal file
227
selfservice/hook/show_verification_ui_test.go
Normal 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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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 := ®istration.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)
|
||||
})
|
||||
})
|
||||
}
|
||||
3
selfservice/hook/stub/bad_template.jsonnet
Normal file
3
selfservice/hook/stub/bad_template.jsonnet
Normal file
@@ -0,0 +1,3 @@
|
||||
function(ctx) {
|
||||
flow_id: ctx.foo,
|
||||
}
|
||||
1
selfservice/hook/stub/cancel_template.jsonnet
Normal file
1
selfservice/hook/stub/cancel_template.jsonnet
Normal file
@@ -0,0 +1 @@
|
||||
error "cancel"
|
||||
27
selfservice/hook/stub/code.schema.json
Normal file
27
selfservice/hook/stub/code.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
selfservice/hook/stub/require_verified.schema.json
Normal file
21
selfservice/hook/stub/require_verified.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
selfservice/hook/stub/stub.schema.json
Normal file
16
selfservice/hook/stub/stub.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
selfservice/hook/stub/test_body.jsonnet
Normal file
10
selfservice/hook/stub/test_body.jsonnet
Normal 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,
|
||||
})
|
||||
24
selfservice/hook/stub/verify.schema.json
Normal file
24
selfservice/hook/stub/verify.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
selfservice/hook/stub/verify_email_and_phone.schema.json
Normal file
31
selfservice/hook/stub/verify_email_and_phone.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
31
selfservice/hook/stub/verify_single_email.schema.json
Normal file
31
selfservice/hook/stub/verify_single_email.schema.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
31
selfservice/hook/stub/verify_two_emails.schema.json
Normal file
31
selfservice/hook/stub/verify_two_emails.schema.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
220
selfservice/hook/verification.go
Normal file
220
selfservice/hook/verification.go
Normal 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
|
||||
}
|
||||
280
selfservice/hook/verification_test.go
Normal file
280
selfservice/hook/verification_test.go
Normal 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 ®istration.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)
|
||||
}
|
||||
253
selfservice/hook/verify_new_address.go
Normal file
253
selfservice/hook/verify_new_address.go
Normal 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
|
||||
}
|
||||
606
selfservice/hook/verify_new_address_test.go
Normal file
606
selfservice/hook/verify_new_address_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
578
selfservice/hook/web_hook.go
Normal file
578
selfservice/hook/web_hook.go
Normal 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))
|
||||
}
|
||||
}
|
||||
1516
selfservice/hook/web_hook_integration_test.go
Normal file
1516
selfservice/hook/web_hook_integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user