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

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

View File

@@ -0,0 +1,16 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/passkey/login.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"csrf_token": {
"type": "string"
},
"passkey_login": {
"type": "string"
},
"method": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,23 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/passkey/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"csrf_token": {
"type": "string"
},
"traits": {
"description": "This field will be overwritten in registration.go's decoder() method. Do not add anything to this field as it has no effect."
},
"method": {
"type": "string"
},
"passkey_register": {
"type": "string"
},
"transient_payload": {
"type": "object",
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,20 @@
{
"$id": "https://schemas.ory.sh/kratos/selfservice/strategy/passkey/settings.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"csrf_token": {
"type": "string"
},
"method": {
"type": "string"
},
"passkey_settings_register": {
"type": "string"
},
"passkey_remove": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,98 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "username webauthn",
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"onload": "window.oryPasskeyLoginAutocompleteInit()",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,81 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "username webauthn",
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,98 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "username webauthn",
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"onload": "window.oryPasskeyLoginAutocompleteInit()",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,98 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "username webauthn",
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"onload": "window.oryPasskeyLoginAutocompleteInit()",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,81 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "username webauthn",
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,98 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "username webauthn",
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"onload": "window.oryPasskeyLoginAutocompleteInit()",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,98 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "username webauthn",
"disabled": false,
"name": "identifier",
"node_type": "input",
"required": true,
"type": "text",
"value": ""
},
"group": "default",
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"onload": "window.oryPasskeyLoginAutocompleteInit()",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,88 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "identifier",
"node_type": "input",
"type": "hidden",
"value": "foo@bar.com"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,88 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "identifier",
"node_type": "input",
"type": "hidden",
"value": "foo@bar.com"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_login",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_challenge",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,123 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_remove",
"node_type": "input",
"type": "submit",
"value": "626172626172"
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"context": {
"added_at": "0001-01-01T00:00:00Z",
"added_at_unix": -62135596800,
"display_name": "bar"
},
"id": 1050020,
"text": "Remove passkey \"bar\"",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_remove",
"node_type": "input",
"type": "submit",
"value": "666f6f666f6f"
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"context": {
"added_at": "0001-01-01T00:00:00Z",
"added_at_unix": -62135596800,
"display_name": "foo"
},
"id": 1050020,
"text": "Remove passkey \"foo\"",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_register_trigger",
"node_type": "input",
"onclick": "window.oryPasskeySettingsRegistration()",
"onclickTrigger": "oryPasskeySettingsRegistration",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1050019,
"text": "Add passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_settings_register",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_create_data",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,24 @@
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_remove",
"type": "submit",
"value": "666f6f666f6f",
"disabled": true,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050020,
"text": "Remove passkey \"foo\"",
"type": "info",
"context": {
"added_at": "0001-01-01T00:00:00Z",
"added_at_unix": -62135596800,
"display_name": "foo"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"passkey_register_trigger": [
""
],
"passkey_remove": [
"666f6f666f6f"
]
}

View File

@@ -0,0 +1,24 @@
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_remove",
"type": "submit",
"value": "666f6f666f6f",
"disabled": true,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050020,
"text": "Remove passkey \"foo\"",
"type": "info",
"context": {
"added_at": "0001-01-01T00:00:00Z",
"added_at_unix": -62135596800,
"display_name": "foo"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"passkey_register_trigger": [
""
],
"passkey_remove": [
"666f6f666f6f"
]
}

View File

@@ -0,0 +1,24 @@
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_remove",
"type": "submit",
"value": "666f6f666f6f",
"disabled": true,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050020,
"text": "Remove passkey \"foo\"",
"type": "info",
"context": {
"added_at": "0001-01-01T00:00:00Z",
"added_at_unix": -62135596800,
"display_name": "foo"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"passkey_register_trigger": [
""
],
"passkey_remove": [
"666f6f666f6f"
]
}

View File

@@ -0,0 +1,75 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_register_trigger",
"node_type": "input",
"onclick": "window.oryPasskeySettingsRegistration()",
"onclickTrigger": "oryPasskeySettingsRegistration",
"type": "button",
"value": ""
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1050019,
"text": "Add passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_settings_register",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_create_data",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
}
]

View File

@@ -0,0 +1,98 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "text",
"value": "",
"required": true,
"autocomplete": "username webauthn",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_challenge",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login",
"type": "hidden",
"disabled": false,
"onload": "window.oryPasskeyLoginAutocompleteInit()",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,88 @@
[
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_challenge",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "hidden",
"value": "",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,23 @@
[
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,23 @@
[
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,23 @@
[
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,23 @@
[
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclick": "window.oryPasskeyLogin()",
"onclickTrigger": "oryPasskeyLogin",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1010021,
"text": "Sign in with passkey",
"type": "info"
}
}
}
]

View File

@@ -0,0 +1,77 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "default",
"attributes": {
"name": "identifier",
"type": "text",
"value": "",
"required": true,
"autocomplete": "username webauthn",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1070004,
"text": "ID",
"type": "info"
}
}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_challenge",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_login",
"type": "hidden",
"disabled": false,
"onload": "window.oryPasskeyLoginAutocompleteInit()",
"onloadTrigger": "oryPasskeyLoginAutocompleteInit",
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,58 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,74 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,74 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-Dg0gN3fy+JoKxRp9Zda/4KYn3SlMdaKjs3fK5g6nDVQ/CVakD1dfMQyvRtJeiAtzSMEFviJbBLcVSrsBPGsFBA==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,58 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,74 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,74 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-Dg0gN3fy+JoKxRp9Zda/4KYn3SlMdaKjs3fK5g6nDVQ/CVakD1dfMQyvRtJeiAtzSMEFviJbBLcVSrsBPGsFBA==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,15 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,15 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,74 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,15 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,74 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"disabled": false,
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,59 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclickTrigger": "oryPasskeySettingsRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050019,
"text": "Add passkey",
"type": "info"
}
}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_settings_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,75 @@
[
{
"type": "input",
"group": "default",
"attributes": {
"name": "csrf_token",
"type": "hidden",
"required": true,
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "script",
"group": "webauthn",
"attributes": {
"async": true,
"referrerpolicy": "no-referrer",
"crossorigin": "anonymous",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"type": "text/javascript",
"id": "webauthn_script",
"node_type": "script"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_register_trigger",
"type": "button",
"value": "",
"disabled": false,
"onclick": "window.oryPasskeySettingsRegistration()",
"onclickTrigger": "oryPasskeySettingsRegistration",
"node_type": "input"
},
"messages": [],
"meta": {
"label": {
"id": 1050019,
"text": "Add passkey",
"type": "info"
}
}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_settings_register",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
},
{
"type": "input",
"group": "passkey",
"attributes": {
"name": "passkey_create_data",
"type": "hidden",
"disabled": false,
"node_type": "input"
},
"messages": [],
"meta": {}
}
]

View File

@@ -0,0 +1,80 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.foobar",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "new-password",
"disabled": false,
"name": "password",
"node_type": "input",
"required": true,
"type": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.username",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,80 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.foobar",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "new-password",
"disabled": false,
"name": "password",
"node_type": "input",
"required": true,
"type": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.username",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,139 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.foobar",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.username",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_register_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"type": "button"
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_register",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_create_data",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "new-password",
"disabled": false,
"name": "password",
"node_type": "input",
"required": true,
"type": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,139 @@
[
{
"attributes": {
"disabled": false,
"name": "csrf_token",
"node_type": "input",
"required": true,
"type": "hidden"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.foobar",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "traits.username",
"node_type": "input",
"required": true,
"type": "text"
},
"group": "default",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"async": true,
"crossorigin": "anonymous",
"id": "webauthn_script",
"integrity": "sha512-9Qlvd9zudkdfprW3Tf8FsDI2bVA667UGRVUBcima8+YLHQenHVyPaAiSTLzQ3V6MrW60xS7NpWod+UTdVa6QmQ==",
"node_type": "script",
"referrerpolicy": "no-referrer",
"type": "text/javascript"
},
"group": "webauthn",
"messages": [],
"meta": {},
"type": "script"
},
{
"attributes": {
"disabled": false,
"name": "passkey_register_trigger",
"node_type": "input",
"onclick": "window.oryPasskeyRegistration()",
"onclickTrigger": "oryPasskeyRegistration",
"type": "button"
},
"group": "passkey",
"messages": [],
"meta": {
"label": {
"id": 1040007,
"text": "Sign up with passkey",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_register",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "passkey_create_data",
"node_type": "input",
"type": "hidden"
},
"group": "passkey",
"messages": [],
"meta": {},
"type": "input"
},
{
"attributes": {
"autocomplete": "new-password",
"disabled": false,
"name": "password",
"node_type": "input",
"required": true,
"type": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1070001,
"text": "Password",
"type": "info"
}
},
"type": "input"
},
{
"attributes": {
"disabled": false,
"name": "method",
"node_type": "input",
"type": "submit",
"value": "password"
},
"group": "password",
"messages": [],
"meta": {
"label": {
"id": 1040001,
"text": "Sign up",
"type": "info"
}
},
"type": "input"
}
]

View File

@@ -0,0 +1,18 @@
{
"credentials": [
{
"id": "OE7fnoAeqaydiBM4+fQsbYMiO+EObq97WYb/c1HuX8Crbsb2777xs+upv7muXE8hOLkm6lQHC1ahegnzw+aIsQ==",
"public_key": "pQECAyYgASFYIPW2FsD6d/Lc7SU33hMhJUxafOA3JWpsLka8eKO+OPRkIlggkkPt8ocrupQOuvy+8HbQLSLiu899EdchJlWdMPE1tiw=",
"attestation_type": "none",
"authenticator": {
"aaguid": "AAAAAAAAAAAAAAAAAAAAAA==",
"sign_count": 3,
"clone_warning": false
},
"display_name": "some-key",
"added_at": "2021-08-17T10:18:55Z",
"is_passwordless": true
}
],
"user_handle": "c29tZS1yYW5kb20tdXNlci1oYW5kbGU="
}

View File

@@ -0,0 +1,13 @@
{
"id": "f5d1b6a3-a4bb-44f7-9161-f4f877efe9ad",
"schema_id": "default",
"schema_url": "http://localhost:4455/schemas/default",
"state": "active",
"state_changed_at": "2021-08-17T12:18:01.690425+02:00",
"traits": {
"email": "foo@bar.com",
"website": "https://www.ory.sh"
},
"created_at": "2021-08-17T12:18:01.690741+02:00",
"updated_at": "2021-08-17T12:18:01.690741+02:00"
}

View File

@@ -0,0 +1,10 @@
{
"passkey_session_data": {
"challenge": "WzZCWULmaq5xTAlA0YYHlqoubqAhe1AWdLRZCIBAMcM",
"user_id": "",
"allowed_credentials": [
"OE7fnoAeqaydiBM4+fQsbYMiO+EObq97WYb/c1HuX8Crbsb2777xs+upv7muXE8hOLkm6lQHC1ahegnzw+aIsQ=="
],
"userVerification": ""
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "OE7fnoAeqaydiBM4-fQsbYMiO-EObq97WYb_c1HuX8Crbsb2777xs-upv7muXE8hOLkm6lQHC1ahegnzw-aIsQ",
"rawId": "OE7fnoAeqaydiBM4-fQsbYMiO-EObq97WYb_c1HuX8Crbsb2777xs-upv7muXE8hOLkm6lQHC1ahegnzw-aIsQ",
"type": "public-key",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MBAAAACg",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiV3paQ1dVTG1hcTV4VEFsQTBZWUhscW91YnFBaGUxQVdkTFJaQ0lCQU1jTSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDQ1NSIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"signature": "MEQCIHtRzzmLJrTPucNIRpPkstxR8oGJEzrm558LFe2jHTesAiAy2SGuBMDkdVdMJU4WJR2qFSpbAHQUvwG--Gv3vK8vDA",
"userHandle": "c29tZS1yYW5kb20tdXNlci1oYW5kbGU="
}
}

View File

@@ -0,0 +1,7 @@
{
"passkey_session_data": {
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
"user_id": "",
"userVerification": ""
}
}

View File

@@ -0,0 +1,7 @@
{
"passkey_session_data": {
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
"user_id": "wrong",
"userVerification": ""
}
}

View File

@@ -0,0 +1,7 @@
{
"passkey_session_data": {
"challenge": "mFtAwmtDDdwcO6200I2H6oWjzOiF21lZhQVlrC4tdaU",
"user_id": "d29OeDNJVjdYR2NRa09RVHhNVG1ZbHE1ejBDYzM1dGV3UWxFT25yaUJKcTUyb0VOR0pUMk5PeXExRXp3Z2M2dg",
"userVerification": ""
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "mK2RV0b2NUGDsj8QqH0XtQ",
"rawId": "mK2RV0b2NUGDsj8QqH0XtQ",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUJYVxRmHaAcJuz7n2X5FJILFPwxIhVpoURyBRglMxnFpdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEJitkVdG9jVBg7I_EKh9F7WlAQIDJiABIVggjEkfDDjIm8yAYfth4u0EV7ApX4kclQONhpK5BLc7W6wiWCCHiHhRNqf8Qhc7bjoIFTqw4lafiC7yrXvojU_WMNcutA",
"clientDataJson": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibUZ0QXdtdEREZHdjTzYyMDBJMkg2b1dqek9pRjIxbFpoUVZsckM0dGRhVSIsIm9yaWdpbiI6ImFuZHJvaWQ6YXBrLWtleS1oYXNoOlMyUmZOWWdKbVFpS2dkNi1zZGJqVzdwaGNMX09UUDR2R0U4TDUxUTJHQjAiLCJhbmRyb2lkUGFja2FnZU5hbWUiOiJjb20udHJwLmFuZC5wZXJzb25hbC5xbCJ9"
},
"type": "public-key"
}

View File

@@ -0,0 +1,14 @@
{
"id": "6e11a9a7-62fd-4c88-871a-097f18f0306f",
"schema_id": "default",
"schema_url": "http://localhost:4455/schemas/default",
"state": "active",
"state_changed_at": "2021-08-17T11:15:59.232051+02:00",
"traits": {
"email": "foo@bar.com",
"website": "https://www.ory.sh"
},
"created_at": "2021-08-17T11:15:59.232288+02:00",
"updated_at": "2021-08-17T11:15:59.232288+02:00"
}

View File

@@ -0,0 +1,7 @@
{
"passkey_session_data": {
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
"user_id": "bhGpp2L9TIiHGgl/GPAwbw==",
"userVerification": ""
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "L1yOrxHy5Lq72lAPahaWdl0q9gsXRzV2BJ4xJmkTVH_8uuVKU-FVbJlVRwYGzPNc1IjCWUYAK0H0YSpd5hz-Pg",
"rawId": "L1yOrxHy5Lq72lAPahaWdl0q9gsXRzV2BJ4xJmkTVH_8uuVKU-FVbJlVRwYGzPNc1IjCWUYAK0H0YSpd5hz-Pg",
"type": "public-key",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAABAAAAAAAAAAAAAAAAAAAAAAAQC9cjq8R8uS6u9pQD2oWlnZdKvYLF0c1dgSeMSZpE1R__LrlSlPhVWyZVUcGBszzXNSIwllGACtB9GEqXeYc_j6lAQIDJiABIVggFFzdor6hBMgrpYLCds8Uu2JtPaaaxKU6LEAUT6QRZ5UiWCA24TI4vED6rrTUjykchoAln67u5GT1nwmzjvrk79HhlQ",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVWx4SFNUa3VNdnRWRG9WOXk1bGh1OU95TlVQOFA3TVAwUllBVDZJbV9yWSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDQ1NSIsImNyb3NzT3JpZ2luIjpmYWxzZX0"
}
}

View File

@@ -0,0 +1,14 @@
{
"id": "6e11a9a7-62fd-4c88-871a-097f18f0306f",
"schema_id": "default",
"schema_url": "http://localhost:4455/schemas/default",
"state": "active",
"state_changed_at": "2021-08-17T11:15:59.232051+02:00",
"traits": {
"email": "foo@bar.com",
"website": "https://www.ory.sh"
},
"created_at": "2021-08-17T11:15:59.232288+02:00",
"updated_at": "2021-08-17T11:15:59.232288+02:00"
}

View File

@@ -0,0 +1,7 @@
{
"passkey_session_data": {
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
"user_id": "bhGpp2L9TIiHGgl/GPAwbw==",
"userVerification": ""
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "L1yOrxHy5Lq72lAPahaWdl0q9gsXRzV2BJ4xJmkTVH_8uuVKU-FVbJlVRwYGzPNc1IjCWUYAK0H0YSpd5hz-Pg",
"rawId": "L1yOrxHy5Lq72lAPahaWdl0q9gsXRzV2BJ4xJmkTVH_8uuVKU-FVbJlVRwYGzPNc1IjCWUYAK0H0YSpd5hz-Pg",
"type": "public-key",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NBAAAABAAAAAAAAAAAAAAAAAAAAAAAQC9cjq8R8uS6u9pQD2oWlnZdKvYLF0c1dgSeMSZpE1R__LrlSlPhVWyZVUcGBszzXNSIwllGACtB9GEqXeYc_j6lAQIDJiABIVggFFzdor6hBMgrpYLCds8Uu2JtPaaaxKU6LEAUT6QRZ5UiWCA24TI4vED6rrTUjykchoAln67u5GT1nwmzjvrk79HhlQ",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVWx4SFNUa3VNdnRWRG9WOXk1bGh1OU95TlVQOFA3TVAwUllBVDZJbV9yWSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NDQ1NSIsImNyb3NzT3JpZ2luIjpmYWxzZX0"
}
}

View File

@@ -0,0 +1,49 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey
import (
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/webauthnx/js"
)
func injectOptions(o []byte) *node.Node {
return &node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: &node.InputAttributes{
Name: node.PasskeyCreateData,
Type: node.InputAttributeTypeHidden,
FieldValue: string(o),
},
}
}
func passkeyRegister() *node.Node {
return &node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: &node.InputAttributes{
Name: node.PasskeyRegister,
Type: node.InputAttributeTypeHidden,
},
}
}
func passkeyRegisterTrigger() *node.Node {
return &node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{Label: text.NewInfoSelfServiceRegistrationRegisterPasskey()},
Attributes: &node.InputAttributes{
Name: node.PasskeyRegisterTrigger,
Type: node.InputAttributeTypeButton,
OnClick: js.WebAuthnTriggersPasskeyRegistration.String() + "()", // defined in webauthn.js
OnClickTrigger: js.WebAuthnTriggersPasskeyRegistration,
},
}
}

View File

@@ -0,0 +1,481 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey
import (
"context"
_ "embed"
"encoding/json"
"net/http"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/ory/x/otelx"
"github.com/ory/kratos/selfservice/strategy/idfirst"
"github.com/ory/kratos/x/webauthnx/js"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flowhelpers"
"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/webauthnx"
"github.com/ory/x/decoderx"
)
var _ login.AAL1FormHydrator = new(Strategy)
func (s *Strategy) populateLoginMethodForPasskeys(r *http.Request, loginFlow *login.Flow) error {
ctx := r.Context()
loginFlow.UI.SetCSRF(s.d.GenerateCSRFToken(r))
ds, err := loginFlow.IdentitySchema.URL(r.Context(), s.d.Config())
if err != nil {
return err
}
identifierLabel, err := login.GetIdentifierLabelFromSchema(r.Context(), ds.String(), s.d.Config().SecurityDisallowRefInIdentitySchemas(r.Context()))
if err != nil {
return err
}
webAuthn, err := webauthn.New(s.d.Config().PasskeyConfig(ctx))
if err != nil {
return errors.WithStack(err)
}
option, sessionData, err := webAuthn.BeginDiscoverableLogin()
if err != nil {
return errors.WithStack(err)
}
loginFlow.InternalContext, err = sjson.SetBytes(
loginFlow.InternalContext,
flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData),
sessionData,
)
if err != nil {
return errors.WithStack(err)
}
injectWebAuthnOptions, err := json.Marshal(option)
if err != nil {
return errors.WithStack(err)
}
loginFlow.UI.Nodes.Upsert(node.NewInputField(
"identifier",
"",
node.DefaultGroup,
node.InputAttributeTypeText,
node.WithRequiredInputAttribute,
func(attributes *node.InputAttributes) {
attributes.Autocomplete = node.InputAttributeAutocompleteUsernameWebauthn
},
).WithMetaLabel(identifierLabel))
loginFlow.UI.Nodes.Upsert(&node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: &node.InputAttributes{
Name: node.PasskeyChallenge,
Type: node.InputAttributeTypeHidden,
FieldValue: string(injectWebAuthnOptions),
},
})
// Inject the WebAuthn JS script only for non-API flows. API flows must not include script nodes.
if loginFlow.Type == flow.TypeBrowser {
loginFlow.UI.Nodes.Upsert(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx)))
}
passkeyLoginAttr := &node.InputAttributes{
Name: node.PasskeyLogin,
Type: node.InputAttributeTypeHidden,
OnLoadTrigger: js.WebAuthnTriggersPasskeyLoginAutocompleteInit,
}
// Only attach raw JS onLoad for browser flows; keep onLoadTrigger as a semantic
// enum for all flow types so SPA/native apps can key off of it.
if loginFlow.Type == flow.TypeBrowser {
passkeyLoginAttr.OnLoad = js.WebAuthnTriggersPasskeyLoginAutocompleteInit.String() + "()"
}
loginFlow.UI.Nodes.Upsert(&node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: passkeyLoginAttr,
})
return nil
}
func (s *Strategy) handleLoginError(r *http.Request, f *login.Flow, err error) error {
if f != nil {
f.UI.Nodes.ResetNodes(node.PasskeyLogin)
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
}
}
return err
}
// Update Login Flow with Passkey Method
//
// swagger:model updateLoginFlowWithPasskeyMethod
type updateLoginFlowWithPasskeyMethod struct {
// Method should be set to "passkey" when logging in using the Passkey strategy.
//
// required: true
Method string `json:"method"`
// Sending the anti-csrf token is only required for browser login flows.
CSRFToken string `json:"csrf_token"`
// Login a WebAuthn Security Key
//
// This must contain the ID of the WebAuthN connection.
Login string `json:"passkey_login"`
}
func (s *Strategy) Login(w http.ResponseWriter, r *http.Request, f *login.Flow, _ *session.Session) (i *identity.Identity, err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.passkey.Strategy.Login")
defer otelx.End(span, &err)
var p updateLoginFlowWithPasskeyMethod
if err := decoderx.Decode(r, &p,
decoderx.HTTPKeepRequestBody(true),
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.MustHTTPRawJSONSchemaCompiler(loginSchema),
decoderx.HTTPDecoderJSONFollowsFormFormat()); err != nil {
return nil, s.handleLoginError(r, f, err)
}
if len(p.Login) > 0 || p.Method == s.SettingsStrategyID() {
// This method has only two submit buttons
p.Method = s.SettingsStrategyID()
} else {
span.SetAttributes(attribute.String("not_responsible_reason", "no login value and mismatched method"))
return nil, flow.ErrStrategyNotResponsible
}
if err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil {
return nil, s.handleLoginError(r, f, err)
}
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
return nil, s.handleLoginError(r, f, err)
}
return s.loginPasswordless(ctx, w, r, f, &p)
}
func (s *Strategy) loginPasswordless(ctx context.Context, w http.ResponseWriter, r *http.Request, f *login.Flow, p *updateLoginFlowWithPasskeyMethod) (i *identity.Identity, err error) {
if err := login.CheckAAL(f, identity.AuthenticatorAssuranceLevel1); err != nil {
trace.SpanFromContext(ctx).SetAttributes(attribute.String("not_responsible_reason", "requested AAL is not AAL1"))
return nil, s.handleLoginError(r, f, err)
}
if err := flow.EnsureCSRF(s.d, r, f.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
return nil, s.handleLoginError(r, f, err)
}
if len(p.Login) == 0 {
// Reset all nodes to not confuse users.
f.UI.Nodes = node.Nodes{}
if err := s.populateLoginMethodForPasskeys(r, f); err != nil {
return nil, s.handleLoginError(r, f, err)
}
redirectTo := f.AppendTo(s.d.Config().SelfServiceFlowLoginUI(ctx)).String()
if x.IsJSONRequest(r) {
s.d.Writer().WriteError(w, r, flow.NewBrowserLocationChangeRequiredError(redirectTo))
} else {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
}
return nil, errors.WithStack(flow.ErrCompletedByStrategy)
}
return s.loginAuthenticate(ctx, r, f, p, identity.AuthenticatorAssuranceLevel1)
}
func (s *Strategy) loginAuthenticate(ctx context.Context, r *http.Request, f *login.Flow, p *updateLoginFlowWithPasskeyMethod, _ identity.AuthenticatorAssuranceLevel) (*identity.Identity, error) {
web, err := webauthn.New(s.d.Config().PasskeyConfig(ctx))
if err != nil {
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to get webAuthn config.").WithDebug(err.Error())))
}
webAuthnResponse, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(p.Login))
if err != nil {
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse WebAuthn response.").WithDebug(err.Error())))
}
var webAuthnSess webauthn.SessionData
if err := json.Unmarshal([]byte(gjson.GetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData)).Raw), &webAuthnSess); err != nil {
return nil, s.handleLoginError(r, f, errors.WithStack(herodot.ErrInternalServerError().
WithReasonf("Expected WebAuthN in internal context to be an object but got: %s", err)))
}
webAuthnSess.UserID = nil
userHandle := webAuthnResponse.Response.UserHandle
credentialType := identity.CredentialsTypePasskey
i, _, err := s.d.PrivilegedIdentityPool().FindByCredentialsIdentifier(ctx, identity.CredentialsTypePasskey, string(userHandle))
if err != nil {
// Migration strategy: Don't give up yet! If we don't find a "passkey" credential
// here, look for a "webauthn" credential next
if i, err = s.d.PrivilegedIdentityPool().FindIdentityByWebauthnUserHandle(ctx, userHandle); err != nil {
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoWebAuthnCredentials()))
}
credentialType = identity.CredentialsTypeWebAuthn
}
err = s.d.PrivilegedIdentityPool().HydrateIdentityAssociations(ctx, i, identity.ExpandCredentials)
if err != nil {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().
WithReason("Could not load identity credentials").
WithWrap(err)), i.ID))
}
c, ok := i.GetCredentials(credentialType)
if !ok {
return nil, s.handleLoginError(r, f, errors.WithStack(schema.NewNoWebAuthnRegistered()))
}
var o identity.CredentialsWebAuthnConfig
if err := json.Unmarshal(c.Config, &o); err != nil {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().
WithReason("The WebAuthn credentials could not be decoded properly").
WithDebug(err.Error()).
WithWrap(err)), i.ID))
}
webAuthCreds := o.Credentials.PasswordlessOnly(&webAuthnResponse.Response.AuthenticatorData.Flags)
validatedCredential, err := web.ValidateDiscoverableLogin(
func(rawID, userHandle []byte) (user webauthn.User, err error) {
return webauthnx.NewUser(userHandle, webAuthCreds, web.Config), nil
}, webAuthnSess, webAuthnResponse)
if err != nil {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(schema.NewWebAuthnVerifierWrongError("#/")), i.ID))
}
// Persist the updated signature counter and clone warning for W3C WebAuthn
// clone detection. This must not block the login: the user already proved
// possession of the authenticator. Use credentialType, which may be webauthn
// (legacy migration path) rather than passkey.
webauthnx.UpdateSignCountAfterLogin(ctx, s.d, i, credentialType, validatedCredential)
// Remove the WebAuthn URL from the internal context now that it is set!
f.InternalContext, err = sjson.DeleteBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData))
if err != nil {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(err), i.ID))
}
f.Active = s.ID()
if err = s.d.LoginFlowPersister().UpdateLoginFlow(ctx, f); err != nil {
return nil, s.handleLoginError(r, f, x.WrapWithIdentityIDError(errors.WithStack(herodot.ErrInternalServerError().WithReason("Could not update flow").WithDebug(err.Error())), i.ID))
}
return i, nil
}
func (s *Strategy) PopulateLoginMethodFirstFactorRefresh(r *http.Request, f *login.Flow, _ *session.Session) error {
ctx := r.Context()
identifier, id, _ := flowhelpers.GuessForcedLoginIdentifier(r, s.d, f, s.ID())
if identifier == "" {
return nil
}
id, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, id.ID)
if err != nil {
return err
}
cred, ok := id.GetCredentials(s.ID())
if !ok {
// Identity has no passkey
return nil
}
var conf identity.CredentialsWebAuthnConfig
if err := json.Unmarshal(cred.Config, &conf); err != nil {
return errors.WithStack(err)
}
webAuthCreds := conf.Credentials.ToWebAuthn()
if len(webAuthCreds) == 0 {
// Identity has no webauthn
return nil
}
passkeyIdentifier := s.PasskeyDisplayNameFromIdentity(ctx, id)
webAuthn, err := webauthn.New(s.d.Config().PasskeyConfig(ctx))
if err != nil {
return errors.WithStack(err)
}
option, sessionData, err := webAuthn.BeginLogin(&webauthnx.User{
Name: passkeyIdentifier,
ID: conf.UserHandle,
Credentials: webAuthCreds,
Config: webAuthn.Config,
})
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to initiate passkey login.").WithDebug(err.Error()))
}
f.InternalContext, err = sjson.SetBytes(
f.InternalContext,
flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData),
sessionData,
)
if err != nil {
return errors.WithStack(err)
}
injectWebAuthnOptions, err := json.Marshal(option)
if err != nil {
return errors.WithStack(err)
}
f.UI.Nodes.Upsert(&node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: &node.InputAttributes{
Name: node.PasskeyChallenge,
Type: node.InputAttributeTypeHidden,
FieldValue: string(injectWebAuthnOptions),
},
})
// Inject the WebAuthn JS script only for non-API flows. API flows must not include script nodes.
if f.Type == flow.TypeBrowser {
f.UI.Nodes.Append(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx)))
}
f.UI.Nodes.Upsert(&node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: &node.InputAttributes{
Name: node.PasskeyLogin,
Type: node.InputAttributeTypeHidden,
},
})
f.UI.Nodes.Append(node.NewInputField(
node.PasskeyLoginTrigger,
"",
node.PasskeyGroup,
node.InputAttributeTypeButton,
node.WithInputAttributes(func(attr *node.InputAttributes) {
attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin
// Only attach raw JS handler for browser flows; API flows must stay script-free.
if f.Type == flow.TypeBrowser {
//nolint:staticcheck
attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js
}
}),
).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey()))
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.SetNode(node.NewInputField(
"identifier",
passkeyIdentifier,
node.DefaultGroup,
node.InputAttributeTypeHidden,
))
return nil
}
func (s *Strategy) PopulateLoginMethodFirstFactor(r *http.Request, f *login.Flow) error {
if err := s.populateLoginMethodForPasskeys(r, f); err != nil {
return err
}
trigger := node.NewInputField(
node.PasskeyLoginTrigger,
"",
node.PasskeyGroup,
node.InputAttributeTypeButton,
node.WithInputAttributes(func(attr *node.InputAttributes) {
attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin
// Only attach raw JS handler for browser flows; API flows must stay script-free.
if f.Type == flow.TypeBrowser {
//nolint:staticcheck
attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js
}
}),
).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey())
f.UI.Nodes.Append(trigger)
return nil
}
func (s *Strategy) PopulateLoginMethodIdentifierFirstCredentials(r *http.Request, sr *login.Flow, opts ...login.FormHydratorModifier) error {
ctx := r.Context()
o := login.NewFormHydratorOptions(opts)
var count int
if o.IdentityHint != nil {
var err error
// If we have an identity hint we can perform identity credentials discovery and
// hide this credential if it should not be included.
count, err = s.CountActiveFirstFactorCredentials(ctx, o.IdentityHint.Credentials)
if err != nil {
return err
}
}
if count > 0 || s.d.Config().SecurityAccountEnumerationMitigate(ctx) {
sr.UI.Nodes.Append(node.NewInputField(
node.PasskeyLoginTrigger,
"",
node.PasskeyGroup,
node.InputAttributeTypeButton,
node.WithInputAttributes(func(attr *node.InputAttributes) {
attr.OnClickTrigger = js.WebAuthnTriggersPasskeyLogin
// Only attach raw JS handler for browser flows; API flows must stay script-free.
if sr.Type == flow.TypeBrowser {
//nolint:staticcheck
attr.OnClick = js.WebAuthnTriggersPasskeyLogin.String() + "()" // this function is defined in webauthn.js
}
}),
).WithMetaLabel(text.NewInfoSelfServiceLoginPasskey()))
}
if count == 0 {
return errors.WithStack(idfirst.ErrNoCredentialsFound)
}
return nil
}
func (s *Strategy) PopulateLoginMethodIdentifierFirstIdentification(r *http.Request, sr *login.Flow) error {
if err := s.populateLoginMethodForPasskeys(r, sr); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,78 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey_test
import (
"encoding/json"
"net/url"
"testing"
"github.com/gofrs/uuid"
"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/testhelpers"
"github.com/ory/kratos/ui/node"
)
// TestLoginUpdatesSignCount is a regression test for HackerOne #3780149 on the
// passkey strategy (which uses ValidateDiscoverableLogin).
//
// The stored passkey credential starts at sign_count = 3 and the assertion
// response reports counter = 10. After a successful login, the stored
// SignCount must be updated to 10 so that W3C clone detection works.
func TestLoginUpdatesSignCount(t *testing.T) {
t.Parallel()
fix := newLoginFixture(t)
fix.conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
storedCredential := func(t *testing.T, idID uuid.UUID) (*identity.Credentials, *identity.AuthenticatorWebAuthn) {
stored, err := fix.reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), idID)
require.NoError(t, err)
c, ok := stored.GetCredentials(identity.CredentialsTypePasskey)
require.True(t, ok)
var conf identity.CredentialsWebAuthnConfig
require.NoError(t, json.Unmarshal(c.Config, &conf))
require.NotEmpty(t, conf.Credentials)
require.NotNil(t, conf.Credentials[0].Authenticator)
return c, conf.Credentials[0].Authenticator
}
id := fix.createIdentityWithPasskey(t, identity.Credentials{
Config: loginPasswordlessCredentials,
Version: 1,
})
// Sanity: the registered credential starts at the fixture's sign_count = 3.
credBefore, authBefore := storedCredential(t, id.ID)
require.EqualValues(t, 3, authBefore.SignCount)
browserClient := testhelpers.NewClientWithCookies(t)
body, _, _ := fix.submitWebAuthnLoginWithClient(t, true, loginPasswordlessContext, browserClient, func(values url.Values) {
values.Set(node.PasskeyLogin, string(loginPasswordlessResponse))
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel1))
require.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
credAfter, auth := storedCredential(t, id.ID)
assert.EqualValues(t, 10, auth.SignCount,
"stored SignCount must be updated from the login assertion for clone detection to work")
// The assertion counter advanced (3 -> 10), so this is not a clone: the
// persisted clone warning must round-trip as false rather than being set
// spuriously on a normal login.
assert.False(t, auth.CloneWarning,
"a normally advancing counter must not set the clone warning")
// The credential row must be updated in place, not deleted and recreated:
// its id and created_at must be stable across the login.
assert.Equal(t, credBefore.ID, credAfter.ID,
"the credential row must be updated in place (stable id), not recreated")
assert.Equal(t, credBefore.CreatedAt, credAfter.CreatedAt,
"persisting the sign counter must not reset the credential's created_at")
}

View File

@@ -0,0 +1,679 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey_test
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"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/login"
"github.com/ory/kratos/selfservice/strategy/idfirst"
"github.com/ory/kratos/selfservice/strategy/passkey"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/contextx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/snapshotx"
)
var (
//go:embed fixtures/login/success/identity.json
loginSuccessIdentity []byte
//go:embed fixtures/login/success/credentials.json
loginPasswordlessCredentials []byte
//go:embed fixtures/login/success/internal_context.json
loginPasswordlessContext []byte
//go:embed fixtures/login/success/response.json
loginPasswordlessResponse []byte
)
func TestPopulateLoginMethod(t *testing.T) {
t.Parallel()
fix := newLoginFixture(t)
s := passkey.NewStrategy(fix.reg)
t.Run("case=API flow builds standard nodes and skips JS", func(t *testing.T) {
// Build a properly initialized API flow to avoid nil UI/container issues.
r := httptest.NewRequest("GET", "/self-service/login/api", nil).WithContext(t.Context())
f, err := login.NewFlow(fix.reg, r, flow.TypeAPI)
require.NoError(t, err)
f.UI.Nodes = make(node.Nodes, 0)
// For API flows, the strategy should build standard nodes and return before adding JS nodes.
assert.Nil(t, s.PopulateLoginMethodFirstFactor(r, f))
// Assert that the passkey challenge input exists (standard node built for API as well).
require.NotNil(t, f.UI.Nodes.Find("passkey_challenge"))
// Assert no script nodes are present for API flows (JS injection is skipped).
for _, n := range f.UI.Nodes {
assert.NotEqual(t, node.Script, n.Type, "API flow must not include script nodes")
}
})
}
func TestCompleteLogin(t *testing.T) {
t.Parallel()
fix := newLoginFixture(t)
t.Run("case=should return webauthn.js", func(t *testing.T) {
res, err := fix.publicTS.Client().Get(fix.publicTS.URL + "/.well-known/ory/webauthn.js")
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, "text/javascript; charset=UTF-8", res.Header.Get("Content-Type"))
})
t.Run("flow=passwordless", func(t *testing.T) {
t.Run("case=passkey button exists", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeLoginFlowViaBrowser(t, client, fix.publicTS, false, true, false, false)
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
"2.attributes.nonce",
"2.attributes.src",
"5.attributes.value",
})
})
// Assert API-specific CSRF/header failures (Cookie/Origin) similar to registration tests
t.Run("AssertCSRFFailuresAPI", func(t *testing.T) {
fix := newLoginFixture(t)
// Create identity with passkey credentials so the test setup is valid
fix.createIdentityWithPasskey(t, identity.Credentials{
Config: loginPasswordlessCredentials,
Version: 1,
})
apiClient := testhelpers.NewDebugClient(t)
// Now test the specific CSRF error causes for API flows: adding Cookie and Origin headers
for _, tc := range []struct {
mod func(http.Header)
expKey string
}{
{
mod: func(h http.Header) { h.Add("Cookie", "name=bar") },
expKey: "Cookie",
},
{
mod: func(h http.Header) { h.Add("Origin", "www.bar.com") },
expKey: "Origin",
},
} {
t.Run(fmt.Sprintf("case=should_fail_with_correct_CSRF_error_cause_local/type=api/%s", tc.expKey), func(t *testing.T) {
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, fix.publicTS, false)
// Attach internal context to the flow
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = loginPasswordlessContext
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
values := url.Values{
"csrf_token": {"invalid_token"},
}
values.Set(node.PasskeyLogin, string(loginPasswordlessResponse))
values.Del("method")
req, err := http.NewRequest("POST", f.Ui.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values)))
require.NoError(t, err)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
tc.mod(req.Header)
res, err := apiClient.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
bodyBytes := ioutilx.MustReadAll(res.Body)
actual := string(bodyBytes)
require.EqualValues(t, http.StatusBadRequest, res.StatusCode)
msg := gjson.Get(actual, "ui.messages.0.text").String()
assert.Contains(t, msg, tc.expKey, "actual payload: %s", actual)
})
}
})
t.Run("case=passkey shows error if user tries to sign in but no such user exists", func(t *testing.T) {
payload := func(v url.Values) {
v.Set("method", "passkey")
v.Set(node.PasskeyLogin, string(loginPasswordlessResponse))
}
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
fix.checkURL(t, shouldRedirect, res)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Equal(t, text.NewErrorValidationSuchNoWebAuthnUser().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
}
t.Run("type=browser", func(t *testing.T) {
body, res := fix.loginViaBrowser(t, false, payload, testhelpers.NewClientWithCookies(t))
check(t, true, body, res)
})
t.Run("type=spa", func(t *testing.T) {
body, res := fix.loginViaBrowser(t, true, payload, testhelpers.NewClientWithCookies(t))
check(t, false, body, res)
})
t.Run("type=api", func(t *testing.T) {
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, fix.publicTS, false)
// Inject internal context required for replaying the WebAuthn response
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = loginPasswordlessContext
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
payload(values)
body, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
check(t, false, body, res)
})
})
t.Run("case=should fail if passkey login is invalid", func(t *testing.T) {
payload := func(v url.Values) {
v.Set("method", "passkey")
v.Set("passkey_login", "invalid passkey data")
}
check := func(t *testing.T, shouldRedirect bool, body string, res *http.Response) {
fix.checkURL(t, shouldRedirect, res)
assert.NotEmpty(t, gjson.Get(body, "id").String(), "%s", body)
assert.Equal(t, "Unable to parse WebAuthn response.", gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
}
t.Run("type=browser", func(t *testing.T) {
body, res := fix.loginViaBrowser(t, false, payload, testhelpers.NewClientWithCookies(t))
check(t, true, body, res)
})
t.Run("type=spa", func(t *testing.T) {
body, res := fix.loginViaBrowser(t, true, payload, testhelpers.NewClientWithCookies(t))
check(t, false, body, res)
})
t.Run("type=api", func(t *testing.T) {
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, fix.publicTS, false)
// Inject internal context required for replaying the WebAuthn response
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = loginPasswordlessContext
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
payload(values)
body, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
check(t, false, body, res)
})
})
t.Run("case=should fail if passkey login is empty", func(t *testing.T) {
payload := func(v url.Values) {
v.Set("method", "passkey")
}
t.Run("type=browser", func(t *testing.T) {
_, res := fix.loginViaBrowser(t, false, payload, testhelpers.NewClientWithCookies(t))
fix.checkURL(t, true, res)
})
t.Run("type=spa", func(t *testing.T) {
body, res := fix.loginViaBrowser(t, true, payload, testhelpers.NewClientWithCookies(t))
fix.checkURL(t, false, res)
assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String(), "%s", body)
})
t.Run("type=api", func(t *testing.T) {
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, fix.publicTS, false)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
payload(values)
body, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
assert.Equal(t, http.StatusUnprocessableEntity, res.StatusCode)
assert.Equal(t, "browser_location_change_required", gjson.Get(body, "error.id").String(), "%s", body)
})
})
t.Run("case=fails with invalid internal state", func(t *testing.T) {
run := func(t *testing.T, spa bool) {
fix.conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
// We load our identity which we will use to replay the webauth session
fix.createIdentityWithPasskey(t, identity.Credentials{
Config: loginPasswordlessCredentials,
Version: 1,
})
browserClient := testhelpers.NewClientWithCookies(t)
body, _, _ := fix.submitWebAuthnLoginWithClient(t, spa, []byte("invalid context"), browserClient, func(values url.Values) {
values.Set(node.PasskeyLogin, string(loginPasswordlessResponse))
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel1))
if spa {
assert.Equal(
t,
"Expected WebAuthN in internal context to be an object but got: unexpected end of JSON input",
gjson.Get(body, "error.reason").String(),
"%s", body,
)
} else {
assert.Equal(
t,
"Expected WebAuthN in internal context to be an object but got: unexpected end of JSON input",
gjson.Get(body, "reason").String(),
"%s", body,
)
}
}
t.Run("type=browser", func(t *testing.T) {
run(t, false)
})
t.Run("type=spa", func(t *testing.T) {
run(t, true)
})
t.Run("type=api", func(t *testing.T) {
fix.conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
fix.createIdentityWithPasskey(t, identity.Credentials{
Config: loginPasswordlessCredentials,
Version: 1,
})
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, fix.publicTS, false)
// Inject invalid internal context
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = []byte("invalid context")
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set(node.PasskeyLogin, string(loginPasswordlessResponse))
body, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
assert.Equal(t, http.StatusInternalServerError, res.StatusCode)
assert.Equal(
t,
"Expected WebAuthN in internal context to be an object but got: unexpected end of JSON input",
gjson.Get(body, "error.reason").String(),
"%s", body,
)
})
})
t.Run("case=succeeds with passwordless login", func(t *testing.T) {
run := func(t *testing.T, spa bool) {
fix.conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
// We load our identity which we will use to replay the webauth session
id := fix.createIdentityWithPasskey(t, identity.Credentials{
Config: loginPasswordlessCredentials,
Version: 1,
})
browserClient := testhelpers.NewClientWithCookies(t)
body, res, f := fix.submitWebAuthnLoginWithClient(t, spa, loginPasswordlessContext, browserClient, func(values url.Values) {
values.Set(node.PasskeyLogin, string(loginPasswordlessResponse))
}, testhelpers.InitFlowWithAAL(identity.AuthenticatorAssuranceLevel1))
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
"2.attributes.nonce",
"2.attributes.src",
"5.attributes.value",
})
prefix := ""
if spa {
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+login.RouteSubmitFlow)
prefix = "session."
} else {
assert.Contains(t, res.Request.URL.String(), fix.redirTS.URL)
}
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, gjson.Get(body, prefix+"authenticator_assurance_level").String(), "%s", body)
assert.EqualValues(t, identity.CredentialsTypePasskey, gjson.Get(body, prefix+"authentication_methods.#(method==passkey).method").String(), "%s", body)
assert.EqualValues(t, id.ID.String(), gjson.Get(body, prefix+"identity.id").String(), "%s", body)
actualFlow, err := fix.reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypePasskey, passkey.InternalContextKeySessionData)))
if spa {
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body)
assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), fix.conf.SelfServiceBrowserDefaultReturnTo(t.Context()).String(), "%s", body)
} else {
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
}
}
runAPI := func(t *testing.T) {
fix.conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
id := fix.createIdentityWithPasskey(t, identity.Credentials{
Config: loginPasswordlessCredentials,
Version: 1,
})
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, fix.publicTS, false)
// Inject internal context
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = loginPasswordlessContext
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
values.Set(node.PasskeyLogin, string(loginPasswordlessResponse))
body, res := testhelpers.LoginMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
"4.attributes.value", // passkey_challenge value for API (no script node)
})
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+login.RouteSubmitFlow)
assert.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
assert.EqualValues(t, identity.AuthenticatorAssuranceLevel1, gjson.Get(body, "session.authenticator_assurance_level").String(), "%s", body)
assert.EqualValues(t, identity.CredentialsTypePasskey, gjson.Get(body, "session.authentication_methods.#(method==passkey).method").String(), "%s", body)
assert.EqualValues(t, id.ID.String(), gjson.Get(body, "session.identity.id").String(), "%s", body)
actualFlow, err := fix.reg.LoginFlowPersister().GetLoginFlow(context.Background(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
assert.Empty(t, gjson.GetBytes(actualFlow.InternalContext, flow.PrefixInternalContextKey(identity.CredentialsTypePasskey, passkey.InternalContextKeySessionData)))
// API flows don't have continue_with redirect
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
}
// We test here that login works even if the identity schema contains
// { webauthn: { identifier: true } } instead of
// { passkey: { display_name: true } }
t.Run("webauthn_identifier", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/login_webauthn.schema.json")
t.Run("type=browser", func(t *testing.T) { run(t, false) })
t.Run("type=spa", func(t *testing.T) { run(t, true) })
t.Run("type=api", func(t *testing.T) { runAPI(t) })
})
t.Run("passkey_display_name", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/login.schema.json")
t.Run("type=browser", func(t *testing.T) { run(t, false) })
t.Run("type=spa", func(t *testing.T) { run(t, true) })
t.Run("type=api", func(t *testing.T) { runAPI(t) })
})
})
})
t.Run("flow=refresh", func(t *testing.T) {
fix := newLoginFixture(t)
fix.conf.MustSet(t.Context(), config.ViperKeySessionWhoAmIAAL, "aal1")
loginFixtureSuccessEmail := gjson.GetBytes(loginSuccessIdentity, "traits.email").String()
run := func(t *testing.T, ctx context.Context, id *identity.Identity, context, response []byte, isSPA bool, expectedAAL identity.AuthenticatorAssuranceLevel) {
body, res, f := fix.submitWebAuthnLogin(t, ctx, isSPA, id, context, func(values url.Values) {
values.Set("identifier", loginFixtureSuccessEmail)
values.Set(node.PasskeyLogin, string(response))
}, testhelpers.InitFlowWithRefresh())
snapshotx.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
"2.attributes.nonce",
"2.attributes.src",
"5.attributes.value",
})
nodes, err := json.Marshal(f.Ui.Nodes)
require.NoError(t, err)
assert.Equal(t, loginFixtureSuccessEmail, gjson.GetBytes(nodes, "#(attributes.name==identifier).attributes.value").String(), "%s", nodes)
prefix := ""
if isSPA {
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+login.RouteSubmitFlow, "%s", body)
prefix = "session."
} else {
assert.Contains(t, res.Request.URL.String(), fix.redirTS.URL, "%s", body)
}
assert.True(t, gjson.Get(body, prefix+"active").Bool(), "%s", body)
assert.EqualValues(t, expectedAAL, gjson.Get(body, prefix+"authenticator_assurance_level").String(), "%s", body)
assert.EqualValues(t, identity.CredentialsTypePasskey, gjson.Get(body, prefix+"authentication_methods.#(method==passkey).method").String(), "%s", body)
assert.Len(t, gjson.Get(body, prefix+"authentication_methods").Array(), 2, "%s", body)
assert.EqualValues(t, id.ID.String(), gjson.Get(body, prefix+"identity.id").String(), "%s", body)
}
expectedAAL := identity.AuthenticatorAssuranceLevel1
for _, tc := range []struct {
creds identity.Credentials
response []byte
context []byte
descript string
}{
{
creds: identity.Credentials{
Config: loginPasswordlessCredentials,
Version: 1,
},
context: loginPasswordlessContext,
response: loginPasswordlessResponse,
descript: "passwordless credentials",
},
} {
t.Run("case=refresh "+tc.descript, func(t *testing.T) {
id := fix.createIdentityWithPasskey(t, tc.creds)
for _, f := range []string{
"browser",
"spa",
"api",
} {
t.Run(f, func(t *testing.T) {
if f == "api" {
// API refresh flow
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, fix.reg, id)
// Pass true for forced to enable refresh in API flow
apiFlow := testhelpers.InitializeLoginFlowViaAPI(t, apiClient, fix.publicTS, true)
// Inject internal context
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(apiFlow.Id))
require.NoError(t, err)
interim.InternalContext = tc.context
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(apiFlow.Ui.Nodes)
values.Set("identifier", loginFixtureSuccessEmail)
values.Set(node.PasskeyLogin, string(tc.response))
body, res := testhelpers.LoginMakeRequest(t, true, false, apiFlow, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+login.RouteSubmitFlow, "%s", body)
assert.True(t, gjson.Get(body, "session.active").Bool(), "%s", body)
assert.EqualValues(t, expectedAAL, gjson.Get(body, "session.authenticator_assurance_level").String(), "%s", body)
assert.EqualValues(t, identity.CredentialsTypePasskey, gjson.Get(body, "session.authentication_methods.#(method==passkey).method").String(), "%s", body)
assert.Len(t, gjson.Get(body, "session.authentication_methods").Array(), 2, "%s", body)
assert.EqualValues(t, id.ID.String(), gjson.Get(body, "session.identity.id").String(), "%s", body)
} else {
run(t, t.Context(), id, tc.context, tc.response, f == "spa", expectedAAL)
}
})
}
})
}
})
}
func createIdentity(t *testing.T, ctx context.Context, reg driver.Registry, id uuid.UUID) *identity.Identity {
i := identity.NewIdentity("default")
i.SetCredentials(identity.CredentialsTypePasskey, identity.Credentials{
Identifiers: []string{id.String()},
Config: loginPasswordlessCredentials,
Type: identity.CredentialsTypePasskey,
Version: 1,
})
require.NoError(t, reg.IdentityManager().Create(ctx, i))
return i
}
func TestFormHydration(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx = contextx.WithConfigValue(ctx, config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".enabled", true)
ctx = contextx.WithConfigValue(
ctx,
config.ViperKeySelfServiceStrategyConfig+"."+string(identity.CredentialsTypePasskey)+".config",
map[string]interface{}{
"rp": map[string]interface{}{
"display_name": "foo",
"id": "localhost",
"origins": []string{"http://localhost"},
},
},
)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/login.schema.json")
s, err := reg.AllLoginStrategies().Strategy(identity.CredentialsTypePasskey)
require.NoError(t, err)
fh, ok := s.(login.AAL1FormHydrator)
require.True(t, ok)
toSnapshot := func(t *testing.T, f *login.Flow) {
t.Helper()
// The CSRF token has a unique value that messes with the snapshot - ignore it.
f.UI.Nodes.ResetNodes("csrf_token")
f.UI.Nodes.ResetNodes("passkey_challenge")
snapshotx.SnapshotT(t, f.UI.Nodes, snapshotx.ExceptNestedKeys("nonce", "src"))
}
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *login.Flow) {
r := httptest.NewRequest("GET", "/self-service/login/browser", nil)
r = r.WithContext(ctx)
t.Helper()
f, err := login.NewFlow(reg, r, flow.TypeBrowser)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f
}
t.Run("method=PopulateLoginMethodFirstFactor", func(t *testing.T) {
r, f := newFlow(ctx, t)
require.NoError(t, fh.PopulateLoginMethodFirstFactor(r, f))
toSnapshot(t, f)
})
t.Run("method=PopulateLoginMethodFirstFactorRefresh", func(t *testing.T) {
r, f := newFlow(ctx, t)
id := createIdentity(t, ctx, reg, x.NewUUID())
r.Header = testhelpers.NewHTTPClientWithIdentitySessionToken(ctx, t, reg, id).Transport.(*testhelpers.TransportWithHeader).GetHeader()
f.Refresh = true
require.NoError(t, fh.PopulateLoginMethodFirstFactorRefresh(r, f, nil))
toSnapshot(t, f)
})
t.Run("method=PopulateLoginMethodIdentifierFirstCredentials", func(t *testing.T) {
t.Run("case=no options", func(t *testing.T) {
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false)
r, f := newFlow(ctx, t)
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound)
toSnapshot(t, f)
})
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true)
r, f := newFlow(ctx, t)
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f), idfirst.ErrNoCredentialsFound)
toSnapshot(t, f)
})
})
t.Run("case=WithIdentifier", func(t *testing.T) {
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false)
r, f := newFlow(ctx, t)
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound)
toSnapshot(t, f)
})
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true)
r, f := newFlow(ctx, t)
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentifier("foo@bar.com")), idfirst.ErrNoCredentialsFound)
toSnapshot(t, f)
})
})
t.Run("case=WithIdentityHint", func(t *testing.T) {
t.Run("case=account enumeration mitigation enabled", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, true)
id := identity.NewIdentity("test-provider")
r, f := newFlow(ctx, t)
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
toSnapshot(t, f)
})
t.Run("case=account enumeration mitigation disabled", func(t *testing.T) {
ctx := contextx.WithConfigValue(ctx, config.ViperKeySecurityAccountEnumerationMitigate, false)
t.Run("case=identity has passkey", func(t *testing.T) {
identifier := x.NewUUID()
id := createIdentity(t, ctx, reg, identifier)
r, f := newFlow(ctx, t)
require.NoError(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)))
// Verify passkey button is present
assert.NotNil(t, f.UI.Nodes.Find(node.PasskeyLoginTrigger), "passkey button should be present when user has credentials")
toSnapshot(t, f)
})
t.Run("case=identity does not have a passkey", func(t *testing.T) {
id := identity.NewIdentity("default")
r, f := newFlow(ctx, t)
require.ErrorIs(t, fh.PopulateLoginMethodIdentifierFirstCredentials(r, f, login.WithIdentityHint(id)), idfirst.ErrNoCredentialsFound)
// Verify passkey button is NOT present
assert.Nil(t, f.UI.Nodes.Find(node.PasskeyLoginTrigger), "passkey button should not be present when user has no credentials")
toSnapshot(t, f)
})
})
})
})
t.Run("method=PopulateLoginMethodIdentifierFirstIdentification", func(t *testing.T) {
r, f := newFlow(ctx, t)
require.NoError(t, fh.PopulateLoginMethodIdentifierFirstIdentification(r, f))
toSnapshot(t, f)
})
}

View File

@@ -0,0 +1,378 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey
import (
"context"
_ "embed"
"encoding/json"
"net/http"
"net/url"
"strings"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/x/otelx"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/ory/herodot"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/ui/container"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x/webauthnx"
"github.com/ory/x/randx"
)
var _ registration.FormHydrator = new(Strategy)
// Update Registration Flow with Passkey Method
//
// swagger:model updateRegistrationFlowWithPasskeyMethod
type updateRegistrationFlowWithPasskeyMethod struct {
// Register a WebAuthn Security Key
//
// It is expected that the JSON returned by the WebAuthn registration process
// is included here.
Register string `json:"passkey_register"`
// CSRFToken is the anti-CSRF token
CSRFToken string `json:"csrf_token"`
// The identity's traits
//
// required: true
Traits json.RawMessage `json:"traits"`
// Method
//
// Should be set to "passkey" when trying to add, update, or remove a Passkey.
//
// required: true
Method string `json:"method"`
// Flow is flow ID.
//
// swagger:ignore
Flow string `json:"flow"`
// Transient data to pass along to any webhooks
//
// required: false
TransientPayload json.RawMessage `json:"transient_payload,omitempty"`
}
func (s *Strategy) handleRegistrationError(_ http.ResponseWriter, r *http.Request, f *registration.Flow, p *updateRegistrationFlowWithPasskeyMethod, err error) error {
if f != nil {
if p != nil {
for _, n := range container.NewFromJSON("", node.DefaultGroup, p.Traits, "traits").Nodes {
// we only set the value and not the whole field because we want to keep types from the initial form generation
f.UI.Nodes.SetValueAttribute(n.ID(), n.Attributes.GetValue())
}
}
if f.Type == flow.TypeBrowser {
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
}
}
return err
}
func (s *Strategy) decode(r *http.Request, ds *url.URL) (*updateRegistrationFlowWithPasskeyMethod, error) {
var p updateRegistrationFlowWithPasskeyMethod
err := registration.DecodeBody(&p, r, registrationSchema, ds)
return &p, err
}
func (s *Strategy) Register(w http.ResponseWriter, r *http.Request, regFlow *registration.Flow, ident *identity.Identity) (err error) {
ctx, span := s.d.Tracer(r.Context()).Tracer().Start(r.Context(), "selfservice.strategy.passkey.Strategy.Register")
defer otelx.End(span, &err)
ds, err := regFlow.IdentitySchema.URL(ctx, s.d.Config())
if err != nil {
return err
}
params, err := s.decode(r, ds)
if err != nil {
return s.handleRegistrationError(w, r, regFlow, params, err)
}
regFlow.TransientPayload = params.TransientPayload
if params.Register == "" ||
params.Register == "true" { // The React SDK sends "true" on empty values, so we ignore these.
span.SetAttributes(attribute.String("not_responsible_reason", "register is empty"))
return flow.ErrStrategyNotResponsible
}
if err := flow.EnsureCSRF(s.d, r, regFlow.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, params.CSRFToken); err != nil {
return s.handleRegistrationError(w, r, regFlow, params, err)
}
params.Method = s.ID().String()
if err := flow.MethodEnabledAndAllowed(ctx, regFlow.GetFlowName(), params.Method, params.Method, s.d); err != nil {
return s.handleRegistrationError(w, r, regFlow, params, err)
}
if len(params.Traits) == 0 {
params.Traits = json.RawMessage("{}")
}
ident.Traits = identity.Traits(params.Traits)
webAuthnSession := gjson.GetBytes(regFlow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData))
if !webAuthnSession.IsObject() {
return s.handleRegistrationError(w, r, regFlow, params, errors.WithStack(
herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN in internal context to be an object.")))
}
var webAuthnSess webauthn.SessionData
if err := json.Unmarshal([]byte(webAuthnSession.Raw), &webAuthnSess); err != nil {
return s.handleRegistrationError(w, r, regFlow, params, errors.WithStack(
herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN in internal context to be an object but got: %s", err)))
}
if len(webAuthnSess.UserID) == 0 {
return s.handleRegistrationError(w, r, regFlow, params, errors.WithStack(
herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN session data to contain a user ID")))
}
webAuthnResponse, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(params.Register))
if err != nil {
return s.handleRegistrationError(w, r, regFlow, params, errors.WithStack(
herodot.ErrBadRequest().WithReasonf("Unable to parse WebAuthn response: %s", err)))
}
webAuthn, err := webauthn.New(s.d.Config().PasskeyConfig(ctx))
if err != nil {
return s.handleRegistrationError(w, r, regFlow, params, errors.WithStack(
herodot.ErrInternalServerError().WithReasonf("Unable to get webAuthn config").WithDebug(err.Error())))
}
credential, err := webAuthn.CreateCredential(&webauthnx.User{
ID: webAuthnSess.UserID,
Config: webAuthn.Config,
}, webAuthnSess, webAuthnResponse)
if err != nil {
if devErr := new(protocol.Error); errors.As(err, &devErr) {
s.d.Logger().WithError(err).WithField("error_devinfo", devErr.DevInfo).Error("Failed to create WebAuthn credential")
}
return s.handleRegistrationError(w, r, regFlow, params, errors.WithStack(
herodot.ErrInternalServerError().WithReasonf("Unable to create WebAuthn credential: %s", err)))
}
credentialWebAuthn := identity.CredentialFromWebAuthn(credential, true)
credentialWebAuthnConfig, err := json.Marshal(identity.CredentialsWebAuthnConfig{
Credentials: identity.CredentialsWebAuthn{*credentialWebAuthn},
UserHandle: webAuthnSess.UserID,
})
if err != nil {
return s.handleRegistrationError(w, r, regFlow, params, errors.WithStack(
herodot.ErrInternalServerError().WithReasonf("Unable to encode identity credentials.").WithDebug(err.Error())))
}
ident.UpsertCredentialsConfig(s.ID(), credentialWebAuthnConfig, 1)
passkeyCred, _ := ident.GetCredentials(s.ID())
passkeyCred.Identifiers = []string{string(webAuthnSess.UserID)}
ident.SetCredentials(s.ID(), *passkeyCred)
if err := s.validateCredentials(ctx, ident); err != nil {
return s.handleRegistrationError(w, r, regFlow, params, err)
}
if err := s.d.RegistrationFlowPersister().UpdateRegistrationFlow(ctx, regFlow); err != nil {
return s.handleRegistrationError(w, r, regFlow, params, err)
}
return nil
}
// passkeyCreateData carries the WebAuthn registration ceremony options to
// the bundled JS. Both display-name fields are populated from the identity
// schema's flagged traits: DisplayNameFieldName is the alphabetically-first
// candidate kept for backward compatibility with custom UIs that only read
// the singular field; DisplayNameFieldNames is the full sorted list. Newer
// clients should consume DisplayNameFieldNames and pick the first form
// field with a non-empty value.
type passkeyCreateData struct {
CredentialOptions *protocol.CredentialCreation `json:"credentialOptions"`
DisplayNameFieldName string `json:"displayNameFieldName"`
DisplayNameFieldNames []string `json:"displayNameFieldNames"`
}
// skipMethodOnMissingDisplayName downgrades errNoDisplayNameTrait to a
// warning so that a passkey method without a usable display-name trait drops
// its UI nodes instead of failing the whole registration flow: all other
// enabled methods must keep working even when the passkey method is
// misconfigured. Any other error is returned unchanged.
func (s *Strategy) skipMethodOnMissingDisplayName(r *http.Request, err error) error {
if !errors.Is(err, errNoDisplayNameTrait) {
return err
}
s.d.Logger().WithRequest(r).Warn("The passkey method is enabled, but the identity schema does not mark any trait as a passkey display name (passkey.display_name) or WebAuthn identifier (webauthn.identifier). The passkey method will not be offered on registration flows. Update the identity schema or disable the passkey method.")
return nil
}
func (s *Strategy) PopulateRegistrationMethod(r *http.Request, f *registration.Flow) error {
ctx := r.Context()
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
opts, err := s.hydratePassKeyRegistrationOptions(ctx, f)
if err != nil {
return s.skipMethodOnMissingDisplayName(r, err)
}
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.Nodes.Upsert(injectOptions(opts))
trigger := passkeyRegisterTrigger()
if f.Type == flow.TypeAPI {
// API flows should not have raw JS onClick handlers, but keep onClickTrigger
// as a semantic enum for SPA/native apps to key off of.
if attr, ok := trigger.Attributes.(*node.InputAttributes); ok {
attr.OnClick = ""
}
}
f.UI.Nodes.Upsert(trigger)
if f.Type == flow.TypeBrowser {
f.UI.Nodes.Upsert(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx)))
}
f.UI.Nodes.Upsert(passkeyRegister())
return nil
}
func (s *Strategy) validateCredentials(ctx context.Context, i *identity.Identity) error {
if err := s.d.IdentityValidator().Validate(ctx, i); err != nil {
return err
}
c := i.GetCredentialsOr(identity.CredentialsTypePasskey, &identity.Credentials{})
if len(c.Identifiers) == 0 {
return schema.NewMissingIdentifierError()
}
return nil
}
func (s *Strategy) PopulateRegistrationMethodCredentials(r *http.Request, f *registration.Flow, options ...registration.FormHydratorModifier) error {
ctx := r.Context()
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
opts, err := s.hydratePassKeyRegistrationOptions(ctx, f)
if err != nil {
return s.skipMethodOnMissingDisplayName(r, err)
}
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.Nodes.Upsert(injectOptions(opts))
trigger := passkeyRegisterTrigger()
if f.Type == flow.TypeAPI {
// API flows should not have raw JS onClick handlers, but keep onClickTrigger
// as a semantic enum for SPA/native apps to key off of.
if attr, ok := trigger.Attributes.(*node.InputAttributes); ok {
attr.OnClick = ""
}
}
f.UI.Nodes.Upsert(trigger)
if f.Type == flow.TypeBrowser {
f.UI.Nodes.Upsert(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx)))
}
f.UI.Nodes.Upsert(passkeyRegister())
return nil
}
func (s *Strategy) PopulateRegistrationMethodProfile(r *http.Request, f *registration.Flow, options ...registration.FormHydratorModifier) error {
ctx := r.Context()
opts, err := s.hydratePassKeyRegistrationOptions(ctx, f)
if err != nil {
// Without hydrated options no passkey nodes were added by
// PopulateRegistrationMethodCredentials either, so there is nothing
// to remove here.
return s.skipMethodOnMissingDisplayName(r, err)
}
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
f.UI.Nodes.RemoveMatching(injectOptions(opts))
f.UI.Nodes.RemoveMatching(passkeyRegisterTrigger())
f.UI.Nodes.RemoveMatching(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx)))
f.UI.Nodes.RemoveMatching(passkeyRegister())
return nil
}
func (s *Strategy) hydratePassKeyRegistrationOptions(ctx context.Context, f *registration.Flow) ([]byte, error) {
defaultSchemaURL, err := f.IdentitySchema.URL(ctx, s.d.Config())
if err != nil {
return nil, err
}
if options := gjson.GetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionOptions)); options.IsObject() {
return []byte(options.Raw), nil
}
createData := new(passkeyCreateData)
fieldNames, err := s.PasskeyDisplayNameFromSchema(ctx, defaultSchemaURL.String())
if err != nil {
return nil, err
}
// fieldNames is guaranteed non-empty here: PasskeyDisplayNameFromSchema
// returns at least one candidate or an error. The singular field keeps the
// alphabetically-first candidate for backward compatibility with custom UIs
// that only read it.
createData.DisplayNameFieldNames = fieldNames
createData.DisplayNameFieldName = fieldNames[0]
webAuthn, err := webauthn.New(s.d.Config().PasskeyConfig(ctx))
if err != nil {
return nil, errors.WithStack(err)
}
user := &webauthnx.User{
Name: "",
ID: []byte(randx.MustString(64, randx.AlphaNum)),
Config: s.d.Config().PasskeyConfig(ctx),
}
option, sessionData, err := webAuthn.BeginRegistration(user)
if err != nil {
return nil, errors.WithStack(err)
}
createData.CredentialOptions = option
injectWebAuthnOptions, err := json.Marshal(createData)
if err != nil {
return nil, errors.WithStack(err)
}
f.InternalContext, err = sjson.SetBytes(
f.InternalContext,
flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData),
sessionData,
)
if err != nil {
return nil, errors.WithStack(err)
}
f.InternalContext, err = sjson.SetRawBytes(
f.InternalContext,
flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionOptions),
injectWebAuthnOptions,
)
if err != nil {
return nil, errors.WithStack(err)
}
return injectWebAuthnOptions, nil
}

View File

@@ -0,0 +1,965 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey_test
import (
"bytes"
"context"
_ "embed"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/ory/x/configx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/registrationhelpers"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/ory/x/assertx"
"github.com/ory/x/contextx"
"github.com/ory/x/ioutilx"
"github.com/ory/x/randx"
"github.com/ory/x/snapshotx"
"github.com/ory/x/sqlxx"
)
var (
flows = []string{"spa", "browser", "api"}
//go:embed fixtures/registration/success/browser/response.json
registrationFixtureSuccessResponse []byte
//go:embed fixtures/registration/success/browser/internal_context.json
registrationFixtureSuccessBrowserInternalContext []byte
//go:embed fixtures/registration/success/android/response.json
registrationFixtureSuccessAndroidResponse []byte
//go:embed fixtures/registration/success/android/internal_context.json
registrationFixtureSuccessAndroidInternalContext []byte
//go:embed fixtures/registration/failure/internal_context_missing_user_id.json
registrationFixtureFailureInternalContextMissingUserID []byte
//go:embed fixtures/registration/failure/internal_context_wrong_user_id.json
registrationFixtureFailureInternalContextWrongUserID []byte
)
func flowIsSPA(flow string) bool {
return flow == "spa"
}
func TestRegistration(t *testing.T) {
t.Parallel()
t.Run("AssertCommonErrorCases", func(t *testing.T) {
registrationhelpers.AssertCommonErrorCases(t, flows)
})
t.Run("AssertRegistrationRespectsValidation", func(t *testing.T) {
t.Parallel()
reg := newRegistrationRegistry(t)
registrationhelpers.AssertRegistrationRespectsValidation(t, reg, flows, func(v url.Values) {
v.Del("traits.foobar")
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
})
})
t.Run("AssertCSRFFailures", func(t *testing.T) {
t.Parallel()
// Use the shared helper for browser and spa flows (they already provide
// the WebAuthn internal context via the UI flow initialization).
reg := newRegistrationRegistry(t)
registrationhelpers.AssertCSRFFailures(t, reg, []string{"spa", "browser"}, func(v url.Values) {
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
})
// Prepare API flow once and persist the internal context so the
// subsequent header-mangling subtests can reuse it.
fix := newRegistrationFixture(t)
apiClient := testhelpers.NewDebugClient(t)
f := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, fix.publicTS)
interim, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = registrationFixtureSuccessBrowserInternalContext
require.NoError(t, fix.reg.RegistrationFlowPersister().UpdateRegistrationFlow(t.Context(), interim))
values := url.Values{
"csrf_token": {"invalid_token"},
"traits.username": {testhelpers.RandomEmail()},
"traits.foobar": {"bar"},
}
values.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
values.Del("method")
// Check that missing CSRF passes for API (registration happens)
actual, res := testhelpers.RegistrationMakeRequest(t, true, false, f, apiClient, testhelpers.EncodeFormAsJSON(t, true, values))
require.EqualValues(t, http.StatusOK, res.StatusCode)
assert.NotEmpty(t, gjson.Get(actual, "identity.id").Raw, "%s", actual)
// Now test the specific CSRF error causes for API flows: adding Cookie and Origin headers
for _, tc := range []struct {
mod func(http.Header)
exp string
expKey string
}{
{
mod: func(h http.Header) { h.Add("Cookie", "name=bar") },
exp: "The HTTP Request Header included the \"Cookie\" key",
expKey: "Cookie",
},
{
mod: func(h http.Header) { h.Add("Origin", "www.bar.com") },
exp: "The HTTP Request Header included the \"Origin\" key",
expKey: "Origin",
},
} {
// Name this test to match the project's existing case naming and mark it as a local variant.
t.Run(fmt.Sprintf("case=should_fail_with_correct_CSRF_error_cause_local/type=api/%s", tc.expKey), func(t *testing.T) {
f2 := testhelpers.InitializeRegistrationFlowViaAPI(t, apiClient, fix.publicTS)
// attach internal context to the new flow
interim2, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), uuid.FromStringOrNil(f2.Id))
require.NoError(t, err)
interim2.InternalContext = registrationFixtureSuccessBrowserInternalContext
require.NoError(t, fix.reg.RegistrationFlowPersister().UpdateRegistrationFlow(t.Context(), interim2))
req, err := http.NewRequest("POST", f2.Ui.Action, bytes.NewBufferString(testhelpers.EncodeFormAsJSON(t, true, values)))
require.NoError(t, err)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
tc.mod(req.Header)
res, err := apiClient.Do(req)
require.NoError(t, err)
defer func() { _ = res.Body.Close() }()
bodyBytes := ioutilx.MustReadAll(res.Body)
actual := string(bodyBytes)
require.EqualValues(t, http.StatusBadRequest, res.StatusCode)
// Extract the first ui message text (API error payload)
msg := gjson.Get(actual, "ui.messages.0.text").String()
// Ensure the API error mentions the header key we added (Cookie/Origin)
assert.Contains(t, msg, tc.expKey, "actual payload: %s", actual)
})
}
})
t.Run("AssertSchemaDoesNotExist", func(t *testing.T) {
t.Parallel()
reg := newRegistrationRegistry(t)
registrationhelpers.AssertSchemaDoesNotExist(t, reg, flows, func(v url.Values) {
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
})
})
t.Run("case=passkey button does not exist when passwordless is disabled", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t, configx.WithValue(config.ViperKeyPasskeyEnabled, false))
uiFlows := []string{"spa", "browser"}
for _, flowType := range uiFlows {
flowType := flowType
t.Run(flowType, func(t *testing.T) {
t.Parallel()
client := testhelpers.NewClientWithCookies(t)
flo := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, flowIsSPA(flowType), false, false)
testhelpers.SnapshotTExcept(t, flo.Ui.Nodes, []string{
"0.attributes.value",
})
})
}
})
t.Run("case=passkey button exists", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
uiFlows := []string{"spa", "browser"}
for _, flowType := range uiFlows {
flowType := flowType
t.Run(flowType, func(t *testing.T) {
t.Parallel()
client := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, flowIsSPA(flowType), false, false)
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value",
"3.attributes.src",
"3.attributes.nonce",
"6.attributes.value",
})
})
}
})
t.Run("case=should return an error because not passing validation", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
values := func(v url.Values) {
v.Set("traits.username", email)
v.Del("traits.foobar")
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual := registrationhelpers.ExpectValidationError(t.Context(), t, fix.publicTS, fix.conf, f, values)
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual)
assert.Equal(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
})
}
})
t.Run("case=should reject invalid transient payload", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
values := func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bar")
v.Set("transient_payload", "42")
v.Set(node.PasskeyRegister, "{}")
v.Del("method")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual := registrationhelpers.ExpectValidationError(t.Context(), t, fix.publicTS, fix.conf, f, values)
assert.NotEmptyf(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Containsf(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Equalf(t, "bar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).attributes.value").String(), "%s", actual)
assert.Equalf(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
assert.Equalf(t, int64(4000026), gjson.Get(actual, "ui.nodes.#(attributes.name==transient_payload).messages.0.id").Int(), "%s", actual)
})
}
})
t.Run("case=should return an error because passkey response is invalid", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
values := func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, "invalid")
v.Set("method", "passkey")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual, _, _ := fix.submitPasskeyRegistration(t.Context(), t, f, testhelpers.NewClientWithCookies(t), values)
assert.NotEmpty(t, gjson.Get(actual, "id").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), node.PasskeyRegister, "csrf_token", "traits.username", "traits.foobar")
assert.Equal(t, "bazbar", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).attributes.value").String(), "%s", actual)
assert.Equal(t, email, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).attributes.value").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "ui.messages.0").String(), `Unable to parse WebAuthn response: Parse error for Registration`, "%s", actual)
})
}
})
t.Run("case=should return an error because internal context is invalid", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
email := testhelpers.RandomEmail()
for _, tc := range []struct {
name string
internalContext string
}{{
name: "invalid json",
internalContext: "invalid",
}, {
name: "missing user ID",
internalContext: string(registrationFixtureFailureInternalContextMissingUserID),
}, {
name: "wrong user ID",
internalContext: string(registrationFixtureFailureInternalContextWrongUserID),
}} {
tc := tc
t.Run("context="+tc.name, func(t *testing.T) {
values := func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
actual, _, _ := fix.submitPasskeyRegistration(t.Context(), t, f, testhelpers.NewClientWithCookies(t), values,
withInternalContext(sqlxx.JSONRawMessage(tc.internalContext)))
if flowIsSPA(f) || f == "api" {
assert.Equal(t, "Internal Server Error", gjson.Get(actual, "error.status").String(), "%s", actual)
} else {
// Browser uses top-level status
assert.Equal(t, "Internal Server Error", gjson.Get(actual, "status").String(), "%s", actual)
}
})
}
})
}
})
t.Run("case=should fail to create identity if schema is missing the identifier", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/noid.schema.json")
email := testhelpers.RandomEmail()
uiFlows := []string{"spa", "browser"}
for _, f := range uiFlows {
t.Run("type="+f, func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
isSPA := f == "spa"
regFlow := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, isSPA, false, false)
// fill out traits and click on "sign up with passkey"
urlValues := testhelpers.SDKFormFieldsToURLValues(regFlow.Ui.Nodes)
urlValues.Set("traits.email", email)
urlValues.Set("method", "passkey")
actual, _ := testhelpers.RegistrationMakeRequest(t, false, isSPA, regFlow, client, urlValues.Encode())
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.email")
assert.Equal(t, text.NewErrorValidationRegistrationNoStrategyFound().Text, gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
})
}
})
t.Run("case=registration flow with multiple flagged traits returns all candidate field names", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/registration-multi-identifier.schema.json")
uiFlows := []string{"spa", "browser"}
for _, f := range uiFlows {
t.Run("type="+f, func(t *testing.T) {
t.Parallel()
client := testhelpers.NewClientWithCookies(t)
isSPA := f == "spa"
// Before the multi-trait fix this would 500 inside InitializeRegistrationFlowViaBrowser
// with "no identifier found".
regFlow := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, isSPA, false, false)
// Find the passkey_create_data hidden input node and extract its value.
var createDataValue string
for _, n := range regFlow.Ui.Nodes {
attrs := n.Attributes.UiNodeInputAttributes
if attrs == nil {
continue
}
if attrs.Name != node.PasskeyCreateData {
continue
}
if v, ok := attrs.Value.(string); ok {
createDataValue = v
}
break
}
require.NotEmpty(t, createDataValue, "passkey_create_data node must be present and have a non-empty value")
// Both flagged trait paths must appear in displayNameFieldNames.
fieldNames := gjson.Get(createDataValue, "displayNameFieldNames")
require.True(t, fieldNames.IsArray(), "displayNameFieldNames must be a JSON array, got: %s", createDataValue)
names := make([]string, 0, len(fieldNames.Array()))
for _, r := range fieldNames.Array() {
names = append(names, r.String())
}
assert.True(t, slices.Contains(names, "traits.email"),
"traits.email must be in displayNameFieldNames, got: %v", names)
assert.True(t, slices.Contains(names, "traits.phone"),
"traits.phone must be in displayNameFieldNames, got: %v", names)
// Backward compatibility: the singular field stays populated with
// the alphabetically-first candidate so custom UIs that only read
// it continue to work.
assert.Equal(t, "traits.email", gjson.Get(createDataValue, "displayNameFieldName").String(),
"displayNameFieldName must be the first sorted candidate")
})
}
})
getPrefix := func(f string) (prefix string) {
if f == "spa" || f == "api" {
prefix = "session."
}
return
}
t.Run("successful registration", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
t.Cleanup(fix.disableSessionAfterRegistration)
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
t.Run("case=should create the identity but not a session", func(t *testing.T) {
fix.useRedirNoSessionTS()
t.Cleanup(fix.useRedirTS)
fix.disableSessionAfterRegistration()
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
email := f + "-" + testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirNoSessionTS.URL+"/registration-return-ts", values(email), withUserID(userID))
if f == "spa" || f == "api" {
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
}
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, "aal1", i.InternalAvailableAAL.String)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
t.Run("case=should accept valid transient payload", func(t *testing.T) {
fix.useRedirNoSessionTS()
t.Cleanup(fix.useRedirTS)
fix.disableSessionAfterRegistration()
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirNoSessionTS.URL+"/registration-return-ts", func(v url.Values) {
values(email)(v)
v.Set("transient_payload.stuff", "42")
}, withUserID(userID))
if f == "spa" {
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
if f == "api" {
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
}
}
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
if f == "spa" {
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(actual, "continue_with.0.action").String(), "%s", actual)
assert.Contains(t, gjson.Get(actual, "continue_with.0.redirect_browser_to").String(), fix.redirNoSessionTS.URL+"/registration-return-ts", "%s", actual)
} else {
assert.Empty(t, gjson.Get(actual, "continue_with").Array(), "%s", actual)
}
})
}
})
t.Run("case=should create the identity and a session and use the correct schema", func(t *testing.T) {
fix.enableSessionAfterRegistration()
fix.conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "advanced-user")
fix.conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "does-not-exist", URL: "file://./stub/profile.schema.json"},
{ID: "advanced-user", URL: "file://./stub/registration.schema.json"},
})
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
t.Run("case=not able to create the same account twice", func(t *testing.T) {
fix.enableSessionAfterRegistration()
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/registration.schema.json")
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
assert.True(t, gjson.Get(actual, getPrefix(f)+"active").Bool(), "%s", actual)
actual, _, _ = fix.makeRegistration(t, f, values(email))
assert.Contains(t, gjson.Get(actual, "ui.action").String(), fix.publicTS.URL+registration.RouteSubmitFlow, "%s", actual)
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username")
assert.Equal(t,
"You tried signing in with "+email+" which is already in use by another account. You can sign in using your passkey.",
gjson.Get(actual, "ui.messages.0.text").String(), "%s", actual)
})
}
})
t.Run("case=reset previous form errors", func(t *testing.T) {
fix.enableSessionAfterRegistration()
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://./stub/registration.schema.json")
for _, f := range []string{"spa", "browser", "api"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
actual, _, _ := fix.makeRegistration(t, f, func(v url.Values) {
v.Del("traits.username")
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
})
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages.0").String(), `Property username is missing`, "%s", actual)
actual, _, _ = fix.makeRegistration(t, f, func(v url.Values) {
v.Set("traits.username", email)
v.Del("traits.foobar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
})
registrationhelpers.CheckFormContent(t, []byte(actual), "csrf_token", "traits.username", "traits.foobar")
assert.Contains(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0").String(), `Property foobar is missing`, "%s", actual)
assert.Empty(t, gjson.Get(actual, "ui.nodes.#(attributes.name==traits.username).messages").Array())
assert.Empty(t, gjson.Get(actual, "ui.nodes.messages").Array())
})
}
})
t.Run("case=should create the identity when using android", func(t *testing.T) {
fix.useRedirNoSessionTS()
t.Cleanup(fix.useRedirTS)
fix.disableSessionAfterRegistration()
prevRPID := fix.conf.GetProvider(t.Context()).String(config.ViperKeyPasskeyRPID)
prevOrigins := fix.conf.GetProvider(t.Context()).String(config.ViperKeyPasskeyRPOrigins)
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPID, "www.troweprice.com")
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPOrigins, []string{"android:apk-key-hash:S2RfNYgJmQiKgd6-sdbjW7phcL_OTP4vGE8L51Q2GB0"})
t.Cleanup(func() {
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPID, prevRPID)
fix.conf.MustSet(t.Context(), config.ViperKeyPasskeyRPOrigins, prevOrigins)
})
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
email := f + "-" + testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
expectReturnTo := fix.redirNoSessionTS.URL + "/registration-return-ts"
actual, res, _ := fix.submitPasskeyAndroidRegistration(t, f, testhelpers.NewClientWithCookies(t), func(v url.Values) {
values(email)(v)
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessAndroidResponse))
}, withUserID(userID))
if f == "spa" || f == "api" {
if f == "spa" {
expectReturnTo = fix.publicTS.URL
}
assert.Equal(t, email, gjson.Get(actual, "identity.traits.username").String(), "%s", actual)
assert.False(t, gjson.Get(actual, "session").Exists(), "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
} else {
assert.Equal(t, "null\n", actual, "because the registration yielded no session, the user is not expected to be signed in: %s", actual)
}
if f != "api" {
assert.Containsf(t, res.Request.URL.String(), expectReturnTo, "%+v\n\t%s", res.Request, assertx.PrettifyJSONPayload(t, actual))
}
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, "aal1", i.InternalAvailableAAL.String)
assert.Equalf(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
})
t.Run("case=multi-schema registration", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
fix.enableSessionAfterRegistration()
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
invalidValues := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "b")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
fix.conf.MustSet(t.Context(), config.ViperKeyDefaultIdentitySchemaID, "does-not-exist")
fix.conf.MustSet(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{
{ID: "does-not-exist", URL: "file://./stub/profile.schema.json"},
{ID: "advanced-user", URL: "file://./stub/registration.schema.json", SelfserviceSelectable: true},
})
for _, f := range flows {
t.Run("type="+f, func(t *testing.T) {
t.Run("should create the identity and a session and use the correct schema", func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID), withInitFlowWithOption([]testhelpers.InitFlowWithOption{testhelpers.InitFlowWithIdentitySchema("advanced-user")}))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
assert.Equal(t, "advanced-user", i.SchemaID, "%s", actual)
})
t.Run("registration should fail with invalid form data using the correct schema", func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-user-" + randx.MustString(8, randx.AlphaNum)
actual, res := fix.makeUnsuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", invalidValues(email), withUserID(userID), withInitFlowWithOption([]testhelpers.InitFlowWithOption{testhelpers.InitFlowWithIdentitySchema("advanced-user")}))
if f == "browser" {
assert.Equal(t, http.StatusOK, res.StatusCode, "%s", actual)
} else {
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "%s", actual)
}
assert.Equal(t, int64(4000003), gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.id").Int(), "%s", actual)
assert.Equal(t, "length must be \u003e= 2, but got 1", gjson.Get(actual, "ui.nodes.#(attributes.name==traits.foobar).messages.0.text").String(), "%s", actual)
})
})
}
})
t.Run("case=profile_first registration flow", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
fix.enableSessionAfterRegistration()
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationFlowStyle, "profile_first")
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
t.Run("case=successful registration", func(t *testing.T) {
for _, f := range []string{"browser", "spa"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-profile-first-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
})
t.Run("case=unified registration flow", func(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t)
fix.enableSessionAfterRegistration()
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceRegistrationFlowStyle, "unified")
values := func(email string) func(v url.Values) {
return func(v url.Values) {
v.Set("traits.username", email)
v.Set("traits.foobar", "bazbar")
v.Set(node.PasskeyRegister, string(registrationFixtureSuccessResponse))
v.Del("method")
}
}
t.Run("case=successful registration", func(t *testing.T) {
for _, f := range []string{"browser", "spa"} {
t.Run("type="+f, func(t *testing.T) {
email := testhelpers.RandomEmail()
userID := f + "-unified-" + randx.MustString(8, randx.AlphaNum)
actual := fix.makeSuccessfulRegistration(t, f, fix.redirTS.URL+"/registration-return-ts", values(email), withUserID(userID))
prefix := getPrefix(f)
assert.Equal(t, email, gjson.Get(actual, prefix+"identity.traits.username").String(), "%s", actual)
assert.True(t, gjson.Get(actual, prefix+"active").Bool(), "%s", actual)
i, _, err := fix.reg.PrivilegedIdentityPool().FindByCredentialsIdentifier(t.Context(), identity.CredentialsTypePasskey, userID)
require.NoError(t, err)
assert.Equal(t, email, gjson.GetBytes(i.Traits, "username").String(), "%s", actual)
})
}
})
})
}
func TestPopulateRegistrationMethod(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/registration.schema.json")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPDisplayName, "localhost")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPID, "localhost")
s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypePasskey)
require.NoError(t, err)
fh, ok := s.(registration.FormHydrator)
require.True(t, ok)
toSnapshot := func(t *testing.T, f node.Nodes, except ...snapshotx.Opt) {
t.Helper()
// The CSRF token has a unique value that messes with the snapshot - ignore it.
f.ResetNodes("csrf_token")
snapshotx.SnapshotT(t, f, append(except, snapshotx.ExceptNestedKeys("nonce", "src"))...)
}
newFlow := func(ctx context.Context, t *testing.T) (*http.Request, *registration.Flow) {
r := httptest.NewRequest("GET", "/self-service/registration/browser", nil)
r = r.WithContext(ctx)
t.Helper()
f, err := registration.NewFlow(reg, r, flow.TypeBrowser)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f
}
newFlowWithType := func(ctx context.Context, t *testing.T, flowType flow.Type) (*http.Request, *registration.Flow) {
r := httptest.NewRequest("GET", "/self-service/registration/browser", nil)
r = r.WithContext(ctx)
t.Helper()
f, err := registration.NewFlow(reg, r, flowType)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f
}
t.Run("method=PopulateRegistrationMethod", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("type=api", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeAPI)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
})
t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) {
r, f := newFlow(ctx, t)
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("type=api", func(t *testing.T) {
r, f := newFlowWithType(ctx, t, flow.TypeAPI)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
})
t.Run("method=idempotency", func(t *testing.T) {
r, f := newFlow(ctx, t)
var snapshots []node.Nodes
t.Run("case=1", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("case=2", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("case=3", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("case=4", func(t *testing.T) {
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
snapshots = append(snapshots, f.UI.Nodes)
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("1.attributes.value"))
})
t.Run("case=evaluate", func(t *testing.T) {
assertx.EqualAsJSON(t, snapshots[0], snapshots[2])
assertx.EqualAsJSONExcept(t, snapshots[1], snapshots[3], []string{"3.attributes.nonce"})
})
})
}
// TestPopulateRegistrationMethodWithoutDisplayNameTrait is a regression test:
// when the passkey method is enabled but the identity schema flags no trait as
// a passkey display name or WebAuthn identifier and offers no untitled
// fallback trait, the passkey form hydrators must skip their UI nodes instead
// of failing the whole registration flow.
func TestPopulateRegistrationMethodWithoutDisplayNameTrait(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/registration-no-passkey-titled.schema.json")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPDisplayName, "localhost")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPID, "localhost")
s, err := reg.AllRegistrationStrategies().Strategy(identity.CredentialsTypePasskey)
require.NoError(t, err)
fh, ok := s.(registration.FormHydrator)
require.True(t, ok)
newFlow := func(t *testing.T, flowType flow.Type) (*http.Request, *registration.Flow) {
t.Helper()
r := httptest.NewRequest("GET", "/self-service/registration/browser", nil)
r = r.WithContext(ctx)
f, err := registration.NewFlow(reg, r, flowType)
require.NoError(t, err)
f.UI.Nodes = make(node.Nodes, 0)
return r, f
}
assertNoPasskeyNodes := func(t *testing.T, nodes node.Nodes) {
t.Helper()
for _, n := range nodes {
assert.NotEqual(t, node.PasskeyGroup, n.Group, "unexpected passkey node: %+v", n)
assert.NotEqual(t, node.WebAuthnScript, n.ID(), "unexpected WebAuthn script node: %+v", n)
}
}
t.Run("method=PopulateRegistrationMethod", func(t *testing.T) {
for _, ft := range []flow.Type{flow.TypeBrowser, flow.TypeAPI} {
t.Run("type="+string(ft), func(t *testing.T) {
r, f := newFlow(t, ft)
require.NoError(t, fh.PopulateRegistrationMethod(r, f))
assertNoPasskeyNodes(t, f.UI.Nodes)
})
}
})
t.Run("method=PopulateRegistrationMethodCredentials", func(t *testing.T) {
r, f := newFlow(t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethodCredentials(r, f))
assertNoPasskeyNodes(t, f.UI.Nodes)
})
t.Run("method=PopulateRegistrationMethodProfile", func(t *testing.T) {
r, f := newFlow(t, flow.TypeBrowser)
require.NoError(t, fh.PopulateRegistrationMethodProfile(r, f))
assertNoPasskeyNodes(t, f.UI.Nodes)
})
}
// TestRegistrationWithoutDisplayNameTrait initializes registration flows
// end-to-end with the passkey method enabled and an identity schema that has
// no display-name candidate trait. Flow creation must succeed and offer the
// remaining methods; only the passkey UI nodes are dropped.
func TestRegistrationWithoutDisplayNameTrait(t *testing.T) {
t.Parallel()
fix := newRegistrationFixture(t, configx.WithValues(
testhelpers.DefaultIdentitySchemaConfig("file://./stub/registration-no-passkey-titled.schema.json")))
assertFlow := func(t *testing.T, f *kratos.RegistrationFlow) {
t.Helper()
var groups []string
for _, n := range f.Ui.Nodes {
groups = append(groups, n.Group)
assert.NotEqual(t, "passkey", n.Group, "unexpected passkey node: %+v", n)
}
assert.Contains(t, groups, "password")
}
t.Run("type=browser", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, false, false, false)
assertFlow(t, f)
})
t.Run("type=spa", func(t *testing.T) {
client := testhelpers.NewClientWithCookies(t)
f := testhelpers.InitializeRegistrationFlowViaBrowser(t, client, fix.publicTS, true, false, false)
assertFlow(t, f)
})
t.Run("type=api", func(t *testing.T) {
f := testhelpers.InitializeRegistrationFlowViaAPI(t, testhelpers.NewDebugClient(t), fix.publicTS)
assertFlow(t, f)
})
}

View File

@@ -0,0 +1,115 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/schema"
"github.com/ory/x/jsonschemax"
)
// errNoDisplayNameTrait is returned by PasskeyDisplayNameFromSchema when the
// identity schema flags no trait as a passkey display name or WebAuthn
// identifier and has no untitled trait to fall back to.
var errNoDisplayNameTrait = errors.New("no identifier found")
type SchemaExtension struct {
WebauthnIdentifier string
PasskeyDisplayName string
sync.Mutex
}
func (e *SchemaExtension) Run(_ jsonschema.ValidationContext, s schema.ExtensionConfig, value any) error {
e.Lock()
defer e.Unlock()
// When a schema flags multiple traits, the validator visits them in an
// unspecified order. Keep the first non-empty value so an empty flagged
// trait never clobbers a populated one. This mirrors the client, which
// names the passkey after the first non-empty candidate field.
if s.Credentials.WebAuthn.Identifier && e.WebauthnIdentifier == "" {
e.WebauthnIdentifier = strings.ToLower(fmt.Sprintf("%s", value))
}
if s.Credentials.Passkey.DisplayName && e.PasskeyDisplayName == "" {
e.PasskeyDisplayName = fmt.Sprintf("%s", value)
}
return nil
}
func (e *SchemaExtension) Finish() error { return nil }
// PasskeyDisplayNameFromIdentity returns the passkey display name from the
// identity. It is usually the email address and used to name the passkey in the
// browser.
func (s *Strategy) PasskeyDisplayNameFromIdentity(ctx context.Context, id *identity.Identity) string {
e := new(SchemaExtension)
// We can ignore teh error here because proper validation happens once the identity is persisted.
_ = s.d.IdentityValidator().ValidateWithRunner(ctx, id, e)
return cmp.Or(e.PasskeyDisplayName, e.WebauthnIdentifier)
}
func (s *Strategy) PasskeyDisplayNameFromTraits(ctx context.Context, traits identity.Traits) string {
id := identity.NewIdentity("")
id.Traits = traits
return s.PasskeyDisplayNameFromIdentity(ctx, id)
}
// PasskeyDisplayNameFromSchema returns every trait path (e.g. ["traits.email", "traits.phone"])
// whose schema flags `passkey.display_name: true` or `webauthn.identifier: true`. The slice
// is sorted alphabetically so server and client agree on precedence. When no
// trait is flagged, it preserves the legacy behavior: fall back to the first
// untitled trait, and return an error only when there is none.
func (s *Strategy) PasskeyDisplayNameFromSchema(ctx context.Context, schemaURL string) ([]string, error) {
runner, err := schema.NewExtensionRunner(ctx)
if err != nil {
return nil, err
}
c, err := schema.NewCompilerWithURL(ctx, schemaURL, s.d.Config().SecurityDisallowRefInIdentitySchemas(ctx))
if err != nil {
return nil, err
}
c.ExtractAnnotations = true
runner.Register(c)
paths, err := jsonschemax.ListPaths(ctx, schemaURL, c)
if err != nil {
return nil, err
}
var result []string
for _, p := range paths {
ext, ok := p.CustomProperties[schema.ExtensionName].(*schema.ExtensionConfig)
if !ok {
continue
}
if ext.Credentials.WebAuthn.Identifier || ext.Credentials.Passkey.DisplayName {
result = append(result, p.Name)
}
}
if len(result) > 0 {
slices.Sort(result)
return result, nil
}
for _, p := range paths {
if strings.HasPrefix(p.Name, "traits.") && p.Title == "" {
return []string{p.Name}, nil
}
}
return nil, errors.WithStack(errNoDisplayNameTrait)
}

View File

@@ -0,0 +1,98 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey_test
import (
"context"
"net/url"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/strategy/passkey"
)
func TestPasskeyDisplayNameFromSchema(t *testing.T) {
t.Parallel()
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
strategy := passkey.NewStrategy(reg)
abs := func(t *testing.T, p string) string {
t.Helper()
a, err := filepath.Abs(p)
require.NoError(t, err)
return (&url.URL{Scheme: "file", Path: a}).String()
}
t.Run("case=single trait flagged returns single path", func(t *testing.T) {
t.Parallel()
paths, err := strategy.PasskeyDisplayNameFromSchema(ctx, abs(t, "stub/registration-single-identifier.schema.json"))
require.NoError(t, err)
assert.Equal(t, []string{"traits.username"}, paths)
})
t.Run("case=multiple traits flagged returns sorted paths", func(t *testing.T) {
t.Parallel()
paths, err := strategy.PasskeyDisplayNameFromSchema(ctx, abs(t, "stub/registration-multi-identifier.schema.json"))
require.NoError(t, err)
assert.Equal(t, []string{"traits.email", "traits.phone"}, paths)
})
t.Run("case=webauthn.identifier-only flagged trait returns its path", func(t *testing.T) {
t.Parallel()
paths, err := strategy.PasskeyDisplayNameFromSchema(ctx, abs(t, "stub/registration-webauthn-only.schema.json"))
require.NoError(t, err)
assert.Equal(t, []string{"traits.username"}, paths)
})
t.Run("case=schema without flagged traits falls back to the untitled trait", func(t *testing.T) {
t.Parallel()
// Preserves the legacy behavior: with no flagged trait, the first
// untitled trait is used as the display-name source.
paths, err := strategy.PasskeyDisplayNameFromSchema(ctx, abs(t, "stub/noid.schema.json"))
require.NoError(t, err)
assert.Equal(t, []string{"traits.email"}, paths)
})
t.Run("case=schema with only titled, unflagged traits returns an error", func(t *testing.T) {
t.Parallel()
paths, err := strategy.PasskeyDisplayNameFromSchema(ctx, abs(t, "stub/registration-no-passkey-titled.schema.json"))
require.ErrorContains(t, err, "no identifier found")
assert.Nil(t, paths)
})
}
// TestPasskeyDisplayNameFromTraits_MultipleFlaggedTraits guards the resolution
// order when an identity schema flags more than one trait as a passkey
// display-name source. A flagged trait that the user left empty must not clobber
// a flagged trait that has a value, regardless of the order the validator visits
// the traits. This mirrors the client-side behavior, which picks the first
// non-empty candidate field.
func TestPasskeyDisplayNameFromTraits_MultipleFlaggedTraits(t *testing.T) {
t.Parallel()
ctx := context.Background()
conf, reg := pkg.NewFastRegistryWithMocks(t)
testhelpers.SetDefaultIdentitySchema(conf, "file://./stub/registration-multi-identifier.schema.json")
strategy := passkey.NewStrategy(reg)
t.Run("case=keeps the non-empty candidate when a later flagged trait is empty", func(t *testing.T) {
t.Parallel()
name := strategy.PasskeyDisplayNameFromTraits(ctx, identity.Traits(`{"email":"alice@example.com","phone":""}`))
assert.Equal(t, "alice@example.com", name)
})
t.Run("case=keeps the non-empty candidate when an earlier flagged trait is empty", func(t *testing.T) {
t.Parallel()
name := strategy.PasskeyDisplayNameFromTraits(ctx, identity.Traits(`{"email":"","phone":"+15555550123"}`))
assert.Equal(t, "+15555550123", name)
})
}

View File

@@ -0,0 +1,444 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"net/http"
"strings"
"go.opentelemetry.io/otel/attribute"
"github.com/ory/x/otelx"
"github.com/ory/kratos/x/webauthnx/js"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/ory/herodot"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/flow/settings"
"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/webauthnx"
"github.com/ory/x/decoderx"
"github.com/ory/x/randx"
"github.com/ory/x/sqlcon"
"github.com/ory/x/sqlxx"
)
func (s *Strategy) SettingsStrategyID() string { return s.ID().String() }
const (
InternalContextKeySessionData = "session_data"
InternalContextKeySessionOptions = "session_options"
)
func (s *Strategy) PopulateSettingsMethod(ctx context.Context, r *http.Request, id *identity.Identity, f *settings.Flow) (err error) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.passkey.Strategy.PopulateSettingsMethod")
defer otelx.End(span, &err)
f.UI.SetCSRF(s.d.GenerateCSRFToken(r))
count, err := s.d.IdentityManager().CountActiveFirstFactorCredentials(ctx, id)
if err != nil {
return err
}
webAuthns, err := s.identityListWebAuthn(id)
if err != nil && !errors.Is(err, sqlcon.ErrNoRows()) {
return err
}
var registrationOptions []webauthn.RegistrationOption
if webAuthns != nil {
exclude := make([]protocol.CredentialDescriptor, 0, len(webAuthns.Credentials))
for k := range webAuthns.Credentials {
// We only show the option to remove a credential, if it is not the last one when passwordless,
// or, if it is for MFA we show it always.
cred := &webAuthns.Credentials[k]
f.UI.Nodes.Append(webauthnx.NewPasskeyUnlink(cred, func(a *node.InputAttributes) {
// Do not remove this node if it is the last credential the identity can sign in with.
a.Disabled = count < 2
}))
exclude = append(exclude, cred.ToWebAuthn().Descriptor())
}
// Exclude every registered passkey: platform authenticators overwrite their stored entry on
// re-enrollment while Kratos keeps both server-side (WebAuthn Level 2, section 5.4.3).
registrationOptions = append(registrationOptions, webauthn.WithExclusions(exclude))
}
web, err := webauthn.New(s.d.Config().PasskeyConfig(ctx))
if err != nil {
return errors.WithStack(err)
}
identifier := s.PasskeyDisplayNameFromIdentity(ctx, id)
if identifier == "" {
f.UI.Messages.Add(text.NewErrorValidationIdentifierMissing())
return nil
}
user := &webauthnx.User{
Name: identifier,
ID: []byte(randx.MustString(64, randx.AlphaNum)),
Config: s.d.Config().PasskeyConfig(ctx),
}
option, sessionData, err := web.BeginRegistration(user, registrationOptions...)
if err != nil {
return errors.WithStack(err)
}
f.InternalContext, err = sjson.SetBytes(f.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData), sessionData)
if err != nil {
return errors.WithStack(err)
}
injectWebAuthnOptions, err := json.Marshal(option)
if err != nil {
return errors.WithStack(err)
}
if f.Type == flow.TypeBrowser {
f.UI.Nodes.Upsert(webauthnx.NewWebAuthnScript(s.d.Config().SelfPublicURL(ctx)))
}
f.UI.Nodes.Upsert(node.NewInputField(
node.PasskeyRegisterTrigger,
"",
node.PasskeyGroup,
node.InputAttributeTypeButton,
node.WithInputAttributes(func(a *node.InputAttributes) {
a.OnClickTrigger = js.WebAuthnTriggersPasskeySettingsRegistration
// Only attach raw JS handler for browser flows; API flows must stay script-free.
if f.Type == flow.TypeBrowser {
//nolint:staticcheck
a.OnClick = js.WebAuthnTriggersPasskeySettingsRegistration.String() + "()"
}
}),
).WithMetaLabel(text.NewInfoSelfServiceSettingsRegisterPasskey()))
f.UI.Nodes.Upsert(&node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: &node.InputAttributes{
Name: node.PasskeySettingsRegister,
Type: node.InputAttributeTypeHidden,
},
})
f.UI.Nodes.Upsert(&node.Node{
Type: node.Input,
Group: node.PasskeyGroup,
Meta: &node.Meta{},
Attributes: &node.InputAttributes{
Name: node.PasskeyCreateData,
Type: node.InputAttributeTypeHidden,
FieldValue: string(injectWebAuthnOptions),
},
})
return nil
}
func (s *Strategy) identityListWebAuthn(id *identity.Identity) (*identity.CredentialsWebAuthnConfig, error) {
cred, ok := id.GetCredentials(s.ID())
if !ok {
return nil, errors.WithStack(sqlcon.ErrNoRows())
}
var cc identity.CredentialsWebAuthnConfig
if err := json.Unmarshal(cred.Config, &cc); err != nil {
return nil, errors.WithStack(err)
}
return &cc, nil
}
func (s *Strategy) Settings(ctx context.Context, w http.ResponseWriter, r *http.Request, f *settings.Flow, ss *session.Session) (_ *settings.UpdateContext, err error) {
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.passkey.Strategy.Settings")
defer otelx.End(span, &err)
var p updateSettingsFlowWithPasskeyMethod
ctxUpdate, err := settings.PrepareUpdate(s.d, w, r, f, ss, settings.ContinuityKey(s.SettingsStrategyID()), &p)
if errors.Is(err, settings.ErrContinuePreviousAction) {
return ctxUpdate, s.continueSettingsFlow(ctx, w, r, ctxUpdate, p)
} else if err != nil {
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
if err := s.decodeSettingsFlow(r, &p); err != nil {
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
if len(p.Register)+len(p.Remove) > 0 {
// This method has only two submit buttons
p.Method = s.SettingsStrategyID()
if err := flow.MethodEnabledAndAllowed(ctx, f.GetFlowName(), s.SettingsStrategyID(), p.Method, s.d); err != nil {
return nil, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
} else {
span.SetAttributes(attribute.String("not_responsible_reason", "neither register nor remove provided"))
return nil, errors.WithStack(flow.ErrStrategyNotResponsible)
}
// This does not come from the payload!
p.Flow = ctxUpdate.Flow.ID.String()
if err := s.continueSettingsFlow(ctx, w, r, ctxUpdate, p); err != nil {
return ctxUpdate, s.handleSettingsError(ctx, w, r, ctxUpdate, p, err)
}
return ctxUpdate, nil
}
// Update Settings Flow with Passkey Method
//
// swagger:model updateSettingsFlowWithPasskeyMethod
type updateSettingsFlowWithPasskeyMethod struct {
// Register a WebAuthn Security Key
//
// It is expected that the JSON returned by the WebAuthn registration process
// is included here.
Register string `json:"passkey_settings_register"`
// Remove a WebAuthn Security Key
//
// This must contain the ID of the WebAuthN connection.
Remove string `json:"passkey_remove"`
// CSRFToken is the anti-CSRF token
CSRFToken string `json:"csrf_token"`
// Method
//
// Should be set to "passkey" when trying to add, update, or remove a webAuthn pairing.
//
// required: true
Method string `json:"method"`
// Flow is flow ID.
//
// swagger:ignore
Flow string `json:"flow"`
}
func (p *updateSettingsFlowWithPasskeyMethod) GetFlowID() uuid.UUID {
return x.ParseUUID(p.Flow)
}
func (p *updateSettingsFlowWithPasskeyMethod) SetFlowID(rid uuid.UUID) {
p.Flow = rid.String()
}
func (s *Strategy) continueSettingsFlow(
ctx context.Context,
w http.ResponseWriter, r *http.Request,
ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasskeyMethod,
) error {
if len(p.Register+p.Remove) > 0 {
if err := flow.MethodEnabledAndAllowed(ctx, flow.SettingsFlow, s.SettingsStrategyID(), s.SettingsStrategyID(), s.d); err != nil {
return err
}
if err := flow.EnsureCSRF(s.d, r, ctxUpdate.Flow.Type, s.d.Config().DisableAPIFlowEnforcement(ctx), s.d.GenerateCSRFToken, p.CSRFToken); err != nil {
return err
}
if !s.d.SessionManager().IsPrivileged(ctx, ctxUpdate.Session) {
return errors.WithStack(settings.NewFlowNeedsReAuth())
}
} else {
return errors.New("ended up in unexpected state")
}
switch {
case len(p.Remove) > 0:
return s.continueSettingsFlowRemove(ctx, w, r, ctxUpdate, p)
case len(p.Register) > 0:
return s.continueSettingsFlowAdd(ctx, ctxUpdate, p)
default:
return errors.New("ended up in unexpected state")
}
}
func (s *Strategy) continueSettingsFlowRemove(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasskeyMethod) error {
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.IdentityID)
if err != nil {
return err
}
cred, ok := i.GetCredentials(s.ID())
if !ok {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("You tried to remove a WebAuthn but you have no WebAuthn set up."))
}
var cc identity.CredentialsWebAuthnConfig
if err := json.Unmarshal(cred.Config, &cc); err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to decode identity credentials.").WithDebug(err.Error()))
}
updated := make([]identity.CredentialWebAuthn, 0)
for k, cred := range cc.Credentials {
if fmt.Sprintf("%x", cred.ID) != p.Remove {
updated = append(updated, cc.Credentials[k])
}
}
if len(updated) == len(cc.Credentials) {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("You tried to remove a passkey which does not exist."))
}
count, err := s.d.IdentityManager().CountActiveFirstFactorCredentials(ctx, i)
if err != nil {
return err
}
if count < 2 {
return s.handleSettingsError(ctx, w, r, ctxUpdate, p, errors.WithStack(webauthnx.ErrNotEnoughCredentials()))
}
if len(updated) == 0 {
i.DeleteCredentialsType(identity.CredentialsTypePasskey)
ctxUpdate.UpdateIdentity(i)
return nil
}
cc.Credentials = updated
cred.Config, err = json.Marshal(cc)
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode identity credentials.").WithDebug(err.Error()))
}
i.SetCredentials(s.ID(), *cred)
ctxUpdate.UpdateIdentity(i)
return nil
}
func (s *Strategy) continueSettingsFlowAdd(ctx context.Context, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasskeyMethod) error {
webAuthnSession := gjson.GetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData))
if !webAuthnSession.IsObject() {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN in internal context to be an object."))
}
var webAuthnSess webauthn.SessionData
if err := json.Unmarshal([]byte(gjson.GetBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData)).Raw), &webAuthnSess); err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Expected WebAuthN in internal context to be an object but got: %s", err))
}
webAuthnResponse, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(p.Register))
if err != nil {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse WebAuthn response: %s", err))
}
web, err := webauthn.New(s.d.Config().PasskeyConfig(ctx))
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to get webAuthn config.").WithDebug(err.Error()))
}
credential, err := web.CreateCredential(&webauthnx.User{
ID: webAuthnSess.UserID,
Config: web.Config,
}, webAuthnSess, webAuthnResponse)
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to create WebAuthn credential: %s", err))
}
i, err := s.d.PrivilegedIdentityPool().GetIdentityConfidential(ctx, ctxUpdate.Session.IdentityID)
if err != nil {
return err
}
cred := i.GetCredentialsOr(s.ID(), &identity.Credentials{Config: sqlxx.JSONRawMessage("{}")})
var cc identity.CredentialsWebAuthnConfig
if err := json.Unmarshal(cred.Config, &cc); err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to decode identity credentials.").WithDebug(err.Error()))
}
for k := range cc.Credentials {
if bytes.Equal(cc.Credentials[k].ID, credential.ID) {
return errors.WithStack(webauthnx.ErrCredentialAlreadyRegistered("#/" + node.PasskeySettingsRegister))
}
}
credentialWebAuthn := identity.CredentialFromWebAuthn(credential, true)
cc.UserHandle = webAuthnSess.UserID
cc.Credentials = append(cc.Credentials, *credentialWebAuthn)
credentialsConfig, err := json.Marshal(cc)
if err != nil {
return errors.WithStack(herodot.ErrInternalServerError().WithReasonf("Unable to encode identity credentials.").WithDebug(err.Error()))
}
i.UpsertCredentialsConfig(s.ID(), credentialsConfig, 1, identity.WithAdditionalIdentifier(string(webAuthnSess.UserID)))
if err := s.validateCredentials(ctx, i); err != nil {
return err
}
// Remove the WebAuthn URL from the internal context now that it is set!
ctxUpdate.Flow.InternalContext, err = sjson.DeleteBytes(ctxUpdate.Flow.InternalContext, flow.PrefixInternalContextKey(s.ID(), InternalContextKeySessionData))
if err != nil {
return err
}
if err := s.d.SettingsFlowPersister().UpdateSettingsFlow(ctx, ctxUpdate.Flow); err != nil {
return err
}
aal := identity.AuthenticatorAssuranceLevel1
// Since we added the method, it also means that we have authenticated it
if err := s.d.SessionManager().SessionAddAuthenticationMethods(ctx, ctxUpdate.Session.ID, session.AuthenticationMethod{
Method: s.ID(),
AAL: aal,
}); err != nil {
return err
}
ctxUpdate.UpdateIdentity(i)
return nil
}
func (s *Strategy) decodeSettingsFlow(r *http.Request, dest interface{}) error {
compiler, err := decoderx.HTTPRawJSONSchemaCompiler(settingsSchema)
if err != nil {
return errors.WithStack(err)
}
return decoderx.Decode(r, dest, compiler,
decoderx.HTTPKeepRequestBody(true),
decoderx.HTTPDecoderAllowedMethods("POST", "GET"),
decoderx.HTTPDecoderSetValidatePayloads(true),
decoderx.HTTPDecoderJSONFollowsFormFormat(),
)
}
func (s *Strategy) handleSettingsError(ctx context.Context, w http.ResponseWriter, r *http.Request, ctxUpdate *settings.UpdateContext, p updateSettingsFlowWithPasskeyMethod, err error) error {
// Do not pause flow if the flow type is an API flow as we can't save cookies in those flows.
if e := new(settings.FlowNeedsReAuth); errors.As(err, &e) && ctxUpdate.Flow != nil && ctxUpdate.Flow.Type == flow.TypeBrowser {
if _, err := s.d.ContinuityManager().Pause(ctx, w, r, settings.ContinuityKey(s.SettingsStrategyID()), continuity.NewCookieReferenceStore(s.d.ContinuityCookieManager(ctx)), settings.ContinuityOptions(p, ctxUpdate.GetSessionIdentity())...); err != nil {
return err
}
}
if ctxUpdate.Flow != nil {
ctxUpdate.Flow.UI.ResetMessages()
ctxUpdate.Flow.UI.SetCSRF(s.d.GenerateCSRFToken(r))
}
return err
}

View File

@@ -0,0 +1,679 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey_test
import (
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/x/configx"
"github.com/ory/kratos/selfservice/flow"
"github.com/ory/kratos/selfservice/strategy/passkey"
"github.com/ory/x/snapshotx"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/ory/kratos/selfservice/flow/settings"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
"github.com/stretchr/testify/require"
"github.com/ory/x/assertx"
"github.com/ory/x/contextx"
"github.com/ory/x/sqlxx"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/x"
)
//go:embed fixtures/settings/success/identity.json
var settingsFixtureSuccessIdentity []byte
//go:embed fixtures/settings/success/response.json
var settingsFixtureSuccessResponse []byte
//go:embed fixtures/settings/success/internal_context.json
var settingsFixtureSuccessInternalContext []byte
func TestCompleteSettings(t *testing.T) {
fix := newSettingsFixture(t)
t.Run("case=invalid passkey config", func(t *testing.T) {
fix := newSettingsFixture(t, configx.WithValue(config.ViperKeyPasskeyRPID, ""))
id := fix.createIdentity(t)
apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, id)
req, err := http.NewRequest("GET", fix.publicTS.URL+settings.RouteInitBrowserFlow, nil)
require.NoError(t, err)
req.Header.Set("Accept", "application/json")
res, err := apiClient.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusInternalServerError, res.StatusCode)
})
t.Run("case=a device is shown which can be unlinked", func(t *testing.T) {
id := fix.createIdentity(t)
apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, fix.publicTS)
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value", // CSRF
"1.attributes.nonce", // script
"1.attributes.src", // script
"6.attributes.value", // passkey_settings_register
})
})
t.Run("case=one activation element is shown", func(t *testing.T) {
id := fix.createIdentityWithoutPasskey(t)
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id))
apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, fix.publicTS)
testhelpers.SnapshotTExcept(t, f.Ui.Nodes, []string{
"0.attributes.value", // CSRF
"1.attributes.nonce", // script
"1.attributes.src", // script
"4.attributes.value", // passkey_create_data
})
})
t.Run("case=registration options exclude already registered passkeys", func(t *testing.T) {
id := fix.createIdentity(t)
apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, fix.publicTS)
nodes, err := json.Marshal(f.Ui.Nodes)
require.NoError(t, err)
options := gjson.GetBytes(nodes, `#(attributes.name=="passkey_create_data").attributes.value`).String()
exclude := gjson.Get(options, "publicKey.excludeCredentials").Array()
require.Len(t, exclude, 2, options)
assert.Equal(t, "public-key", exclude[0].Get("type").String())
assert.Equal(t, "Zm9vZm9v", exclude[0].Get("id").String())
assert.Equal(t, "YmFyYmFy", exclude[1].Get("id").String())
})
t.Run("case=registration options have no exclusions without passkeys", func(t *testing.T) {
id := fix.createIdentityWithoutPasskey(t)
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id))
apiClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, apiClient, true, fix.publicTS)
nodes, err := json.Marshal(f.Ui.Nodes)
require.NoError(t, err)
options := gjson.GetBytes(nodes, `#(attributes.name=="passkey_create_data").attributes.value`).String()
require.NotEmpty(t, options)
assert.False(t, gjson.Get(options, "publicKey.excludeCredentials").Exists(), options)
})
t.Run("case=passkey nodes exist for API but without browser script", func(t *testing.T) {
id := fix.createIdentityWithoutPasskey(t)
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id))
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, fix.reg, id)
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, fix.publicTS)
// Passkey nodes should exist for API
hasPasskeyGroup := false
hasWebAuthnScript := false
for _, n := range f.Ui.Nodes {
if n.Group == "passkey" {
hasPasskeyGroup = true
}
if n.Type == "script" {
hasWebAuthnScript = true
}
}
assert.True(t, hasPasskeyGroup, "passkey group should be present for API flows")
assert.False(t, hasWebAuthnScript, "WebAuthn script should not be present for API flows")
})
doAPIFlow := func(t *testing.T, v func(url.Values), id *identity.Identity) (string, *http.Response) {
apiClient := testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, fix.reg, id)
f := testhelpers.InitializeSettingsFlowViaAPI(t, apiClient, fix.publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
v(values)
payload := testhelpers.EncodeFormAsJSON(t, true, values)
return testhelpers.SettingsMakeRequest(t, true, false, f, apiClient, payload)
}
doBrowserFlow := func(t *testing.T, spa bool, v func(url.Values), id *identity.Identity) (string, *http.Response) {
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, id)
f := testhelpers.InitializeSettingsFlowViaBrowser(t, browserClient, spa, fix.publicTS)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
v(values)
return testhelpers.SettingsMakeRequest(t, false, spa, f, browserClient, testhelpers.EncodeFormAsJSON(t, spa, values))
}
t.Run("case=fails with invalid passkey payload for API", func(t *testing.T) {
id := fix.createIdentityWithoutPasskey(t)
body, res := doAPIFlow(t, func(v url.Values) {
v.Set(node.PasskeySettingsRegister, "{}")
v.Set("method", "passkey")
}, id)
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
// API should process the request but fail on invalid WebAuthn payload
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "Parse error for Registration", "%s", body)
})
t.Run("case=fails with browser submit because csrf token is missing", func(t *testing.T) {
run := func(t *testing.T, spa bool) {
id := fix.createIdentityWithoutPasskey(t)
body, res := doBrowserFlow(t, spa, func(v url.Values) {
v.Del("csrf_token")
v.Set(node.PasskeySettingsRegister, "{}")
v.Set("method", "passkey")
}, id)
if spa {
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "error.reason").String(), body)
} else {
assert.Contains(t, res.Request.URL.String(), fix.errTS.URL)
assert.Equal(t, nosurfx.ErrInvalidCSRFToken().Reason(), gjson.Get(body, "reason").String(), body)
}
}
t.Run("type=browser", func(t *testing.T) {
run(t, false)
})
t.Run("type=spa", func(t *testing.T) {
run(t, true)
})
t.Run("type=api", func(t *testing.T) {
// API should work without CSRF token
id := fix.createIdentityWithoutPasskey(t)
body, res := doAPIFlow(t, func(v url.Values) {
v.Del("csrf_token")
v.Set(node.PasskeySettingsRegister, "{}")
v.Set("method", "passkey")
}, id)
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
// API should process and fail on invalid payload, not CSRF
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "Parse error for Registration", "%s", body)
})
})
t.Run("case=fails when register payload is invalid", func(t *testing.T) {
run := func(t *testing.T, spa bool) {
id := fix.createIdentityWithoutPasskey(t)
body, res := doBrowserFlow(t, spa, func(v url.Values) {
v.Set(node.PasskeySettingsRegister, "{}")
v.Set("method", "passkey")
}, id)
if spa {
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
} else {
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL)
}
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "Parse error for Registration", "%s", body)
}
t.Run("type=browser", func(t *testing.T) {
run(t, false)
})
t.Run("type=spa", func(t *testing.T) {
run(t, true)
})
t.Run("type=api", func(t *testing.T) {
id := fix.createIdentityWithoutPasskey(t)
body, res := doAPIFlow(t, func(v url.Values) {
v.Set(node.PasskeySettingsRegister, "{}")
v.Set("method", "passkey")
}, id)
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
assert.Contains(t, gjson.Get(body, "ui.messages.0.text").String(), "Parse error for Registration", "%s", body)
})
})
t.Run("case=requires privileged session for register", func(t *testing.T) {
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "1ns")
t.Cleanup(func() {
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter, "5m")
})
run := func(t *testing.T, spa bool) {
id := fix.createIdentityWithoutPasskey(t)
body, res := doBrowserFlow(t, spa, func(v url.Values) {
v.Set(node.PasskeySettingsRegister, "{}")
v.Set("method", "passkey")
}, id)
if spa {
assert.NotEmpty(t, gjson.Get(body, "redirect_browser_to").String())
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assertx.EqualAsJSONExcept(t, settings.NewFlowNeedsReAuth(), json.RawMessage(body), []string{"redirect_browser_to"})
} else {
assert.Contains(t, res.Request.URL.String(), fix.loginTS.URL+"/login-ts")
assertx.EqualAsJSON(t, text.NewInfoLoginReAuth(), json.RawMessage(gjson.Get(body, "ui.messages.0").Raw))
}
}
t.Run("type=browser", func(t *testing.T) {
run(t, false)
})
t.Run("type=spa", func(t *testing.T) {
run(t, true)
})
t.Run("type=api", func(t *testing.T) {
id := fix.createIdentityWithoutPasskey(t)
body, res := doAPIFlow(t, func(v url.Values) {
v.Set(node.PasskeySettingsRegister, "{}")
v.Set("method", "passkey")
}, id)
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
// API flow should fail with 403 Forbidden due to insufficient privileges
assert.Equal(t, http.StatusForbidden, res.StatusCode)
assertx.EqualAsJSON(t, settings.NewFlowNeedsReAuth().DefaultError, json.RawMessage(gjson.Get(body, "error").Raw))
})
})
t.Run("case=add a passkey", func(t *testing.T) {
run := func(t *testing.T, flowType string) {
// We load our identity which we will use to replay the webauth session
var id identity.Identity
require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id))
id.NID = x.NewUUID()
_ = fix.reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID)
var f *kratos.SettingsFlow
var client *http.Client
var body string
var res *http.Response
if flowType == "api" {
client = testhelpers.NewHTTPClientWithIdentitySessionToken(t.Context(), t, fix.reg, &id)
f = testhelpers.InitializeSettingsFlowViaAPI(t, client, fix.publicTS)
} else {
client = testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, &id)
f = testhelpers.InitializeSettingsFlowViaBrowser(t, client, flowType == "spa", fix.publicTS)
}
// We inject the session to replay
interim, err := fix.reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = settingsFixtureSuccessInternalContext
require.NoError(t, fix.reg.SettingsFlowPersister().UpdateSettingsFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
// We use the response replay
values.Set("method", "passkey")
values.Set(node.PasskeySettingsRegister, string(settingsFixtureSuccessResponse))
if flowType == "api" {
body, res = testhelpers.SettingsMakeRequest(t, true, false, f, client, testhelpers.EncodeFormAsJSON(t, true, values))
} else {
body, res = testhelpers.SettingsMakeRequest(t, false, flowType == "spa", f, client, testhelpers.EncodeFormAsJSON(t, flowType == "spa", values))
}
require.Equal(t, http.StatusOK, res.StatusCode, "%s", body)
switch flowType {
case "spa":
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
case "browser":
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL)
}
// For API, no redirect check needed
assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body)
actual, err := fix.reg.Persister().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
cred, ok := actual.GetCredentials(identity.CredentialsTypePasskey)
require.True(t, ok)
assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1)
actualFlow, err := fix.reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
// new session data has been generated
assert.NotEqual(t, settingsFixtureSuccessInternalContext,
gjson.GetBytes(actualFlow.InternalContext,
flow.PrefixInternalContextKey(identity.CredentialsTypePasskey, passkey.InternalContextKeySessionData)))
testhelpers.EnsureAAL(t, client, fix.publicTS, "aal1", string(identity.CredentialsTypePasskey))
if flowType == "spa" {
assert.EqualValues(t, flow.ContinueWithActionRedirectBrowserToString, gjson.Get(body, "continue_with.0.action").String(), "%s", body)
assert.Contains(t, gjson.Get(body, "continue_with.0.redirect_browser_to").String(), fix.uiTS.URL, "%s", body)
} else {
assert.Empty(t, gjson.Get(body, "continue_with").Array(), "%s", body)
}
}
t.Run("type=browser", func(t *testing.T) {
run(t, "browser")
})
t.Run("type=spa", func(t *testing.T) {
run(t, "spa")
})
t.Run("type=api", func(t *testing.T) {
run(t, "api")
})
})
t.Run("case=fails to add a passkey that is already registered", func(t *testing.T) {
// We load our identity which we will use to replay the webauth session
var id identity.Identity
require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id))
id.NID = x.NewUUID()
_ = fix.reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID)
client := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, &id)
// Seed the identity with the same credential ID the replayed response will try to register.
credID, err := base64.RawURLEncoding.DecodeString(gjson.GetBytes(settingsFixtureSuccessResponse, "rawId").String())
require.NoError(t, err)
conf, err := json.Marshal(identity.CredentialsWebAuthnConfig{
Credentials: identity.CredentialsWebAuthn{{ID: credID, DisplayName: "existing passkey"}},
})
require.NoError(t, err)
confidential, err := fix.reg.PrivilegedIdentityPool().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
confidential.SetCredentials(identity.CredentialsTypePasskey, identity.Credentials{
Type: identity.CredentialsTypePasskey,
Config: conf,
})
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), confidential))
f := testhelpers.InitializeSettingsFlowViaBrowser(t, client, true, fix.publicTS)
// We inject the session to replay
interim, err := fix.reg.SettingsFlowPersister().GetSettingsFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = settingsFixtureSuccessInternalContext
require.NoError(t, fix.reg.SettingsFlowPersister().UpdateSettingsFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
// The existing passkey's remove button is disabled (it is the identity's only first-factor
// credential), so a real browser would not submit its value. Unlike a browser, the test
// helper copies every node's value regardless of the disabled attribute, so drop it here to
// avoid accidentally triggering the remove path instead of the add path under test.
values.Del(node.PasskeyRemove)
values.Set("method", "passkey")
values.Set(node.PasskeySettingsRegister, string(settingsFixtureSuccessResponse))
body, res := testhelpers.SettingsMakeRequest(t, false, true, f, client, testhelpers.EncodeFormAsJSON(t, true, values))
assert.Equal(t, http.StatusBadRequest, res.StatusCode, body)
assert.Contains(t, body, "already registered", body)
actual, err := fix.reg.Persister().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
cred, ok := actual.GetCredentials(identity.CredentialsTypePasskey)
require.True(t, ok)
assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1, "the duplicate credential must not be persisted")
})
t.Run("case=fails to remove passkey if it is the last credential available", func(t *testing.T) {
run := func(t *testing.T, flowType string) {
id := fix.createIdentity(t)
id.DeleteCredentialsType(identity.CredentialsTypePassword)
conf := sqlxx.JSONRawMessage(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo","is_passwordless":true}]}`)
id.UpsertCredentialsConfig(identity.CredentialsTypePasskey, conf, 0)
require.NoError(t, fix.reg.IdentityManager().Update(t.Context(), id, identity.ManagerAllowWriteProtectedTraits))
var body string
var res *http.Response
if flowType == "api" {
body, res = doAPIFlow(t, func(v url.Values) {
// The remove key should be empty
snapshotx.SnapshotT(t, v, snapshotx.ExceptPaths("csrf_token", "passkey_create_data"))
v.Set(node.PasskeyRemove, "666f6f666f6f")
}, id)
} else {
body, res = doBrowserFlow(t, flowType == "spa", func(v url.Values) {
// The remove key should be empty
snapshotx.SnapshotT(t, v, snapshotx.ExceptPaths("csrf_token", "passkey_create_data"))
v.Set(node.PasskeyRemove, "666f6f666f6f")
}, id)
}
switch flowType {
case "spa":
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
case "browser":
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL)
}
// For API, no redirect check needed
t.Run("response", func(t *testing.T) {
assert.EqualValues(t, flow.StateShowForm, gjson.Get(body, "state").String(), body)
snapshotx.SnapshotT(t, json.RawMessage(gjson.Get(body, "ui.nodes.#(attributes.name==passkey_remove)").String()))
actual, err := fix.reg.Persister().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
cred, ok := actual.GetCredentials(identity.CredentialsTypePasskey)
assert.True(t, ok)
assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1)
})
}
t.Run("type=browser", func(t *testing.T) {
run(t, "browser")
})
t.Run("type=spa", func(t *testing.T) {
run(t, "spa")
})
t.Run("type=api", func(t *testing.T) {
run(t, "api")
})
})
t.Run("case=remove all passkeys", func(t *testing.T) {
run := func(t *testing.T, flowType string) {
id := fix.createIdentity(t)
allCred, ok := id.GetCredentials(identity.CredentialsTypePasskey)
assert.True(t, ok)
var cc identity.CredentialsWebAuthnConfig
require.NoError(t, json.Unmarshal(allCred.Config, &cc))
require.Len(t, cc.Credentials, 2)
for _, cred := range cc.Credentials {
var body string
var res *http.Response
if flowType == "api" {
body, res = doAPIFlow(t, func(v url.Values) {
v.Set(node.PasskeyRemove, fmt.Sprintf("%x", cred.ID))
}, id)
} else {
body, res = doBrowserFlow(t, flowType == "spa", func(v url.Values) {
v.Set(node.PasskeyRemove, fmt.Sprintf("%x", cred.ID))
}, id)
}
switch flowType {
case "spa":
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
case "browser":
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL)
}
// For API, no redirect check needed
assert.EqualValues(t, flow.StateSuccess, gjson.Get(body, "state").String(), body)
}
actual, err := fix.reg.Persister().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
_, ok = actual.GetCredentials(identity.CredentialsTypePasskey)
assert.False(t, ok)
// Check not to remove other credentials with webauthn
_, ok = actual.GetCredentials(identity.CredentialsTypePassword)
assert.True(t, ok)
}
t.Run("type=browser", func(t *testing.T) {
run(t, "browser")
})
t.Run("type=spa", func(t *testing.T) {
run(t, "spa")
})
t.Run("type=api", func(t *testing.T) {
run(t, "api")
})
})
t.Run("case=fails with browser submit register payload is invalid", func(t *testing.T) {
run := func(t *testing.T, spa bool) {
id := fix.createIdentity(t)
body, res := doBrowserFlow(t, spa, func(v url.Values) {
v.Set(node.PasskeyRemove, fmt.Sprintf("%x", []byte("foofoo")))
}, id)
if spa {
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
} else {
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL)
}
assert.EqualValues(t, flow.StateSuccess, json.RawMessage(gjson.Get(body, "state").String()))
actual, err := fix.reg.Persister().GetIdentityConfidential(t.Context(), id.ID)
require.NoError(t, err)
cred, ok := actual.GetCredentials(identity.CredentialsTypePasskey)
assert.True(t, ok)
assert.Len(t, gjson.GetBytes(cred.Config, "credentials").Array(), 1)
assert.Equal(t, "bar", gjson.GetBytes(cred.Config, "credentials.0.display_name").String())
}
t.Run("type=browser", func(t *testing.T) {
run(t, false)
})
t.Run("type=spa", func(t *testing.T) {
run(t, true)
})
})
t.Run("case=is not responsible if neither remove or register is set", func(t *testing.T) {
run := func(t *testing.T, spa bool) {
id := fix.createIdentity(t)
body, res := doBrowserFlow(t, spa, func(v url.Values) {
v.Set(node.PasskeyRemove, "")
v.Set(node.PasskeyRegister, "")
}, id)
if spa {
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+settings.RouteSubmitFlow)
} else {
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL)
}
assert.Equal(t, text.NewErrorValidationSettingsNoStrategyFound().Text, gjson.Get(body, "ui.messages.0.text").String(), "%s", body)
}
t.Run("type=browser", func(t *testing.T) {
run(t, false)
})
t.Run("type=spa", func(t *testing.T) {
run(t, true)
})
})
t.Run("case=should fail if no identifier was set in the schema", func(t *testing.T) {
testhelpers.SetDefaultIdentitySchema(fix.conf, "file://stub/missing-identifier.schema.json")
for _, f := range []string{"spa", "browser"} {
t.Run("type="+f, func(t *testing.T) {
isSPA := f == "spa"
var id identity.Identity
require.NoError(t, json.Unmarshal(settingsFixtureSuccessIdentity, &id))
_ = fix.reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID)
id.NID = x.NewUUID()
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(t.Context(), t, fix.reg, &id)
req, err := http.NewRequest("GET", fix.publicTS.URL+settings.RouteInitBrowserFlow, nil)
require.NoError(t, err)
if isSPA {
req.Header.Set("Accept", "application/json")
}
res, err := browserClient.Do(req)
require.NoError(t, err)
actual := x.MustReadAll(res.Body)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, text.NewErrorValidationIdentifierMissing().Text, gjson.GetBytes(actual, "ui.messages.0.text").String(), "%s", actual)
})
}
})
}
func TestPopulateSettingsMethod(t *testing.T) {
ctx := context.Background()
_, reg := pkg.NewFastRegistryWithMocks(t)
ctx = testhelpers.WithDefaultIdentitySchema(ctx, "file://stub/settings.schema.json")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPDisplayName, "localhost")
ctx = contextx.WithConfigValue(ctx, config.ViperKeyPasskeyRPID, "localhost")
s, err := reg.AllSettingsStrategies().Strategy(string(identity.CredentialsTypePasskey))
require.NoError(t, err)
toSnapshot := func(t *testing.T, f node.Nodes, except ...snapshotx.Opt) {
t.Helper()
// The CSRF token has a unique value that messes with the snapshot - ignore it.
f.ResetNodes("csrf_token")
snapshotx.SnapshotT(t, f, append(except, snapshotx.ExceptNestedKeys("nonce", "src"))...)
}
newFlowWithType := func(ctx context.Context, t *testing.T, flowType flow.Type) (*http.Request, *settings.Flow, *identity.Identity) {
r := httptest.NewRequest("GET", "/self-service/settings/browser", nil)
r = r.WithContext(ctx)
t.Helper()
id := identity.NewIdentity("default")
id.Traits = identity.Traits(`{"email":"testuser@ory.sh"}`)
f, err := settings.NewFlow(reg, r, id, flowType)
f.UI.Nodes = make(node.Nodes, 0)
require.NoError(t, err)
return r, f, id
}
t.Run("method=PopulateSettingsMethod", func(t *testing.T) {
t.Run("type=browser", func(t *testing.T) {
r, f, id := newFlowWithType(ctx, t, flow.TypeBrowser)
require.NoError(t, s.PopulateSettingsMethod(ctx, r, id, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("4.attributes.value"))
})
t.Run("type=api", func(t *testing.T) {
r, f, id := newFlowWithType(ctx, t, flow.TypeAPI)
require.NoError(t, s.PopulateSettingsMethod(ctx, r, id, f))
toSnapshot(t, f.UI.Nodes, snapshotx.ExceptPaths("3.attributes.value"))
})
})
}

View File

@@ -0,0 +1,117 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey
import (
"context"
"encoding/json"
"strings"
"github.com/ory/kratos/x/nosurfx"
"github.com/pkg/errors"
"github.com/ory/kratos/continuity"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/selfservice/errorx"
"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"
"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"
)
type dependencies interface {
logrusx.Provider
httpx.WriterProvider
nosurfx.CSRFTokenGeneratorProvider
nosurfx.CSRFProvider
otelx.Provider
config.Provider
continuity.ManagementProvider
x.CookieProvider
errorx.ManagementProvider
hash.HashProvider
registration.HandlerProvider
registration.HooksProvider
registration.ErrorHandlerProvider
registration.HookExecutorProvider
registration.FlowPersistenceProvider
login.HooksProvider
login.ErrorHandlerProvider
login.HookExecutorProvider
login.FlowPersistenceProvider
login.HandlerProvider
settings.FlowPersistenceProvider
settings.HookExecutorProvider
settings.HooksProvider
settings.ErrorHandlerProvider
identity.PrivilegedPoolProvider
identity.ValidationProvider
identity.ActiveCredentialsCounterStrategyProvider
identity.ManagementProvider
session.HandlerProvider
session.ManagementProvider
}
var (
_ login.Strategy = (*Strategy)(nil)
_ registration.Strategy = (*Strategy)(nil)
_ identity.ActiveCredentialsCounter = (*Strategy)(nil)
)
type Strategy struct{ d dependencies }
func NewStrategy(d dependencies) *Strategy { return &Strategy{d: d} }
func (*Strategy) ID() identity.CredentialsType {
return identity.CredentialsTypePasskey
}
func (*Strategy) NodeGroup() node.UiNodeGroup {
return node.PasskeyGroup
}
func (s *Strategy) CompletedAuthenticationMethod(context.Context) session.AuthenticationMethod {
return session.AuthenticationMethod{
Method: identity.CredentialsTypePasskey,
AAL: identity.AuthenticatorAssuranceLevel1,
}
}
func (s *Strategy) CountActiveMultiFactorCredentials(_ context.Context, _ map[identity.CredentialsType]identity.Credentials) (count int, err error) {
return 0, nil
}
func (s *Strategy) CountActiveFirstFactorCredentials(_ context.Context, cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
return s.countCredentials(cc)
}
func (s *Strategy) countCredentials(cc map[identity.CredentialsType]identity.Credentials) (count int, err error) {
for _, c := range cc {
if c.Type == s.ID() && len(c.Config) > 0 && len(strings.Join(c.Identifiers, "")) > 0 {
var conf identity.CredentialsWebAuthnConfig
if err = json.Unmarshal(c.Config, &conf); err != nil {
return 0, errors.WithStack(err)
}
count += len(conf.Credentials)
}
}
return
}

View File

@@ -0,0 +1,15 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey
import _ "embed"
//go:embed .schema/registration.schema.json
var registrationSchema []byte
//go:embed .schema/login.schema.json
var loginSchema []byte
//go:embed .schema/settings.schema.json
var settingsSchema []byte

View File

@@ -0,0 +1,31 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"website": {
"type": "string"
},
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"passkey": {
"display_name": true
}
}
}
}
},
"required": []
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,31 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"website": {
"type": "string"
},
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
}
}
}
}
},
"required": []
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,21 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
}
},
"required": [
"email"
]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,18 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email"
}
}
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,25 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"passkey": {
"identifier": true
}
}
}
}
}
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,37 @@
{
"$id": "https://example.com/multi-identifier.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "E-Mail",
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"passkey": { "display_name": true }
}
}
},
"phone": {
"type": "string",
"format": "tel",
"title": "Phone number",
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"passkey": { "display_name": true }
}
}
}
},
"required": ["email"]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,18 @@
{
"$id": "https://example.com/no-passkey-titled.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Username"
}
}
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,26 @@
{
"$id": "https://example.com/single-identifier.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Username",
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"passkey": { "display_name": true },
"webauthn": { "identifier": true }
}
}
}
},
"required": ["username"]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,25 @@
{
"$id": "https://example.com/webauthn-only.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Username",
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"webauthn": { "identifier": true }
}
}
}
},
"required": ["username"]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,35 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"foobar": {
"type": "string",
"minLength": 2
},
"username": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"passkey": {
"display_name": true
},
"webauthn": {
"identifier": true
}
}
}
}
},
"required": ["foobar", "username"]
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,31 @@
{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"website": {
"type": "string"
},
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"passkey": {
"display_name": true
}
}
}
}
},
"required": []
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,415 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package passkey_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/sjson"
"github.com/ory/x/configx"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
kratos "github.com/ory/kratos/pkg/httpclient"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/kratos/selfservice/flow/login"
"github.com/ory/kratos/selfservice/flow/registration"
"github.com/ory/kratos/ui/node"
"github.com/ory/kratos/x"
"github.com/ory/x/assertx"
"github.com/ory/x/sqlxx"
)
func newRegistrationRegistry(t *testing.T, cfgOpts ...configx.OptionModifier) *driver.RegistryDefault {
_, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{
configx.WithValues(map[string]any{
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
config.ViperKeySelfServiceRegistrationEnableLegacyOneStep: true,
config.ViperKeySelfServiceRegistrationLoginHints: true,
}),
withEnabledPasskeyStrategy,
}, cfgOpts...)...)
return reg
}
func newLoginRegistry(t *testing.T, cfgOpts ...configx.OptionModifier) *driver.RegistryDefault {
_, reg := pkg.NewFastRegistryWithMocks(t, append([]configx.OptionModifier{
configx.WithValues(map[string]any{
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": true,
config.ViperKeySelfServiceRegistrationLoginHints: true,
}),
withEnabledPasskeyStrategy,
}, cfgOpts...)...)
return reg
}
var withEnabledPasskeyStrategy = func() configx.OptionModifier {
key := config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePasskey)
return configx.WithValues(map[string]any{
key + ".enabled": true,
key + ".config.rp.display_name": "Ory Corp",
key + ".config.rp.id": "localhost",
key + ".config.rp.origins": []string{"http://localhost:4455"},
})
}()
type fixture struct {
conf *config.Config
reg *driver.RegistryDefault
publicTS *httptest.Server
redirTS *httptest.Server
redirNoSessionTS *httptest.Server
uiTS *httptest.Server
errTS *httptest.Server
loginTS *httptest.Server
}
func newRegistrationFixture(t *testing.T, cfgOpts ...configx.OptionModifier) *fixture {
fix := new(fixture)
fix.reg = newRegistrationRegistry(t, append([]configx.OptionModifier{
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/registration.schema.json")),
configx.WithValue(config.ViperKeySecretsDefault, []string{"not-a-secure-session-key"}),
}, cfgOpts...)...)
fix.conf = fix.reg.Config()
fix.publicTS, _ = testhelpers.NewKratosServer(t, fix.reg)
_ = testhelpers.NewErrorTestServer(t, fix.reg)
_ = testhelpers.NewRegistrationUIFlowEchoServer(t, fix.reg)
_ = testhelpers.NewRedirSessionEchoTS(t, fix.reg)
fix.redirTS = testhelpers.NewRedirSessionEchoTS(t, fix.reg)
fix.redirNoSessionTS = testhelpers.NewRedirNoSessionTS(t, fix.reg)
fix.useReturnToFromTS(fix.redirTS)
return fix
}
func newLoginFixture(t *testing.T, cfgOpts ...configx.OptionModifier) *fixture {
fix := new(fixture)
fix.reg = newLoginRegistry(t, append([]configx.OptionModifier{
configx.WithValues(map[string]any{
config.ViperKeySelfServiceStrategyConfig + "." + string(identity.CredentialsTypePassword) + ".enabled": false,
config.ViperKeySecretsDefault: []string{"not-a-secure-session-key"},
}),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/login.schema.json")),
}, cfgOpts...)...)
fix.conf = fix.reg.Config()
fix.publicTS, _ = testhelpers.NewKratosServer(t, fix.reg)
fix.errTS = testhelpers.NewErrorTestServer(t, fix.reg)
fix.uiTS = testhelpers.NewLoginUIFlowEchoServer(t, fix.reg)
fix.loginTS = fix.uiTS
// Overwrite these two to make it more explicit when tests fail
fix.conf.MustSet(t.Context(), config.ViperKeySelfServiceLoginUI, fix.uiTS.URL+"/login-ts")
fix.redirTS = testhelpers.NewRedirSessionEchoTS(t, fix.reg)
fix.redirNoSessionTS = testhelpers.NewRedirNoSessionTS(t, fix.reg)
fix.useReturnToFromTS(fix.redirTS)
return fix
}
func newSettingsFixture(t *testing.T, cfgOpts ...configx.OptionModifier) *fixture {
fix := newLoginFixture(t, append([]configx.OptionModifier{
configx.WithValues(map[string]any{
config.ViperKeySelfServiceSettingsPrivilegedAuthenticationAfter: "1m",
config.ViperKeySelfServiceSettingsRequiredAAL: "aal1",
config.ViperKeySessionWhoAmIAAL: "aal1",
config.ViperKeySelfServiceStrategyConfig + ".profile.enabled": false,
}),
configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/settings.schema.json")),
}, cfgOpts...)...)
fix.uiTS = testhelpers.NewSettingsUIFlowEchoServer(t, fix.reg)
return fix
}
func (fix *fixture) checkURL(t *testing.T, shouldRedirect bool, res *http.Response) {
if shouldRedirect {
assert.Contains(t, res.Request.URL.String(), fix.uiTS.URL+"/login-ts")
} else {
assert.Contains(t, res.Request.URL.String(), fix.publicTS.URL+login.RouteSubmitFlow)
}
}
func (fix *fixture) loginViaBrowser(t *testing.T, spa bool, cb func(url.Values), browserClient *http.Client, opts ...testhelpers.InitFlowWithOption) (string, *http.Response) {
f := testhelpers.InitializeLoginFlowViaBrowser(t, browserClient, fix.publicTS, false, spa, false, false, opts...)
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
cb(values)
return testhelpers.LoginMakeRequest(t, false, spa, f, browserClient, values.Encode())
}
func (fix *fixture) createIdentityWithPasskey(t *testing.T, c identity.Credentials) *identity.Identity {
var id identity.Identity
require.NoError(t, json.Unmarshal(loginSuccessIdentity, &id))
id.SetCredentials(identity.CredentialsTypePasskey, identity.Credentials{
Identifiers: []string{"some-random-user-handle"},
Config: c.Config,
Type: identity.CredentialsTypePasskey,
Version: c.Version,
})
// We clean up the identity in case it has been created before
_ = fix.reg.PrivilegedIdentityPool().DeleteIdentity(t.Context(), id.ID)
require.NoError(t, fix.reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), &id))
return &id
}
func (fix *fixture) submitWebAuthnLoginFlowWithClient(t *testing.T, isSPA bool, f *kratos.LoginFlow, contextFixture []byte, client *http.Client, cb func(values url.Values)) (string, *http.Response, *kratos.LoginFlow) {
// We inject the session to replay
interim, err := fix.reg.LoginFlowPersister().GetLoginFlow(t.Context(), uuid.FromStringOrNil(f.Id))
require.NoError(t, err)
interim.InternalContext = contextFixture
require.NoError(t, fix.reg.LoginFlowPersister().UpdateLoginFlow(t.Context(), interim))
values := testhelpers.SDKFormFieldsToURLValues(f.Ui.Nodes)
cb(values)
// We use the response replay
body, res := testhelpers.LoginMakeRequest(t, false, isSPA, f, client, values.Encode())
return body, res, f
}
func (fix *fixture) submitWebAuthnLoginWithClient(t *testing.T, isSPA bool, contextFixture []byte, client *http.Client, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) {
f := testhelpers.InitializeLoginFlowViaBrowser(t, client, fix.publicTS, false, isSPA, false, false, opts...)
return fix.submitWebAuthnLoginFlowWithClient(t, isSPA, f, contextFixture, client, cb)
}
func (fix *fixture) submitWebAuthnLogin(t *testing.T, ctx context.Context, isSPA bool, id *identity.Identity, contextFixture []byte, cb func(values url.Values), opts ...testhelpers.InitFlowWithOption) (string, *http.Response, *kratos.LoginFlow) {
browserClient := testhelpers.NewHTTPClientWithIdentitySessionCookie(ctx, t, fix.reg, id)
return fix.submitWebAuthnLoginWithClient(t, isSPA, contextFixture, browserClient, cb, opts...)
}
// useReturnToFromTS sets the "return to" server, which will assert the session
// state (redirTS: enforce that a session exists, redirNoSessionTS: enforce that
// no session exists)
func (fix *fixture) useReturnToFromTS(ts *httptest.Server) {
fix.conf.MustSet(context.TODO(), config.ViperKeySelfServiceBrowserDefaultReturnTo, ts.URL+"/default-return-to")
fix.conf.MustSet(context.TODO(), config.ViperKeySelfServiceRegistrationAfter+"."+config.DefaultBrowserReturnURL, ts.URL+"/registration-return-ts")
}
func (fix *fixture) useRedirTS() { fix.useReturnToFromTS(fix.redirTS) }
func (fix *fixture) useRedirNoSessionTS() { fix.useReturnToFromTS(fix.redirNoSessionTS) }
func (fix *fixture) disableSessionAfterRegistration() {
fix.conf.MustSet(context.TODO(), config.HookStrategyKey(
config.ViperKeySelfServiceRegistrationAfter,
identity.CredentialsTypePasskey.String(),
), nil)
}
func (fix *fixture) enableSessionAfterRegistration() {
fix.conf.MustSet(context.TODO(), config.HookStrategyKey(
config.ViperKeySelfServiceRegistrationAfter,
identity.CredentialsTypePasskey.String(),
), []config.SelfServiceHook{{Name: "session"}})
}
type submitPasskeyOpt struct {
initFlowOpts []testhelpers.InitFlowWithOption
userID string
internalContext sqlxx.JSONRawMessage
}
type submitPasskeyOption func(o *submitPasskeyOpt)
func withUserID(id string) submitPasskeyOption {
return func(o *submitPasskeyOpt) {
o.userID = base64.StdEncoding.EncodeToString([]byte(id))
}
}
func withInternalContext(ic sqlxx.JSONRawMessage) submitPasskeyOption {
return func(o *submitPasskeyOpt) {
o.internalContext = ic
}
}
func withInitFlowWithOption(ifo []testhelpers.InitFlowWithOption) submitPasskeyOption {
return func(o *submitPasskeyOpt) {
o.initFlowOpts = ifo
}
}
func (fix *fixture) submitPasskeyBrowserRegistration(
t *testing.T,
flowType string,
client *http.Client,
cb func(values url.Values),
opts ...submitPasskeyOption,
) (string, *http.Response, *kratos.RegistrationFlow) {
return fix.submitPasskeyRegistration(t.Context(), t, flowType, client, cb, append([]submitPasskeyOption{withInternalContext(registrationFixtureSuccessBrowserInternalContext)}, opts...)...)
}
func (fix *fixture) submitPasskeyAndroidRegistration(
t *testing.T,
flowType string,
client *http.Client,
cb func(values url.Values),
opts ...submitPasskeyOption,
) (string, *http.Response, *kratos.RegistrationFlow) {
return fix.submitPasskeyRegistration(t.Context(), t, flowType, client, cb,
append([]submitPasskeyOption{withInternalContext(
registrationFixtureSuccessAndroidInternalContext,
)}, opts...)...)
}
func (fix *fixture) submitPasskeyRegistration(
ctx context.Context,
t *testing.T,
flowType string,
client *http.Client,
cb func(values url.Values),
opts ...submitPasskeyOption,
) (string, *http.Response, *kratos.RegistrationFlow) {
o := &submitPasskeyOpt{}
for _, fn := range opts {
fn(o)
}
isSPA := flowType == "spa"
isAPI := flowType == "api"
var regFlow *kratos.RegistrationFlow
if isAPI {
regFlow = testhelpers.InitializeRegistrationFlowViaAPI(t, client, fix.publicTS, o.initFlowOpts...)
} else {
regFlow = testhelpers.InitializeRegistrationFlowViaBrowserCtx(ctx, t, client, fix.publicTS, isSPA, false, false, o.initFlowOpts...)
}
// First step: fill out traits and click on "sign up with passkey"
values := testhelpers.SDKFormFieldsToURLValues(regFlow.Ui.Nodes)
cb(values)
passkeyRegisterVal := values.Get(node.PasskeyRegister) // needed in the second step
values.Del(node.PasskeyRegister)
values.Set("method", "passkey")
// Encode payload based on flow type
var payload string
if isAPI {
payload = testhelpers.EncodeFormAsJSON(t, true, values)
} else {
payload = values.Encode()
}
_, _ = testhelpers.RegistrationMakeRequest(t, isAPI, isSPA, regFlow, client, payload)
// We inject the session to replay
interim, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(ctx, uuid.FromStringOrNil(regFlow.Id))
require.NoError(t, err)
if len(o.internalContext) > 0 {
interim.InternalContext = o.internalContext
}
if o.userID != "" {
interim.InternalContext, err = sjson.SetBytes(interim.InternalContext, "passkey_session_data.user_id", o.userID)
require.NoError(t, err)
}
require.NoError(t, fix.reg.RegistrationFlowPersister().UpdateRegistrationFlow(ctx, interim))
// Second step: fill out passkey response
values.Set(node.PasskeyRegister, passkeyRegisterVal)
if isAPI {
payload = testhelpers.EncodeFormAsJSON(t, true, values)
} else {
payload = values.Encode()
}
body, res := testhelpers.RegistrationMakeRequest(t, isAPI, isSPA, regFlow, client, payload)
return body, res, regFlow
}
func (fix *fixture) makeRegistration(t *testing.T, flowType string, values func(v url.Values), opts ...submitPasskeyOption) (actual string, res *http.Response, fetchedFlow *registration.Flow) {
actual, res, actualFlow := fix.submitPasskeyBrowserRegistration(t, flowType, testhelpers.NewClientWithCookies(t), values, opts...)
fetchedFlow, err := fix.reg.RegistrationFlowPersister().GetRegistrationFlow(t.Context(), uuid.FromStringOrNil(actualFlow.Id))
require.NoError(t, err)
return actual, res, fetchedFlow
}
func (fix *fixture) makeSuccessfulRegistration(t *testing.T, flowType string, expectReturnTo string, values func(v url.Values), opts ...submitPasskeyOption) (actual string) {
actual, res, _ := fix.makeRegistration(t, flowType, values, opts...)
if flowType == "spa" {
expectReturnTo = fix.publicTS.URL
}
if flowType == "api" {
// API does not redirect; response body should contain JSON with identity/session fields.
// Leave detailed assertions to the calling test.
return actual
}
assert.Contains(t, res.Request.URL.String(), expectReturnTo, "%+v\n\t%s", res.Request, assertx.PrettifyJSONPayload(t, actual))
return actual
}
func (fix *fixture) makeUnsuccessfulRegistration(t *testing.T, flowType string, expectReturnTo string, values func(v url.Values), opts ...submitPasskeyOption) (actual string, res *http.Response) {
actual, res, _ = fix.makeRegistration(t, flowType, values, opts...)
return actual, res
}
func (fix *fixture) createIdentityWithoutPasskey(t *testing.T) *identity.Identity {
id := fix.createIdentity(t)
delete(id.Credentials, identity.CredentialsTypePasskey)
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), id))
return id
}
func (fix *fixture) createIdentityAndReturnIdentifier(t *testing.T, conf []byte) (*identity.Identity, string) {
identifier := x.NewUUID().String() + "@ory.sh"
password := x.NewUUID().String()
p, err := fix.reg.Hasher(t.Context()).Generate(t.Context(), []byte(password))
require.NoError(t, err)
i := &identity.Identity{
Traits: identity.Traits(fmt.Sprintf(`{"email":"%s"}`, identifier)),
VerifiableAddresses: []identity.VerifiableAddress{
{
Value: identifier,
Verified: false,
CreatedAt: time.Now(),
},
},
}
if conf == nil {
conf = []byte(`{"credentials":[{"id":"Zm9vZm9v","display_name":"foo"},{"id":"YmFyYmFy","display_name":"bar"}]}`)
}
require.NoError(t, fix.reg.PrivilegedIdentityPool().CreateIdentity(t.Context(), i))
i.Credentials = map[identity.CredentialsType]identity.Credentials{
identity.CredentialsTypePassword: {
Type: identity.CredentialsTypePassword,
Identifiers: []string{identifier},
Config: sqlxx.JSONRawMessage(`{"hashed_password":"` + string(p) + `"}`),
},
identity.CredentialsTypePasskey: {
Type: identity.CredentialsTypePasskey,
Identifiers: []string{identifier},
Config: conf,
},
}
require.NoError(t, fix.reg.PrivilegedIdentityPool().UpdateIdentity(t.Context(), i))
return i, identifier
}
func (fix *fixture) createIdentity(t *testing.T) *identity.Identity {
id, _ := fix.createIdentityAndReturnIdentifier(t, nil)
return id
}