Repository: go-webauthn/webauthn
Pull request #124 (2023-02-16 22:37:20)

feat(webauthn): allow encoding user.id as a string
This allows encoding the user.id attribute as a string which is useful for implementations of the browser element which do not decode this value and you want to use a 64 byte string for.


Patch:
diff --git a/protocol/entities.go b/protocol/entities.go
index a20f0e0..814e608 100644
--- a/protocol/entities.go
+++ b/protocol/entities.go
@@ -46,9 +46,10 @@ type UserEntity struct {
 	// For example, "Alex P. Müller" or "田中 倫". The Relying Party SHOULD let
 	// the user choose this, and SHOULD NOT restrict the choice more than necessary.
 	DisplayName string `json:"displayName,omitempty"`
+
 	// ID is the user handle of the user account entity. To ensure secure operation,
 	// authentication and authorization decisions MUST be made on the basis of this id
 	// member, not the displayName nor name members. See Section 6.1 of
 	// [RFC8266](https://www.w3.org/TR/webauthn/#biblio-rfc8266).
-	ID URLEncodedBase64 `json:"id"`
+	ID interface{} `json:"id"`
 }
diff --git a/webauthn/const.go b/webauthn/const.go
index 257797c..9ab74f2 100644
--- a/webauthn/const.go
+++ b/webauthn/const.go
@@ -5,8 +5,9 @@ import (
 )
 
 const (
-	errFmtEmptyField     = "the field '%s' must be configured but it is empty"
-	errFmtConfigValidate = "error occurred validating the configuration: %w"
+	errFmtFieldEmpty       = "the field '%s' must be configured but it is empty"
+	errFmtFieldNotValidURI = "field '%s' is not a valid URI: %w"
+	errFmtConfigValidate   = "error occurred validating the configuration: %w"
 )
 
 const (
diff --git a/webauthn/registration.go b/webauthn/registration.go
index 27876ba..d2bc1d3 100644
--- a/webauthn/registration.go
+++ b/webauthn/registration.go
@@ -29,8 +29,16 @@ func (webauthn *WebAuthn) BeginRegistration(user User, opts ...RegistrationOptio
 		return nil, nil, err
 	}
 
+	var entityUserID interface{}
+
+	if webauthn.Config.EncodeUserIDAsString {
+		entityUserID = string(user.WebAuthnID())
+	} else {
+		entityUserID = protocol.URLEncodedBase64(user.WebAuthnID())
+	}
+
 	entityUser := protocol.UserEntity{
-		ID:          user.WebAuthnID(),
+		ID:          entityUserID,
 		DisplayName: user.WebAuthnDisplayName(),
 		CredentialEntity: protocol.CredentialEntity{
 			Name: user.WebAuthnName(),
diff --git a/webauthn/types.go b/webauthn/types.go
index 191ebc3..c120a8b 100644
--- a/webauthn/types.go
+++ b/webauthn/types.go
@@ -26,19 +26,16 @@ type WebAuthn struct {
 
 // Config represents the WebAuthn configuration.
 type Config struct {
-	// RPDisplayName configures the display name for the Relying Party Server. This can be any string.
-	RPDisplayName string
-
 	// RPID configures the Relying Party Server ID. This should generally be the origin without a scheme and port.
 	RPID string
 
+	// RPDisplayName configures the display name for the Relying Party Server. This can be any string.
+	RPDisplayName string
+
 	// RPOrigins configures the list of Relying Party Server Origins that are permitted. These should be fully
 	// qualified origins.
 	RPOrigins []string
 
-	// RPIcon
-	RPIcon string
-
 	// AttestationPreference sets the default attestation conveyance preferences.
 	AttestationPreference protocol.ConveyancePreference
 
@@ -48,20 +45,30 @@ type Config struct {
 	// Debug enables various debug options.
 	Debug bool
 
-	// Timeout configures the default timeout in milliseconds.
-	//
-	// Deprecated: Use Timeouts instead.
-	Timeout int
+	// EncodeUserIDAsString ensures the user.id value during registrations is encoded as a raw UTF8 string. This is
+	// useful when you only use printable ASCII characters for the random user.id but the browser library does not
+	// decode the URL Safe Base64 data.
+	EncodeUserIDAsString bool
 
 	// Timeouts configures various timeouts.
 	Timeouts TimeoutsConfig
 
+	validated bool
+
+	// RPIcon sets the icon URL for the Relying Party Server.
+	//
+	// Deprecated: this option has been removed from newer specifications due to security considerations.
+	RPIcon string
+
 	// RPOrigin configures the permitted Relying Party Server Origin.
 	//
 	// Deprecated: Use RPOrigins instead.
 	RPOrigin string
 
-	validated bool
+	// Timeout configures the default timeout in milliseconds.
+	//
+	// Deprecated: Use Timeouts instead.
+	Timeout int
 }
 
 // TimeoutsConfig represents the WebAuthn timeouts configuration.
@@ -91,16 +98,23 @@ func (config *Config) validate() error {
 	}
 
 	if len(config.RPDisplayName) == 0 {
-		return fmt.Errorf(errFmtEmptyField, "RPDisplayName")
+		return fmt.Errorf(errFmtFieldEmpty, "RPDisplayName")
 	}
 
 	if len(config.RPID) == 0 {
-		return fmt.Errorf(errFmtEmptyField, "RPID")
+		return fmt.Errorf(errFmtFieldEmpty, "RPID")
+	}
+
+	var err error
+
+	if _, err = url.Parse(config.RPID); err != nil {
+		return fmt.Errorf(errFmtFieldNotValidURI, "RPID", err)
 	}
 
-	_, err := url.Parse(config.RPID)
-	if err != nil {
-		return fmt.Errorf("RPID not valid URI: %+v", err)
+	if config.RPIcon != "" {
+		if _, err = url.Parse(config.RPIcon); err != nil {
+			return fmt.Errorf(errFmtFieldNotValidURI, "RPIcon", err)
+		}
 	}
 
 	defaultTimeoutConfig := defaultTimeout
@@ -161,6 +175,8 @@ type User interface {
 	// To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id
 	// member, not the displayName nor name members. See Section 6.1 of [RFC8266].
 	//
+	// It's recommended this value is completely random and uses the entire 64 bytes.
+	//
 	// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dom-publickeycredentialuserentity-id)
 	WebAuthnID() []byte
 


Test patch:
diff --git a/webauthn/registration_test.go b/webauthn/registration_test.go
index 9647eed..ed689ab 100644
--- a/webauthn/registration_test.go
+++ b/webauthn/registration_test.go
@@ -1,9 +1,11 @@
 package webauthn
 
 import (
+	"encoding/json"
 	"testing"
 
 	"github.com/go-webauthn/webauthn/protocol"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestRegistration_FinishRegistrationFailure(t *testing.T) {
@@ -26,3 +28,32 @@ func TestRegistration_FinishRegistrationFailure(t *testing.T) {
 		t.Errorf("FinishRegistration() credential = %v, want nil", credential)
 	}
 }
+
+func TestEntityEncoding(t *testing.T) {
+	testCases := []struct {
+		name           string
+		b64            bool
+		have, expected string
+	}{
+		{"ShouldEncodeBase64", true, "abc", "{\"name\":\"\",\"id\":\"YWJj\"}"},
+		{"ShouldEncodeString", false, "abc", "{\"name\":\"\",\"id\":\"abc\"}"},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			entityUser := protocol.UserEntity{}
+
+			if tc.b64 {
+				entityUser.ID = protocol.URLEncodedBase64(tc.have)
+			} else {
+				entityUser.ID = tc.have
+			}
+
+			data, err := json.Marshal(entityUser)
+
+			assert.NoError(t, err)
+
+			assert.Equal(t, tc.expected, string(data))
+		})
+	}
+}