99 lines
4.1 KiB
Go
99 lines
4.1 KiB
Go
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)
|
||
|
|
}
|