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

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

View File

@@ -0,0 +1,55 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
//go:generate curl https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/main/aaguid.json --output passkey-aaguids.json
package aaguid
import (
_ "embed"
"encoding/json"
"maps"
"github.com/gofrs/uuid"
)
var (
//go:embed aaguids.json
rawAAGUIDs []byte
//go:embed passkey-aaguids.json
rawPasskeyAAGUIDs []byte
aaguids map[string]AAGUID
)
type AAGUID struct {
Name string `json:"name"`
IconDark string `json:"icon_dark"`
IconLight string `json:"icon_light"`
}
func init() {
err := json.Unmarshal(rawAAGUIDs, &aaguids)
if err != nil {
panic(err)
}
var passkeyAAGUIDs map[string]AAGUID
err = json.Unmarshal(rawPasskeyAAGUIDs, &passkeyAAGUIDs)
if err != nil {
panic(err)
}
maps.Copy(aaguids, passkeyAAGUIDs)
}
func Lookup(id []byte) *AAGUID {
uid, err := uuid.FromBytes(id)
if err != nil {
return nil
}
if aaguid, ok := aaguids[uid.String()]; ok {
return &aaguid
}
return nil
}

View File

@@ -0,0 +1,51 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package aaguid
import (
"testing"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
)
func TestLookup(t *testing.T) {
goodUUIDBytes := uuid.Must(uuid.FromString("adce0002-35bc-c60a-648b-0b25f1f05503")).Bytes()
badUUIDBytes := uuid.Must(uuid.NewV4()).Bytes()
tests := []struct {
name string
id []byte
want string
}{
{
name: "GoodUUID",
id: goodUUIDBytes,
want: "Chrome on Mac",
},
{
name: "BadUUID",
id: badUUIDBytes,
},
{
name: "NilUUID",
id: nil,
},
{
name: "EmptyUUID",
id: []byte{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := Lookup(tt.id)
if tt.want == "" {
assert.Nil(t, res)
} else {
assert.Equal(t, tt.want, res.Name)
}
})
}
}

File diff suppressed because one or more lines are too long

23
x/webauthnx/errors.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package webauthnx
import (
"github.com/pkg/errors"
"github.com/ory/jsonschema/v3"
)
var ErrNoCredentials = errors.New("required credentials not found")
func ErrNotEnoughCredentials() *jsonschema.ValidationError {
return &jsonschema.ValidationError{Message: "unable to remove this security key because it would lock you out of your account", InstancePtr: "#/webauthn_remove"}
}
// ErrCredentialAlreadyRegistered is returned when a settings flow submits a credential whose ID
// is already registered on the identity. Compliant browsers prevent this via excludeCredentials,
// so this guards against clients that ignore the exclusion list.
func ErrCredentialAlreadyRegistered(instancePtr string) *jsonschema.ValidationError {
return &jsonschema.ValidationError{Message: "this security key or passkey is already registered with your account", InstancePtr: instancePtr}
}

53
x/webauthnx/handler.go Normal file
View File

@@ -0,0 +1,53 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package webauthnx
import (
_ "embed"
"net/http"
"github.com/ory/x/httprouterx"
)
//go:embed js/webauthn.js
var jsOnLoad []byte
const ScriptURL = "/.well-known/ory/webauthn.js"
// swagger:model webAuthnJavaScript
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type webAuthnJavaScript string
// swagger:route GET /.well-known/ory/webauthn.js frontend getWebAuthnJavaScript
//
// # Get WebAuthn JavaScript
//
// This endpoint provides JavaScript which is needed in order to perform WebAuthn login and registration.
//
// If you are building a JavaScript Browser App (e.g. in ReactJS or AngularJS) you will need to load this file:
//
// ```html
// <script src="https://public-kratos.example.org/.well-known/ory/webauthn.js" type="script" async />
// ```
//
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
//
// Produces:
// - text/javascript
//
// Schemes: http, https
//
// Responses:
// 200: webAuthnJavaScript
//
// Extensions:
// x-ory-ratelimit-bucket: hydra-public-high
func RegisterWebauthnRoute(r *httprouterx.RouterPublic) {
r.GET(ScriptURL, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=UTF-8")
_, _ = w.Write(jsOnLoad)
})
}

22
x/webauthnx/js/trigger.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package js
import "fmt"
// swagger:enum WebAuthnTriggers
type WebAuthnTriggers string
const (
WebAuthnTriggersWebAuthnRegistration WebAuthnTriggers = "oryWebAuthnRegistration"
WebAuthnTriggersWebAuthnLogin WebAuthnTriggers = "oryWebAuthnLogin"
WebAuthnTriggersPasskeyLogin WebAuthnTriggers = "oryPasskeyLogin"
WebAuthnTriggersPasskeyLoginAutocompleteInit WebAuthnTriggers = "oryPasskeyLoginAutocompleteInit"
WebAuthnTriggersPasskeyRegistration WebAuthnTriggers = "oryPasskeyRegistration"
WebAuthnTriggersPasskeySettingsRegistration WebAuthnTriggers = "oryPasskeySettingsRegistration"
)
func (r WebAuthnTriggers) String() string {
return fmt.Sprintf("window.%s", string(r))
}

View File

@@ -0,0 +1,14 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package js
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestToString(t *testing.T) {
assert.Equal(t, "window.oryWebAuthnRegistration", WebAuthnTriggersWebAuthnRegistration.String())
}

446
x/webauthnx/js/webauthn.js Normal file
View File

@@ -0,0 +1,446 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
; (function () {
if (!window) {
return
}
if (!window.PublicKeyCredential) {
console.log("This browser does not support WebAuthn!")
return
}
if (window.__oryWebAuthnInitialized) {
return
}
function __oryWebAuthnBufferDecode(value) {
return Uint8Array.from(
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
function (c) {
return c.charCodeAt(0)
},
)
}
function __oryWebAuthnBufferEncode(value) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "")
}
function __oryWebAuthnLogin(
options,
resultQuerySelector = '*[name="webauthn_login"]',
triggerQuerySelector = '*[name="webauthn_login_trigger"]',
) {
if (!window.PublicKeyCredential) {
alert("This browser does not support WebAuthn!")
}
const triggerEl = document.querySelector(triggerQuerySelector)
let opt = options
if (!opt) {
opt = JSON.parse(triggerEl.value)
}
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
opt.publicKey.allowCredentials = opt.publicKey.allowCredentials.map(
function (value) {
return {
...value,
id: __oryWebAuthnBufferDecode(value.id),
}
},
)
navigator.credentials
.get(opt)
.then(function (credential) {
document.querySelector(resultQuerySelector).value = JSON.stringify({
id: credential.id,
rawId: __oryWebAuthnBufferEncode(credential.rawId),
type: credential.type,
response: {
authenticatorData: __oryWebAuthnBufferEncode(
credential.response.authenticatorData,
),
clientDataJSON: __oryWebAuthnBufferEncode(
credential.response.clientDataJSON,
),
signature: __oryWebAuthnBufferEncode(credential.response.signature),
userHandle: __oryWebAuthnBufferEncode(
credential.response.userHandle,
),
},
})
triggerEl.closest("form").submit()
})
.catch((err) => {
alert(err)
})
}
function __oryWebAuthnRegistration(
options,
resultQuerySelector = '*[name="webauthn_register"]',
triggerQuerySelector = '*[name="webauthn_register_trigger"]',
) {
if (!window.PublicKeyCredential) {
alert("This browser does not support WebAuthn!")
}
const triggerEl = document.querySelector(triggerQuerySelector)
let opt = options
if (!opt) {
opt = JSON.parse(triggerEl.value)
}
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
if (opt.publicKey.excludeCredentials) {
opt.publicKey.excludeCredentials = opt.publicKey.excludeCredentials.map(
function (value) {
return {
...value,
id: __oryWebAuthnBufferDecode(value.id),
}
},
)
}
navigator.credentials
.create(opt)
.then(function (credential) {
document.querySelector(resultQuerySelector).value = JSON.stringify({
id: credential.id,
rawId: __oryWebAuthnBufferEncode(credential.rawId),
type: credential.type,
response: {
attestationObject: __oryWebAuthnBufferEncode(
credential.response.attestationObject,
),
clientDataJSON: __oryWebAuthnBufferEncode(
credential.response.clientDataJSON,
),
},
})
triggerEl.closest("form").submit()
})
.catch((err) => {
alert(err)
})
}
async function __oryPasskeyLoginAutocompleteInit() {
const dataEl = document.getElementsByName("passkey_challenge")[0]
const resultEl = document.getElementsByName("passkey_login")[0]
const identifierEl = document.getElementsByName("identifier")[0]
if (!dataEl || !resultEl || !identifierEl) {
console.error(
"Unable to initialize WebAuthn / Passkey autocomplete because one or more required form fields are missing.",
)
return
}
if (
!window.PublicKeyCredential ||
!window.PublicKeyCredential.isConditionalMediationAvailable ||
window.Cypress // Cypress auto-fills the autocomplete, which we don't want
) {
console.log("This browser does not support Passkey / WebAuthn!")
return
}
const isCMA = await PublicKeyCredential.isConditionalMediationAvailable()
if (!isCMA) {
console.log(
"This browser does not support WebAuthn Conditional Mediation!",
)
return
}
let opt = JSON.parse(dataEl.value)
if (opt.publicKey.user && opt.publicKey.user.id) {
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
}
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
// If this is set we already have a request ongoing which we need to abort.
if (window.abortPasskeyConditionalUI) {
window.abortPasskeyConditionalUI.abort(
"Canceling Passkey autocomplete to complete trigger-based passkey login.",
)
window.abortPasskeyConditionalUI = undefined
}
// Allow aborting through a global variable
window.abortPasskeyConditionalUI = new AbortController()
navigator.credentials
.get({
publicKey: opt.publicKey,
mediation: "conditional",
signal: abortPasskeyConditionalUI.signal,
})
.then(function (credential) {
resultEl.value = JSON.stringify({
id: credential.id,
rawId: __oryWebAuthnBufferEncode(credential.rawId),
type: credential.type,
response: {
authenticatorData: __oryWebAuthnBufferEncode(
credential.response.authenticatorData,
),
clientDataJSON: __oryWebAuthnBufferEncode(
credential.response.clientDataJSON,
),
signature: __oryWebAuthnBufferEncode(credential.response.signature),
userHandle: __oryWebAuthnBufferEncode(
credential.response.userHandle,
),
},
})
resultEl.closest("form").submit()
})
.catch((err) => {
console.trace(err)
console.log(err)
})
}
function __oryPasskeyLogin() {
const dataEl = document.getElementsByName("passkey_challenge")[0]
const resultEl = document.getElementsByName("passkey_login")[0]
if (!dataEl || !resultEl) {
console.error(
"Unable to initialize WebAuthn / Passkey autocomplete because one or more required form fields are missing.",
)
return
}
if (!window.PublicKeyCredential) {
console.log("This browser does not support WebAuthn!")
return
}
let opt = JSON.parse(dataEl.value)
if (opt.publicKey.user && opt.publicKey.user.id) {
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
}
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
if (opt.publicKey.allowCredentials) {
opt.publicKey.allowCredentials = opt.publicKey.allowCredentials.map(
function (cred) {
return {
...cred,
id: __oryWebAuthnBufferDecode(cred.id),
}
},
)
}
if (window.abortPasskeyConditionalUI) {
window.abortPasskeyConditionalUI.abort(
"Canceling Passkey autocomplete to complete trigger-based passkey login.",
)
window.abortPasskeyConditionalUI = undefined
}
navigator.credentials
.get({
publicKey: opt.publicKey,
})
.then(function (credential) {
console.trace('login', credential)
resultEl.value = JSON.stringify({
id: credential.id,
rawId: __oryWebAuthnBufferEncode(credential.rawId),
type: credential.type,
response: {
authenticatorData: __oryWebAuthnBufferEncode(
credential.response.authenticatorData,
),
clientDataJSON: __oryWebAuthnBufferEncode(
credential.response.clientDataJSON,
),
signature: __oryWebAuthnBufferEncode(credential.response.signature),
userHandle: __oryWebAuthnBufferEncode(
credential.response.userHandle,
),
},
})
resultEl.closest("form").submit()
})
.catch((err) => {
// Calling this again will enable the autocomplete once again.
if (err instanceof DOMException && err.name === "SecurityError") {
console.error(`A security exception occurred while loading Passkeys / WebAuthn. To troubleshoot, please head over to https://www.ory.com/docs/troubleshooting/passkeys-webauthn-security-error. The original error message is: ${err.message}`)
} else {
console.error("[Ory/Passkey] An unknown error occurred while getting passkey credentials", err)
}
console.trace(err)
// Try re-initializing autocomplete
return __oryPasskeyLoginAutocompleteInit()
})
}
function __oryPasskeyRegistration() {
const dataEl = document.getElementsByName("passkey_create_data")[0]
const resultEl = document.getElementsByName("passkey_register")[0]
if (!dataEl || !resultEl) {
console.debug("__oryPasskeyRegistration: mandatory fields not found")
return
}
const createData = JSON.parse(dataEl.value)
// Resolve the passkey display name from the first non-empty candidate field.
// Newer servers publish `displayNameFieldNames` (an array of all candidate
// field names from the identity schema). Older servers only publish
// `displayNameFieldName` (a single field name). Fall back accordingly.
const candidateFieldNames =
Array.isArray(createData.displayNameFieldNames) &&
createData.displayNameFieldNames.length > 0
? createData.displayNameFieldNames
: [createData.displayNameFieldName]
const form = dataEl.closest("form")
let displayName = ""
for (const fieldName of candidateFieldNames) {
if (!fieldName) continue
const el = form.querySelector("[name='" + fieldName + "']")
if (el && el.value) {
displayName = el.value
break
}
}
let opts = createData.credentialOptions
opts.publicKey.user.name = displayName
opts.publicKey.user.displayName = displayName
opts.publicKey.user.id = __oryWebAuthnBufferDecode(opts.publicKey.user.id)
opts.publicKey.challenge = __oryWebAuthnBufferDecode(
opts.publicKey.challenge,
)
if (opts.publicKey.excludeCredentials) {
opts.publicKey.excludeCredentials = opts.publicKey.excludeCredentials.map(
function (value) {
return {
...value,
id: __oryWebAuthnBufferDecode(value.id),
}
},
)
}
navigator.credentials
.create(opts)
.then(function (credential) {
resultEl.value = JSON.stringify({
id: credential.id,
rawId: __oryWebAuthnBufferEncode(credential.rawId),
type: credential.type,
response: {
attestationObject: __oryWebAuthnBufferEncode(
credential.response.attestationObject,
),
clientDataJSON: __oryWebAuthnBufferEncode(
credential.response.clientDataJSON,
),
},
})
resultEl.closest("form").submit()
})
.catch((err) => {
console.error(err)
})
}
function __oryPasskeySettingsRegistration() {
const dataEl = document.getElementsByName("passkey_create_data")[0]
const resultEl = document.getElementsByName("passkey_settings_register")[0]
if (!dataEl || !resultEl) {
console.debug(
"__oryPasskeySettingsRegistration: mandatory fields not found",
)
return
}
let opt = JSON.parse(dataEl.value)
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
if (opt.publicKey.excludeCredentials) {
opt.publicKey.excludeCredentials = opt.publicKey.excludeCredentials.map(
function (value) {
return {
...value,
id: __oryWebAuthnBufferDecode(value.id),
}
},
)
}
navigator.credentials
.create(opt)
.then(function (credential) {
resultEl.value = JSON.stringify({
id: credential.id,
rawId: __oryWebAuthnBufferEncode(credential.rawId),
type: credential.type,
response: {
attestationObject: __oryWebAuthnBufferEncode(
credential.response.attestationObject,
),
clientDataJSON: __oryWebAuthnBufferEncode(
credential.response.clientDataJSON,
),
},
})
resultEl.closest("form").submit()
})
.catch((err) => {
console.error(err)
})
}
// Deprecated naming with underscores - kept for support with Ory Elements v0
window.__oryWebAuthnLogin = __oryWebAuthnLogin
window.__oryWebAuthnRegistration = __oryWebAuthnRegistration
window.__oryPasskeySettingsRegistration = __oryPasskeySettingsRegistration
window.__oryPasskeyLogin = __oryPasskeyLogin
window.__oryPasskeyRegistration = __oryPasskeyRegistration
window.__oryPasskeyLoginAutocompleteInit = __oryPasskeyLoginAutocompleteInit
// Current naming - use with Ory Elements v1
window.oryWebAuthnLogin = __oryWebAuthnLogin
window.oryWebAuthnRegistration = __oryWebAuthnRegistration
window.oryPasskeySettingsRegistration = __oryPasskeySettingsRegistration
window.oryPasskeyLogin = __oryPasskeyLogin
window.oryPasskeyRegistration = __oryPasskeyRegistration
window.oryPasskeyLoginAutocompleteInit = __oryPasskeyLoginAutocompleteInit
window.__oryWebAuthnInitialized = true
window.dispatchEvent(
new CustomEvent("oryWebAuthnInitialized"),
)
})()

87
x/webauthnx/nodes.go Normal file
View File

@@ -0,0 +1,87 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package webauthnx
import (
"cmp"
"crypto/sha512"
_ "embed"
"encoding/base64"
"fmt"
"net/url"
"github.com/ory/kratos/x/webauthnx/js"
"github.com/ory/x/urlx"
"github.com/ory/kratos/identity"
"github.com/ory/kratos/text"
"github.com/ory/kratos/ui/node"
)
func NewWebAuthnConnectionTrigger(options string) *node.Node {
return node.NewInputField(node.WebAuthnRegisterTrigger, "", node.WebAuthnGroup,
node.InputAttributeTypeButton, node.WithInputAttributes(func(a *node.InputAttributes) {
//nolint:staticcheck
a.OnClick = fmt.Sprintf("%s(%s)", js.WebAuthnTriggersWebAuthnRegistration, options)
a.OnClickTrigger = js.WebAuthnTriggersWebAuthnRegistration
a.FieldValue = options
}))
}
func NewWebAuthnScript(base *url.URL) *node.Node {
src := urlx.AppendPaths(base, ScriptURL).String()
integrity := sha512.Sum512(jsOnLoad)
return node.NewScriptField(
node.WebAuthnScript,
src,
node.WebAuthnGroup,
fmt.Sprintf("sha512-%s", base64.StdEncoding.EncodeToString(integrity[:])),
)
}
func NewWebAuthnConnectionInput() *node.Node {
return node.NewInputField(node.WebAuthnRegister, "", node.WebAuthnGroup,
node.InputAttributeTypeHidden)
}
func NewWebAuthnLoginTrigger(options string) *node.Node {
return node.NewInputField(node.WebAuthnLoginTrigger, "", node.WebAuthnGroup,
node.InputAttributeTypeButton, node.WithInputAttributes(func(a *node.InputAttributes) {
//nolint:staticcheck
a.OnClick = fmt.Sprintf("%s(%s)", js.WebAuthnTriggersWebAuthnLogin, options)
a.FieldValue = options
a.OnClickTrigger = js.WebAuthnTriggersWebAuthnLogin
}))
}
func NewWebAuthnLoginInput() *node.Node {
return node.NewInputField(node.WebAuthnLogin, "", node.WebAuthnGroup,
node.InputAttributeTypeHidden)
}
func NewWebAuthnConnectionName() *node.Node {
return node.NewInputField(node.WebAuthnRegisterDisplayName, "", node.WebAuthnGroup, node.InputAttributeTypeText).
WithMetaLabel(text.NewInfoSelfServiceRegisterWebAuthnDisplayName())
}
func NewWebAuthnUnlink(c *identity.CredentialWebAuthn, opts ...node.InputAttributesModifier) *node.Node {
return node.NewInputField(
node.WebAuthnRemove,
fmt.Sprintf("%x", c.ID),
node.WebAuthnGroup,
node.InputAttributeTypeSubmit,
opts...,
).WithMetaLabel(text.NewInfoSelfServiceRemoveWebAuthn(cmp.Or(c.DisplayName, "unnamed"), c.AddedAt))
}
func NewPasskeyUnlink(c *identity.CredentialWebAuthn, opts ...node.InputAttributesModifier) *node.Node {
return node.NewInputField(
node.PasskeyRemove,
fmt.Sprintf("%x", c.ID),
node.PasskeyGroup,
node.InputAttributeTypeSubmit,
opts...,
).WithMetaLabel(text.NewInfoSelfServiceRemovePasskey(cmp.Or(c.DisplayName, "unnamed"), c.AddedAt))
}

66
x/webauthnx/signcount.go Normal file
View File

@@ -0,0 +1,66 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package webauthnx
import (
"context"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/ory/kratos/identity"
"github.com/ory/x/logrusx"
)
// SignCountDependencies is the minimal set of registry dependencies required to
// persist an updated WebAuthn signature counter after a login.
type SignCountDependencies interface {
logrusx.Provider
identity.PrivilegedPoolProvider
}
// UpdateSignCountAfterLogin writes the mutable authenticator state reported
// during a successful login back onto the stored credential: the signature
// counter, the clone warning, and the backup state.
//
// It implements the relying-party side of W3C WebAuthn signature-counter
// handling (https://www.w3.org/TR/webauthn-2/#sctn-sign-counter). When the
// go-webauthn library detects that the counter did not advance it sets
// CloneWarning, which we persist on the credential but deliberately do NOT act
// on to block the login:
//
// - Synced passkeys (Apple iCloud, Google) legitimately always report a
// counter of 0, so a hard "reject if not increasing" rule would lock out
// real users.
// - The user already proved possession of the authenticator, so a failure to
// persist the counter must not turn a successful login into an error.
//
// The clone warning is durably stored on the credential and is inspectable via
// the admin identity API, so there is no separate security log event.
//
// The write goes through UpdateCredentialsConfig, which re-reads the stored
// config under an exclusive row lock and applies UpdateFromLogin to the latest
// committed value, so concurrent logins of the same credential serialize
// instead of clobbering each other's counter. That lock lives in its own
// transaction and is deliberately NOT enrolled in the login request: the
// assertion has already succeeded, so a failed or slow counter write must never
// roll back or block the login. Persistence is therefore best-effort — errors
// are logged, not returned.
func UpdateSignCountAfterLogin(
ctx context.Context,
d SignCountDependencies,
i *identity.Identity,
credentialsType identity.CredentialsType,
validated *webauthn.Credential,
) {
err := d.PrivilegedIdentityPool().UpdateCredentialsConfig(ctx, i.ID, credentialsType,
identity.UpdateConfig(func(conf *identity.CredentialsWebAuthnConfig) error {
conf.Credentials.UpdateFromLogin(validated)
return nil
}),
)
if err != nil {
d.Logger().WithError(err).WithField("identity_id", i.ID).WithField("network_id", i.NID).
Warn("Unable to persist updated WebAuthn signature counter. The login succeeded but clone detection state was not persisted.")
}
}

47
x/webauthnx/user.go Normal file
View File

@@ -0,0 +1,47 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package webauthnx
import (
"cmp"
"github.com/go-webauthn/webauthn/webauthn"
)
var _ webauthn.User = (*User)(nil)
type User struct {
Name string
ID []byte
Credentials []webauthn.Credential
Config *webauthn.Config
}
func NewUser(id []byte, credentials []webauthn.Credential, config *webauthn.Config) *User {
return &User{
ID: id,
Credentials: credentials,
Config: config,
}
}
func (u *User) WebAuthnID() []byte {
return u.ID
}
func (u *User) WebAuthnName() string {
return cmp.Or(u.Name, u.Config.RPDisplayName)
}
func (u *User) WebAuthnDisplayName() string {
return cmp.Or(u.Name, u.Config.RPDisplayName)
}
func (u *User) WebAuthnIcon() string {
return "" // Icon option has been removed due to security considerations.
}
func (u *User) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}