Files
upstream-ory-kratos/identity/handler.go
upstream-mirror c60cc7ca75
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
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

1528 lines
49 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package identity
import (
"context"
"encoding/json"
"io"
"net/http"
"slices"
"strings"
"time"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/gofrs/uuid"
"github.com/ory/x/crdbx"
"github.com/ory/x/pagination/keysetpagination"
"github.com/ory/x/pagination/migrationpagination"
"github.com/ory/x/pagination/pagepagination"
"github.com/ory/x/sqlcon"
"github.com/ory/kratos/hash"
"github.com/ory/kratos/selfservice/strategy/deviceauthn"
"github.com/ory/kratos/x"
"github.com/ory/kratos/cipher"
"github.com/ory/herodot"
"github.com/pkg/errors"
"github.com/ory/x/decoderx"
"github.com/ory/x/jsonx"
"github.com/ory/x/openapix"
"github.com/ory/x/region"
"github.com/ory/x/sqlxx"
"github.com/ory/x/urlx"
"github.com/ory/kratos/driver/config"
)
const (
RouteCollection = "/identities"
RouteItem = RouteCollection + "/{id}"
RouteCredentialItem = RouteItem + "/credentials/{type}"
BatchPatchIdentitiesLimit = 1000
BatchPatchIdentitiesWithPasswordLimit = 200
)
type (
dependencies interface {
PoolProvider
PrivilegedPoolProvider
ManagementProvider
httpx.WriterProvider
config.Provider
nosurfx.CSRFProvider
cipher.Provider
hash.HashProvider
}
HandlerProvider interface {
IdentityHandler() *Handler
}
Handler struct{ r dependencies }
)
func NewHandler(r dependencies) *Handler { return &Handler{r: r} }
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.r.CSRFHandler().IgnoreGlobs(
RouteCollection,
RouteCollection+"/*",
RouteCollection+"/*/credentials/*",
httprouterx.AdminPrefix+RouteCollection,
httprouterx.AdminPrefix+RouteCollection+"/*",
httprouterx.AdminPrefix+RouteCollection+"/*/credentials/*",
)
public.GET(RouteCollection, redir.RedirectToAdminRoute(h.r))
public.GET(RouteCollection+"/by/external/{externalID}", redir.RedirectToAdminRoute(h.r))
public.GET(RouteItem, redir.RedirectToAdminRoute(h.r))
public.DELETE(RouteItem, redir.RedirectToAdminRoute(h.r))
public.POST(RouteCollection, redir.RedirectToAdminRoute(h.r))
public.PUT(RouteItem, redir.RedirectToAdminRoute(h.r))
public.PATCH(RouteItem, redir.RedirectToAdminRoute(h.r))
public.DELETE(RouteCredentialItem, redir.RedirectToAdminRoute(h.r))
public.GET(httprouterx.AdminPrefix+RouteCollection, redir.RedirectToAdminRoute(h.r))
public.GET(httprouterx.AdminPrefix+RouteCollection+"/by/external/{externalID}", redir.RedirectToAdminRoute(h.r))
public.GET(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r))
public.DELETE(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r))
public.POST(httprouterx.AdminPrefix+RouteCollection, redir.RedirectToAdminRoute(h.r))
public.PUT(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r))
public.PATCH(httprouterx.AdminPrefix+RouteItem, redir.RedirectToAdminRoute(h.r))
public.DELETE(httprouterx.AdminPrefix+RouteCredentialItem, redir.RedirectToAdminRoute(h.r))
}
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
admin.GET(RouteCollection, h.list)
admin.GET(RouteItem, h.get)
admin.GET(RouteCollection+"/by/external/{externalID}", h.getByExternalID)
admin.DELETE(RouteItem, h.delete)
admin.PATCH(RouteItem, h.patch)
admin.POST(RouteCollection, h.create)
admin.PATCH(RouteCollection, h.batchPatchIdentities)
admin.PUT(RouteItem, h.update)
admin.DELETE(RouteCredentialItem, h.deleteIdentityCredentials)
}
// Paginated Identity List Response
//
// swagger:response listIdentities
type _ struct {
migrationpagination.ResponseHeaderAnnotation
// List of identities
//
// in:body
Body []Identity
}
// Paginated List Identity Parameters
//
// Note: Filters cannot be combined.
//
// swagger:parameters listIdentities
type _ struct {
migrationpagination.RequestParameters
// Retrieve multiple identities by their IDs.
//
// This parameter has the following limitations:
//
// - Duplicate or non-existent IDs are ignored.
// - The order of returned IDs may be different from the request.
// - This filter does not support pagination. You must implement your own pagination as the maximum number of items returned by this endpoint may not exceed a certain threshold (currently 500).
//
// required: false
// in: query
IdsFilter []string `json:"ids"`
// CredentialsIdentifier is the identifier (username, email) of the credentials to look up using exact match.
// Only one of CredentialsIdentifier and CredentialsIdentifierSimilar can be used.
//
// required: false
// in: query
CredentialsIdentifier string `json:"credentials_identifier"`
// This is an EXPERIMENTAL parameter that WILL CHANGE. Do NOT rely on consistent, deterministic behavior.
// THIS PARAMETER WILL BE REMOVED IN AN UPCOMING RELEASE WITHOUT ANY MIGRATION PATH.
//
// CredentialsIdentifierSimilar is the (partial) identifier (username, email) of the credentials to look up using similarity search.
// Only one of CredentialsIdentifier and CredentialsIdentifierSimilar can be used.
//
// required: false
// in: query
CredentialsIdentifierSimilar string `json:"preview_credentials_identifier_similar"`
// Include Credentials in Response
//
// Include any credential, for example `password` or `oidc`, in the response. When set to `oidc`, This will return
// the initial OAuth 2.0 Access Token, OAuth 2.0 Refresh Token, and the OpenID Connect ID Token if available.
//
// required: false
// in: query
DeclassifyCredentials []string `json:"include_credential"`
// List identities that belong to a specific organization.
//
// required: false
// in: query
OrganizationID string `json:"organization_id"`
crdbx.ConsistencyRequestParameters
}
func parseListIdentitiesParameters(r *http.Request) (params ListIdentityParameters, err error) {
query := r.URL.Query()
var requestedFilters int
params.Expand = ExpandDefault
if ids := query["ids"]; len(ids) > 0 {
requestedFilters++
for _, v := range ids {
id, err := uuid.FromString(v)
if err != nil {
return params, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid UUID value `%s` for parameter `ids`.", v))
}
params.IdsFilter = append(params.IdsFilter, id)
}
}
if len(params.IdsFilter) > 500 {
return params, errors.WithStack(herodot.ErrBadRequest().WithReason("The number of ids to filter must not exceed 500."))
}
if orgID := query.Get("organization_id"); orgID != "" {
requestedFilters++
params.OrganizationID, err = uuid.FromString(orgID)
if err != nil {
return params, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid UUID value `%s` for parameter `organization_id`.", orgID))
}
}
if identifier := query.Get("credentials_identifier"); identifier != "" {
requestedFilters++
params.Expand = ExpandEverything
params.CredentialsIdentifier = identifier
}
if identifier := query.Get("preview_credentials_identifier_similar"); identifier != "" {
requestedFilters++
params.Expand = ExpandEverything
params.CredentialsIdentifierSimilar = identifier
}
for _, v := range query["include_credential"] {
params.Expand = ExpandEverything
tc, ok := ParseCredentialsType(v)
if !ok {
return params, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid value `%s` for parameter `include_credential`.", v))
}
params.DeclassifyCredentials = append(params.DeclassifyCredentials, tc)
}
if requestedFilters > 1 {
return params, errors.WithStack(herodot.ErrBadRequest().WithReason("You cannot combine multiple filters in this API"))
}
params.KeySetPagination, params.PagePagination, err = x.ParseKeysetOrPagePagination(r)
if err != nil {
return params, err
}
params.ConsistencyLevel = crdbx.ConsistencyLevelFromRequest(r)
return params, nil
}
// swagger:route GET /admin/identities identity listIdentities
//
// # List Identities
//
// Lists all [identities](https://www.ory.com/docs/kratos/concepts/identity-user-model) in the system. Note: filters cannot be combined.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 200: listIdentities
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-medium
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
params, err := parseListIdentitiesParameters(r)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
is, nextPage, err := h.r.IdentityPool().ListIdentities(r.Context(), params)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
if params.PagePagination != nil {
total := int64(len(is))
if params.CredentialsIdentifier == "" {
total, err = h.r.IdentityPool().CountIdentities(r.Context())
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
}
u := *r.URL
pagepagination.PaginationHeader(w, &u, total, params.PagePagination.Page, params.PagePagination.ItemsPerPage)
} else if nextPage != nil {
u := *r.URL
keysetpagination.Header(w, &u, nextPage)
}
// Identities using the marshaler for including metadata_admin
isam := make([]WithCredentialsAndAdminMetadataInJSON, len(is))
for i, identity := range is {
emit, err := identity.WithDeclassifiedCredentials(r.Context(), h.r, params.DeclassifyCredentials)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
isam[i] = WithCredentialsAndAdminMetadataInJSON(*emit)
}
h.r.Writer().Write(w, r, isam)
}
// Get Identity Parameters
//
// swagger:parameters getIdentity
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getIdentity struct {
// ID must be set to the ID of identity you want to get
//
// required: true
// in: path
ID string `json:"id"`
// Include Credentials in Response
//
// Include any credential, for example `password` or `oidc`, in the response. When set to `oidc`, This will return
// the initial OAuth 2.0 Access Token, OAuth 2.0 Refresh Token, and the OpenID Connect ID Token if available.
//
// required: false
// in: query
DeclassifyCredentials []CredentialsType `json:"include_credential"`
}
// Get Identity By External ID Parameters
//
// swagger:parameters getIdentityByExternalID
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type getIdentityByExternalID struct {
// ExternalID must be set to the ID of identity you want to get
//
// required: true
// in: path
ExternalID string `json:"externalID"`
// Include Credentials in Response
//
// Include any credential, for example `password` or `oidc`, in the response. When set to `oidc`, This will return
// the initial OAuth 2.0 Access Token, OAuth 2.0 Refresh Token, and the OpenID Connect ID Token if available.
//
// required: false
// in: query
DeclassifyCredentials []CredentialsType `json:"include_credential"`
}
// swagger:route GET /admin/identities/{id} identity getIdentity
//
// # Get an Identity
//
// Return an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) by its ID. You can optionally
// include credentials (e.g. social sign in connections) in the response by using the `include_credential` query parameter.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 200: identity
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-high
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
i, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), x.ParseUUID(r.PathValue("id")))
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
includeCredentials := r.URL.Query()["include_credential"]
var declassify []CredentialsType
for _, v := range includeCredentials {
tc, ok := ParseCredentialsType(v)
if ok {
declassify = append(declassify, tc)
} else {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid value `%s` for parameter `include_credential`.", declassify)))
return
}
}
emit, err := i.WithDeclassifiedCredentials(r.Context(), h.r, declassify)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
h.r.Writer().Write(w, r, WithCredentialsAndAdminMetadataInJSON(*emit))
}
// swagger:route GET /admin/identities/by/external/{externalID} identity getIdentityByExternalID
//
// # Get an Identity by its External ID
//
// Return an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) by its external ID. You can optionally
// include credentials (e.g. social sign in connections) in the response by using the `include_credential` query parameter.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 200: identity
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-medium
func (h *Handler) getByExternalID(w http.ResponseWriter, r *http.Request) {
externalID := r.PathValue("externalID")
if externalID == "" {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("The external ID must not be empty.")))
return
}
i, err := h.r.PrivilegedIdentityPool().FindIdentityByExternalID(r.Context(), externalID, ExpandEverything)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
includeCredentials := r.URL.Query()["include_credential"]
var declassify []CredentialsType
for _, v := range includeCredentials {
tc, ok := ParseCredentialsType(v)
if ok {
declassify = append(declassify, tc)
} else {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Invalid value `%s` for parameter `include_credential`.", declassify)))
return
}
}
emit, err := i.WithDeclassifiedCredentials(r.Context(), h.r, declassify)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
h.r.Writer().Write(w, r, WithCredentialsAndAdminMetadataInJSON(*emit))
}
// Create Identity Parameters
//
// swagger:parameters createIdentity
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type createIdentity struct {
// in: body
Body CreateIdentityBody
}
// Create Identity Body
//
// swagger:model createIdentityBody
type CreateIdentityBody struct {
// SchemaID is the ID of the JSON Schema to be used for validating the identity's traits.
//
// required: true
SchemaID string `json:"schema_id"`
// Traits represent an identity's traits. The identity is able to create, modify, and delete traits
// in a self-service manner. The input will always be validated against the JSON Schema defined
// in `schema_url`.
//
// required: true
Traits json.RawMessage `json:"traits"`
// Credentials represents all credentials that can be used for authenticating this identity.
//
// Use this structure to import credentials for a user.
Credentials *IdentityWithCredentials `json:"credentials"`
// VerifiableAddresses contains all the addresses that can be verified by the user.
//
// Use this structure to import verified addresses for an identity. Please keep in mind
// that the address needs to be represented in the Identity Schema or this field will be overwritten
// on the next identity update.
VerifiableAddresses []VerifiableAddress `json:"verifiable_addresses"`
// RecoveryAddresses contains all the addresses that can be used to recover an identity.
//
// Use this structure to import recovery addresses for an identity. Please keep in mind
// that the address needs to be represented in the Identity Schema or this field will be overwritten
// on the next identity update.
RecoveryAddresses []RecoveryAddress `json:"recovery_addresses"`
// Store metadata about the identity which the identity itself can see when calling for example the
// session endpoint. Do not store sensitive information (e.g. credit score) about the identity in this field.
MetadataPublic json.RawMessage `json:"metadata_public"`
// Store metadata about the user which is only accessible through admin APIs such as `GET /admin/identities/<id>`.
MetadataAdmin json.RawMessage `json:"metadata_admin,omitempty"`
// State is the identity's state.
//
// required: false
State State `json:"state"`
// OrganizationID is the ID of the organization to which the identity belongs.
//
// required: false
OrganizationID uuid.NullUUID `json:"organization_id"`
// ExternalID is an optional external ID of the identity. This is used to link
// the identity to an external system. If set, the external ID must be unique
// across all identities.
//
// required: false
ExternalID string `json:"external_id,omitempty"`
// Region is the Ory Network region this identity will be created in.
// Optional; defaults to the project home region if omitted. Only effective
// on the Ory Network.
//
// required: false
Region region.Region `json:"region,omitempty"`
}
// Create Identity and Import Credentials
//
// swagger:model identityWithCredentials
type IdentityWithCredentials struct {
// Password if set will import a password credential.
Password *AdminIdentityImportCredentialsPassword `json:"password"`
// OIDC if set will import an OIDC credential.
OIDC *AdminIdentityImportCredentialsOIDC `json:"oidc"`
// SAML if set will import a SAML credential.
SAML *AdminIdentityImportCredentialsSAML `json:"saml"`
// TOTP if set will import a TOTP credential.
TOTP *AdminIdentityImportCredentialsTOTP `json:"totp"`
// WebAuthn if set will import a WebAuthn credential.
WebAuthn *AdminIdentityImportCredentialsWebAuthn `json:"webauthn"`
// Passkey if set will import a Passkey credential.
Passkey *AdminIdentityImportCredentialsPasskey `json:"passkey"`
// LookupSecret if set will import a magic code credential.
LookupSecret *AdminIdentityImportCredentialsLookupSecret `json:"lookup_secret"`
}
// Create Identity and Import TOTP 2FA Credentials
//
// swagger:model identityWithCredentialsTotp
type AdminIdentityImportCredentialsTOTP struct {
// Configuration options for the import.
Config AdminIdentityImportCredentialsTOTPConfig `json:"config"`
}
// Create Identity and Import TOTP 2FA Credentials Configuration
//
// swagger:model identityWithCredentialsTotpConfig
type AdminIdentityImportCredentialsTOTPConfig struct {
// TOTPURL is the TOTP URL
//
// For more details see: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
TOTPURL string `json:"totp_url"`
}
// Create Identity and Import WebAuthn Credentials
//
// swagger:model identityWithCredentialsWebAuthn
type AdminIdentityImportCredentialsWebAuthn struct {
// Configuration options for the import.
Config AdminIdentityImportCredentialsWebAuthnConfig `json:"config"`
}
// Create Identity and Import WebAuthn Credentials Configuration
//
// swagger:model identityWithCredentialsWebAuthnConfig
type AdminIdentityImportCredentialsWebAuthnConfig struct {
// List of webauthn credentials.
Credentials CredentialsWebAuthn `json:"credentials"`
// UserHandle is the user handle of the webauthn credential.
UserHandle []byte `json:"user_handle"`
}
// Create Identity and Import Passkey Credentials
//
// swagger:model identityWithCredentialsPasskey
type AdminIdentityImportCredentialsPasskey struct {
// Configuration options for the import.
Config AdminIdentityImportCredentialsPasskeyConfig `json:"config"`
}
// Create Identity and Import Passkey Credentials Configuration
//
// swagger:model identityWithCredentialsPasskeyConfig
type AdminIdentityImportCredentialsPasskeyConfig struct {
// List of webauthn credentials.
Credentials CredentialsWebAuthn `json:"credentials"`
// UserHandle is the user handle of the webauthn credential.
UserHandle []byte `json:"user_handle"`
}
// Create Identity and Import Lookup Secret Credentials
//
// swagger:model identityWithCredentialsLookup Secret
type AdminIdentityImportCredentialsLookupSecret struct {
// Configuration options for the import.
Config AdminIdentityImportCredentialsLookupSecretConfig `json:"config"`
}
// Create Identity and Import Lookup Secret Credentials Configuration
//
// swagger:model identityWithCredentialsLookup SecretConfig
type AdminIdentityImportCredentialsLookupSecretConfig struct {
// Codes is a list of "lookup secret" codes configured for the user.
Codes []RecoveryCode `json:"codes"`
}
// Create Identity and Import Password Credentials
//
// swagger:model identityWithCredentialsPassword
type AdminIdentityImportCredentialsPassword struct {
// Configuration options for the import.
Config AdminIdentityImportCredentialsPasswordConfig `json:"config"`
}
// Create Identity and Import Password Credentials Configuration
//
// swagger:model identityWithCredentialsPasswordConfig
type AdminIdentityImportCredentialsPasswordConfig struct {
// The hashed password in [PHC format](https://www.ory.com/docs/kratos/manage-identities/import-user-accounts-identities#hashed-passwords)
HashedPassword string `json:"hashed_password"`
// The password in plain text if no hash is available.
Password string `json:"password"`
// If set to true, the password will be migrated using the password migration hook.
UsePasswordMigrationHook bool `json:"use_password_migration_hook,omitempty"`
}
// Create Identity and Import Social Sign In Credentials
//
// swagger:model identityWithCredentialsOidc
type AdminIdentityImportCredentialsOIDC struct {
// Configuration options for the import.
Config AdminIdentityImportCredentialsOIDCConfig `json:"config"`
}
// swagger:model identityWithCredentialsOidcConfig
type AdminIdentityImportCredentialsOIDCConfig struct {
// A list of OpenID Connect Providers
Providers []AdminCreateIdentityImportCredentialsOIDCProvider `json:"providers"`
}
// Create Identity and Import Social Sign In Credentials Configuration
//
// swagger:model identityWithCredentialsOidcConfigProvider
type AdminCreateIdentityImportCredentialsOIDCProvider struct {
// The subject (`sub`) of the OpenID Connect connection. Usually the `sub` field of the ID Token.
//
// required: true
Subject string `json:"subject"`
// The OpenID Connect provider to link the subject to. Usually something like `google` or `github`.
//
// required: true
Provider string `json:"provider"`
// If set, this credential allows the user to sign in using the OpenID Connect provider without setting the subject first.
//
// required: false
UseAutoLink bool `json:"use_auto_link,omitempty"`
// The organization to assign for the provider.
Organization uuid.NullUUID `json:"organization,omitempty"`
}
// Payload to import SAML credentials
//
// swagger:model identityWithCredentialsSaml
type AdminIdentityImportCredentialsSAML struct {
// Configuration options for the import.
Config AdminIdentityImportCredentialsSAMLConfig `json:"config"`
}
// Payload of SAML providers
//
// swagger:model identityWithCredentialsSamlConfig
type AdminIdentityImportCredentialsSAMLConfig struct {
// A list of SAML Providers
Providers []AdminCreateIdentityImportCredentialsSAMLProvider `json:"providers"`
}
// Payload of specific SAML provider
//
// swagger:model identityWithCredentialsSamlConfigProvider
type AdminCreateIdentityImportCredentialsSAMLProvider struct {
// The unique subject of the SAML connection. This value must be immutable at the source.
//
// required: true
Subject string `json:"subject"`
// The SAML provider to link the subject to.
//
// required: true
Provider string `json:"provider"`
// The organization to assign for the provider.
Organization uuid.NullUUID `json:"organization"`
}
// swagger:route POST /admin/identities identity createIdentity
//
// # Create an Identity
//
// Create an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model). This endpoint can also be used to
// [import credentials](https://www.ory.com/docs/kratos/manage-identities/import-user-accounts-identities)
// for instance passwords, social sign in configurations, or multifactor methods.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 201: identity
// 400: errorGeneric
// 409: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
var cr CreateIdentityBody
if err := jsonx.NewStrictDecoder(r.Body).Decode(&cr); err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithError(err.Error())))
return
}
i, err := h.identityFromCreateIdentityBody(r.Context(), &cr)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
if err := h.r.IdentityManager().Create(r.Context(), i); err != nil {
if errors.Is(err, sqlcon.ErrUniqueViolation()) {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrConflict().WithReason("This identity conflicts with another identity that already exists.")))
} else {
h.r.Writer().WriteError(w, r, err)
}
return
}
h.r.Writer().WriteCreated(w, r,
urlx.AppendPaths(
h.r.Config().SelfAdminURL(r.Context()),
"identities",
i.ID.String(),
).String(),
WithCredentialsNoConfigAndAdminMetadataInJSON(*i),
)
}
func (h *Handler) identityFromCreateIdentityBody(ctx context.Context, cr *CreateIdentityBody) (*Identity, error) {
stateChangedAt := sqlxx.NullTime(time.Now().UTC())
state := StateActive
if cr.State != "" {
if err := cr.State.IsValid(); err != nil {
return nil, errors.WithStack(herodot.ErrBadRequest().WithReasonf("%s", err).WithWrap(err))
}
state = cr.State
}
// Validate at the API boundary; the persister re-checks under home_region.
if cr.Region != "" && !cr.Region.Valid() {
return nil, region.NewErrInvalid()
}
i := &Identity{
SchemaID: cr.SchemaID,
Traits: []byte(cr.Traits),
State: state,
StateChangedAt: &stateChangedAt,
VerifiableAddresses: cr.VerifiableAddresses,
RecoveryAddresses: cr.RecoveryAddresses,
MetadataAdmin: []byte(cr.MetadataAdmin),
MetadataPublic: []byte(cr.MetadataPublic),
OrganizationID: cr.OrganizationID,
ExternalID: sqlxx.NullString(cr.ExternalID),
Region: cr.Region,
}
// Lowercase all emails, because the schema extension will otherwise not find them.
for k := range i.VerifiableAddresses {
i.VerifiableAddresses[k].Value = strings.ToLower(i.VerifiableAddresses[k].Value)
}
for k := range i.RecoveryAddresses {
i.RecoveryAddresses[k].Value = strings.ToLower(i.RecoveryAddresses[k].Value)
}
if err := h.importCredentials(ctx, i, cr.Credentials); err != nil {
return nil, err
}
return i, nil
}
// swagger:route PATCH /admin/identities identity batchPatchIdentities
//
// # Create multiple identities
//
// Creates multiple [identities](https://www.ory.com/docs/kratos/concepts/identity-user-model).
//
// You can also use this endpoint to [import credentials](https://www.ory.com/docs/kratos/manage-identities/import-user-accounts-identities),
// including passwords, social sign-in settings, and multi-factor authentication methods.
//
// If the patch includes hashed passwords you can import up to 1,000 identities per request.
//
// If the patch includes at least one plaintext password you can import up to 200 identities per request.
//
// Avoid importing large batches with plaintext passwords. They can cause timeouts as the passwords need to be hashed before they are stored.
//
// If at least one identity is imported successfully, the response status is 200 OK.
// If all imports fail, the response is one of the following 4xx errors:
// - 400 Bad Request: The request payload is invalid or improperly formatted.
// - 409 Conflict: Duplicate identities or conflicting data were detected.
//
// If you get a 504 Gateway Timeout:
// - Reduce the batch size
// - Avoid duplicate identities
// - Pre-hash passwords with BCrypt
//
// If the issue persists, contact support.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 200: batchPatchIdentitiesResponse
// 400: errorGeneric
// 409: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) batchPatchIdentities(w http.ResponseWriter, r *http.Request) {
var (
req BatchPatchIdentitiesBody
res batchPatchIdentitiesResponse
)
if err := jsonx.NewStrictDecoder(r.Body).Decode(&req); err != nil {
h.r.Writer().WriteErrorCode(w, r, http.StatusBadRequest, errors.WithStack(err))
return
}
if len(req.Identities) > BatchPatchIdentitiesLimit {
h.r.Writer().WriteErrorCode(w, r, http.StatusBadRequest,
errors.WithStack(herodot.ErrBadRequest().WithReasonf(
"The maximum number of identities per request that can be created or deleted at once is %d.",
BatchPatchIdentitiesLimit)))
return
}
res.Identities = make([]*BatchIdentityPatchResponse, len(req.Identities))
// Array to look up the index of the identity in the identities array.
indexInIdentities := make([]*int, len(req.Identities))
identities := make([]*Identity, 0, len(req.Identities))
var withUnHashedPasswordCount int
for i, patch := range req.Identities {
if patch.Create != nil {
res.Identities[i] = &BatchIdentityPatchResponse{
Action: ActionCreate,
PatchID: patch.ID,
}
identity, err := h.identityFromCreateIdentityBody(r.Context(), patch.Create)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
identities = append(identities, identity)
idx := len(identities) - 1
indexInIdentities[i] = &idx
if patch.Create.Credentials != nil && patch.Create.Credentials.Password != nil &&
patch.Create.Credentials.Password.Config.Password != "" {
withUnHashedPasswordCount++
}
}
}
if withUnHashedPasswordCount > BatchPatchIdentitiesWithPasswordLimit {
h.r.Writer().WriteErrorCode(w, r, http.StatusBadRequest,
errors.WithStack(herodot.ErrBadRequest().WithReasonf(
"The maximum number of identities per request that can be created with a plaintext password is %d.",
BatchPatchIdentitiesWithPasswordLimit)))
return
}
err := h.r.IdentityManager().CreateIdentities(r.Context(), identities)
partialErr := new(CreateIdentitiesError)
if err != nil && !errors.As(err, &partialErr) {
h.r.Writer().WriteError(w, r, err)
return
}
for resIdx, identitiesIdx := range indexInIdentities {
if identitiesIdx != nil {
ident := identities[*identitiesIdx]
// Check if the identity was created successfully.
if failed := partialErr.Find(ident); failed != nil {
res.Identities[resIdx].Action = ActionError
res.Identities[resIdx].Error = failed.Error
} else {
res.Identities[resIdx].IdentityID = &ident.ID
}
}
}
h.r.Writer().Write(w, r, &res)
}
// Update Identity Parameters
//
// swagger:parameters updateIdentity
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type updateIdentity struct {
// ID must be set to the ID of identity you want to update
//
// required: true
// in: path
ID string `json:"id"`
// in: body
Body UpdateIdentityBody
}
// Update Identity Body
//
// swagger:model updateIdentityBody
type UpdateIdentityBody struct {
// SchemaID is the ID of the JSON Schema to be used for validating the identity's traits. If set
// will update the Identity's SchemaID.
//
// required: true
SchemaID string `json:"schema_id"`
// Traits represent an identity's traits. The identity is able to create, modify, and delete traits
// in a self-service manner. The input will always be validated against the JSON Schema defined
// in `schema_id`.
//
// required: true
Traits json.RawMessage `json:"traits"`
// Credentials represents all credentials that can be used for authenticating this identity.
//
// Use this structure to import credentials for a user.
// Note: this wil override completely identity's credentials. If used incorrectly, this can cause a user to lose
// access to their account!
Credentials *IdentityWithCredentials `json:"credentials"`
// Store metadata about the identity which the identity itself can see when calling for example the
// session endpoint. Do not store sensitive information (e.g. credit score) about the identity in this field.
MetadataPublic json.RawMessage `json:"metadata_public"`
// Store metadata about the user which is only accessible through admin APIs such as `GET /admin/identities/<id>`.
MetadataAdmin json.RawMessage `json:"metadata_admin,omitempty"`
// State is the identity's state.
//
// required: true
State State `json:"state"`
// ExternalID is an optional external ID of the identity. This is used to link
// the identity to an external system. If set, the external ID must be unique
// across all identities.
//
// required: false
ExternalID string `json:"external_id,omitempty"`
// Region is the Ory Network region this identity is homed in. Optional;
// omit to leave the current region unchanged.
//
// required: false
Region region.Region `json:"region,omitempty"`
}
// swagger:route PUT /admin/identities/{id} identity updateIdentity
//
// # Update an Identity
//
// This endpoint updates an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model). The full identity
// payload (except credentials) is expected.
//
// It is possible to update the identity's credentials as well. Using this operation, credentials will not be overwritten
// but instead added to the list. For example, if a user has a social sign in connection set up, updating the credentials
// will keep the social sign in connection and add the new credentials to the list. This prevents accidentally overwriting
// credentials and locking out users. A complete view of all credential types is here:
//
// - `password`: The existing password credential will be completely replaced with the new configuration. You can provide either a hashed password, a plaintext password (which will be hashed), or enable the password migration hook.
// - `oidc`, `saml`: The existing OIDC and SAML credentials will be kept and the new credentials will be added to the list.
// - `totp`: The existing TOTP credentials will be replaced with the new configuration.
// - `lookup_secret`: The existing Lookup Secret codes will be kept and the new codes will be added to the list.
// - `webauthn`, `passkey`: The existing credentials are preserved, new credentials are added, and credentials with matching IDs are updated with new values. If a new `user_handle` is provided, it's added to the identity's identifiers list while preserving previous user handles.
// - `code`: To import code credentials, configure your identity schema to use one of the identity traits as an identifier source (`{"ory.sh/kratos":{"code":{"identifier":true", "via":"email"}}}`).
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 200: identity
// 400: errorGeneric
// 404: errorGeneric
// 409: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
var ur UpdateIdentityBody
if err := decoderx.Decode(r, &ur,
decoderx.HTTPJSONDecoder()); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
id := x.ParseUUID(r.PathValue("id"))
identity, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), id)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
if ur.SchemaID != "" {
identity.SchemaID = ur.SchemaID
}
if ur.State != "" && identity.State != ur.State {
if err := ur.State.IsValid(); err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("%s", err).WithWrap(err)))
return
}
stateChangedAt := sqlxx.NullTime(time.Now())
identity.State = ur.State
identity.StateChangedAt = &stateChangedAt
}
identity.Traits = []byte(ur.Traits)
identity.MetadataPublic = []byte(ur.MetadataPublic)
identity.MetadataAdmin = []byte(ur.MetadataAdmin)
identity.ExternalID = sqlxx.NullString(ur.ExternalID)
// Empty preserves the current region; older clients that pre-date the
// field must not silently wipe it.
if ur.Region != "" {
if !ur.Region.Valid() {
h.r.Writer().WriteError(w, r, region.NewErrInvalid())
return
}
identity.Region = ur.Region
}
// Although this is PUT and not PATCH, if the Credentials are not supplied keep the old one
if ur.Credentials != nil {
if err := h.importCredentials(r.Context(), identity, ur.Credentials); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
}
if err := h.r.IdentityManager().Update(
r.Context(),
identity,
ManagerAllowWriteProtectedTraits,
); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
h.r.Writer().Write(w, r, WithCredentialsNoConfigAndAdminMetadataInJSON(*identity))
}
// Delete Identity Parameters
//
// swagger:parameters deleteIdentity
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type deleteIdentity struct {
// ID is the identity's ID.
//
// required: true
// in: path
ID string `json:"id"`
}
// swagger:route DELETE /admin/identities/{id} identity deleteIdentity
//
// # Delete an Identity
//
// Calling this endpoint irrecoverably and permanently deletes the [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) given its ID. This action can not be undone.
// This endpoint returns 204 when the identity was deleted or 404 if the identity was not found.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 204: emptyResponse
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
if err := h.r.PrivilegedIdentityPool().DeleteIdentity(r.Context(), x.ParseUUID(r.PathValue("id"))); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Patch Identity Parameters
//
// swagger:parameters patchIdentity
//
//nolint:deadcode,unused
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
type patchIdentity struct {
// ID must be set to the ID of identity you want to update
//
// required: true
// in: path
ID string `json:"id"`
// in: body
Body openapix.JSONPatchDocument
}
// patchOp is a minimal JSON Patch op used for /region pre-processing.
type patchOp struct {
Op string `json:"op"`
Path string `json:"path"`
From string `json:"from,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
}
// applyRegionPatchOps applies /region ops to ident.Region and returns the
// patch with those ops removed. Only add/replace are accepted — remove and
// other ops on /region return 400 (callers must always set a region
// explicitly).
func applyRegionPatchOps(body []byte, ident *Identity) ([]byte, error) {
var ops []patchOp
if err := json.Unmarshal(body, &ops); err != nil {
return nil, errors.WithStack(herodot.ErrBadRequest().
WithReason("failed to unmarshal JSON patch").
WithError(err.Error()).
WithWrap(err))
}
rest := make([]patchOp, 0, len(ops))
for _, op := range ops {
if op.Path != "/region" {
rest = append(rest, op)
continue
}
if op.Op != "add" && op.Op != "replace" {
return nil, errors.WithStack(herodot.ErrBadRequest().
WithReasonf("operation %q is not supported on /region", op.Op))
}
var v string
if err := json.Unmarshal(op.Value, &v); err != nil {
return nil, errors.WithStack(herodot.ErrBadRequest().
WithReason("failed to unmarshal").WithWrap(err))
}
r := region.Region(v)
if !r.Valid() {
return nil, region.NewErrInvalid()
}
ident.Region = r
}
if len(rest) == 0 {
return []byte("[]"), nil
}
return json.Marshal(rest)
}
// swagger:route PATCH /admin/identities/{id} identity patchIdentity
//
// # Patch an Identity
//
// Partially updates an [identity's](https://www.ory.com/docs/kratos/concepts/identity-user-model) field using [JSON Patch](https://jsonpatch.com/).
// The fields `id`, `stateChangedAt` and `credentials` can not be updated using this method.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 200: identity
// 400: errorGeneric
// 404: errorGeneric
// 409: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) patch(w http.ResponseWriter, r *http.Request) {
requestBody, err := io.ReadAll(r.Body)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
id := x.ParseUUID(r.PathValue("id"))
ident, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(r.Context(), id)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
// Apply /region ops to ident.Region and strip them from the patch.
// ApplyJSONPatch can't handle them directly because the field is
// omitempty and a "remove" on an absent key would error.
filteredPatch, err := applyRegionPatchOps(requestBody, ident)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
oldState := ident.State
patchedIdentity, err := jsonx.ApplyJSONPatch(filteredPatch, WithCredentialsAndAdminMetadataInJSON(*ident), "/id", "/stateChangedAt", "/credentials", "/credentials/oidc", "/credentials/oidc/**", "/credentials/saml", "/credentials/saml/**")
if err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(
herodot.ErrBadRequest().
WithReason("failed to apply JSON patch").
WithError(err.Error()).
WithWrap(err),
))
return
}
// Reject patches that desynchronize a credential's "type" field from
// its map key. The patch deny-list lets callers replace whole
// `/credentials/<key>` subtrees or add new keys; a value without a
// "type" field would otherwise either fail deep inside the persister
// with a misleading "code bug" 500 (new key path) or silently
// overwrite the existing credential's config (replace path) — for
// password credentials this destroys `hashed_password`.
// IdentityManager.ValidateIdentity also enforces this as a safeguard,
// but the handler-level check rejects the request before the
// confidential-identity reload and gives a sharper error path.
if err := ValidateCredentialsIntegrity(patchedIdentity.Credentials); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
// ApplyJSONPatch can't see omitempty-stripped fields; carry forward.
patchedIdentity.Region = ident.Region
if oldState != patchedIdentity.State {
// Check if the changed state was actually valid
if err := patchedIdentity.State.IsValid(); err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(
herodot.ErrBadRequest().
WithReasonf("The supplied state ('%s') was not valid. Valid states are ('%s', '%s').", string(patchedIdentity.State), StateActive, StateInactive).
WithErrorf("%v", err).
WithWrap(err),
))
return
}
// If the state changed, we need to update the timestamp of it
stateChangedAt := sqlxx.NullTime(time.Now())
patchedIdentity.StateChangedAt = &stateChangedAt
}
updatedIdentity := Identity(patchedIdentity)
if err := h.r.IdentityManager().Update(
r.Context(),
&updatedIdentity,
ManagerAllowWriteProtectedTraits,
); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
h.r.Writer().Write(w, r, WithCredentialsNoConfigAndAdminMetadataInJSON(updatedIdentity))
}
// Delete Credential Parameters
//
// swagger:parameters deleteIdentityCredentials
type _ struct {
// ID is the identity's ID.
//
// required: true
// in: path
ID string `json:"id"`
// Type is the type of credentials to delete.
//
// required: true
// in: path
Type CredentialsType `json:"type"`
// Identifier is the identifier of the credential to delete. It is required
// for the `oidc`, `saml`, and `deviceauthn` credential types: for `oidc`
// and `saml` it selects the provider link to remove, for `deviceauthn` it
// is the `client_key_id` of the device key to revoke. Find the identifier
// by calling the `GET /admin/identities/{id}?include_credential={type}`
// endpoint.
//
// required: false
// in: query
Identifier string `json:"identifier"`
}
// swagger:route DELETE /admin/identities/{id}/credentials/{type} identity deleteIdentityCredentials
//
// # Delete a credential for a specific identity
//
// Delete an [identity](https://www.ory.com/docs/kratos/concepts/identity-user-model) credential by its type.
// You cannot delete passkeys or code auth credentials through this API.
//
// Consumes:
// - application/json
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Security:
// oryAccessToken:
//
// Responses:
// 204: emptyResponse
// 400: errorGeneric
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-low
func (h *Handler) deleteIdentityCredentials(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// DeviceAuthn revokes a single key selected by the `identifier` query
// parameter (the key's client_key_id) rather than dropping the whole
// credential. Its read-modify-write runs under an exclusive row lock (see
// deleteDeviceAuthnKey), so it does not go through the shared in-memory
// read-then-update path below.
if CredentialsType(r.PathValue("type")) == CredentialsTypeDeviceAuthn {
if err := h.deleteDeviceAuthnKey(ctx, x.ParseUUID(r.PathValue("id")), r.URL.Query().Get("identifier")); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
identity, err := h.r.PrivilegedIdentityPool().GetIdentityConfidential(ctx, x.ParseUUID(r.PathValue("id")))
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
cred, ok := identity.GetCredentials(CredentialsType(r.PathValue("type")))
if !ok {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrNotFound().WithReasonf("You tried to remove a %s but this user have no %s set up.", r.PathValue("type"), r.PathValue("type"))))
return
}
switch cred.Type {
case CredentialsTypeLookup, CredentialsTypeTOTP:
identity.DeleteCredentialsType(cred.Type)
case CredentialsTypeWebAuthn:
if err = identity.deleteCredentialWebAuthFromIdentity(); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
case CredentialsTypePassword, CredentialsTypeOIDC, CredentialsTypeSAML:
firstFactor, err := h.r.IdentityManager().CountActiveFirstFactorCredentials(ctx, identity)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
if firstFactor < 2 {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("You cannot remove the last first factor credential.")))
return
}
switch cred.Type {
case CredentialsTypePassword:
if err := identity.deleteCredentialPassword(); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
case CredentialsTypeOIDC, CredentialsTypeSAML:
if err := identity.deleteCredentialOIDCSAMLFromIdentity(cred.Type, r.URL.Query().Get("identifier")); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
}
default:
// A bunch of credential type deletions are not yet implemented, e.g. passkeys, etc.
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Credentials type %s cannot be deleted.", cred.Type)))
return
}
if err := h.r.IdentityManager().Update(
ctx,
identity,
ManagerAllowWriteProtectedTraits,
); err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// deleteDeviceAuthnKey revokes the DeviceAuthn key whose client_key_id equals
// identifier. The read-modify-write of the credential config runs under the
// persister's exclusive row lock (SELECT ... FOR UPDATE), so concurrent
// revocations serialize and none is lost; the credential's identifier rows are
// synced to the surviving keys inside the same transaction. When the last key
// is removed the config is left empty (the credential row is not deleted),
// mirroring the DeviceAuthn settings flow. It returns 400 when identifier is
// empty and 404 when the identity has no DeviceAuthn credential or no key
// matches.
func (h *Handler) deleteDeviceAuthnKey(ctx context.Context, id uuid.UUID, identifier string) error {
if identifier == "" {
return errors.WithStack(herodot.ErrBadRequest().WithReason("You must provide an identifier to delete this credential."))
}
return h.r.PrivilegedIdentityPool().UpdateCredentialsConfig(
ctx,
id,
CredentialsTypeDeviceAuthn,
UpdateConfig(func(cfg *deviceauthn.CredentialsDeviceAuthnConfig) error {
before := len(cfg.Credentials)
cfg.Credentials = slices.DeleteFunc(cfg.Credentials, func(k deviceauthn.Key) bool {
return k.ClientKeyID == identifier
})
if len(cfg.Credentials) == before {
return errors.WithStack(herodot.ErrNotFound().WithReasonf("The identifier `%s` was not found among deviceauthn credentials.", identifier))
}
return nil
}),
// The identifier rows are derived state of the config: sync them to the
// surviving keys' client_key_ids under the same lock.
WithDerivedIdentifiers(func(newConfig []byte) ([]string, error) {
var cfg deviceauthn.CredentialsDeviceAuthnConfig
if err := json.Unmarshal(newConfig, &cfg); err != nil {
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReason("Unable to decode identity credentials.").WithDebug(err.Error()))
}
ids := make([]string, len(cfg.Credentials))
for i, k := range cfg.Credentials {
ids[i] = k.ClientKeyID
}
return ids, nil
}),
)
}