Files
verdande/internal/auth/webauthn.go
T

99 lines
4.1 KiB
Go
Raw Normal View History

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)
}