diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 621a006..f313ebf 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -388,6 +388,195 @@ paths:
"204": { description: Off. }
"422": { $ref: "#/components/responses/Validation" }
+ /api/v1/auth/passkeys:
+ get:
+ tags: [auth]
+ summary: The keys registered on this account.
+ description: |
+ A passkey is a key on a device rather than a secret in a head. The private
+ half never leaves the device, and the signature is bound to the origin that
+ asked for it — so a convincing copy of the sign-in page at another address
+ gets a signature it cannot use.
+
+ One row per credential, because a person has a laptop and a phone and losing
+ one must not mean losing the way in. The name is theirs to write: a list
+ nobody can read is a list nobody revokes from.
+
+ Sessions only, like `/tokens` — a leaked API token must not be able to add
+ its own way in, which would turn a theft into a tenancy.
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ passkeys:
+ type: array
+ items: { $ref: "#/components/schemas/Passkey" }
+ available:
+ type: boolean
+ description: |
+ Whether this deployment can offer passkeys at all. False when
+ `VERDANDE_BASE_URL` is an IP address or otherwise not a
+ domain an authenticator will accept — the interface asks once
+ rather than discovering it from a 503 it caused by offering a
+ button.
+ "403": { $ref: "#/components/responses/Forbidden" }
+
+ /api/v1/auth/passkeys/register/begin:
+ post:
+ tags: [auth]
+ summary: Start registering a key. Returns the options and a challenge id.
+ description: |
+ Registration is done by somebody already signed in: you cannot be handed a
+ way into an account you have not proved you own.
+
+ The challenge is kept server-side rather than in a cookie, because the whole
+ point of it is that the server chose it and remembers choosing it. It is
+ answerable once and expires after five minutes.
+ responses:
+ "200":
+ description: Pass `options` to `navigator.credentials.create()`.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ challenge_id: { type: string }
+ options: { type: object, additionalProperties: true }
+ "503":
+ description: |
+ `VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
+ rather than 404: this version has passkeys, this deployment cannot offer
+ them, and those are different things to fix.
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Error" }
+
+ /api/v1/auth/passkeys/register/finish:
+ post:
+ tags: [auth]
+ summary: Finish registering a key.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ challenge_id: { type: string }
+ name:
+ type: string
+ description: What to call it in the list — "min bærbare".
+ credential:
+ type: object
+ additionalProperties: true
+ description: The response from `navigator.credentials.create()`.
+ responses:
+ "201":
+ description: Registered.
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/Passkey" }
+ "400":
+ description: The attempt expired, was already used, or did not verify.
+ "503":
+ description: Passkeys are unavailable on this instance.
+
+ /api/v1/auth/passkeys/{passkeyID}:
+ parameters:
+ - { name: passkeyID, in: path, required: true, schema: { type: string } }
+ patch:
+ tags: [auth]
+ summary: Rename a key.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name: { type: string }
+ responses:
+ "204": { description: Renamed. }
+ "404": { $ref: "#/components/responses/NotFound" }
+ delete:
+ tags: [auth]
+ summary: Remove a key.
+ description: |
+ Scoped in the SQL rather than checked first: an id read in one step and
+ deleted in the next is a window, and the reason somebody is on this screen
+ is usually that they think a device is gone.
+ responses:
+ "204": { description: Removed. }
+ "404": { $ref: "#/components/responses/NotFound" }
+
+ /api/v1/auth/passkey/login/begin:
+ post:
+ tags: [auth]
+ summary: Start signing in with a key. No session, and no email.
+ description: |
+ A discoverable login: nobody has said who they are yet, and that is the
+ point. The device knows which account its key belongs to, so there is no
+ address to type and no list of accounts for anybody to probe.
+ responses:
+ "200":
+ description: Pass `options` to `navigator.credentials.get()`.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ challenge_id: { type: string }
+ options: { type: object, additionalProperties: true }
+ "429": { $ref: "#/components/responses/RateLimited" }
+ "503":
+ description: Passkeys are unavailable on this instance.
+
+ /api/v1/auth/passkey/login/finish:
+ post:
+ tags: [auth]
+ summary: Finish signing in with a key.
+ description: |
+ A key that verified its owner — a PIN, a fingerprint, a face — is both
+ factors at once, and completes the login outright. One that only proved
+ possession still stops at the TOTP step, exactly as a password does.
+
+ A counter that has gone backwards means the credential has been cloned. It
+ is refused rather than logged: that counter is the only signal this design
+ gives.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ challenge_id: { type: string }
+ credential:
+ type: object
+ additionalProperties: true
+ description: The response from `navigator.credentials.get()`.
+ responses:
+ "200":
+ description: Signed in, or `totp_required` when the key was possession only.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ user: { $ref: "#/components/schemas/User" }
+ totp_required: { type: boolean }
+ "401":
+ description: |
+ One message for an unknown key and for a bad signature. Telling them
+ apart is a way to learn which credentials exist here.
+ "429": { $ref: "#/components/responses/RateLimited" }
+ "503":
+ description: Passkeys are unavailable on this instance.
+
/api/v1/auth/sessions:
get:
tags: [auth]
@@ -3127,6 +3316,32 @@ components:
same thing once decoded, and telling "put it nowhere" apart from "I
did not mention it" is what makes this a PATCH.
+ Passkey:
+ type: object
+ properties:
+ id: { type: string }
+ name:
+ type: string
+ description: What the owner called it. "min bærbare" is what makes a list reviewable.
+ aaguid:
+ type: string
+ description: |
+ Identifies the authenticator model, which is what lets a list say
+ something recognisable beside a key somebody named months ago.
+ discoverable:
+ type: boolean
+ description: |
+ Whether the credential lives on the device. Only these can start a login
+ with no email typed first.
+ user_verified:
+ type: boolean
+ description: |
+ Whether the authenticator verified the person — a PIN, a fingerprint, a
+ face. One that did is both factors at once; one that only proved
+ possession is a first factor and still meets the TOTP step.
+ created_at: { type: string, format: date-time }
+ last_used_at: { type: string, format: date-time }
+
ProjectGroup:
type: object
properties:
diff --git a/go.mod b/go.mod
index 1ccf47a..2f2cdf0 100644
--- a/go.mod
+++ b/go.mod
@@ -3,21 +3,30 @@ module github.com/kristianwind/verdande
go 1.26.3
require (
+ github.com/coder/websocket v1.8.15
github.com/go-chi/chi/v5 v5.3.1
+ github.com/go-webauthn/webauthn v0.17.4
github.com/pquerna/otp v1.5.0
+ github.com/teambition/rrule-go v1.8.2
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.56.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
- github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.2 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/go-webauthn/x v0.2.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/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- github.com/teambition/rrule-go v1.8.2 // indirect
+ github.com/tinylib/msgp v1.6.4 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
diff --git a/go.sum b/go.sum
index 5240294..47ca1ee 100644
--- a/go.sum
+++ b/go.sum
@@ -2,12 +2,27 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
-github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+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-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+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.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
+github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
+github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
+github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
+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/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/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -18,6 +33,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
@@ -25,10 +42,17 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
@@ -39,6 +63,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index 6106d0f..d20dca9 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
}
return code
}
+
+// The relying party is derived from the instance's own base URL, and the two ways
+// that goes wrong are both silent until a browser refuses.
+func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
+ for _, c := range []struct {
+ base string
+ ok bool
+ why string
+ }{
+ {"https://verdande.example.dk", true, "a real domain"},
+ {"http://localhost:5173", true, "localhost, which every browser excepts"},
+ {"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
+
+ // An IP address is not a domain. The browser throws SecurityError long
+ // after the server has happily issued a challenge, so it is refused here
+ // and the endpoints answer 503 instead.
+ {"http://127.0.0.1:8080", false, "an IPv4 literal"},
+ {"http://[::1]:8080", false, "an IPv6 literal"},
+ {"", false, "no address at all"},
+ {"not a url", false, "not an address"},
+ } {
+ _, err := NewWebAuthn(c.base, "verdande")
+ if c.ok && err != nil {
+ t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
+ }
+ if !c.ok && err == nil {
+ t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
+ }
+ }
+}
diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go
new file mode 100644
index 0000000..f0cc89c
--- /dev/null
+++ b/internal/auth/webauthn.go
@@ -0,0 +1,98 @@
+package auth
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net"
+ "net/url"
+ "strings"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+// Passkeys, wrapped so the rest of the app never touches the protocol types.
+//
+// A password and a TOTP code are both shared secrets: the server keeps something
+// that can be stolen from it, and the person keeps something that can be talked
+// out of them. A passkey is neither. The private half never leaves the device, and
+// the signature is bound to the origin that asked for it — so a convincing copy of
+// the sign-in page at another address gets a signature it cannot use.
+//
+// That binding is also the one thing that can be got wrong here, and it is
+// invisible when it is: the RP ID has to be the registrable domain of the address
+// people actually visit. Get it wrong and registration works, login works on your
+// machine, and everybody behind a different hostname is quietly locked out.
+
+// WebAuthn is the relying party — this instance, as an authenticator sees it.
+type WebAuthn struct{ *webauthn.WebAuthn }
+
+// NewWebAuthn derives the relying party from the instance's own base URL.
+//
+// Derived rather than configured, because a second setting that has to agree with
+// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
+// the failure is a login that cannot be completed, reported as "the button does
+// nothing".
+func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
+ parsed, err := url.Parse(strings.TrimSpace(baseURL))
+ if err != nil || parsed.Host == "" {
+ return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
+ }
+
+ // The RP ID is the host without the port. A port in it is rejected by every
+ // browser, and localhost:5173 during development is exactly where somebody
+ // would first put one.
+ rpID := parsed.Hostname()
+
+ // An IP address is not a domain, and no browser will accept one as an RP ID —
+ // `SecurityError: This is an invalid domain`, thrown in the browser, long after
+ // the server has happily issued a challenge. Refused here instead, so the
+ // endpoints answer 503 and the interface never offers a button that cannot
+ // work. `localhost` is the exception every browser makes, and it is what
+ // development runs on.
+ if ip := net.ParseIP(rpID); ip != nil {
+ return nil, fmt.Errorf(
+ "auth: passkeys need a domain name, and %q is an IP address — "+
+ "set VERDANDE_BASE_URL to the address people actually visit", rpID)
+ }
+
+ w, err := webauthn.New(&webauthn.Config{
+ RPID: rpID,
+ RPDisplayName: displayName,
+ // The origin is the full scheme://host:port, which is what the browser
+ // sends and what the signature is checked against.
+ RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("auth: passkeys: %w", err)
+ }
+ return &WebAuthn{w}, nil
+}
+
+// A PasskeyUser is what the library needs to know about an account.
+//
+// The handle is the account id rather than anything a person chose. It is written
+// into the credential on the device and cannot be changed afterwards, so it must
+// not be an email address: changing your email would orphan every key you own.
+type PasskeyUser struct {
+ ID string
+ Email string
+ Name string
+ Credentials []webauthn.Credential
+}
+
+func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
+func (u *PasskeyUser) WebAuthnName() string { return u.Email }
+func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
+func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
+
+// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
+// go in a BLOB well enough, but the id is also compared against what the browser
+// sends back as base64url, and one encoding on both sides is one thing to be
+// wrong about.
+func EncodeCredentialID(raw []byte) string {
+ return base64.RawURLEncoding.EncodeToString(raw)
+}
+
+func DecodeCredentialID(encoded string) ([]byte, error) {
+ return base64.RawURLEncoding.DecodeString(encoded)
+}
diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go
index 633510c..af2d55f 100644
--- a/internal/httpapi/auth_test.go
+++ b/internal/httpapi/auth_test.go
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
- BaseURL: "http://127.0.0.1",
+ // `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
+ // party id, and every browser makes the same exception for localhost.
+ BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
diff --git a/internal/httpapi/integrations_test.go b/internal/httpapi/integrations_test.go
index afb1734..aac6431 100644
--- a/internal/httpapi/integrations_test.go
+++ b/internal/httpapi/integrations_test.go
@@ -1172,7 +1172,7 @@ func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
}
// The single most likely thing to be wrong, so it is spelled out rather than
// left for somebody to reconstruct.
- if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
+ if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
}
diff --git a/internal/httpapi/passkey_handlers.go b/internal/httpapi/passkey_handlers.go
new file mode 100644
index 0000000..f2141c7
--- /dev/null
+++ b/internal/httpapi/passkey_handlers.go
@@ -0,0 +1,404 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+
+ "github.com/kristianwind/verdande/internal/auth"
+ "github.com/kristianwind/verdande/internal/store"
+)
+
+// Passkeys: a key on a device instead of a secret in a head.
+//
+// Four endpoints, in two pairs. Registration is done by somebody already signed in
+// — you cannot be handed a way into an account you have not proved you own.
+// Logging in is done by somebody who is not, and is the one place here that is
+// reachable without a session.
+//
+// Each ceremony is two requests because the browser has to talk to the
+// authenticator in between: the server issues a challenge, the device signs it,
+// the server checks the signature against what it remembers issuing. The challenge
+// lives in the database rather than in a cookie, because the whole point of it is
+// that the server chose it and remembers choosing it.
+
+const challengeTTL = 5 * time.Minute
+
+// passkeyAvailable answers 503 rather than 404 when the relying party could not be
+// built. 404 would read as "this version does not have passkeys"; the truth is
+// that this deployment cannot offer them, which is a different thing to fix.
+func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
+ if s.passkeys != nil {
+ return true
+ }
+ writeError(w, http.StatusServiceUnavailable, CodeInternal,
+ "passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
+ return false
+}
+
+type passkeyJSON struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ // AAGUID identifies the authenticator model, which is what lets the list say
+ // something recognisable beside a key somebody named three months ago.
+ AAGUID string `json:"aaguid,omitempty"`
+ Discoverable bool `json:"discoverable"`
+ UserVerified bool `json:"user_verified"`
+ CreatedAt string `json:"created_at"`
+ LastUsedAt string `json:"last_used_at,omitempty"`
+}
+
+func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
+ keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
+ if err != nil {
+ s.internal(w, r, "list passkeys", err)
+ return
+ }
+ out := make([]passkeyJSON, 0, len(keys))
+ for _, k := range keys {
+ j := passkeyJSON{
+ ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
+ Discoverable: k.Discoverable, UserVerified: k.UserVerified,
+ CreatedAt: k.CreatedAt.Format(time.RFC3339),
+ }
+ if !k.LastUsedAt.IsZero() {
+ j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
+ }
+ out = append(out, j)
+ }
+ // Whether this deployment can offer them at all, alongside the ones that exist.
+ // The interface asks one question rather than discovering the answer from a 503
+ // it triggered by offering a button — an address an authenticator refuses is a
+ // deployment fact, not an error somebody caused.
+ writeJSON(w, http.StatusOK, map[string]any{
+ "passkeys": out, "available": s.passkeys != nil,
+ })
+}
+
+// --- registering a key ------------------------------------------------------------
+
+func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
+ if !s.passkeyAvailable(w, r) {
+ return
+ }
+ user := userFrom(r.Context())
+
+ account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
+ if err != nil {
+ s.internal(w, r, "load passkeys", err)
+ return
+ }
+
+ // Resident keys, and the authenticator asked to verify its owner. Both are
+ // what make this a *passkey* rather than a second factor: only a discoverable
+ // credential can start a login with no email typed, and only a verified one
+ // stands in for the password rather than beside it.
+ creation, session, err := s.passkeys.BeginRegistration(account,
+ webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
+ webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
+ ResidentKey: protocol.ResidentKeyRequirementPreferred,
+ UserVerification: protocol.VerificationPreferred,
+ }),
+ // Excluding what they already have, so a second registration on the same
+ // device replaces nothing and reports itself instead of silently making a
+ // duplicate.
+ webauthn.WithExclusions(account.excludeList()),
+ )
+ if err != nil {
+ s.internal(w, r, "begin passkey registration", err)
+ return
+ }
+ s.issueChallenge(w, r, user.ID, "register", session, creation)
+}
+
+type finishRegistrationRequest struct {
+ ChallengeID string `json:"challenge_id"`
+ Name string `json:"name"`
+ Credential json.RawMessage `json:"credential"`
+}
+
+func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
+ if !s.passkeyAvailable(w, r) {
+ return
+ }
+ var req finishRegistrationRequest
+ if err := decodeJSON(w, r, &req); err != nil {
+ return
+ }
+ user := userFrom(r.Context())
+
+ session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
+ if !ok {
+ return
+ }
+
+ parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
+ return
+ }
+
+ account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
+ if err != nil {
+ s.internal(w, r, "load passkeys", err)
+ return
+ }
+
+ credential, err := s.passkeys.CreateCredential(account, *session, parsed)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
+ return
+ }
+
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ name = "Passkey"
+ }
+ key := &store.Passkey{
+ UserID: user.ID,
+ CredentialID: auth.EncodeCredentialID(credential.ID),
+ PublicKey: credential.PublicKey,
+ AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
+ SignCount: credential.Authenticator.SignCount,
+ Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
+ UserVerified: credential.Flags.UserVerified,
+ Name: name,
+ }
+ if err := s.db.CreatePasskey(r.Context(), key); err != nil {
+ s.internal(w, r, "store passkey", err)
+ return
+ }
+ writeJSON(w, http.StatusCreated, passkeyJSON{
+ ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
+ Discoverable: key.Discoverable, UserVerified: key.UserVerified,
+ CreatedAt: key.CreatedAt.Format(time.RFC3339),
+ })
+}
+
+// --- signing in with a key ---------------------------------------------------------
+
+func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
+ if !s.passkeyAvailable(w, r) {
+ return
+ }
+
+ // Discoverable: nobody has said who they are yet, and that is the point. The
+ // device knows which account its key belongs to, so there is no email to type
+ // and no list of accounts for anybody to probe.
+ assertion, session, err := s.passkeys.BeginDiscoverableLogin()
+ if err != nil {
+ s.internal(w, r, "begin passkey login", err)
+ return
+ }
+ s.issueChallenge(w, r, "", "login", session, assertion)
+}
+
+type finishLoginRequest struct {
+ ChallengeID string `json:"challenge_id"`
+ Credential json.RawMessage `json:"credential"`
+}
+
+func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
+ if !s.passkeyAvailable(w, r) {
+ return
+ }
+ var req finishLoginRequest
+ if err := decodeJSON(w, r, &req); err != nil {
+ return
+ }
+
+ session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
+ if !ok {
+ return
+ }
+
+ parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
+ return
+ }
+
+ // Which account, resolved from the credential the device signed with. The
+ // handler is given the raw id and returns the owner; the library checks the
+ // signature against that owner's stored public key.
+ var matched *store.Passkey
+ credential, err := s.passkeys.ValidateDiscoverableLogin(
+ func(rawID, userHandle []byte) (webauthn.User, error) {
+ key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
+ if err != nil {
+ return nil, err
+ }
+ matched = key
+ owner, err := s.db.UserByID(r.Context(), key.UserID)
+ if err != nil {
+ return nil, err
+ }
+ return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
+ }, *session, parsed)
+ if err != nil || matched == nil {
+ // One message, as with a wrong password: telling "no such key" apart from
+ // "bad signature" is a way to learn which credentials exist here.
+ writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
+ return
+ }
+
+ // A counter that goes backwards means the credential has been cloned. Not
+ // every authenticator keeps one, so zero on both sides is silence rather than
+ // suspicion — but a real counter that has gone down is the one signal this
+ // design gives, and it is refused rather than logged.
+ if credential.Authenticator.CloneWarning {
+ s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
+ writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
+ return
+ }
+
+ owner, err := s.db.UserByID(r.Context(), matched.UserID)
+ if err != nil {
+ s.internal(w, r, "load account", err)
+ return
+ }
+
+ if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
+ s.log.Warn("record passkey use", "err", err)
+ }
+ s.loginLimiter.reset(clientIP(r))
+
+ // A verified key is both factors at once: the device proved possession and the
+ // person proved they were there, which is exactly what a password plus a code
+ // is for. One that only proved possession still stops at the code.
+ pending := owner.TOTPEnabled && !credential.Flags.UserVerified
+ ttl := s.cfg.SessionTTL
+ if pending {
+ ttl = 10 * time.Minute
+ }
+ token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
+ if err != nil {
+ s.internal(w, r, "create session", err)
+ return
+ }
+ s.setSessionCookie(w, token, ttl)
+
+ if pending {
+ writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
+ return
+ }
+ uj := toUserJSON(owner)
+ writeJSON(w, http.StatusOK, loginResponse{User: &uj})
+}
+
+// --- managing them ------------------------------------------------------------------
+
+func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Name string `json:"name"`
+ }
+ if err := decodeJSON(w, r, &req); err != nil {
+ return
+ }
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ writeFieldErrors(w, map[string]string{"name": "required"})
+ return
+ }
+ if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
+ userFrom(r.Context()).ID, name); err != nil {
+ s.storeError(w, r, "rename passkey", err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
+ if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
+ userFrom(r.Context()).ID); err != nil {
+ s.storeError(w, r, "delete passkey", err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// --- shared plumbing -----------------------------------------------------------------
+
+// passkeyAccount is the library's view of an account, plus the rows behind it.
+type passkeyAccount struct {
+ *auth.PasskeyUser
+ stored []store.Passkey
+}
+
+func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
+ out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
+ for _, c := range a.PasskeyUser.Credentials {
+ out = append(out, c.Descriptor())
+ }
+ return out
+}
+
+func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
+ keys, err := s.db.ListPasskeys(r.Context(), userID)
+ if err != nil {
+ return nil, err
+ }
+ credentials := make([]webauthn.Credential, 0, len(keys))
+ for _, k := range keys {
+ raw, err := auth.DecodeCredentialID(k.CredentialID)
+ if err != nil {
+ continue
+ }
+ credentials = append(credentials, webauthn.Credential{
+ ID: raw,
+ PublicKey: k.PublicKey,
+ Authenticator: webauthn.Authenticator{
+ SignCount: k.SignCount,
+ },
+ })
+ }
+ return &passkeyAccount{
+ PasskeyUser: &auth.PasskeyUser{
+ ID: userID, Email: email, Name: name, Credentials: credentials,
+ },
+ stored: keys,
+ }, nil
+}
+
+// issueChallenge stores the server's side of a ceremony and hands the browser the
+// options plus the id it will answer with.
+func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
+ encoded, err := json.Marshal(session)
+ if err != nil {
+ s.internal(w, r, "encode webauthn session", err)
+ return
+ }
+ id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
+ if err != nil {
+ s.internal(w, r, "store challenge", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
+}
+
+// takeChallenge reads a challenge once and checks it belongs where it is being
+// used. A registration challenge answered by a login — or one account's answered
+// by another — is refused rather than merely failing later.
+func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
+ owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, CodeBadRequest,
+ "that attempt has expired or was already used; start again")
+ return nil, false
+ }
+ if userID != "" && owner != userID {
+ writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
+ return nil, false
+ }
+ var session webauthn.SessionData
+ if err := json.Unmarshal(raw, &session); err != nil {
+ s.internal(w, r, "decode webauthn session", err)
+ return nil, false
+ }
+ return &session, true
+}
diff --git a/internal/httpapi/passkey_test.go b/internal/httpapi/passkey_test.go
new file mode 100644
index 0000000..8a278e4
--- /dev/null
+++ b/internal/httpapi/passkey_test.go
@@ -0,0 +1,201 @@
+package httpapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/kristianwind/verdande/internal/store"
+)
+
+// What can be tested without an authenticator, which is most of the surface that
+// actually goes wrong.
+//
+// The signature itself cannot be produced here — that needs a real device or a
+// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
+// What is testable is everything around it: who may reach these endpoints, that a
+// challenge is answerable exactly once, that one account's challenge cannot be
+// answered by another, and that an expired one is refused. Those are the parts a
+// mistake would make silently permissive.
+
+func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := ts.apiToken(t, admin.ID)
+
+ // A leaked API token must not be able to add its own way in: that would turn a
+ // theft into a tenancy.
+ for _, c := range []struct{ method, path string }{
+ {"GET", "/api/v1/auth/passkeys"},
+ {"POST", "/api/v1/auth/passkeys/register/begin"},
+ {"POST", "/api/v1/auth/passkeys/register/finish"},
+ } {
+ req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := (&http.Client{}).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusForbidden {
+ t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
+ }
+ }
+}
+
+// Beginning a registration hands back options and a challenge id, and the options
+// carry the things that make this a passkey rather than a second factor.
+func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
+ }
+ if body["challenge_id"] == "" || body["challenge_id"] == nil {
+ t.Fatal("no challenge id came back")
+ }
+
+ options, _ := body["options"].(map[string]any)
+ publicKey, _ := options["publicKey"].(map[string]any)
+ if publicKey == nil {
+ t.Fatalf("no publicKey in the options: %v", options)
+ }
+ selection, _ := publicKey["authenticatorSelection"].(map[string]any)
+ if selection["residentKey"] != "preferred" {
+ t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
+ "with no email typed", selection["residentKey"])
+ }
+ if selection["userVerification"] != "preferred" {
+ t.Errorf("userVerification = %v — a key that only proves possession cannot "+
+ "stand in for the password", selection["userVerification"])
+ }
+ // The user handle must be the account id rather than the email: it is written
+ // into the credential and cannot be changed, so an email there would orphan
+ // every key the moment somebody changed their address.
+ user, _ := publicKey["user"].(map[string]any)
+ if user["name"] != "kristian@example.dk" {
+ t.Errorf("user.name = %v", user["name"])
+ }
+ if id, _ := user["id"].(string); id == "" {
+ t.Error("no user handle in the options")
+ }
+}
+
+// A challenge is answerable exactly once, and only by whom it was issued to.
+func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+ other := ts.newUser(t, "anden@example.dk", "Anden")
+
+ _, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
+ challenge := begun["challenge_id"].(string)
+
+ // Somebody else's challenge, answered by this account. Refused before the
+ // signature is even looked at.
+ resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
+ "challenge_id": challenge, "credential": json.RawMessage(`{}`),
+ })
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
+ }
+
+ // And it is spent: the rightful owner cannot use it either now, because taking
+ // it is what deleted it. That is deliberate — a challenge that survives a
+ // failed attempt is a challenge somebody can keep trying.
+ resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
+ "challenge_id": challenge, "credential": json.RawMessage(`{}`),
+ })
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
+ }
+}
+
+// Logging in with a key needs no session and says nothing about who exists here.
+func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ // No session at all — a fresh client, which is the whole point.
+ fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
+
+ resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
+ }
+ challenge := body["challenge_id"].(string)
+
+ options, _ := body["options"].(map[string]any)
+ publicKey, _ := options["publicKey"].(map[string]any)
+ // A discoverable login must not list credentials: that list would say which
+ // keys exist on this instance to anybody who asked.
+ if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
+ if list, _ := allowed.([]any); len(list) > 0 {
+ t.Errorf("the login options list %d credentials; a discoverable login must not",
+ len(list))
+ }
+ }
+
+ // A garbage answer is refused with the same message a wrong key would get.
+ resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
+ "challenge_id": challenge, "credential": json.RawMessage(`{}`),
+ })
+ if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
+ }
+}
+
+// A key belongs to its owner, and nobody else can rename or remove it.
+func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+ other := ts.newUser(t, "anden@example.dk", "Anden")
+
+ admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Written straight to the store: registering one properly needs a device.
+ key := &store.Passkey{
+ UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
+ }
+ if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
+ t.Fatal(err)
+ }
+
+ _, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
+ if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
+ t.Fatalf("the owner sees %d keys, want 1", len(keys))
+ }
+ _, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
+ if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
+ t.Errorf("somebody else sees %d of this account's keys", len(keys))
+ }
+
+ for _, c := range []struct {
+ what, method string
+ body any
+ }{
+ {"rename", "PATCH", map[string]any{"name": "min nu"}},
+ {"delete", "DELETE", nil},
+ } {
+ resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
+ if resp.StatusCode != http.StatusNotFound {
+ t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
+ }
+ }
+
+ if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
+ map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
+ t.Errorf("the owner renaming: status %d", resp.StatusCode)
+ }
+ if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
+ t.Errorf("the owner deleting: status %d", resp.StatusCode)
+ }
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 55138f2..1050c0a 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
+ "github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
"github.com/kristianwind/verdande/internal/mcp"
@@ -39,6 +40,12 @@ type Server struct {
mcp *mcp.Server
updates *update.Checker
+ // Nil when the base URL is not something an authenticator will accept — a bare
+ // hostname with no scheme, say. The endpoints answer 503 rather than the
+ // process refusing to start: passkeys are one way in among three, and an
+ // instance should not be unreachable because one of them cannot be offered.
+ passkeys *auth.WebAuthn
+
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
+ if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
+ s.passkeys = w
+ } else {
+ log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
+ }
s.mcp = s.buildMCP()
s.updates = update.New(Version, cfg.UpdateCheck)
@@ -132,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/password/reset",
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
+ // Signing in with a passkey. Rate-limited like the password path: a
+ // signature cannot be guessed, but the endpoint can still be used to
+ // hammer the database.
+ r.Method(http.MethodPost, "/passkey/login/begin",
+ s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
+ r.Method(http.MethodPost, "/passkey/login/finish",
+ s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
+
// The second step of a login: a session that has passed the password
// but not yet the code, and can reach nothing else.
r.Group(func(r chi.Router) {
@@ -160,6 +180,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireSession)
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
+
+ // Registering a key, and the list of them. Sessions only, for
+ // the same reason: a leaked token must not be able to add its
+ // own way in — that would turn a theft into a tenancy.
+ r.Get("/passkeys", s.handleListPasskeys)
+ r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
+ r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
+ r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
+ r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
})
})
})
diff --git a/internal/store/migrations/0013_passkeys.sql b/internal/store/migrations/0013_passkeys.sql
new file mode 100644
index 0000000..811f857
--- /dev/null
+++ b/internal/store/migrations/0013_passkeys.sql
@@ -0,0 +1,73 @@
+-- Passkeys: a key on a device instead of a secret in a head.
+--
+-- The account already has two ways in — a password, and a TOTP code on top of it.
+-- Both are shared secrets: the server keeps something that can be stolen from it,
+-- and the person keeps something that can be phished out of them. A passkey is
+-- neither. The private half never leaves the device, the public half is all this
+-- table holds, and the signature is bound to the origin — so a convincing copy of
+-- the sign-in page at a different address gets nothing.
+--
+-- One row per credential rather than per account, because a person has more than
+-- one device and losing a laptop should not mean losing the way in. The name is
+-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
+-- list nobody can read is a list nobody revokes from.
+--
+-- `sign_count` is the authenticator's own counter. It is stored to be compared:
+-- a counter that goes backwards means the credential has been cloned, which is
+-- the one thing this design cannot otherwise detect. Not every authenticator keeps
+-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
+CREATE TABLE passkeys (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ -- The credential id the authenticator generated, raw bytes, base64url as
+ -- stored. Unique across the instance: it is what a login is looked up by,
+ -- before anybody has said who they are.
+ credential_id TEXT NOT NULL,
+ public_key BLOB NOT NULL,
+ -- The AAGUID identifies the authenticator model, which is what lets a list say
+ -- "iCloud Keychain" beside a key somebody named three months ago and forgot.
+ aaguid TEXT NOT NULL DEFAULT '',
+ sign_count INTEGER NOT NULL DEFAULT 0,
+ -- Whether the credential lives on the device itself (a resident key). Only
+ -- those can start a login with no email typed first, so the interface has to
+ -- know which it has before it offers that.
+ discoverable INTEGER NOT NULL DEFAULT 0,
+ -- Whether the authenticator verified the person — a PIN, a fingerprint, a
+ -- face. A key that only proves possession is a first factor; one that verified
+ -- its owner is both, and that is what decides whether a password is still
+ -- asked for.
+ user_verified INTEGER NOT NULL DEFAULT 0,
+ name TEXT NOT NULL DEFAULT '',
+ created_at INTEGER NOT NULL,
+ last_used_at INTEGER
+);
+
+CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
+CREATE INDEX idx_passkeys_user ON passkeys (user_id);
+
+-- The challenge a registration or a login is answered against.
+--
+-- Kept server-side rather than in a cookie: the whole point of the challenge is
+-- that the server chose it and remembers choosing it. One row per attempt, deleted
+-- when it is answered, and swept when it expires — a challenge that outlives its
+-- ceremony is a replay waiting to be tried.
+--
+-- user_id is null for a login that has not said who it is yet, which is the case
+-- a discoverable credential exists for.
+CREATE TABLE webauthn_challenges (
+ id TEXT PRIMARY KEY,
+ user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
+ challenge TEXT NOT NULL,
+ -- 'register' or 'login'. A challenge issued for one must not be answerable by
+ -- the other: registering is done by somebody already signed in, and logging in
+ -- is not.
+ purpose TEXT NOT NULL,
+ -- The library's own session state, which carries the allowed credentials and
+ -- the verification requirement it will check the answer against.
+ session BLOB NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ CHECK (purpose IN ('register', 'login'))
+);
+
+CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
diff --git a/internal/store/passkeys.go b/internal/store/passkeys.go
new file mode 100644
index 0000000..6cb147a
--- /dev/null
+++ b/internal/store/passkeys.go
@@ -0,0 +1,206 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+)
+
+// A Passkey is one credential on one device.
+//
+// One row per credential rather than per account: a person has a laptop and a
+// phone, and losing one must not mean losing the way in. The name is theirs to
+// write — "min bærbare" is what makes the list reviewable, and a list nobody can
+// read is a list nobody revokes from.
+type Passkey struct {
+ ID string
+ UserID string
+ CredentialID string
+ PublicKey []byte
+ AAGUID string
+ SignCount uint32
+ Discoverable bool
+ UserVerified bool
+ Name string
+ CreatedAt time.Time
+ LastUsedAt time.Time
+}
+
+func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
+ if p.ID == "" {
+ p.ID = NewID()
+ }
+ p.CreatedAt = time.Now().UTC()
+ _, err := db.ExecContext(ctx, `
+ INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
+ discoverable, user_verified, name, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
+ boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
+ return err
+}
+
+func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
+ discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
+ FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanPasskeys(rows)
+}
+
+// PasskeyByCredentialID is how a login finds the account, before anybody has said
+// who they are. The credential id is unique across the instance, which is what
+// makes that possible.
+func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
+ rows, err := db.QueryContext(ctx, `
+ SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
+ discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
+ FROM passkeys WHERE credential_id = ?`, credentialID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ list, err := scanPasskeys(rows)
+ if err != nil {
+ return nil, err
+ }
+ if len(list) == 0 {
+ return nil, ErrNotFound
+ }
+ return &list[0], nil
+}
+
+// TouchPasskey records a use and the authenticator's counter.
+//
+// The counter is stored to be compared. One that goes backwards means the
+// credential has been cloned, which is the single thing this design cannot
+// otherwise detect — and not every authenticator keeps one, so zero means "not
+// offered" rather than "suspicious".
+func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
+ _, err := db.ExecContext(ctx,
+ `UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
+ signCount, time.Now().Unix(), id)
+ return err
+}
+
+func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
+ res, err := db.ExecContext(ctx,
+ `UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
+// id read in one step and deleted in the next is a window, and the reason somebody
+// is on this screen at all is usually that they think a device is gone.
+func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
+ res, err := db.ExecContext(ctx,
+ `DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// CountPasskeys is what stops somebody removing their last way in when it is the
+// only one they have.
+func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
+ var n int
+ err := db.QueryRowContext(ctx,
+ `SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
+ return n, err
+}
+
+func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
+ out := []Passkey{}
+ for rows.Next() {
+ var p Passkey
+ var discoverable, verified int
+ var created, used int64
+ if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
+ &p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
+ return nil, err
+ }
+ p.Discoverable = discoverable == 1
+ p.UserVerified = verified == 1
+ p.CreatedAt = time.Unix(created, 0).UTC()
+ if used > 0 {
+ p.LastUsedAt = time.Unix(used, 0).UTC()
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+// --- challenges -------------------------------------------------------------------
+
+// StoreChallenge keeps the server's side of one ceremony.
+//
+// Server-side rather than in a cookie: the whole point of a challenge is that the
+// server chose it and remembers choosing it. It is deleted when answered and swept
+// when it expires — a challenge that outlives its ceremony is a replay waiting to
+// be tried.
+func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
+ id := NewID()
+ now := time.Now()
+ _, err := db.ExecContext(ctx, `
+ INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
+ return id, err
+}
+
+// TakeChallenge reads a challenge and removes it in the same transaction.
+//
+// Taken rather than read, because a challenge is answerable exactly once. Two
+// requests racing the same one would otherwise both succeed, which is the whole
+// shape of a replay.
+func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
+ err = db.Tx(ctx, func(tx *sql.Tx) error {
+ var owner sql.NullString
+ var expires int64
+ row := tx.QueryRowContext(ctx,
+ `SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
+ id, purpose)
+ if err := row.Scan(&owner, &session, &expires); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+ if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
+ return err
+ }
+ if time.Now().Unix() > expires {
+ return ErrNotFound
+ }
+ userID = owner.String
+ return nil
+ })
+ return userID, session, err
+}
+
+// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
+// itself; an abandoned one is what this is for.
+func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
+ res, err := db.ExecContext(ctx,
+ `DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+}
diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js
index 06934cf..0260e1a 100644
--- a/web/e2e/smoke.spec.js
+++ b/web/e2e/smoke.spec.js
@@ -50,9 +50,16 @@ function watchForTrouble(page) {
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
- if (url.includes('/api/') && !response.ok()) {
- trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
- }
+ if (!url.includes('/api/') || response.ok()) return;
+
+ // A 401 from /auth/me is the app asking "am I signed in?" and being told no,
+ // which is the correct answer on the sign-in page and after signing out.
+ // Every other non-ok response is worth failing over — that is what this
+ // watcher is for.
+ const path = new URL(url).pathname;
+ if (path.endsWith('/auth/me') && response.status() === 401) return;
+
+ trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
});
return trouble;
}
@@ -577,7 +584,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
await invitee.goto(link);
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
- await invitee.getByLabel('Navn').fill('Nabo');
+ await invitee.getByLabel('Navn', { exact: true }).fill('Nabo');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
@@ -734,7 +741,7 @@ test('en administrator kan invitere en bruger til instansen', async ({ browser,
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const invitee = await context.newPage();
await invitee.goto(link);
- await invitee.getByLabel('Navn').fill('Anders');
+ await invitee.getByLabel('Navn', { exact: true }).fill('Anders');
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await invitee.getByRole('button', { name: 'Opret konto' }).click();
@@ -807,7 +814,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const andreas = await context.newPage();
await andreas.goto(link);
- await andreas.getByLabel('Navn').fill('andreas');
+ await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await andreas.getByRole('button', { name: 'Opret konto' }).click();
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
@@ -964,7 +971,7 @@ test('historik-siden viser hændelser fra et projekt, administratoren ikke er me
expect(link).toContain('/invite?token=');
await theirs.goto(link);
- await theirs.getByLabel('Navn').fill('Sigrid');
+ await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid');
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await theirs.getByRole('button', { name: 'Opret konto' }).click();
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
@@ -1447,6 +1454,97 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
expect(trouble).toEqual([]);
});
+/**
+ * A passkey, registered and then signed in with, end to end.
+ *
+ * The Go tests cover who may reach these endpoints and that a challenge is spent
+ * when it is used. What they cannot do is produce a signature — that needs an
+ * authenticator. Chrome has a virtual one behind CDP, and it is the only way to
+ * find out whether the encoding is right: every boundary here is a base64url
+ * conversion, and one wrong byte produces a signature that will not verify,
+ * reported as "that key was not accepted" — which reads as a broken key rather
+ * than a broken encoder.
+ *
+ * Chromium only, and deliberately so: this is testing our ceremony, not WebKit's
+ * authenticator.
+ */
+test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => {
+ test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium');
+
+ // Its own context, at `localhost` rather than the loopback address the rest of
+ // the suite uses. WebAuthn refuses an IP as a relying party id — the browser
+ // throws `SecurityError: This is an invalid domain` — and localhost is the
+ // exception every browser makes. It is not the shared baseURL because
+ // resolving `localhost` is slow enough here to cost the whole suite minutes.
+ //
+ // Cookies are bound to an origin, so the signed-in state from the setup project
+ // does not carry across: this signs in with a password first, which is also the
+ // honest order — you register a key on an account you have already proved you
+ // own.
+ const origin = 'http://localhost:8097';
+ const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
+ const page = await context.newPage();
+ const trouble = watchForTrouble(page);
+
+ await page.goto(`${origin}/`);
+ await page.getByLabel(/E-mail/).fill(USER.email);
+ await page.getByLabel(/Adgangskode/).fill(USER.password);
+ await page.getByRole('button', { name: 'Log ind', exact: true }).click();
+ await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
+
+ // A virtual authenticator that verifies its owner, so the key counts as both
+ // factors — which is what lets the login complete without a password.
+ const client = await page.context().newCDPSession(page);
+ await client.send('WebAuthn.enable');
+ const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'internal',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ automaticPresenceSimulation: true
+ }
+ });
+
+ await page.goto(`${origin}/indstillinger`);
+ await page.getByLabel('Navn på nøglen').fill('Testnøglen');
+ await page.getByRole('button', { name: 'Tilføj en nøgle' }).click();
+
+ // It is in the list, and it counts as both factors — the virtual authenticator
+ // verifies, so the login below needs no password.
+ const key = page.locator('li').filter({ hasText: 'Testnøglen' });
+ await expect(key).toBeVisible();
+ await expect(key.getByText('begge faktorer')).toBeVisible();
+
+ // It survives a reload, so this is the server's row and not the page's state.
+ await page.reload();
+ await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible();
+
+ // Now sign out and back in with it. No email typed: the device knows which
+ // account its key belongs to, which is also why this page cannot be used to
+ // find out who has an account here.
+ await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
+ // `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
+ // Playwright matches an accessible name loosely unless told otherwise.
+ await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
+ await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
+
+ // And the key records that it was used, which is what makes the list worth
+ // reading when somebody wonders whether a device is still in play.
+ await page.goto(`${origin}/indstillinger`);
+ await expect(
+ page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
+ ).toBeVisible();
+
+ await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
+ await context.close();
+ expect(trouble).toEqual([]);
+});
+
/**
* The sidebar never scrolls sideways.
*
@@ -1465,7 +1563,9 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
await page.goto('/indstillinger');
// A name long enough to overflow the column at its default width.
- await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
+ // `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
+ // ambiguous locator waits for one of them to go away rather than failing.
+ await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
await page.getByRole('button', { name: 'Gem' }).first().click();
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
@@ -1480,7 +1580,7 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
// Put the name back, so the tests after this one see the account they expect.
- await page.getByLabel('Navn').fill(USER.name);
+ await page.getByLabel('Navn', { exact: true }).fill(USER.name);
await page.getByRole('button', { name: 'Gem' }).first().click();
expect(trouble).toEqual([]);
@@ -1535,6 +1635,16 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
// inside a board was exactly true.
await page.getByRole('button', { name: 'Board', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
+
+ // A board gets the width a reading column withholds. This was `.view:has(.board)`
+ // for as long as the board has existed and never once matched: `.board` is inside
+ // a child component, and Svelte scopes a selector to the component that wrote it.
+ // The rule read correctly, compiled, and could not fire.
+ const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
+ await page.getByRole('button', { name: 'Liste', exact: true }).click();
+ const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
+ expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow);
+ await page.getByRole('button', { name: 'Board', exact: true }).click();
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
diff --git a/web/playwright.config.js b/web/playwright.config.js
index 9690638..810aa7e 100644
--- a/web/playwright.config.js
+++ b/web/playwright.config.js
@@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) {
}
const PORT = 8097;
+// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
+// id — `SecurityError: This is an invalid domain` — and localhost is the exception
+// every browser makes. The passkey test cannot run against a loopback address.
const baseURL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
@@ -118,7 +121,15 @@ export default defineConfig({
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
- VERDANDE_BASE_URL: baseURL,
+ // `localhost`, while the suite itself drives `127.0.0.1`. These are two
+ // different things: the suite's address is where the browser goes, and
+ // this one is what the server calls itself — which is now also the
+ // WebAuthn relying party id, and WebAuthn refuses an IP address.
+ //
+ // The passkey test navigates to localhost explicitly so its origin
+ // matches. Everything else stays on the loopback address, where it has
+ // always been.
+ VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
index 22fb8a4..fb2bf57 100644
--- a/web/src/lib/api.js
+++ b/web/src/lib/api.js
@@ -181,6 +181,15 @@ export const api = {
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
+
+ // --- passkeys
+ listPasskeys: () => get('/auth/passkeys'),
+ beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
+ finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
+ renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
+ deletePasskey: (id) => del(`/auth/passkeys/${id}`),
+ beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
+ finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
endSession: (id) => del(`/auth/sessions/${id}`),
// --- users, administrators only
diff --git a/web/src/lib/components/SignIn.svelte b/web/src/lib/components/SignIn.svelte
index 49cd30f..6062f9d 100644
--- a/web/src/lib/components/SignIn.svelte
+++ b/web/src/lib/components/SignIn.svelte
@@ -20,6 +20,7 @@
import { app } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
+ import { available, signIn } from '$lib/passkey.js';
// loading | login | setup | totp | forgot | sent | invite | reset | done
let mode = $state('loading');
@@ -31,6 +32,41 @@
let fields = $state({});
let busy = $state(false);
+ // --- passkey -----------------------------------------------------------------
+
+ let passkeyReady = $state(false);
+
+ $effect(() => {
+ available().then((ok) => (passkeyReady = ok));
+ });
+
+ /**
+ * No email is asked for and none is sent: the device knows which account its
+ * key belongs to. That is not only convenience — it means this page cannot be
+ * used to find out who has an account here.
+ */
+ async function signInWithPasskey() {
+ error = '';
+ busy = true;
+ try {
+ const result = await signIn();
+ if (result.totp_required) {
+ mode = 'totp';
+ } else {
+ app.user = result.user;
+ await app.load();
+ }
+ } catch (e) {
+ // A cancelled prompt is not a failure. Somebody changed their mind, and
+ // an error for that reads as though the key was rejected.
+ if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
+ error = humanMessage(e);
+ }
+ } finally {
+ busy = false;
+ }
+ }
+
/**
* The token from an emailed link, if this is one.
*
@@ -267,6 +303,15 @@
+
+
+ {#if passkeyReady}
+
+ {/if}
+
@@ -381,6 +426,22 @@
cursor: default;
}
+ /* Set apart from the primary button but not made secondary: it is an equal way
+ in, not a fallback. */
+ .passkey {
+ width: 100%;
+ padding: var(--s2) var(--s4);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius);
+ font-size: var(--text-sm);
+ color: var(--ink);
+ transition: border-color var(--fast) var(--ease);
+ }
+
+ .passkey:hover {
+ border-color: var(--accent);
+ }
+
.link {
color: var(--ink-faint);
font-size: var(--text-sm);
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index 06a2add..743aff0 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -613,5 +613,27 @@ export const da = {
'done.title': 'Færdige',
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
'done.empty': 'Du har ikke lukket noget endnu.',
- 'done.unknownDay': 'Uden dato'
+ 'done.unknownDay': 'Uden dato',
+
+ // --- passkeys ----------------------------------------------------------------------
+ 'passkey.title': 'Passkeys',
+ 'passkey.hint':
+ 'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
+ 'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
+ 'passkey.none': 'Ingen nøgler endnu.',
+ 'passkey.name': 'Navn på nøglen',
+ 'passkey.namePlaceholder': 'fx min bærbare',
+ 'passkey.add': 'Tilføj en nøgle',
+ 'passkey.registering': 'Venter på enheden…',
+ 'passkey.remove': 'Fjern',
+ 'passkey.removeQuestion': 'Fjern "{name}"? Enheden kan ikke logge ind med den bagefter.',
+ 'passkey.bothFactors': 'begge faktorer',
+ 'passkey.possessionOnly': 'kun besiddelse — koden bliver stadig krævet',
+ 'passkey.lastUsed': 'sidst brugt {when}',
+ 'passkey.neverUsed': 'aldrig brugt',
+ 'passkey.signIn': 'Log ind med en passkey',
+ 'passkey.signingIn': 'Venter på enheden…',
+
+ 'passkey.unavailable':
+ 'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.'
};
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index e5bbe5b..c1fa9b9 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -608,5 +608,27 @@ export const en = {
'done.title': 'Done',
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
'done.empty': 'You have not closed anything yet.',
- 'done.unknownDay': 'No date'
+ 'done.unknownDay': 'No date',
+
+ // --- passkeys ----------------------------------------------------------------------
+ 'passkey.title': 'Passkeys',
+ 'passkey.hint':
+ 'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
+ 'passkey.unsupported': 'This browser does not support passkeys.',
+ 'passkey.none': 'No keys yet.',
+ 'passkey.name': 'Name for the key',
+ 'passkey.namePlaceholder': 'e.g. my laptop',
+ 'passkey.add': 'Add a key',
+ 'passkey.registering': 'Waiting for the device…',
+ 'passkey.remove': 'Remove',
+ 'passkey.removeQuestion': 'Remove "{name}"? That device cannot sign in with it afterwards.',
+ 'passkey.bothFactors': 'both factors',
+ 'passkey.possessionOnly': 'possession only — the code is still required',
+ 'passkey.lastUsed': 'last used {when}',
+ 'passkey.neverUsed': 'never used',
+ 'passkey.signIn': 'Sign in with a passkey',
+ 'passkey.signingIn': 'Waiting for the device…',
+
+ 'passkey.unavailable':
+ 'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.'
};
diff --git a/web/src/lib/passkey.js b/web/src/lib/passkey.js
new file mode 100644
index 0000000..7c7a704
--- /dev/null
+++ b/web/src/lib/passkey.js
@@ -0,0 +1,115 @@
+/**
+ * The browser half of a passkey ceremony.
+ *
+ * WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every
+ * boundary between this and the server is a base64url conversion. Getting one of
+ * them wrong produces a signature that will not verify, reported as "that key was
+ * not accepted" — which reads as a broken key rather than a broken encoder. That
+ * is the whole reason this is one file and not three call sites.
+ */
+import { api } from './api.js';
+
+/** Whether this browser can do it at all. */
+export function supported() {
+ return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential);
+}
+
+/**
+ * Whether a key can be offered without the person typing anything first.
+ *
+ * Not the same question as `supported()`: a browser can do WebAuthn and still have
+ * nowhere to put a discoverable credential. Asking first is what stops the sign-in
+ * page offering a button that cannot work.
+ */
+export async function available() {
+ if (!supported()) return false;
+ try {
+ return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ } catch {
+ return false;
+ }
+}
+
+const toBuffer = (value) => {
+ const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
+ const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '=');
+ return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
+};
+
+const toBase64URL = (buffer) =>
+ btoa(String.fromCharCode(...new Uint8Array(buffer)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+
+/** Registers a key on the signed-in account. Returns the row the server stored. */
+export async function register(name) {
+ const { challenge_id, options } = await api.beginPasskeyRegistration();
+ const publicKey = {
+ ...options.publicKey,
+ challenge: toBuffer(options.publicKey.challenge),
+ user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) },
+ excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({
+ ...c,
+ id: toBuffer(c.id)
+ }))
+ };
+
+ const credential = await navigator.credentials.create({ publicKey });
+ if (!credential) throw new Error('cancelled');
+
+ return api.finishPasskeyRegistration({
+ challenge_id,
+ name,
+ credential: {
+ id: credential.id,
+ rawId: toBase64URL(credential.rawId),
+ type: credential.type,
+ response: {
+ clientDataJSON: toBase64URL(credential.response.clientDataJSON),
+ attestationObject: toBase64URL(credential.response.attestationObject)
+ },
+ clientExtensionResults: credential.getClientExtensionResults()
+ }
+ });
+}
+
+/**
+ * Signs in with a key.
+ *
+ * No email is asked for and none is sent: the device knows which account its key
+ * belongs to. That is not only convenience — it means the sign-in page cannot be
+ * used to find out who has an account here.
+ */
+export async function signIn() {
+ const { challenge_id, options } = await api.beginPasskeyLogin();
+ const publicKey = {
+ ...options.publicKey,
+ challenge: toBuffer(options.publicKey.challenge),
+ allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({
+ ...c,
+ id: toBuffer(c.id)
+ }))
+ };
+
+ const credential = await navigator.credentials.get({ publicKey });
+ if (!credential) throw new Error('cancelled');
+
+ return api.finishPasskeyLogin({
+ challenge_id,
+ credential: {
+ id: credential.id,
+ rawId: toBase64URL(credential.rawId),
+ type: credential.type,
+ response: {
+ clientDataJSON: toBase64URL(credential.response.clientDataJSON),
+ authenticatorData: toBase64URL(credential.response.authenticatorData),
+ signature: toBase64URL(credential.response.signature),
+ userHandle: credential.response.userHandle
+ ? toBase64URL(credential.response.userHandle)
+ : null
+ },
+ clientExtensionResults: credential.getClientExtensionResults()
+ }
+ });
+}
diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte
index b5cf3ac..e8a3f7b 100644
--- a/web/src/routes/indstillinger/+page.svelte
+++ b/web/src/routes/indstillinger/+page.svelte
@@ -3,12 +3,67 @@
import { api, humanMessage } from '$lib/api.js';
import { app, theme, THEMES } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
+ import { supported, register } from '$lib/passkey.js';
+ import { ago } from '$lib/when.js';
// --- profile ---------------------------------------------------------------
let name = $state('');
let timezone = $state('');
let locale = $state('da');
+
+ // --- passkeys -------------------------------------------------------------------
+
+ let passkeys = $state([]);
+ let passkeyName = $state('');
+ let passkeyError = $state('');
+ let registering = $state(false);
+ let passkeySupported = $state(true);
+
+ $effect(() => {
+ passkeySupported = supported();
+ if (!passkeySupported) return;
+ api
+ .listPasskeys()
+ .then((r) => {
+ passkeys = r.passkeys;
+ // The server has the other half of the answer: this browser can do
+ // passkeys, and this deployment may still be on an address no
+ // authenticator will accept.
+ passkeySupported = r.available;
+ })
+ .catch(() => {});
+ });
+
+ async function addPasskey(event) {
+ event.preventDefault();
+ passkeyError = '';
+ registering = true;
+ try {
+ passkeys = [...passkeys, await register(passkeyName.trim())];
+ passkeyName = '';
+ } catch (e) {
+ // A cancelled prompt is not a failure — the person changed their mind,
+ // and an error message for that reads as though something broke.
+ if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
+ passkeyError = humanMessage(e);
+ }
+ } finally {
+ registering = false;
+ }
+ }
+
+ async function removePasskey(key) {
+ if (!confirm(t('passkey.removeQuestion', { name: key.name }))) return;
+ const previous = passkeys;
+ passkeys = passkeys.filter((k) => k.id !== key.id);
+ try {
+ await api.deletePasskey(key.id);
+ } catch (e) {
+ passkeys = previous;
+ app.toast(humanMessage(e));
+ }
+ }
let profileErrors = $state({});
let profileSaved = $state(false);
let savingProfile = $state(false);
@@ -187,16 +242,6 @@
* older than a week gets its date instead: "for 23 dage siden" is a number
* nobody can place.
*/
- function ago(iso) {
- const then = new Date(iso);
- const seconds = Math.round((Date.now() - then) / 1000);
-
- if (seconds < 60) return 'lige nu';
- if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
- if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
- if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
- return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
- }
@@ -413,6 +458,47 @@
{/if}
+
+
+
+