// Package webauthn wraps github.com/go-webauthn/webauthn behind a small // interface so the enroll/login flows (SPEC-006) can be exercised without a // browser. The ceremony is browser-bound, so tests inject a fake Service. // // The package persists nothing: it turns a begin/response pair into a // Credential the caller stores (SPEC-004 §4 `webauthn_credentials`), keeping // the storage decision in the auth service. // // Spec: docs/specs/spec-006-enroll-login.md §4/§5. package webauthn import ( "context" "encoding/json" "fmt" "net/http" "github.com/go-webauthn/webauthn/protocol" lib "github.com/go-webauthn/webauthn/webauthn" ) // Credential is the persisted WebAuthn credential (SPEC-004 §4). It is the // subset of the library's Credential the core stores. type Credential struct { // ID is the credential id (primary key). ID []byte // PublicKey is the COSE-encoded credential public key. PublicKey []byte // SignCount is the authenticator's signature counter (clone detection). SignCount uint32 // Transports are the authenticator transport hints, comma-joined at rest. Transports []string // AAGUID identifies the authenticator model (may be empty). AAGUID []byte } // User is the Relying Party's user record for a ceremony. ID is the WebAuthn // user handle (<= 64 bytes) — it must be stable for the life of the identity. type User struct { ID []byte Name string DisplayName string } // Begin pairs the browser options (JSON) with the server-side session data the // finish step must carry. The flow serializes it into a signed cookie between // the two calls. type Begin struct { // Options is the JSON for navigator.credentials.create/get. Options json.RawMessage // SessionData is the opaque server state (challenge, expiry, …). SessionData []byte } // Service is WebAuthn behind an interface (browser-bound, so tests inject a // fake). Finish* parse the response from r's body. type Service interface { BeginRegistration(ctx context.Context, u User, existing []Credential) (*Begin, error) FinishRegistration(ctx context.Context, u User, b *Begin, r *http.Request) (Credential, error) BeginLogin(ctx context.Context, u User, creds []Credential) (*Begin, error) FinishLogin(ctx context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error) } // Config is the Relying Party configuration (derived from server.base_url). type Config struct { // RPID is the relying party id — the origin's host without scheme/port. RPID string // RPDisplayName is a human-readable name. RPDisplayName string // RPOrigins are the permitted origins, fully qualified. RPOrigins []string } // impl is the real Service backed by go-webauthn. type impl struct { wa *lib.WebAuthn } // New builds the real WebAuthn service. It fails closed when the configuration // is invalid (no origin, malformed RPID). func New(cfg Config) (Service, error) { if cfg.RPDisplayName == "" { cfg.RPDisplayName = "MyDay" } wa, err := lib.New(&lib.Config{ RPID: cfg.RPID, RPDisplayName: cfg.RPDisplayName, RPOrigins: cfg.RPOrigins, }) if err != nil { return nil, fmt.Errorf("webauthn: config: %w", err) } return &impl{wa: wa}, nil } var _ Service = (*impl)(nil) // BeginRegistration implements Service. func (w *impl) BeginRegistration(_ context.Context, u User, existing []Credential) (*Begin, error) { creation, session, err := w.wa.BeginRegistration(buildUser(u, existing)) if err != nil { return nil, fmt.Errorf("webauthn: begin registration: %w", err) } return newBegin(creation, session) } // FinishRegistration implements Service. func (w *impl) FinishRegistration(_ context.Context, u User, b *Begin, r *http.Request) (Credential, error) { session, err := decodeSession(b) if err != nil { return Credential{}, err } cred, err := w.wa.FinishRegistration(buildUser(u, nil), session, r) if err != nil { return Credential{}, fmt.Errorf("webauthn: finish registration: %w", err) } return fromLib(cred), nil } // BeginLogin implements Service. func (w *impl) BeginLogin(_ context.Context, u User, creds []Credential) (*Begin, error) { assertion, session, err := w.wa.BeginLogin(buildUser(u, creds)) if err != nil { return nil, fmt.Errorf("webauthn: begin login: %w", err) } return newBegin(assertion, session) } // FinishLogin implements Service. func (w *impl) FinishLogin(_ context.Context, u User, b *Begin, r *http.Request, creds []Credential) (Credential, error) { session, err := decodeSession(b) if err != nil { return Credential{}, err } cred, err := w.wa.FinishLogin(buildUser(u, creds), session, r) if err != nil { return Credential{}, fmt.Errorf("webauthn: finish login: %w", err) } return fromLib(cred), nil } // newBegin marshals the options and the session data into a Begin. func newBegin(options any, session *lib.SessionData) (*Begin, error) { opts, err := json.Marshal(options) if err != nil { return nil, fmt.Errorf("webauthn: marshal options: %w", err) } sd, err := json.Marshal(session) if err != nil { return nil, fmt.Errorf("webauthn: marshal session: %w", err) } return &Begin{Options: opts, SessionData: sd}, nil } // decodeSession restores the library session data from a Begin. func decodeSession(b *Begin) (lib.SessionData, error) { if b == nil || len(b.SessionData) == 0 { return lib.SessionData{}, fmt.Errorf("webauthn: missing ceremony state") } var session lib.SessionData if err := json.Unmarshal(b.SessionData, &session); err != nil { return lib.SessionData{}, fmt.Errorf("webauthn: decode session: %w", err) } return session, nil } // fromLib maps a library credential to the persisted subset. func fromLib(c *lib.Credential) Credential { if c == nil { return Credential{} } transports := make([]string, 0, len(c.Transport)) for _, t := range c.Transport { transports = append(transports, string(t)) } return Credential{ ID: c.ID, PublicKey: c.PublicKey, SignCount: c.Authenticator.SignCount, Transports: transports, AAGUID: c.Authenticator.AAGUID, } } // libUser adapts our User to the library's User interface. type libUser struct { id []byte name string displayName string creds []lib.Credential } func (u libUser) WebAuthnID() []byte { return u.id } func (u libUser) WebAuthnName() string { return u.name } func (u libUser) WebAuthnDisplayName() string { return u.displayName } func (u libUser) WebAuthnCredentials() []lib.Credential { return u.creds } // buildUser adapts a User plus its stored credentials for the library. func buildUser(u User, creds []Credential) libUser { libCreds := make([]lib.Credential, 0, len(creds)) for _, c := range creds { transports := make([]protocol.AuthenticatorTransport, 0, len(c.Transports)) for _, t := range c.Transports { transports = append(transports, protocol.AuthenticatorTransport(t)) } libCreds = append(libCreds, lib.Credential{ ID: c.ID, PublicKey: c.PublicKey, Transport: transports, Authenticator: lib.Authenticator{ AAGUID: c.AAGUID, SignCount: c.SignCount, }, }) } return libUser{id: u.ID, name: u.Name, displayName: u.DisplayName, creds: libCreds} }