Repository: go-webauthn/webauthn (go)

Issue:
RP ID should be optional
### Version

0.8.4

### Description

Hi,

It seems like not passing RP ID is [invalid](https://github.com/go-webauthn/webauthn/blob/master/webauthn/types.go#L104), but it should be [valid](https://www.w3.org/TR/webauthn-2/#rp-id). In case we don't set the RP ID in the options, it will use the current domain. This will also make it usable in localhost, for example.

> By default, the [RP ID](https://www.w3.org/TR/webauthn-2/#rp-id) for a WebAuthn operation is set to the caller’s [origin](https://html.spec.whatwg.org/multipage/webappapis.html#concept-settings-object-origin)'s [effective domain](https://html.spec.whatwg.org/multipage/origin.html#concept-origin-effective-domain).

### Reproduction

Try to not pass RP ID

### Expectations

_No response_

### Documentation

_No response_

Pull request description:
feat(config): allow rpid to be defined at execution time
This allows the Relying Party ID and Name to be configured at runtime rather than at configuration time.

Closes #165

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
	- Added validation for Relying Party ID in the login and registration processes.
	- Introduced functions to set Relying Party ID and name for registration.
- **Bug Fixes**
	- Adjusted error formatting for invalid URIs and configuration validation.
- **Tests**
	- Added new test cases for login and registration processes focusing on Relying Party ID and name validations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Patch:
diff --git a/webauthn/const.go b/webauthn/const.go
index 9ab74f2..7f08c3d 100644
--- a/webauthn/const.go
+++ b/webauthn/const.go
@@ -5,7 +5,6 @@ import (
 )
 
 const (
-	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"
 )
diff --git a/webauthn/login.go b/webauthn/login.go
index 69ecb97..dd6bd7a 100644
--- a/webauthn/login.go
+++ b/webauthn/login.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/go-webauthn/webauthn/protocol"
@@ -70,6 +71,12 @@ func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protoco
 		opt(&assertion.Response)
 	}
 
+	if len(assertion.Response.RelyingPartyID) == 0 {
+		return nil, nil, fmt.Errorf("error generating assertion: the relying party id must be provided via the configuration or a functional option for a login")
+	} else if _, err = url.Parse(assertion.Response.RelyingPartyID); err != nil {
+		return nil, nil, fmt.Errorf("error generating assertion: the relying party id failed to validate as it's not a valid uri with error: %w", err)
+	}
+
 	if assertion.Response.Timeout == 0 {
 		switch {
 		case assertion.Response.UserVerification == protocol.VerificationDiscouraged:
@@ -147,6 +154,13 @@ func WithAppIdExtension(appid string) LoginOption {
 	}
 }
 
+// WithLoginRelyingPartyID sets the Relying Party ID for this particular login.
+func WithLoginRelyingPartyID(id string) LoginOption {
+	return func(cco *protocol.PublicKeyCredentialRequestOptions) {
+		cco.RelyingPartyID = id
+	}
+}
+
 // FinishLogin takes the response from the client and validate it against the user credentials and stored session data.
 func (webauthn *WebAuthn) FinishLogin(user User, session SessionData, response *http.Request) (*Credential, error) {
 	parsedResponse, err := protocol.ParseCredentialRequestResponse(response)
diff --git a/webauthn/registration.go b/webauthn/registration.go
index 92566bd..a0d6e3a 100644
--- a/webauthn/registration.go
+++ b/webauthn/registration.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"net/http"
+	"net/url"
 	"time"
 
 	"github.com/go-webauthn/webauthn/protocol"
@@ -69,6 +70,16 @@ func (webauthn *WebAuthn) BeginRegistration(user User, opts ...RegistrationOptio
 		opt(&creation.Response)
 	}
 
+	if len(creation.Response.RelyingParty.ID) == 0 {
+		return nil, nil, fmt.Errorf("error generating credential creation: the relying party id must be provided via the configuration or a functional option for a creation")
+	} else if _, err = url.Parse(creation.Response.RelyingParty.ID); err != nil {
+		return nil, nil, fmt.Errorf("error generating credential creation: the relying party id failed to validate as it's not a valid uri with error: %w", err)
+	}
+
+	if len(creation.Response.RelyingParty.Name) == 0 {
+		return nil, nil, fmt.Errorf("error generating credential creation: the relying party display name must be provided via the configuration or a functional option for a creation")
+	}
+
 	if creation.Response.Timeout == 0 {
 		switch {
 		case creation.Response.AuthenticatorSelection.UserVerification == protocol.VerificationDiscouraged:
@@ -176,6 +187,20 @@ func WithAppIdExcludeExtension(appid string) RegistrationOption {
 	}
 }
 
+// WithRegistrationRelyingPartyID sets the relying party id for the registration.
+func WithRegistrationRelyingPartyID(id string) RegistrationOption {
+	return func(cco *protocol.PublicKeyCredentialCreationOptions) {
+		cco.RelyingParty.ID = id
+	}
+}
+
+// WithRegistrationRelyingPartyName sets the relying party name for the registration.
+func WithRegistrationRelyingPartyName(name string) RegistrationOption {
+	return func(cco *protocol.PublicKeyCredentialCreationOptions) {
+		cco.RelyingParty.Name = name
+	}
+}
+
 // FinishRegistration takes the response from the authenticator and client and verify the credential against the user's
 // credentials and session data.
 func (webauthn *WebAuthn) FinishRegistration(user User, session SessionData, response *http.Request) (*Credential, error) {
diff --git a/webauthn/types.go b/webauthn/types.go
index 161c45b..a0dcc67 100644
--- a/webauthn/types.go
+++ b/webauthn/types.go
@@ -91,18 +91,12 @@ func (config *Config) validate() error {
 		return nil
 	}
 
-	if len(config.RPDisplayName) == 0 {
-		return fmt.Errorf(errFmtFieldEmpty, "RPDisplayName")
-	}
-
-	if len(config.RPID) == 0 {
-		return fmt.Errorf(errFmtFieldEmpty, "RPID")
-	}
-
 	var err error
 
-	if _, err = url.Parse(config.RPID); err != nil {
-		return fmt.Errorf(errFmtFieldNotValidURI, "RPID", err)
+	if len(config.RPID) != 0 {
+		if _, err = url.Parse(config.RPID); err != nil {
+			return fmt.Errorf(errFmtFieldNotValidURI, "RPID", err)
+		}
 	}
 
 	defaultTimeoutConfig := defaultTimeout
diff --git a/webauthn/user.go b/webauthn/types_test.go
similarity index 68%
rename from webauthn/user.go
rename to webauthn/types_test.go
index 045ed8f..b154bd9 100644
--- a/webauthn/user.go
+++ b/webauthn/types_test.go
@@ -1,8 +1,8 @@
 package webauthn
 
-// TODO: move this to a _test.go file.
 type defaultUser struct {
-	id []byte
+	id          []byte
+	credentials []Credential
 }
 
 var _ User = (*defaultUser)(nil)
@@ -19,10 +19,6 @@ func (user *defaultUser) WebAuthnDisplayName() string {
 	return "New User"
 }
 
-func (user *defaultUser) WebAuthnIcon() string {
-	return "https://pics.com/avatar.png"
-}
-
 func (user *defaultUser) WebAuthnCredentials() []Credential {
-	return []Credential{}
+	return user.credentials
 }


Test patch:
diff --git a/webauthn/login_test.go b/webauthn/login_test.go
index 29fdbc5..a869cf9 100644
--- a/webauthn/login_test.go
+++ b/webauthn/login_test.go
@@ -3,6 +3,9 @@ package webauthn
 import (
 	"testing"
 
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
 	"github.com/go-webauthn/webauthn/protocol"
 )
 
@@ -26,3 +29,82 @@ func TestLogin_FinishLoginFailure(t *testing.T) {
 		t.Errorf("FinishLogin() credential = %v, want nil", credential)
 	}
 }
+
+func TestWithLoginRelyingPartyID(t *testing.T) {
+	testCases := []struct {
+		name       string
+		have       *Config
+		opts       []LoginOption
+		expectedID string
+		err        string
+	}{
+		{
+			name: "OptionDefinedInConfig",
+			have: &Config{
+				RPID:          "https://example.com",
+				RPDisplayName: "Test Display Name",
+				RPOrigins:     []string{"https://example.com"},
+			},
+			opts:       nil,
+			expectedID: "https://example.com",
+		},
+		{
+			name: "OptionDefinedInConfigAndOpts",
+			have: &Config{
+				RPID:          "https://example.com",
+				RPDisplayName: "Test Display Name",
+				RPOrigins:     []string{"https://example.com"},
+			},
+			opts:       []LoginOption{WithLoginRelyingPartyID("https://a.example.com")},
+			expectedID: "https://a.example.com",
+		},
+		{
+			name: "OptionDefinedInConfigWithNoErrAndInOptsWithError",
+			have: &Config{
+				RPID:          "https://example.com",
+				RPDisplayName: "Test Display Name",
+				RPOrigins:     []string{"https://example.com"},
+			},
+			opts: []LoginOption{WithLoginRelyingPartyID("---::~!!~@#M!@OIK#N!@IOK@@@@@@@@@@")},
+			err:  "error generating assertion: the relying party id failed to validate as it's not a valid uri with error: parse \"---::~!!~@\": first path segment in URL cannot contain colon",
+		},
+		{
+			name: "OptionDefinedInOpts",
+			have: &Config{
+				RPOrigins: []string{"https://example.com"},
+			},
+			opts:       []LoginOption{WithLoginRelyingPartyID("https://example.com")},
+			expectedID: "https://example.com",
+		},
+		{
+			name: "OptionIDNotDefined",
+			have: &Config{
+				RPOrigins: []string{"https://example.com"},
+			},
+			opts: nil,
+			err:  "error generating assertion: the relying party id must be provided via the configuration or a functional option for a login",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			w, err := New(tc.have)
+			assert.NoError(t, err)
+
+			user := &defaultUser{
+				credentials: []Credential{
+					{},
+				},
+			}
+
+			creation, _, err := w.BeginLogin(user, tc.opts...)
+			if tc.err != "" {
+				assert.EqualError(t, err, tc.err)
+			} else {
+				assert.NoError(t, err)
+				require.NotNil(t, creation)
+				assert.Equal(t, tc.expectedID, creation.Response.RelyingPartyID)
+			}
+		})
+	}
+}
diff --git a/webauthn/registration_test.go b/webauthn/registration_test.go
index a2c597d..3aa7b96 100644
--- a/webauthn/registration_test.go
+++ b/webauthn/registration_test.go
@@ -4,10 +4,100 @@ import (
 	"encoding/json"
 	"testing"
 
-	"github.com/go-webauthn/webauthn/protocol"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/go-webauthn/webauthn/protocol"
 )
 
+func TestWithRegistrationRelyingPartyID(t *testing.T) {
+	testCases := []struct {
+		name         string
+		have         *Config
+		opts         []RegistrationOption
+		expectedID   string
+		expectedName string
+		err          string
+	}{
+		{
+			name: "OptionDefinedInConfig",
+			have: &Config{
+				RPID:          "https://example.com",
+				RPDisplayName: "Test Display Name",
+				RPOrigins:     []string{"https://example.com"},
+			},
+			opts:         nil,
+			expectedID:   "https://example.com",
+			expectedName: "Test Display Name",
+		},
+		{
+			name: "OptionDefinedInConfigAndOpts",
+			have: &Config{
+				RPID:          "https://example.com",
+				RPDisplayName: "Test Display Name",
+				RPOrigins:     []string{"https://example.com"},
+			},
+			opts:         []RegistrationOption{WithRegistrationRelyingPartyID("https://a.example.com"), WithRegistrationRelyingPartyName("Test Display Name2")},
+			expectedID:   "https://a.example.com",
+			expectedName: "Test Display Name2",
+		},
+		{
+			name: "OptionDefinedInConfigWithNoErrAndInOptsWithError",
+			have: &Config{
+				RPID:          "https://example.com",
+				RPDisplayName: "Test Display Name",
+				RPOrigins:     []string{"https://example.com"},
+			},
+			opts: []RegistrationOption{WithRegistrationRelyingPartyID("---::~!!~@#M!@OIK#N!@IOK@@@@@@@@@@"), WithRegistrationRelyingPartyName("Test Display Name2")},
+			err:  "error generating credential creation: the relying party id failed to validate as it's not a valid uri with error: parse \"---::~!!~@\": first path segment in URL cannot contain colon",
+		},
+		{
+			name: "OptionDefinedInOpts",
+			have: &Config{
+				RPOrigins: []string{"https://example.com"},
+			},
+			opts:         []RegistrationOption{WithRegistrationRelyingPartyID("https://example.com"), WithRegistrationRelyingPartyName("Test Display Name")},
+			expectedID:   "https://example.com",
+			expectedName: "Test Display Name",
+		},
+		{
+			name: "OptionDisplayNameNotDefined",
+			have: &Config{
+				RPOrigins: []string{"https://example.com"},
+			},
+			opts: []RegistrationOption{WithRegistrationRelyingPartyID("https://example.com")},
+			err:  "error generating credential creation: the relying party display name must be provided via the configuration or a functional option for a creation",
+		},
+		{
+			name: "OptionIDNotDefined",
+			have: &Config{
+				RPOrigins: []string{"https://example.com"},
+			},
+			opts: []RegistrationOption{WithRegistrationRelyingPartyName("Test Display Name")},
+			err:  "error generating credential creation: the relying party id must be provided via the configuration or a functional option for a creation",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			w, err := New(tc.have)
+			assert.NoError(t, err)
+
+			user := &defaultUser{}
+
+			creation, _, err := w.BeginRegistration(user, tc.opts...)
+			if tc.err != "" {
+				assert.EqualError(t, err, tc.err)
+			} else {
+				assert.NoError(t, err)
+				require.NotNil(t, creation)
+				assert.Equal(t, tc.expectedID, creation.Response.RelyingParty.ID)
+				assert.Equal(t, tc.expectedName, creation.Response.RelyingParty.Name)
+			}
+		})
+	}
+}
+
 func TestRegistration_FinishRegistrationFailure(t *testing.T) {
 	user := &defaultUser{
 		id: []byte("123"),