auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-08-21 17:33:00 -04:00
parent fb6bbd0dad
commit bfe6cfd29e
230 changed files with 44547 additions and 17 deletions
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MPL-2.0
package authhttp
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"gamertan.com/web/authwebauthn"
)
const maxPasskeyBodyBytes = 160 << 10
type PasskeyFinish struct {
CeremonyToken string `json:"ceremony_token"`
Credential json.RawMessage `json:"credential"`
}
func WritePasskeyBegin(response http.ResponseWriter, result authwebauthn.BeginResult) error {
if len(result.CeremonyToken) < 32 || len(result.PublicKey) == 0 || !json.Valid(result.PublicKey) || result.ExpiresAt.IsZero() {
return errors.New("authhttp: invalid passkey ceremony")
}
response.Header().Set("Cache-Control", "no-store")
response.Header().Set("Content-Type", "application/json; charset=utf-8")
response.Header().Set("X-Content-Type-Options", "nosniff")
encoder := json.NewEncoder(response)
encoder.SetEscapeHTML(true)
return encoder.Encode(result)
}
func ReadPasskeyFinish(request *http.Request) (PasskeyFinish, error) {
if request == nil || request.Method != http.MethodPost {
return PasskeyFinish{}, errors.New("authhttp: passkey response requires POST")
}
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
return PasskeyFinish{}, errors.New("authhttp: passkey response requires application/json")
}
body, err := io.ReadAll(io.LimitReader(request.Body, maxPasskeyBodyBytes+1))
if err != nil || len(body) > maxPasskeyBodyBytes {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var input PasskeyFinish
if err = decoder.Decode(&input); err != nil {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
var trailing any
if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return PasskeyFinish{}, errors.New("authhttp: passkey response contains trailing data")
}
input.CeremonyToken = strings.TrimSpace(input.CeremonyToken)
input.Credential = bytes.TrimSpace(input.Credential)
if len(input.CeremonyToken) < 32 || len(input.CeremonyToken) > 128 || len(input.Credential) == 0 || len(input.Credential) > maxPasskeyBodyBytes || !json.Valid(input.Credential) {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
return input, nil
}
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MPL-2.0
package authhttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gamertan.com/web/authwebauthn"
)
func TestPasskeyJSONBoundary(t *testing.T) {
result := authwebauthn.BeginResult{CeremonyToken: strings.Repeat("a", 43), PublicKey: []byte(`{"challenge":"example"}`), ExpiresAt: time.Now().UTC().Add(time.Minute)}
recorder := httptest.NewRecorder()
if err := WritePasskeyBegin(recorder, result); err != nil {
t.Fatal(err)
}
if recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("Content-Type") != "application/json; charset=utf-8" {
t.Fatalf("headers=%v", recorder.Header())
}
request := httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/passkey/finish", strings.NewReader(`{"ceremony_token":"`+strings.Repeat("b", 43)+`","credential":{"id":"x"}}`))
request.Header.Set("Content-Type", "application/json")
finish, err := ReadPasskeyFinish(request)
if err != nil {
t.Fatal(err)
}
if finish.CeremonyToken == "" || string(finish.Credential) != `{"id":"x"}` {
t.Fatalf("finish=%+v", finish)
}
}
func TestPasskeyJSONRejectsWrongMethodUnknownFieldsAndOversize(t *testing.T) {
for name, request := range map[string]*http.Request{
"method": httptest.NewRequest(http.MethodGet, "https://tend.gamertan.com/", nil),
"unknown": httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/", strings.NewReader(`{"ceremony_token":"`+strings.Repeat("b", 43)+`","credential":{},"extra":true}`)),
"large": httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/", strings.NewReader(strings.Repeat("x", maxPasskeyBodyBytes+1))),
} {
t.Run(name, func(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
if _, err := ReadPasskeyFinish(request); err == nil {
t.Fatal("accepted invalid request")
}
})
}
}