From ecd5f8642f3153c5c7bc28589e6863ab31ffd98d Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 18 May 2026 13:34:22 -0700 Subject: [PATCH] feat(passkey): add WebAuthn passkey ceremony package --- go.mod | 15 +- go.sum | 38 +- passkey/clone.go | 103 ++++ passkey/doc.go | 8 + passkey/errors.go | 34 ++ passkey/service.go | 559 +++++++++++++++++++ passkey/service_test.go | 1115 ++++++++++++++++++++++++++++++++++++++ passkey/store.go | 30 + passkey/types.go | 174 ++++++ passkey/webauthn_user.go | 112 ++++ 10 files changed, 2177 insertions(+), 11 deletions(-) create mode 100644 passkey/clone.go create mode 100644 passkey/doc.go create mode 100644 passkey/errors.go create mode 100644 passkey/service.go create mode 100644 passkey/service_test.go create mode 100644 passkey/store.go create mode 100644 passkey/types.go create mode 100644 passkey/webauthn_user.go diff --git a/go.mod b/go.mod index 25e2272..b65e14e 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/casbin/casbin/v3 v3.10.0 + github.com/go-webauthn/webauthn v0.17.3 github.com/google/cel-go v0.28.0 github.com/jackc/pgx/v5 v5.9.2 github.com/lestrrat-go/jwx/v3 v3.1.1 @@ -34,10 +35,15 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.5 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -62,25 +68,28 @@ require ( github.com/moby/term v0.5.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/valyala/fastjson v1.6.10 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - golang.org/x/crypto v0.50.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index ec22ab5..a52b460 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -54,13 +56,25 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo= +github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ= +github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY= +github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -121,6 +135,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -145,12 +161,16 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -167,10 +187,12 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= @@ -178,12 +200,12 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= diff --git a/passkey/clone.go b/passkey/clone.go new file mode 100644 index 0000000..e3e6623 --- /dev/null +++ b/passkey/clone.go @@ -0,0 +1,103 @@ +package passkey + +import ( + "maps" + + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/meigma/authkit" +) + +func cloneConfig(config Config) Config { + return Config{ + RPID: config.RPID, + RPDisplayName: config.RPDisplayName, + RPOrigins: cloneStrings(config.RPOrigins), + RegistrationTimeout: config.RegistrationTimeout, + LoginTimeout: config.LoginTimeout, + } +} + +func cloneUser(user User) User { + return User{ + RPID: user.RPID, + PrincipalID: user.PrincipalID, + Handle: cloneBytes(user.Handle), + Name: user.Name, + DisplayName: user.DisplayName, + } +} + +func cloneCredential(credential Credential) Credential { + return Credential{ + RPID: credential.RPID, + PrincipalID: credential.PrincipalID, + UserHandle: cloneBytes(credential.UserHandle), + CredentialID: cloneBytes(credential.CredentialID), + WebAuthn: cloneWebAuthnCredential(credential.WebAuthn), + } +} + +func cloneRegistration(registration Registration) Registration { + return Registration{ + User: cloneUser(registration.User), + Credential: cloneCredential(registration.Credential), + Identity: cloneIdentity(registration.Identity), + } +} + +func cloneCredentials(credentials []Credential) []Credential { + if len(credentials) == 0 { + return nil + } + clones := make([]Credential, 0, len(credentials)) + for _, credential := range credentials { + clones = append(clones, cloneCredential(credential)) + } + + return clones +} + +func cloneWebAuthnCredential(credential webauthn.Credential) webauthn.Credential { + clone := credential + clone.ID = cloneBytes(credential.ID) + clone.PublicKey = cloneBytes(credential.PublicKey) + clone.Transport = append(clone.Transport[:0:0], credential.Transport...) + clone.Authenticator.AAGUID = cloneBytes(credential.Authenticator.AAGUID) + clone.Attestation.ClientDataJSON = cloneBytes(credential.Attestation.ClientDataJSON) + clone.Attestation.ClientDataHash = cloneBytes(credential.Attestation.ClientDataHash) + clone.Attestation.AuthenticatorData = cloneBytes(credential.Attestation.AuthenticatorData) + clone.Attestation.Object = cloneBytes(credential.Attestation.Object) + + return clone +} + +func cloneIdentity(identity authkit.Identity) authkit.Identity { + clone := identity + if identity.Claims != nil { + clone.Claims = make(map[string]any, len(identity.Claims)) + maps.Copy(clone.Claims, identity.Claims) + } + + return clone +} + +func cloneBytes(value []byte) []byte { + if len(value) == 0 { + return nil + } + + clone := make([]byte, len(value)) + copy(clone, value) + return clone +} + +func cloneStrings(values []string) []string { + if len(values) == 0 { + return nil + } + + clone := make([]string, len(values)) + copy(clone, values) + return clone +} diff --git a/passkey/doc.go b/passkey/doc.go new file mode 100644 index 0000000..102e19b --- /dev/null +++ b/passkey/doc.go @@ -0,0 +1,8 @@ +// Package passkey verifies WebAuthn passkey ceremonies for explicit authkit exchange flows. +// +// The package owns relying-party ceremony mechanics and credential state, but it +// does not provide HTTP handlers, browser session management, CSRF protection, or +// UI flows. Consumers should use the returned WebAuthn options and session data +// in their own transport layer, then exchange verified identities through +// authkit's onboarding or exchange packages. +package passkey diff --git a/passkey/errors.go b/passkey/errors.go new file mode 100644 index 0000000..f10e69c --- /dev/null +++ b/passkey/errors.go @@ -0,0 +1,34 @@ +package passkey + +import ( + "errors" + "fmt" + + "github.com/meigma/authkit" +) + +var ( + // ErrUserNotFound indicates that a passkey user handle is not known for a relying party. + ErrUserNotFound = errors.New("passkey: user not found") + + // ErrUserExists indicates that a passkey user handle already exists for a relying party and principal. + ErrUserExists = errors.New("passkey: user exists") + + // ErrCredentialExists indicates that a passkey credential is already stored. + ErrCredentialExists = errors.New("passkey: credential exists") + + // ErrCloneWarning indicates that an authenticator counter signaled possible credential cloning. + ErrCloneWarning = errors.New("passkey: clone warning") +) + +func unauthenticated(reason string) error { + return fmt.Errorf("%w: %s", authkit.ErrUnauthenticated, reason) +} + +func cloneWarning() error { + return fmt.Errorf("%w: %w", authkit.ErrUnauthenticated, ErrCloneWarning) +} + +func internalError(op string, err error) error { + return fmt.Errorf("%w: passkey: %s: %w", authkit.ErrInternal, op, err) +} diff --git a/passkey/service.go b/passkey/service.go new file mode 100644 index 0000000..2774765 --- /dev/null +++ b/passkey/service.go @@ -0,0 +1,559 @@ +package passkey + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/meigma/authkit" +) + +const ( + providerPrefix = "passkey:" + userHandleBytes = 64 + defaultRegistrationTimeout = 5 * time.Minute + defaultLoginTimeout = 5 * time.Minute +) + +type relyingParty interface { + BeginRegistration(user webauthn.User, opts ...webauthn.RegistrationOption) ( + *protocol.CredentialCreation, + *webauthn.SessionData, + error, + ) + CreateCredential( + user webauthn.User, + session webauthn.SessionData, + parsedResponse *protocol.ParsedCredentialCreationData, + ) (*webauthn.Credential, error) + BeginDiscoverableLogin(opts ...webauthn.LoginOption) (*protocol.CredentialAssertion, *webauthn.SessionData, error) + ValidatePasskeyLogin( + handler webauthn.DiscoverableUserHandler, + session webauthn.SessionData, + parsedResponse *protocol.ParsedCredentialAssertionData, + ) (webauthn.User, *webauthn.Credential, error) +} + +type creationParser func([]byte) (*protocol.ParsedCredentialCreationData, error) +type assertionParser func([]byte) (*protocol.ParsedCredentialAssertionData, error) + +// Service runs WebAuthn passkey registration and login ceremonies. +type Service struct { + store Store + config Config + rp relyingParty + parseCreationResponse creationParser + parseAssertionResponse assertionParser +} + +// NewService constructs a passkey service for config. +func NewService(store Store, config Config) (*Service, error) { + if store == nil { + return nil, errors.New("passkey: store is required") + } + if err := validateConfig(config); err != nil { + return nil, err + } + + rp, err := webauthn.New(&webauthn.Config{ + RPID: config.RPID, + RPDisplayName: config.RPDisplayName, + RPOrigins: cloneStrings(config.RPOrigins), + AuthenticatorSelection: passkeyAuthenticatorSelection(), + Timeouts: webauthn.TimeoutsConfig{ + Registration: enforcedTimeout(registrationTimeout(config)), + Login: enforcedTimeout(loginTimeout(config)), + }, + }) + if err != nil { + return nil, fmt.Errorf("passkey: create relying party: %w", err) + } + + return newService(store, config, rp) +} + +func newService(store Store, config Config, rp relyingParty) (*Service, error) { + if store == nil { + return nil, errors.New("passkey: store is required") + } + if rp == nil { + return nil, errors.New("passkey: relying party is required") + } + if err := validateConfig(config); err != nil { + return nil, err + } + + return &Service{ + store: store, + config: cloneConfig(config), + rp: rp, + parseCreationResponse: protocol.ParseCredentialCreationResponseBytes, + parseAssertionResponse: protocol.ParseCredentialRequestResponseBytes, + }, nil +} + +// BeginRegistration starts a passkey registration ceremony for an existing authkit principal. +func (s *Service) BeginRegistration( + ctx context.Context, + req BeginRegistrationRequest, +) (BeginRegistrationResult, error) { + if err := ctx.Err(); err != nil { + return BeginRegistrationResult{}, err + } + if req.PrincipalID == "" { + return BeginRegistrationResult{}, errors.New("passkey: principal ID is required") + } + if req.Name == "" { + return BeginRegistrationResult{}, errors.New("passkey: user name is required") + } + if req.DisplayName == "" { + return BeginRegistrationResult{}, errors.New("passkey: user display name is required") + } + + user, credentials, err := s.registrationUser(ctx, req) + if err != nil { + return BeginRegistrationResult{}, err + } + + webUser, err := newWebAuthnUser(user, credentials) + if err != nil { + return BeginRegistrationResult{}, internalError("validate credentials", err) + } + + creation, session, err := s.rp.BeginRegistration( + webUser, + webauthn.WithAuthenticatorSelection(passkeyAuthenticatorSelection()), + webauthn.WithExclusions(credentialExclusions(webUser)), + ) + if err != nil { + return BeginRegistrationResult{}, internalError("begin registration", err) + } + if creation == nil || session == nil { + return BeginRegistrationResult{}, internalError( + "begin registration", + errors.New("relying party returned nil result"), + ) + } + + return BeginRegistrationResult{ + Creation: creation, + SessionData: *session, + User: cloneUser(user), + }, nil +} + +// FinishRegistration verifies a passkey registration response and stores its credential. +func (s *Service) FinishRegistration( + ctx context.Context, + req FinishRegistrationRequest, +) (FinishRegistrationResult, error) { + if err := ctx.Err(); err != nil { + return FinishRegistrationResult{}, err + } + if req.PrincipalID == "" { + return FinishRegistrationResult{}, errors.New("passkey: principal ID is required") + } + user, err := s.finishRegistrationUser(req) + if err != nil { + return FinishRegistrationResult{}, err + } + if len(req.Response) == 0 { + return FinishRegistrationResult{}, unauthenticated("registration response is required") + } + + credentials, err := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) + if err != nil { + return FinishRegistrationResult{}, internalError("list credentials", err) + } + + parsed, err := s.parseCreationResponse(req.Response) + if err != nil { + return FinishRegistrationResult{}, unauthenticated("invalid registration response") + } + webUser, err := newWebAuthnUser(user, credentials) + if err != nil { + return FinishRegistrationResult{}, internalError("validate credentials", err) + } + + session := sessionRequiringUserVerification(req.SessionData) + upstreamCredential, err := s.rp.CreateCredential(webUser, session, parsed) + if err != nil { + return FinishRegistrationResult{}, unauthenticated("registration verification failed") + } + if upstreamCredential == nil { + return FinishRegistrationResult{}, internalError( + "finish registration", + errors.New("relying party returned nil credential"), + ) + } + + identity := identityForCredential(s.config.RPID, user.Handle, upstreamCredential.ID) + credential := Credential{ + RPID: s.config.RPID, + PrincipalID: user.PrincipalID, + UserHandle: cloneBytes(user.Handle), + CredentialID: cloneBytes(upstreamCredential.ID), + WebAuthn: cloneWebAuthnCredential(*upstreamCredential), + } + expectedRegistration := Registration{ + User: user, + Credential: credential, + Identity: identity, + } + registration, err := s.store.CreateRegistration(ctx, expectedRegistration) + if err != nil { + if errors.Is(err, ErrCredentialExists) || errors.Is(err, ErrUserExists) { + return FinishRegistrationResult{}, err + } + return FinishRegistrationResult{}, internalError("create registration", err) + } + if err := validateRegistrationResult(registration, expectedRegistration); err != nil { + return FinishRegistrationResult{}, internalError("validate registration result", err) + } + + return FinishRegistrationResult{ + Identity: identity, + Link: registration.Link, + Credential: cloneCredential(registration.Credential), + }, nil +} + +// BeginLogin starts a discoverable passkey login ceremony. +func (s *Service) BeginLogin(ctx context.Context, _ BeginLoginRequest) (BeginLoginResult, error) { + if err := ctx.Err(); err != nil { + return BeginLoginResult{}, err + } + + assertion, session, err := s.rp.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + return BeginLoginResult{}, internalError("begin login", err) + } + if assertion == nil || session == nil { + return BeginLoginResult{}, internalError("begin login", errors.New("relying party returned nil result")) + } + + return BeginLoginResult{ + Assertion: assertion, + SessionData: *session, + }, nil +} + +// FinishLogin verifies a discoverable passkey login response. +func (s *Service) FinishLogin(ctx context.Context, req FinishLoginRequest) (FinishLoginResult, error) { + if err := ctx.Err(); err != nil { + return FinishLoginResult{}, err + } + if len(req.Response) == 0 { + return FinishLoginResult{}, unauthenticated("login response is required") + } + + parsed, err := s.parseAssertionResponse(req.Response) + if err != nil { + return FinishLoginResult{}, unauthenticated("invalid login response") + } + session := sessionRequiringUserVerification(req.SessionData) + user, upstreamCredential, err := s.rp.ValidatePasskeyLogin(s.discoverableUserHandler(ctx), session, parsed) + if err != nil { + if errors.Is(err, authkit.ErrInternal) { + return FinishLoginResult{}, err + } + return FinishLoginResult{}, unauthenticated("login verification failed") + } + if upstreamCredential == nil { + return FinishLoginResult{}, internalError("finish login", errors.New("relying party returned nil credential")) + } + passkeyUser, ok := user.(webAuthnUser) + if !ok { + return FinishLoginResult{}, internalError("finish login", errors.New("unexpected WebAuthn user type")) + } + storedCredential, ok := credentialByID(passkeyUser.credentials, upstreamCredential.ID) + if !ok { + return FinishLoginResult{}, internalError( + "match credential", + fmt.Errorf("credential %q is not stored for passkey user", credentialIDString(upstreamCredential.ID)), + ) + } + + credential := Credential{ + RPID: storedCredential.RPID, + PrincipalID: storedCredential.PrincipalID, + UserHandle: cloneBytes(storedCredential.UserHandle), + CredentialID: cloneBytes(storedCredential.CredentialID), + WebAuthn: cloneWebAuthnCredential(*upstreamCredential), + } + if upstreamCredential.Authenticator.CloneWarning { + if err := s.store.UpdateCredentialAfterLogin(ctx, credential); err != nil { + return FinishLoginResult{}, internalError("update credential after clone warning", err) + } + + return FinishLoginResult{}, cloneWarning() + } + + identity := identityForCredential(s.config.RPID, storedCredential.UserHandle, storedCredential.CredentialID) + if err := s.validateLinkedPrincipal(ctx, identity, passkeyUser.user.PrincipalID); err != nil { + return FinishLoginResult{}, err + } + if err := s.store.UpdateCredentialAfterLogin(ctx, credential); err != nil { + return FinishLoginResult{}, internalError("update credential after login", err) + } + + return FinishLoginResult{ + Identity: identity, + User: cloneUser(passkeyUser.user), + Credential: cloneCredential(credential), + }, nil +} + +func (s *Service) validateLinkedPrincipal( + ctx context.Context, + identity authkit.Identity, + wantPrincipalID string, +) error { + principal, err := s.store.ResolveIdentity(ctx, identity) + if errors.Is(err, authkit.ErrUnresolvedIdentity) { + return err + } + if err != nil { + return internalError("resolve identity", err) + } + if principal == nil { + return internalError("resolve identity", errors.New("store returned nil principal")) + } + if principal.ID != wantPrincipalID { + return internalError( + "resolve identity", + fmt.Errorf("identity resolved to principal %q, want %q", principal.ID, wantPrincipalID), + ) + } + + return nil +} + +func (s *Service) registrationUser( + ctx context.Context, + req BeginRegistrationRequest, +) (User, []Credential, error) { + user, err := s.store.FindUserByPrincipal(ctx, s.config.RPID, req.PrincipalID) + if err == nil { + credentials, listErr := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) + if listErr != nil { + return User{}, nil, internalError("list credentials", listErr) + } + + return cloneUser(user), credentials, nil + } + if !errors.Is(err, ErrUserNotFound) { + return User{}, nil, internalError("find user by principal", err) + } + + handle := make([]byte, userHandleBytes) + if _, err := rand.Read(handle); err != nil { + return User{}, nil, internalError("generate user handle", err) + } + + user = User{ + RPID: s.config.RPID, + PrincipalID: req.PrincipalID, + Handle: handle, + Name: req.Name, + DisplayName: req.DisplayName, + } + + return cloneUser(user), nil, nil +} + +func (s *Service) finishRegistrationUser(req FinishRegistrationRequest) (User, error) { + user := req.User + if user.RPID == "" && user.PrincipalID == "" && len(user.Handle) == 0 { + return User{}, unauthenticated("registration user session data is required") + } + if user.RPID != s.config.RPID { + return User{}, unauthenticated("registration user relying party does not match") + } + if user.PrincipalID != req.PrincipalID { + return User{}, unauthenticated("registration user principal does not match") + } + if len(user.Handle) == 0 { + return User{}, unauthenticated("registration user handle is required") + } + if user.Name == "" { + return User{}, unauthenticated("registration user name is required") + } + if user.DisplayName == "" { + return User{}, unauthenticated("registration user display name is required") + } + + return cloneUser(user), nil +} + +func (s *Service) discoverableUserHandler(ctx context.Context) webauthn.DiscoverableUserHandler { + return func(_, userHandle []byte) (webauthn.User, error) { + user, err := s.store.FindUserByHandle(ctx, s.config.RPID, userHandle) + if errors.Is(err, ErrUserNotFound) { + return nil, err + } + if err != nil { + return nil, internalError("find user by handle", err) + } + credentials, err := s.store.ListCredentials(ctx, s.config.RPID, user.Handle) + if err != nil { + return nil, internalError("list credentials", err) + } + + webUser, err := newWebAuthnUser(user, credentials) + if err != nil { + return nil, internalError("validate credentials", err) + } + + return webUser, nil + } +} + +func validateConfig(config Config) error { + if strings.TrimSpace(config.RPID) == "" { + return errors.New("passkey: RP ID is required") + } + if strings.TrimSpace(config.RPDisplayName) == "" { + return errors.New("passkey: RP display name is required") + } + if len(config.RPOrigins) == 0 { + return errors.New("passkey: RP origins are required") + } + for i, origin := range config.RPOrigins { + if strings.TrimSpace(origin) == "" { + return fmt.Errorf("passkey: RP origin %d is required", i) + } + } + if config.RegistrationTimeout < 0 { + return errors.New("passkey: registration timeout must be positive") + } + if config.LoginTimeout < 0 { + return errors.New("passkey: login timeout must be positive") + } + + return nil +} + +func passkeyAuthenticatorSelection() protocol.AuthenticatorSelection { + return protocol.AuthenticatorSelection{ + RequireResidentKey: protocol.ResidentKeyRequired(), + ResidentKey: protocol.ResidentKeyRequirementRequired, + UserVerification: protocol.VerificationRequired, + } +} + +func credentialExclusions(user webAuthnUser) []protocol.CredentialDescriptor { + credentials := user.WebAuthnCredentials() + if len(credentials) == 0 { + return nil + } + + return webauthn.Credentials(credentials).CredentialDescriptors() +} + +func sessionRequiringUserVerification(session webauthn.SessionData) webauthn.SessionData { + session.UserVerification = protocol.VerificationRequired + + return session +} + +func validateRegistrationResult(result RegistrationResult, expected Registration) error { + if err := validateRegistrationUser(result.User, expected.User); err != nil { + return err + } + if err := validateRegistrationCredential(result.Credential, expected.Credential); err != nil { + return err + } + if err := validateRegistrationLink(result.Link, expected); err != nil { + return err + } + + return nil +} + +func validateRegistrationUser(got User, want User) error { + switch { + case got.RPID != want.RPID: + return fmt.Errorf("registration user RP ID %q, want %q", got.RPID, want.RPID) + case got.PrincipalID != want.PrincipalID: + return fmt.Errorf("registration user principal %q, want %q", got.PrincipalID, want.PrincipalID) + case !bytes.Equal(got.Handle, want.Handle): + return errors.New("registration user handle does not match verified ceremony") + } + + return nil +} + +func validateRegistrationCredential(got Credential, want Credential) error { + switch { + case got.RPID != want.RPID: + return fmt.Errorf("registration credential RP ID %q, want %q", got.RPID, want.RPID) + case got.PrincipalID != want.PrincipalID: + return fmt.Errorf("registration credential principal %q, want %q", got.PrincipalID, want.PrincipalID) + case !bytes.Equal(got.UserHandle, want.UserHandle): + return errors.New("registration credential user handle does not match verified ceremony") + case !bytes.Equal(got.CredentialID, want.CredentialID): + return errors.New("registration credential ID does not match verified ceremony") + case len(got.WebAuthn.ID) > 0 && !bytes.Equal(got.WebAuthn.ID, want.CredentialID): + return errors.New("registration credential WebAuthn ID does not match verified ceremony") + } + + return nil +} + +func validateRegistrationLink(got authkit.ExternalIdentity, expected Registration) error { + switch { + case got.Provider != expected.Identity.Provider: + return fmt.Errorf("registration link provider %q, want %q", got.Provider, expected.Identity.Provider) + case got.Subject != expected.Identity.Subject: + return fmt.Errorf("registration link subject %q, want %q", got.Subject, expected.Identity.Subject) + case got.PrincipalID != expected.User.PrincipalID: + return fmt.Errorf("registration link principal %q, want %q", got.PrincipalID, expected.User.PrincipalID) + } + + return nil +} + +func enforcedTimeout(timeout time.Duration) webauthn.TimeoutConfig { + return webauthn.TimeoutConfig{ + Enforce: true, + Timeout: timeout, + TimeoutUVD: timeout, + } +} + +func registrationTimeout(config Config) time.Duration { + if config.RegistrationTimeout != 0 { + return config.RegistrationTimeout + } + + return defaultRegistrationTimeout +} + +func loginTimeout(config Config) time.Duration { + if config.LoginTimeout != 0 { + return config.LoginTimeout + } + + return defaultLoginTimeout +} + +func identityForCredential(rpID string, userHandle []byte, credentialID []byte) authkit.Identity { + return authkit.Identity{ + Provider: providerPrefix + rpID, + Subject: base64.RawURLEncoding.EncodeToString(userHandle), + CredentialID: base64.RawURLEncoding.EncodeToString(credentialID), + } +} + +func credentialIDString(credentialID []byte) string { + return base64.RawURLEncoding.EncodeToString(credentialID) +} diff --git a/passkey/service_test.go b/passkey/service_test.go new file mode 100644 index 0000000..0373792 --- /dev/null +++ b/passkey/service_test.go @@ -0,0 +1,1115 @@ +package passkey + +import ( + "context" + "encoding/base64" + "errors" + "testing" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" +) + +const ( + testRPID = "example.test" + testPrincipalID = "principal_123" +) + +func TestNewServiceValidatesConfig(t *testing.T) { + validConfig := testConfig() + + tests := []struct { + name string + store Store + config Config + wantErr string + }{ + { + name: "missing store", + config: validConfig, + wantErr: "store is required", + }, + { + name: "missing RP ID", + store: newFakeStore(), + config: Config{ + RPDisplayName: validConfig.RPDisplayName, + RPOrigins: validConfig.RPOrigins, + }, + wantErr: "RP ID is required", + }, + { + name: "missing RP display name", + store: newFakeStore(), + config: Config{ + RPID: validConfig.RPID, + RPOrigins: validConfig.RPOrigins, + }, + wantErr: "RP display name is required", + }, + { + name: "missing RP origins", + store: newFakeStore(), + config: Config{ + RPID: validConfig.RPID, + RPDisplayName: validConfig.RPDisplayName, + }, + wantErr: "RP origins are required", + }, + { + name: "blank RP origin", + store: newFakeStore(), + config: Config{ + RPID: validConfig.RPID, + RPDisplayName: validConfig.RPDisplayName, + RPOrigins: []string{""}, + }, + wantErr: "RP origin 0 is required", + }, + { + name: "negative registration timeout", + store: newFakeStore(), + config: Config{ + RPID: validConfig.RPID, + RPDisplayName: validConfig.RPDisplayName, + RPOrigins: validConfig.RPOrigins, + RegistrationTimeout: -time.Second, + }, + wantErr: "registration timeout must be positive", + }, + { + name: "negative login timeout", + store: newFakeStore(), + config: Config{ + RPID: validConfig.RPID, + RPDisplayName: validConfig.RPDisplayName, + RPOrigins: validConfig.RPOrigins, + LoginTimeout: -time.Second, + }, + wantErr: "login timeout must be positive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, err := NewService(tt.store, tt.config) + + require.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, service) + }) + } +} + +func TestNewServiceAcceptsValidConfig(t *testing.T) { + service, err := NewService(newFakeStore(), testConfig()) + + require.NoError(t, err) + assert.NotNil(t, service) +} + +func TestNewServiceEnforcesDefaultCeremonyTimeouts(t *testing.T) { + service, err := NewService(newFakeStore(), testConfig()) + require.NoError(t, err) + + registration, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ + PrincipalID: testPrincipalID, + Name: "ada@example.test", + DisplayName: "Ada Lovelace", + }) + require.NoError(t, err) + assert.Equal(t, int(defaultRegistrationTimeout.Milliseconds()), registration.Creation.Response.Timeout) + assert.False(t, registration.SessionData.Expires.IsZero()) + + login, err := service.BeginLogin(context.Background(), BeginLoginRequest{}) + require.NoError(t, err) + assert.Equal(t, int(defaultLoginTimeout.Milliseconds()), login.Assertion.Response.Timeout) + assert.False(t, login.SessionData.Expires.IsZero()) +} + +func TestNewServiceAcceptsCustomCeremonyTimeouts(t *testing.T) { + const ( + registrationTimeout = 2 * time.Minute + loginTimeout = 90 * time.Second + ) + config := testConfig() + config.RegistrationTimeout = registrationTimeout + config.LoginTimeout = loginTimeout + service, err := NewService(newFakeStore(), config) + require.NoError(t, err) + + registration, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ + PrincipalID: testPrincipalID, + Name: "ada@example.test", + DisplayName: "Ada Lovelace", + }) + require.NoError(t, err) + assert.Equal(t, int(registrationTimeout.Milliseconds()), registration.Creation.Response.Timeout) + assert.False(t, registration.SessionData.Expires.IsZero()) + + login, err := service.BeginLogin(context.Background(), BeginLoginRequest{}) + require.NoError(t, err) + assert.Equal(t, int(loginTimeout.Milliseconds()), login.Assertion.Response.Timeout) + assert.False(t, login.SessionData.Expires.IsZero()) +} + +func TestBeginRegistrationGeneratesSessionUser(t *testing.T) { + store := newFakeStore() + rp := newFakeRelyingParty() + service := newTestService(t, store, rp) + + result, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ + PrincipalID: testPrincipalID, + Name: "ada@example.test", + DisplayName: "Ada Lovelace", + }) + + require.NoError(t, err) + assert.Same(t, rp.creation, result.Creation) + assert.Equal(t, *rp.registrationSession, result.SessionData) + assert.Empty(t, store.usersByPrincipal) + assert.Empty(t, store.createdRegistrations) + assert.Equal(t, testRPID, result.User.RPID) + assert.Equal(t, testPrincipalID, result.User.PrincipalID) + assert.Len(t, result.User.Handle, userHandleBytes) + assert.Equal(t, "ada@example.test", result.User.Name) + assert.Equal(t, "Ada Lovelace", result.User.DisplayName) + assert.Equal(t, protocol.VerificationRequired, result.SessionData.UserVerification) + assert.Equal(t, protocol.VerificationRequired, rp.creation.Response.AuthenticatorSelection.UserVerification) + assert.Equal(t, protocol.ResidentKeyRequirementRequired, rp.creation.Response.AuthenticatorSelection.ResidentKey) + assert.Equal(t, result.User.Handle, rp.registrationUser.WebAuthnID()) + assert.Empty(t, rp.registrationUser.WebAuthnCredentials()) + assert.Empty(t, result.Creation.Response.CredentialExcludeList) +} + +func TestBeginRegistrationReusesPasskeyUserAndCredentials(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + service := newTestService(t, store, rp) + + result, err := service.BeginRegistration(context.Background(), BeginRegistrationRequest{ + PrincipalID: testPrincipalID, + Name: "ignored@example.test", + DisplayName: "Ignored", + }) + + require.NoError(t, err) + assert.Empty(t, store.createdRegistrations) + assert.Equal(t, user, result.User) + credentials := rp.registrationUser.WebAuthnCredentials() + require.Len(t, credentials, 1) + assert.Equal(t, []byte("credential-1"), credentials[0].ID) + assert.Equal(t, []byte("public-key"), credentials[0].PublicKey) + exclusions := result.Creation.Response.CredentialExcludeList + require.Len(t, exclusions, 1) + assert.Equal(t, protocol.PublicKeyCredentialType, exclusions[0].Type) + assert.Equal(t, []byte("credential-1"), []byte(exclusions[0].CredentialID)) +} + +func TestFinishRegistrationStoresCredentialAndReturnsIdentity(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + Authenticator: webauthn.Authenticator{ + SignCount: 2, + }, + } + service := newTestService(t, store, rp) + response := []byte(`{"id":"credential-1"}`) + identity := identityForCredential(testRPID, user.Handle, rp.createdCredential.ID) + service.parseCreationResponse = func(data []byte) (*protocol.ParsedCredentialCreationData, error) { + assert.Equal(t, response, data) + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: response, + }) + + require.NoError(t, err) + assert.Equal(t, []string{"createRegistration"}, store.calls) + require.Len(t, store.createdRegistrations, 1) + createdRegistration := store.createdRegistrations[0] + assert.Equal(t, user, createdRegistration.User) + assert.Equal(t, identity, createdRegistration.Identity) + createdCredential := createdRegistration.Credential + assert.Equal(t, testRPID, createdCredential.RPID) + assert.Equal(t, testPrincipalID, createdCredential.PrincipalID) + assert.Equal(t, user.Handle, createdCredential.UserHandle) + assert.Equal(t, []byte("credential-1"), createdCredential.CredentialID) + assert.Equal(t, []byte("public-key"), createdCredential.WebAuthn.PublicKey) + assert.Equal(t, uint32(2), createdCredential.WebAuthn.Authenticator.SignCount) + assert.Equal(t, createdCredential, result.Credential) + assert.Equal(t, identity, result.Identity) + assert.Equal(t, authkit.ExternalIdentity{ + Provider: identity.Provider, + Subject: identity.Subject, + PrincipalID: testPrincipalID, + }, result.Link) + assert.Equal(t, user.Handle, rp.createCredentialUser.WebAuthnID()) + assert.Equal(t, webauthn.SessionData{ + Challenge: "registration-challenge", + UserVerification: protocol.VerificationRequired, + }, rp.createCredentialSession) +} + +func TestFinishRegistrationRejectsMismatchedRegistrationResult(t *testing.T) { + tests := []struct { + name string + mutate func(RegistrationResult) RegistrationResult + }{ + { + name: "user RP ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.User.RPID = "other.example.test" + return result + }, + }, + { + name: "user principal", + mutate: func(result RegistrationResult) RegistrationResult { + result.User.PrincipalID = "other-principal" + return result + }, + }, + { + name: "user handle", + mutate: func(result RegistrationResult) RegistrationResult { + result.User.Handle = []byte("other-handle") + return result + }, + }, + { + name: "credential RP ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.RPID = "other.example.test" + return result + }, + }, + { + name: "credential principal", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.PrincipalID = "other-principal" + return result + }, + }, + { + name: "credential user handle", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.UserHandle = []byte("other-handle") + return result + }, + }, + { + name: "credential ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.CredentialID = []byte("other-credential") + return result + }, + }, + { + name: "credential WebAuthn ID", + mutate: func(result RegistrationResult) RegistrationResult { + result.Credential.WebAuthn.ID = []byte("other-credential") + return result + }, + }, + { + name: "link provider", + mutate: func(result RegistrationResult) RegistrationResult { + result.Link.Provider = "passkey:other.example.test" + return result + }, + }, + { + name: "link subject", + mutate: func(result RegistrationResult) RegistrationResult { + result.Link.Subject = "other-subject" + return result + }, + }, + { + name: "link principal", + mutate: func(result RegistrationResult) RegistrationResult { + result.Link.PrincipalID = "other-principal" + return result + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.createRegistrationResult = tt.mutate + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + } + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + assert.Empty(t, result) + }) + } +} + +func TestFinishRegistrationOverridesDowngradedUserVerification(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{ + Challenge: "registration-challenge", + UserVerification: protocol.VerificationDiscouraged, + }, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Equal(t, protocol.VerificationRequired, rp.createCredentialSession.UserVerification) +} + +func TestFinishRegistrationReturnsDuplicateCredential(t *testing.T) { + store := newFakeStore() + store.putUser(testUser()) + store.createRegistrationErr = ErrCredentialExists + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: testUser(), + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, ErrCredentialExists) + require.NotErrorIs(t, err, authkit.ErrInternal) + assert.Empty(t, result) +} + +func TestFinishRegistrationReturnsDuplicateUser(t *testing.T) { + store := newFakeStore() + store.createRegistrationErr = ErrUserExists + user := testUser() + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, ErrUserExists) + require.NotErrorIs(t, err, authkit.ErrInternal) + assert.Empty(t, result) +} + +func TestFinishRegistrationWrapsStoreFailures(t *testing.T) { + storeErr := errors.New("store failed") + store := newFakeStore() + store.putUser(testUser()) + store.createRegistrationErr = storeErr + rp := newFakeRelyingParty() + rp.createdCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseCreationResponse = func([]byte) (*protocol.ParsedCredentialCreationData, error) { + return &protocol.ParsedCredentialCreationData{}, nil + } + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: testUser(), + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, storeErr) + assert.Empty(t, store.createdRegistrations) + assert.Empty(t, result) +} + +func TestFinishRegistrationRejectsMalformedResponses(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + service := newTestService(t, store, newFakeRelyingParty()) + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + User: user, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`not-json`), + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} + +func TestFinishRegistrationRequiresSessionUser(t *testing.T) { + service := newTestService(t, newFakeStore(), newFakeRelyingParty()) + + result, err := service.FinishRegistration(context.Background(), FinishRegistrationRequest{ + PrincipalID: testPrincipalID, + SessionData: webauthn.SessionData{Challenge: "registration-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} + +func TestBeginLoginReturnsDiscoverableAssertion(t *testing.T) { + rp := newFakeRelyingParty() + service := newTestService(t, newFakeStore(), rp) + + result, err := service.BeginLogin(context.Background(), BeginLoginRequest{}) + + require.NoError(t, err) + assert.Same(t, rp.assertion, result.Assertion) + assert.Equal(t, *rp.loginSession, result.SessionData) + assert.Equal(t, protocol.VerificationRequired, result.SessionData.UserVerification) + assert.Equal(t, protocol.VerificationRequired, result.Assertion.Response.UserVerification) +} + +func TestFinishLoginUpdatesCredentialAndReturnsIdentity(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + Authenticator: webauthn.Authenticator{ + SignCount: 7, + }, + } + service := newTestService(t, store, rp) + response := []byte(`{"id":"credential-1"}`) + service.parseAssertionResponse = func(data []byte) (*protocol.ParsedCredentialAssertionData, error) { + assert.Equal(t, response, data) + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: response, + }) + + require.NoError(t, err) + require.NotNil(t, store.updatedCredential) + assert.Equal(t, []byte("credential-1"), store.updatedCredential.CredentialID) + assert.Equal(t, uint32(7), store.updatedCredential.WebAuthn.Authenticator.SignCount) + assert.Equal(t, *store.updatedCredential, result.Credential) + assert.Equal(t, user, result.User) + assert.Equal(t, authkit.Identity{ + Provider: "passkey:" + testRPID, + Subject: base64.RawURLEncoding.EncodeToString(user.Handle), + CredentialID: base64.RawURLEncoding.EncodeToString([]byte("credential-1")), + }, result.Identity) + require.NotNil(t, rp.validatedHandlerUser) + assert.Equal(t, user.Handle, rp.validatedHandlerUser.WebAuthnID()) + assert.Equal(t, webauthn.SessionData{ + Challenge: "login-challenge", + UserVerification: protocol.VerificationRequired, + }, rp.validateSession) +} + +func TestFinishLoginOverridesDowngradedUserVerification(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{ + Challenge: "login-challenge", + UserVerification: protocol.VerificationDiscouraged, + }, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Equal(t, protocol.VerificationRequired, rp.validateSession.UserVerification) +} + +func TestFinishLoginWrapsCredentialUpdateFailures(t *testing.T) { + updateErr := errors.New("update failed") + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), testPrincipalID) + store.updateCredentialErr = updateErr + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, updateErr) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsCloneWarning(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + Authenticator: webauthn.Authenticator{ + CloneWarning: true, + }, + } + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrUnauthenticated) + require.ErrorIs(t, err, ErrCloneWarning) + require.NotNil(t, store.updatedCredential) + assert.True(t, store.updatedCredential.WebAuthn.Authenticator.CloneWarning) + assert.Empty(t, result) +} + +func TestFinishLoginReturnsInternalWhenCloneWarningUpdateFails(t *testing.T) { + updateErr := errors.New("update failed") + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.updateCredentialErr = updateErr + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ + ID: []byte("credential-1"), + Authenticator: webauthn.Authenticator{ + CloneWarning: true, + }, + } + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, updateErr) + require.NotErrorIs(t, err, ErrCloneWarning) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsMismatchedStoredCredential(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: "principal_other", + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + assert.Nil(t, rp.validatedHandlerUser) + assert.Nil(t, store.updatedCredential) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsValidatedCredentialNotLoadedFromStore(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(Credential{ + RPID: testRPID, + PrincipalID: testPrincipalID, + UserHandle: user.Handle, + CredentialID: []byte("credential-1"), + WebAuthn: webauthn.Credential{ + ID: []byte("credential-1"), + PublicKey: []byte("public-key"), + }, + }) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-2")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-2"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.NotErrorIs(t, err, authkit.ErrUnresolvedIdentity) + assert.Nil(t, store.updatedCredential) + assert.Empty(t, result) +} + +func TestFinishLoginReturnsUnresolvedIdentityForUnlinkedCredential(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrUnresolvedIdentity) + require.NotErrorIs(t, err, authkit.ErrUnauthenticated) + assert.Empty(t, result) +} + +func TestFinishLoginRejectsDivergedIdentityLink(t *testing.T) { + store := newFakeStore() + user := testUser() + store.putUser(user) + store.putCredential(testCredential(user)) + store.putLink(identityForCredential(testRPID, user.Handle, []byte("credential-1")), "principal_other") + rp := newFakeRelyingParty() + rp.validateUserHandle = user.Handle + rp.validatedCredential = &webauthn.Credential{ID: []byte("credential-1")} + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + assert.Nil(t, store.updatedCredential) + assert.Empty(t, result) +} + +func TestFinishLoginWrapsDiscoverableLookupFailures(t *testing.T) { + lookupErr := errors.New("lookup failed") + store := newFakeStore() + store.findUserByHandleErr = lookupErr + rp := newFakeRelyingParty() + rp.validateUserHandle = []byte("user-handle") + service := newTestService(t, store, rp) + service.parseAssertionResponse = func([]byte) (*protocol.ParsedCredentialAssertionData, error) { + return &protocol.ParsedCredentialAssertionData{}, nil + } + + result, err := service.FinishLogin(context.Background(), FinishLoginRequest{ + SessionData: webauthn.SessionData{Challenge: "login-challenge"}, + Response: []byte(`{"id":"credential-1"}`), + }) + + require.ErrorIs(t, err, authkit.ErrInternal) + require.ErrorIs(t, err, lookupErr) + assert.Empty(t, result) +} + +func newTestService(t *testing.T, store Store, rp *fakeRelyingParty) *Service { + t.Helper() + + service, err := newService(store, testConfig(), rp) + require.NoError(t, err) + + return service +} + +func testConfig() Config { + return Config{ + RPID: testRPID, + RPDisplayName: "Authkit Test", + RPOrigins: []string{"https://example.test"}, + } +} + +func testUser() User { + return User{ + RPID: testRPID, + PrincipalID: testPrincipalID, + Handle: []byte("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), + Name: "ada@example.test", + DisplayName: "Ada Lovelace", + } +} + +func testCredential(user User) Credential { + credentialID := []byte("credential-1") + + return Credential{ + RPID: user.RPID, + PrincipalID: user.PrincipalID, + UserHandle: user.Handle, + CredentialID: credentialID, + WebAuthn: webauthn.Credential{ + ID: credentialID, + PublicKey: []byte("public-key"), + }, + } +} + +type fakeRelyingParty struct { + creation *protocol.CredentialCreation + registrationSession *webauthn.SessionData + registrationUser webauthn.User + + createdCredential *webauthn.Credential + createCredentialUser webauthn.User + createCredentialSession webauthn.SessionData + createCredentialErr error + + assertion *protocol.CredentialAssertion + loginSession *webauthn.SessionData + + validateUserHandle []byte + validatedHandlerUser webauthn.User + validatedCredential *webauthn.Credential + validateSession webauthn.SessionData + validateCredentialErr error +} + +func newFakeRelyingParty() *fakeRelyingParty { + return &fakeRelyingParty{ + creation: &protocol.CredentialCreation{}, + registrationSession: &webauthn.SessionData{Challenge: "registration-challenge"}, + assertion: &protocol.CredentialAssertion{}, + loginSession: &webauthn.SessionData{Challenge: "login-challenge"}, + } +} + +func (f *fakeRelyingParty) BeginRegistration( + user webauthn.User, + opts ...webauthn.RegistrationOption, +) (*protocol.CredentialCreation, *webauthn.SessionData, error) { + f.registrationUser = user + for _, opt := range opts { + opt(&f.creation.Response) + } + session := *f.registrationSession + session.UserVerification = f.creation.Response.AuthenticatorSelection.UserVerification + f.registrationSession = &session + + return f.creation, f.registrationSession, nil +} + +func (f *fakeRelyingParty) CreateCredential( + user webauthn.User, + session webauthn.SessionData, + _ *protocol.ParsedCredentialCreationData, +) (*webauthn.Credential, error) { + f.createCredentialUser = user + f.createCredentialSession = session + if f.createCredentialErr != nil { + return nil, f.createCredentialErr + } + + return f.createdCredential, nil +} + +func (f *fakeRelyingParty) BeginDiscoverableLogin( + opts ...webauthn.LoginOption, +) (*protocol.CredentialAssertion, *webauthn.SessionData, error) { + for _, opt := range opts { + opt(&f.assertion.Response) + } + session := *f.loginSession + session.UserVerification = f.assertion.Response.UserVerification + f.loginSession = &session + + return f.assertion, f.loginSession, nil +} + +func (f *fakeRelyingParty) ValidatePasskeyLogin( + handler webauthn.DiscoverableUserHandler, + session webauthn.SessionData, + _ *protocol.ParsedCredentialAssertionData, +) (webauthn.User, *webauthn.Credential, error) { + f.validateSession = session + if f.validateCredentialErr != nil { + return nil, nil, f.validateCredentialErr + } + + user, err := handler([]byte("credential-1"), f.validateUserHandle) + if err != nil { + return nil, nil, err + } + f.validatedHandlerUser = user + + return user, f.validatedCredential, nil +} + +type fakeStore struct { + usersByPrincipal map[string]User + usersByHandle map[string]User + credentials map[string][]Credential + links map[string]authkit.ExternalIdentity + + createdRegistrations []Registration + updatedCredential *Credential + calls []string + + createRegistrationErr error + createRegistrationResult func(RegistrationResult) RegistrationResult + updateCredentialErr error + findUserByHandleErr error + resolveIdentityErr error +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + usersByPrincipal: make(map[string]User), + usersByHandle: make(map[string]User), + credentials: make(map[string][]Credential), + links: make(map[string]authkit.ExternalIdentity), + } +} + +func (s *fakeStore) ResolveIdentity( + _ context.Context, + identity authkit.Identity, +) (*authkit.Principal, error) { + if s.resolveIdentityErr != nil { + return nil, s.resolveIdentityErr + } + + link, ok := s.links[identityKey(identity.Provider, identity.Subject)] + if !ok { + return nil, authkit.ErrUnresolvedIdentity + } + + return &authkit.Principal{ + ID: link.PrincipalID, + }, nil +} + +func (s *fakeStore) FindUserByPrincipal(_ context.Context, rpID string, principalID string) (User, error) { + user, ok := s.usersByPrincipal[rpID+"\x00"+principalID] + if !ok { + return User{}, ErrUserNotFound + } + + return cloneUser(user), nil +} + +func (s *fakeStore) FindUserByHandle(_ context.Context, rpID string, handle []byte) (User, error) { + if s.findUserByHandleErr != nil { + return User{}, s.findUserByHandleErr + } + + user, ok := s.usersByHandle[handleKey(rpID, handle)] + if !ok { + return User{}, ErrUserNotFound + } + + return cloneUser(user), nil +} + +func (s *fakeStore) ListCredentials(_ context.Context, rpID string, userHandle []byte) ([]Credential, error) { + return cloneCredentials(s.credentials[handleKey(rpID, userHandle)]), nil +} + +func (s *fakeStore) CreateRegistration( + _ context.Context, + registration Registration, +) (RegistrationResult, error) { + s.calls = append(s.calls, "createRegistration") + if s.createRegistrationErr != nil { + return RegistrationResult{}, s.createRegistrationErr + } + + cloned := cloneRegistration(registration) + s.createdRegistrations = append(s.createdRegistrations, cloned) + s.putUser(cloned.User) + s.putCredential(cloned.Credential) + link := authkit.ExternalIdentity{ + Provider: cloned.Identity.Provider, + Subject: cloned.Identity.Subject, + PrincipalID: cloned.User.PrincipalID, + } + s.links[identityKey(link.Provider, link.Subject)] = link + + result := RegistrationResult{ + User: cloneUser(cloned.User), + Credential: cloneCredential(cloned.Credential), + Link: link, + } + if s.createRegistrationResult != nil { + result = s.createRegistrationResult(result) + } + + return result, nil +} + +func (s *fakeStore) UpdateCredentialAfterLogin(_ context.Context, credential Credential) error { + if s.updateCredentialErr != nil { + return s.updateCredentialErr + } + + clone := cloneCredential(credential) + s.updatedCredential = &clone + + return nil +} + +func (s *fakeStore) putUser(user User) { + cloned := cloneUser(user) + s.usersByPrincipal[cloned.RPID+"\x00"+cloned.PrincipalID] = cloned + s.usersByHandle[handleKey(cloned.RPID, cloned.Handle)] = cloned +} + +func (s *fakeStore) putCredential(credential Credential) { + cloned := cloneCredential(credential) + key := handleKey(cloned.RPID, cloned.UserHandle) + s.credentials[key] = append(s.credentials[key], cloned) +} + +func (s *fakeStore) putLink(identity authkit.Identity, principalID string) { + s.links[identityKey(identity.Provider, identity.Subject)] = authkit.ExternalIdentity{ + Provider: identity.Provider, + Subject: identity.Subject, + PrincipalID: principalID, + } +} + +func handleKey(rpID string, handle []byte) string { + return rpID + "\x00" + string(handle) +} + +func identityKey(provider string, subject string) string { + return provider + "\x00" + subject +} diff --git a/passkey/store.go b/passkey/store.go new file mode 100644 index 0000000..2be781f --- /dev/null +++ b/passkey/store.go @@ -0,0 +1,30 @@ +package passkey + +import ( + "context" + + "github.com/meigma/authkit" +) + +// Store persists passkey users and credentials for one or more relying parties. +type Store interface { + authkit.PrincipalResolver + + // FindUserByPrincipal returns the WebAuthn user for principalID and rpID. + FindUserByPrincipal(ctx context.Context, rpID string, principalID string) (User, error) + + // FindUserByHandle returns the WebAuthn user with handle for rpID. + FindUserByHandle(ctx context.Context, rpID string, handle []byte) (User, error) + + // ListCredentials returns credentials owned by userHandle for rpID. + // Unknown handles should return an empty slice, not ErrUserNotFound. + ListCredentials(ctx context.Context, rpID string, userHandle []byte) ([]Credential, error) + + // CreateRegistration atomically stores a passkey user, credential, and identity link. + // An existing identical user handle should be accepted when adding another credential. + // It returns ErrUserExists or ErrCredentialExists for registration conflicts. + CreateRegistration(ctx context.Context, registration Registration) (RegistrationResult, error) + + // UpdateCredentialAfterLogin persists credential metadata updated by a successful login. + UpdateCredentialAfterLogin(ctx context.Context, credential Credential) error +} diff --git a/passkey/types.go b/passkey/types.go new file mode 100644 index 0000000..851cbce --- /dev/null +++ b/passkey/types.go @@ -0,0 +1,174 @@ +package passkey + +import ( + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/meigma/authkit" +) + +// Config describes the WebAuthn relying party that issues passkey challenges. +type Config struct { + // RPID is the relying-party identifier, usually the effective domain. + RPID string + + // RPDisplayName is the human-readable relying-party name shown by authenticators. + RPDisplayName string + + // RPOrigins are accepted browser origins for passkey responses. + RPOrigins []string + + // RegistrationTimeout overrides the server-enforced registration ceremony timeout. + // Zero selects the package default. + RegistrationTimeout time.Duration + + // LoginTimeout overrides the server-enforced login ceremony timeout. + // Zero selects the package default. + LoginTimeout time.Duration +} + +// User describes an authkit principal's WebAuthn user account for one relying party. +type User struct { + // RPID identifies the relying party this passkey user belongs to. + RPID string + + // PrincipalID identifies the authkit principal represented by this passkey user. + PrincipalID string + + // Handle is the opaque WebAuthn user handle for this principal and relying party. + Handle []byte + + // Name is the human-palatable WebAuthn account name. + Name string + + // DisplayName is the display name shown during passkey registration. + DisplayName string +} + +// Credential describes a stored WebAuthn passkey credential. +type Credential struct { + // RPID identifies the relying party this credential belongs to. + RPID string + + // PrincipalID identifies the authkit principal that owns the credential. + PrincipalID string + + // UserHandle is the opaque WebAuthn user handle that owns the credential. + UserHandle []byte + + // CredentialID is the WebAuthn credential identifier. + CredentialID []byte + + // WebAuthn contains the upstream credential record that must be preserved for verification. + WebAuthn webauthn.Credential +} + +// Registration is the atomic storage unit for a completed passkey registration. +type Registration struct { + // User is the WebAuthn user handle that owns Credential. + User User + + // Credential is the verified passkey credential to persist. + Credential Credential + + // Identity is the passkey identity to link to User.PrincipalID. + Identity authkit.Identity +} + +// RegistrationResult describes an atomically persisted passkey registration. +type RegistrationResult struct { + // User is the persisted WebAuthn user handle. + User User + + // Credential is the persisted passkey credential. + Credential Credential + + // Link is the canonical authkit external identity link for Identity. + Link authkit.ExternalIdentity +} + +// BeginRegistrationRequest describes a passkey registration ceremony start. +type BeginRegistrationRequest struct { + // PrincipalID identifies the authkit principal receiving the passkey. + PrincipalID string + + // Name is the human-palatable WebAuthn account name. + Name string + + // DisplayName is the display name shown during passkey registration. + DisplayName string +} + +// BeginRegistrationResult contains browser options and server-side session data for registration. +type BeginRegistrationResult struct { + // Creation is the WebAuthn credential creation payload to send to the browser. + Creation *protocol.CredentialCreation + + // SessionData must be securely stored by the consumer until FinishRegistration. + SessionData webauthn.SessionData + + // User is the passkey user used for the ceremony and must be stored with SessionData. + User User +} + +// FinishRegistrationRequest describes a passkey registration ceremony finish. +type FinishRegistrationRequest struct { + // PrincipalID identifies the authkit principal receiving the passkey. + PrincipalID string + + // User is the exact passkey user returned by BeginRegistration. + User User + + // SessionData is the exact session data returned by BeginRegistration. + SessionData webauthn.SessionData + + // Response is the raw WebAuthn registration response JSON from the browser. + Response []byte +} + +// FinishRegistrationResult contains the stored credential and verified identity. +type FinishRegistrationResult struct { + // Identity is the verified passkey identity for onboarding or attachment. + Identity authkit.Identity + + // Link is the canonical authkit external identity link for Identity. + Link authkit.ExternalIdentity + + // Credential is the credential stored for future passkey logins. + Credential Credential +} + +// BeginLoginRequest describes a discoverable passkey login ceremony start. +type BeginLoginRequest struct{} + +// BeginLoginResult contains browser options and server-side session data for login. +type BeginLoginResult struct { + // Assertion is the WebAuthn credential assertion payload to send to the browser. + Assertion *protocol.CredentialAssertion + + // SessionData must be securely stored by the consumer until FinishLogin. + SessionData webauthn.SessionData +} + +// FinishLoginRequest describes a discoverable passkey login ceremony finish. +type FinishLoginRequest struct { + // SessionData is the exact session data returned by BeginLogin. + SessionData webauthn.SessionData + + // Response is the raw WebAuthn assertion response JSON from the browser. + Response []byte +} + +// FinishLoginResult contains the verified credential owner and identity. +type FinishLoginResult struct { + // Identity is the verified passkey identity for authkit exchange. + Identity authkit.Identity + + // User is the passkey user authenticated by the ceremony. + User User + + // Credential is the credential updated after successful login validation. + Credential Credential +} diff --git a/passkey/webauthn_user.go b/passkey/webauthn_user.go new file mode 100644 index 0000000..1fbe81e --- /dev/null +++ b/passkey/webauthn_user.go @@ -0,0 +1,112 @@ +package passkey + +import ( + "bytes" + "fmt" + + "github.com/go-webauthn/webauthn/webauthn" +) + +type webAuthnUser struct { + user User + credentials []Credential +} + +func newWebAuthnUser(user User, credentials []Credential) (webAuthnUser, error) { + validCredentials, err := credentialsForUser(user, credentials) + if err != nil { + return webAuthnUser{}, err + } + + return webAuthnUser{ + user: cloneUser(user), + credentials: validCredentials, + }, nil +} + +func (u webAuthnUser) WebAuthnID() []byte { + return cloneBytes(u.user.Handle) +} + +func (u webAuthnUser) WebAuthnName() string { + return u.user.Name +} + +func (u webAuthnUser) WebAuthnDisplayName() string { + return u.user.DisplayName +} + +func (u webAuthnUser) WebAuthnCredentials() []webauthn.Credential { + credentials := make([]webauthn.Credential, 0, len(u.credentials)) + for _, credential := range u.credentials { + upstream := cloneWebAuthnCredential(credential.WebAuthn) + if len(upstream.ID) == 0 { + upstream.ID = cloneBytes(credential.CredentialID) + } + credentials = append(credentials, upstream) + } + + return credentials +} + +func credentialsForUser(user User, credentials []Credential) ([]Credential, error) { + if len(credentials) == 0 { + return nil, nil + } + + valid := make([]Credential, 0, len(credentials)) + for _, credential := range credentials { + if credential.RPID != user.RPID { + return nil, fmt.Errorf( + "credential relying party %q does not match user relying party %q", + credential.RPID, + user.RPID, + ) + } + if credential.PrincipalID != user.PrincipalID { + return nil, fmt.Errorf( + "credential principal %q does not match user principal %q", + credential.PrincipalID, + user.PrincipalID, + ) + } + if !bytes.Equal(credential.UserHandle, user.Handle) { + return nil, errorsForCredentialUserHandle(credential) + } + if len(credential.CredentialID) == 0 { + return nil, errorsForCredentialID("credential ID is required") + } + if len(credential.WebAuthn.ID) > 0 && !bytes.Equal(credential.WebAuthn.ID, credential.CredentialID) { + return nil, errorsForCredentialID("credential WebAuthn ID does not match credential ID") + } + + cloned := cloneCredential(credential) + if len(cloned.WebAuthn.ID) == 0 { + cloned.WebAuthn.ID = cloneBytes(cloned.CredentialID) + } + valid = append(valid, cloned) + } + + return valid, nil +} + +func credentialByID(credentials []Credential, credentialID []byte) (Credential, bool) { + for _, credential := range credentials { + if bytes.Equal(credential.CredentialID, credentialID) { + return cloneCredential(credential), true + } + } + + return Credential{}, false +} + +func errorsForCredentialUserHandle(credential Credential) error { + return fmt.Errorf( + "credential %q user handle does not match passkey user", + credentialIDString(credential.CredentialID), + ) +} + +func errorsForCredentialID(reason string) error { + return fmt.Errorf("credential invalid: %s", reason) +}