feat(P05): WebAuthn connector for Dex (REQ-148, D-240, C-38)
---ci---
project: orca
phase: 5
milestone: v0.12
status: execute
---/ci---
internal/webauthn/store.go: SQLite credential store (0600, public
keys only). Put/Get/List/Delete/UpdateSignCount.
internal/webauthn/connector.go: WebAuthn ceremony handler for the
bundled Dex. BeginRegistration/FinishRegistration/BeginLogin/FinishLogin
at /orca/webauthn/{register,login}. go-webauthn library for crypto.
RP ID = cluster Traefik domain (C-38). Public-key credentials only
(private key never leaves authenticator; R-021 invariant holds).
9 tests pass (4 store + 5 connector). go vet clean. Full build green.
This commit is contained in:
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/hcl/v2 v2.24.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
@@ -17,14 +18,22 @@ require (
|
||||
github.com/agext/levenshtein v1.2.1 // indirect
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // 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-cmp v0.7.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // 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/spf13/pflag v1.0.5 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zclconf/go-cty v1.16.3 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
|
||||
@@ -9,12 +9,26 @@ 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-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
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-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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -31,6 +45,10 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -38,10 +56,18 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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/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=
|
||||
github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
|
||||
github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
|
||||
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
|
||||
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
@@ -60,6 +86,7 @@ golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
// Package webauthn: connector.go implements the WebAuthn ceremony
|
||||
// handler for the bundled Dex (REQ-148, D-240, C-38). It serves
|
||||
// registration + login endpoints at /orca/webauthn/{register,login}
|
||||
// behind Traefik (R-017, step-ca cert, HTTPS secure context).
|
||||
//
|
||||
// The connector uses github.com/go-webauthn/webauthn for the
|
||||
// cryptographic ceremony logic. Credential storage is in store.go
|
||||
// (SQLite, 0600, public keys only).
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// Connector is the WebAuthn ceremony handler. It is mounted behind
|
||||
// Traefik and called by the bundled Dex.
|
||||
type Connector struct {
|
||||
w *webauthn.WebAuthn
|
||||
store *Store
|
||||
rpID string
|
||||
origin string
|
||||
}
|
||||
|
||||
// NewConnector builds a WebAuthn connector with the given RP ID
|
||||
// (the cluster's Traefik-served domain, C-38) and origin (the full
|
||||
// HTTPS URL).
|
||||
func NewConnector(store *Store, rpID, rpOrigin string) (*Connector, error) {
|
||||
wconfig := &webauthn.Config{
|
||||
RPDisplayName: "Orca",
|
||||
RPID: rpID,
|
||||
RPOrigins: []string{rpOrigin},
|
||||
}
|
||||
w, err := webauthn.New(wconfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: new: %w", err)
|
||||
}
|
||||
return &Connector{
|
||||
w: w,
|
||||
store: store,
|
||||
rpID: rpID,
|
||||
origin: rpOrigin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegistrationSession holds the in-flight registration challenge.
|
||||
type RegistrationSession struct {
|
||||
UserID string
|
||||
Challenge *webauthn.SessionData
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// sessionStore holds in-flight sessions (registration + login). In
|
||||
// production this would be a Redis/shared cache; for the bundled
|
||||
// single-lead Dex, an in-memory map with TTL is sufficient.
|
||||
type sessionStore struct {
|
||||
sessions map[string]*RegistrationSession
|
||||
}
|
||||
var regSessions = &sessionStore{sessions: make(map[string]*RegistrationSession)}
|
||||
|
||||
// sessionTTL is the max time a registration/login session is valid.
|
||||
const sessionTTL = 5 * time.Minute
|
||||
|
||||
// cleanSessions removes expired sessions.
|
||||
func cleanSessions() {
|
||||
now := time.Now()
|
||||
for id, s := range regSessions.sessions {
|
||||
if now.Sub(s.CreatedAt) > sessionTTL {
|
||||
delete(regSessions.sessions, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BeginRegistration starts the WebAuthn registration ceremony.
|
||||
// GET /orca/webauthn/register?username=<name>
|
||||
// Returns the creation options (challenge) for the browser.
|
||||
func (c *Connector) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userID := []byte(username)
|
||||
existing, _ := c.store.GetCredential(username)
|
||||
var creds []webauthn.Credential
|
||||
if existing != nil {
|
||||
creds = append(creds, webauthn.Credential{
|
||||
ID: existing.CredentialID,
|
||||
PublicKey: existing.PublicKey,
|
||||
AttestationType: "none",
|
||||
})
|
||||
}
|
||||
user := &webauthnUser{id: userID, name: username, credentials: creds}
|
||||
options, session, err := c.w.BeginRegistration(user)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("begin registration: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sessionID := base64.RawURLEncoding.EncodeToString(userID)
|
||||
regSessions.sessions[sessionID] = &RegistrationSession{
|
||||
UserID: username,
|
||||
Challenge: session,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(options)
|
||||
}
|
||||
|
||||
// FinishRegistration completes the WebAuthn registration ceremony.
|
||||
// POST /orca/webauthn/register/finish?username=<name>
|
||||
// Body: the attestation response from the browser.
|
||||
func (c *Connector) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sessionID := base64.RawURLEncoding.EncodeToString([]byte(username))
|
||||
session, ok := regSessions.sessions[sessionID]
|
||||
if !ok {
|
||||
http.Error(w, "no registration session; call /register first", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if time.Since(session.CreatedAt) > sessionTTL {
|
||||
delete(regSessions.sessions, sessionID)
|
||||
http.Error(w, "session expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("parse attestation: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user := &webauthnUser{id: []byte(username), name: username}
|
||||
cred, err := c.w.CreateCredential(user, *session.Challenge, parsed)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("create credential: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
storeCred := &Credential{
|
||||
UserID: username,
|
||||
CredentialID: cred.ID,
|
||||
PublicKey: cred.PublicKey,
|
||||
SignCount: 0,
|
||||
AAGUID: "",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := c.store.PutCredential(storeCred); err != nil {
|
||||
http.Error(w, fmt.Sprintf("store credential: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
delete(regSessions.sessions, sessionID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "registered", "user_id": username})
|
||||
}
|
||||
|
||||
// LoginSession holds the in-flight login challenge.
|
||||
type LoginSession struct {
|
||||
UserID string
|
||||
Challenge *webauthn.SessionData
|
||||
CreatedAt time.Time
|
||||
}
|
||||
var loginSessions = map[string]*LoginSession{}
|
||||
|
||||
// BeginLogin starts the WebAuthn login ceremony.
|
||||
// GET /orca/webauthn/login?username=<name>
|
||||
func (c *Connector) BeginLogin(w http.ResponseWriter, r *http.Request) {
|
||||
cleanSessions()
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
existing, _ := c.store.GetCredential(username)
|
||||
if existing == nil {
|
||||
http.Error(w, "user not registered", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
user := &webauthnUser{
|
||||
id: []byte(username),
|
||||
name: username,
|
||||
credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}},
|
||||
}
|
||||
options, session, err := c.w.BeginLogin(user)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("begin login: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
loginSessions[username] = &LoginSession{
|
||||
UserID: username,
|
||||
Challenge: session,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(options)
|
||||
}
|
||||
|
||||
// FinishLogin completes the WebAuthn login ceremony.
|
||||
// POST /orca/webauthn/login/finish?username=<name>
|
||||
func (c *Connector) FinishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
session, ok := loginSessions[username]
|
||||
if !ok {
|
||||
http.Error(w, "no login session; call /login first", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if time.Since(session.CreatedAt) > sessionTTL {
|
||||
delete(loginSessions, username)
|
||||
http.Error(w, "session expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
existing, _ := c.store.GetCredential(username)
|
||||
if existing == nil {
|
||||
http.Error(w, "user not registered", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
user := &webauthnUser{
|
||||
id: []byte(username),
|
||||
name: username,
|
||||
credentials: []webauthn.Credential{{ID: existing.CredentialID, PublicKey: existing.PublicKey}},
|
||||
}
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("parse assertion: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cred, err := c.w.ValidateLogin(user, *session.Challenge, parsed)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("validate login: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = c.store.UpdateSignCount(username, cred.Authenticator.SignCount)
|
||||
delete(loginSessions, username)
|
||||
// The OIDC sub is the username (the connector maps credential ID
|
||||
// to sub). Dex uses this to issue the ID token.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "authenticated",
|
||||
"sub": username,
|
||||
})
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler mux for the WebAuthn connector.
|
||||
// Mount under /orca/webauthn/ behind Traefik.
|
||||
func (c *Connector) Routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/orca/webauthn/register", c.BeginRegistration)
|
||||
mux.HandleFunc("/orca/webauthn/register/finish", c.FinishRegistration)
|
||||
mux.HandleFunc("/orca/webauthn/login", c.BeginLogin)
|
||||
mux.HandleFunc("/orca/webauthn/login/finish", c.FinishLogin)
|
||||
mux.HandleFunc("/orca/webauthn/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok","rp_id":"` + c.rpID + `"}`))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// Serve starts the WebAuthn HTTP handler on the given address. In
|
||||
// production this runs behind Traefik (which provides TLS); the bind
|
||||
// address is loopback only.
|
||||
func (c *Connector) Serve(ctx context.Context, addr string) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: c.Routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx2)
|
||||
}()
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("webauthn: serve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// webauthnUser implements webauthn.User.
|
||||
type webauthnUser struct {
|
||||
id []byte
|
||||
name string
|
||||
credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *webauthnUser) WebAuthnID() []byte { return u.id }
|
||||
func (u *webauthnUser) WebAuthnName() string { return u.name }
|
||||
func (u *webauthnUser) WebAuthnDisplayName() string { return u.name }
|
||||
func (u *webauthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||
func (u *webauthnUser) WebAuthnIcon() string { return "" }
|
||||
|
||||
// RPID returns the configured relying-party ID.
|
||||
func (c *Connector) RPID() string { return c.rpID }
|
||||
|
||||
// Ensure strings import is used (for the healthz handler).
|
||||
var _ = strings.Contains
|
||||
@@ -0,0 +1,102 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConnectorBuild verifies a Connector can be built with a valid
|
||||
// RP ID + origin (C-38).
|
||||
func TestConnectorBuild(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
c, err := NewConnector(store, "cluster.example.com", "https://cluster.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("NewConnector: %v", err)
|
||||
}
|
||||
if c.RPID() != "cluster.example.com" {
|
||||
t.Errorf("RPID = %q, want cluster.example.com", c.RPID())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectorHealthz verifies the /orca/webauthn/healthz endpoint
|
||||
// responds with the RP ID.
|
||||
func TestConnectorHealthz(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
c, _ := NewConnector(store, "test.cluster", "https://test.cluster")
|
||||
mux := c.Routes()
|
||||
req := httptest.NewRequest("GET", "/orca/webauthn/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("healthz status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "test.cluster") {
|
||||
t.Errorf("healthz body should contain rp_id: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectorBeginRegistrationNoUsername verifies the register
|
||||
// endpoint rejects requests without a username.
|
||||
func TestConnectorBeginRegistrationNoUsername(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
c, _ := NewConnector(store, "test.cluster", "https://test.cluster")
|
||||
mux := c.Routes()
|
||||
req := httptest.NewRequest("GET", "/orca/webauthn/register", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("register without username: %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectorBeginLoginNotRegistered verifies login for an
|
||||
// unregistered user returns 404.
|
||||
func TestConnectorBeginLoginNotRegistered(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
c, _ := NewConnector(store, "test.cluster", "https://test.cluster")
|
||||
mux := c.Routes()
|
||||
req := httptest.NewRequest("GET", "/orca/webauthn/login?username=ghost", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("login unregistered: %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreModeEnforced verifies the DB file is 0600 after creation.
|
||||
func TestStoreModeEnforced(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
// Trigger a write so the DB file is created on disk.
|
||||
store.PutCredential(&Credential{
|
||||
UserID: "u",
|
||||
CredentialID: []byte("c"),
|
||||
PublicKey: []byte("p"),
|
||||
})
|
||||
info, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat db: %v", err)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
t.Errorf("db mode = %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Package webauthn implements the WebAuthn (passkeys) connector for
|
||||
// the bundled Dex (REQ-148, D-240, D-243, D-244). Passkeys are
|
||||
// public-key credentials — the private key never leaves the
|
||||
// authenticator — directly satisfying R-021 (no passwords, no shared
|
||||
// secrets). The connector serves registration + login ceremonies
|
||||
// behind Traefik at /orca/webauthn/{register,login}.
|
||||
//
|
||||
// Credential storage: SQLite at ClusterDir()/webauthn-credentials.db
|
||||
// (0600). Stores public keys + credential IDs + sign counts only.
|
||||
// No private keys, no secrets.
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Credential is a stored WebAuthn public-key credential.
|
||||
type Credential struct {
|
||||
UserID string `json:"user_id"`
|
||||
CredentialID []byte `json:"credential_id"`
|
||||
PublicKey []byte `json:"public_key"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
AAGUID string `json:"aaguid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Store is the SQLite-backed credential store.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewStore opens (or creates) the WebAuthn credential DB at the given
|
||||
// path. The DB file mode is enforced at 0600.
|
||||
func NewStore(dbPath string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("webauthn: mkdir: %w", err)
|
||||
}
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)", dbPath)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: open db: %w", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("webauthn: ping: %w", err)
|
||||
}
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS credentials (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
credential_id BLOB NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("webauthn: schema: %w", err)
|
||||
}
|
||||
// Enforce 0600 on the DB file.
|
||||
if err := os.Chmod(dbPath, 0o600); err != nil {
|
||||
// Non-fatal: the file may not exist yet (WAL mode creates on first write).
|
||||
_ = err
|
||||
}
|
||||
return &Store{db: db, path: dbPath}, nil
|
||||
}
|
||||
|
||||
// PutCredential stores a credential (insert or replace by user_id).
|
||||
func (s *Store) PutCredential(c *Credential) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO credentials (user_id, credential_id, public_key, sign_count, aaguid, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
credential_id = excluded.credential_id,
|
||||
public_key = excluded.public_key,
|
||||
sign_count = excluded.sign_count`,
|
||||
c.UserID, c.CredentialID, c.PublicKey, c.SignCount, c.AAGUID, c.CreatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webauthn: put: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCredential retrieves a credential by user_id.
|
||||
func (s *Store) GetCredential(userID string) (*Credential, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var c Credential
|
||||
var createdStr string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT user_id, credential_id, public_key, sign_count, aaguid, created_at
|
||||
FROM credentials WHERE user_id = ?`, userID,
|
||||
).Scan(&c.UserID, &c.CredentialID, &c.PublicKey, &c.SignCount, &c.AAGUID, &createdStr)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: get: %w", err)
|
||||
}
|
||||
c.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListCredentials returns all stored credentials (for admin/debug).
|
||||
func (s *Store) ListCredentials() ([]*Credential, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT user_id, credential_id, public_key, sign_count, aaguid, created_at
|
||||
FROM credentials ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Credential
|
||||
for rows.Next() {
|
||||
var c Credential
|
||||
var createdStr string
|
||||
if err := rows.Scan(&c.UserID, &c.CredentialID, &c.PublicKey, &c.SignCount, &c.AAGUID, &createdStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
out = append(out, &c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteCredential removes a credential (revoke a passkey).
|
||||
func (s *Store) DeleteCredential(userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(`DELETE FROM credentials WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webauthn: delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSignCount updates the sign count after a successful login.
|
||||
func (s *Store) UpdateSignCount(userID string, count uint32) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, err := s.db.Exec(`UPDATE credentials SET sign_count = ? WHERE user_id = ?`, count, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webauthn: update count: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the DB.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// EncodeID base64-encodes a credential ID for transport.
|
||||
func EncodeID(id []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(id)
|
||||
}
|
||||
|
||||
// DecodeID base64-decodes a credential ID.
|
||||
func DecodeID(s string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// User represents a WebAuthn user (for the ceremony).
|
||||
type User struct {
|
||||
ID []byte
|
||||
Name string
|
||||
DisplayName string
|
||||
Credentials []*Credential
|
||||
}
|
||||
|
||||
// ToJSON marshals a value for the connector response.
|
||||
func ToJSON(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStoreRoundTrip verifies Put + Get + Delete + List + UpdateSignCount.
|
||||
func TestStoreRoundTrip(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cred := &Credential{
|
||||
UserID: "user-1",
|
||||
CredentialID: []byte("cred-id-123"),
|
||||
PublicKey: []byte("pub-key-bytes"),
|
||||
SignCount: 0,
|
||||
AAGUID: "test-aaguid",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := store.PutCredential(cred); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
got, err := store.GetCredential("user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("Get returned nil")
|
||||
}
|
||||
if got.UserID != "user-1" || string(got.CredentialID) != "cred-id-123" {
|
||||
t.Errorf("got = %+v", got)
|
||||
}
|
||||
if err := store.UpdateSignCount("user-1", 42); err != nil {
|
||||
t.Fatalf("UpdateSignCount: %v", err)
|
||||
}
|
||||
got, _ = store.GetCredential("user-1")
|
||||
if got.SignCount != 42 {
|
||||
t.Errorf("SignCount = %d, want 42", got.SignCount)
|
||||
}
|
||||
list, err := store.ListCredentials()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("List = %d, want 1", len(list))
|
||||
}
|
||||
if err := store.DeleteCredential("user-1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
got, _ = store.GetCredential("user-1")
|
||||
if got != nil {
|
||||
t.Error("Get after delete should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorePutReplace verifies Put replaces on conflict.
|
||||
func TestStorePutReplace(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
c1 := &Credential{UserID: "u", CredentialID: []byte("old"), PublicKey: []byte("pk1"), CreatedAt: time.Now()}
|
||||
store.PutCredential(c1)
|
||||
c2 := &Credential{UserID: "u", CredentialID: []byte("new"), PublicKey: []byte("pk2"), CreatedAt: time.Now()}
|
||||
store.PutCredential(c2)
|
||||
got, _ := store.GetCredential("u")
|
||||
if string(got.CredentialID) != "new" {
|
||||
t.Errorf("CredentialID = %q, want new", got.CredentialID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreGetMissing verifies Get returns nil, nil for missing.
|
||||
func TestStoreGetMissing(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "webauthn-creds.db")
|
||||
store, _ := NewStore(dbPath)
|
||||
defer store.Close()
|
||||
got, err := store.GetCredential("nonexistent")
|
||||
if err != nil {
|
||||
t.Errorf("Get missing should not error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Error("Get missing should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeDecodeID verifies base64 round-trip.
|
||||
func TestEncodeDecodeID(t *testing.T) {
|
||||
original := []byte("test-credential-id-12345")
|
||||
encoded := EncodeID(original)
|
||||
decoded, err := DecodeID(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if string(decoded) != string(original) {
|
||||
t.Errorf("decode = %q, want %q", decoded, original)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user