snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
16
selfservice/strategy/passkey/.schema/login.schema.json
Normal file
16
selfservice/strategy/passkey/.schema/login.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
selfservice/strategy/passkey/.schema/settings.schema.json
Normal file
20
selfservice/strategy/passkey/.schema/settings.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"passkey_register_trigger": [
|
||||
""
|
||||
],
|
||||
"passkey_remove": [
|
||||
"666f6f666f6f"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"passkey_register_trigger": [
|
||||
""
|
||||
],
|
||||
"passkey_remove": [
|
||||
"666f6f666f6f"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"passkey_register_trigger": [
|
||||
""
|
||||
],
|
||||
"passkey_remove": [
|
||||
"666f6f666f6f"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
[
|
||||
{
|
||||
"type": "input",
|
||||
"group": "default",
|
||||
"attributes": {
|
||||
"name": "csrf_token",
|
||||
"type": "hidden",
|
||||
"required": true,
|
||||
"disabled": false,
|
||||
"node_type": "input"
|
||||
},
|
||||
"messages": [],
|
||||
"meta": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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="
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"passkey_session_data": {
|
||||
"challenge": "WzZCWULmaq5xTAlA0YYHlqoubqAhe1AWdLRZCIBAMcM",
|
||||
"user_id": "",
|
||||
"allowed_credentials": [
|
||||
"OE7fnoAeqaydiBM4+fQsbYMiO+EObq97WYb/c1HuX8Crbsb2777xs+upv7muXE8hOLkm6lQHC1ahegnzw+aIsQ=="
|
||||
],
|
||||
"userVerification": ""
|
||||
}
|
||||
}
|
||||
@@ -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="
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"passkey_session_data": {
|
||||
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
|
||||
"user_id": "",
|
||||
"userVerification": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"passkey_session_data": {
|
||||
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
|
||||
"user_id": "wrong",
|
||||
"userVerification": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"passkey_session_data": {
|
||||
"challenge": "mFtAwmtDDdwcO6200I2H6oWjzOiF21lZhQVlrC4tdaU",
|
||||
"user_id": "d29OeDNJVjdYR2NRa09RVHhNVG1ZbHE1ejBDYzM1dGV3UWxFT25yaUJKcTUyb0VOR0pUMk5PeXExRXp3Z2M2dg",
|
||||
"userVerification": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "mK2RV0b2NUGDsj8QqH0XtQ",
|
||||
"rawId": "mK2RV0b2NUGDsj8QqH0XtQ",
|
||||
"response": {
|
||||
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUJYVxRmHaAcJuz7n2X5FJILFPwxIhVpoURyBRglMxnFpdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEJitkVdG9jVBg7I_EKh9F7WlAQIDJiABIVggjEkfDDjIm8yAYfth4u0EV7ApX4kclQONhpK5BLc7W6wiWCCHiHhRNqf8Qhc7bjoIFTqw4lafiC7yrXvojU_WMNcutA",
|
||||
"clientDataJson": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibUZ0QXdtdEREZHdjTzYyMDBJMkg2b1dqek9pRjIxbFpoUVZsckM0dGRhVSIsIm9yaWdpbiI6ImFuZHJvaWQ6YXBrLWtleS1oYXNoOlMyUmZOWWdKbVFpS2dkNi1zZGJqVzdwaGNMX09UUDR2R0U4TDUxUTJHQjAiLCJhbmRyb2lkUGFja2FnZU5hbWUiOiJjb20udHJwLmFuZC5wZXJzb25hbC5xbCJ9"
|
||||
},
|
||||
"type": "public-key"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"passkey_session_data": {
|
||||
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
|
||||
"user_id": "bhGpp2L9TIiHGgl/GPAwbw==",
|
||||
"userVerification": ""
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"passkey_session_data": {
|
||||
"challenge": "UlxHSTkuMvtVDoV9y5lhu9OyNUP8P7MP0RYAT6Im_rY",
|
||||
"user_id": "bhGpp2L9TIiHGgl/GPAwbw==",
|
||||
"userVerification": ""
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
49
selfservice/strategy/passkey/nodes.go
Normal file
49
selfservice/strategy/passkey/nodes.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
481
selfservice/strategy/passkey/passkey_login.go
Normal file
481
selfservice/strategy/passkey/passkey_login.go
Normal 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
|
||||
}
|
||||
78
selfservice/strategy/passkey/passkey_login_signcount_test.go
Normal file
78
selfservice/strategy/passkey/passkey_login_signcount_test.go
Normal 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")
|
||||
}
|
||||
679
selfservice/strategy/passkey/passkey_login_test.go
Normal file
679
selfservice/strategy/passkey/passkey_login_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
378
selfservice/strategy/passkey/passkey_registration.go
Normal file
378
selfservice/strategy/passkey/passkey_registration.go
Normal 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
|
||||
}
|
||||
965
selfservice/strategy/passkey/passkey_registration_test.go
Normal file
965
selfservice/strategy/passkey/passkey_registration_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
115
selfservice/strategy/passkey/passkey_schema_extension.go
Normal file
115
selfservice/strategy/passkey/passkey_schema_extension.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
444
selfservice/strategy/passkey/passkey_settings.go
Normal file
444
selfservice/strategy/passkey/passkey_settings.go
Normal 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
|
||||
}
|
||||
679
selfservice/strategy/passkey/passkey_settings_test.go
Normal file
679
selfservice/strategy/passkey/passkey_settings_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
}
|
||||
117
selfservice/strategy/passkey/passkey_strategy.go
Normal file
117
selfservice/strategy/passkey/passkey_strategy.go
Normal 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
|
||||
}
|
||||
15
selfservice/strategy/passkey/schema.go
Normal file
15
selfservice/strategy/passkey/schema.go
Normal 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
|
||||
31
selfservice/strategy/passkey/stub/login.schema.json
Normal file
31
selfservice/strategy/passkey/stub/login.schema.json
Normal 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
|
||||
}
|
||||
31
selfservice/strategy/passkey/stub/login_webauthn.schema.json
Normal file
31
selfservice/strategy/passkey/stub/login_webauthn.schema.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
18
selfservice/strategy/passkey/stub/noid.schema.json
Normal file
18
selfservice/strategy/passkey/stub/noid.schema.json
Normal 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
|
||||
}
|
||||
25
selfservice/strategy/passkey/stub/profile.schema.json
Normal file
25
selfservice/strategy/passkey/stub/profile.schema.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
35
selfservice/strategy/passkey/stub/registration.schema.json
Normal file
35
selfservice/strategy/passkey/stub/registration.schema.json
Normal 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
|
||||
}
|
||||
31
selfservice/strategy/passkey/stub/settings.schema.json
Normal file
31
selfservice/strategy/passkey/stub/settings.schema.json
Normal 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
|
||||
}
|
||||
415
selfservice/strategy/passkey/testfixture_test.go
Normal file
415
selfservice/strategy/passkey/testfixture_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user