Repository: go-webauthn/webauthn
Pull request #81 (2022-12-06 16:32:53)

feat: add config option to add multiple rp origins
Hi, I implemented a simple solution that solves #76 

james-d-elliott: Hey Freddy! Looking great so far. Thanks for contributing. 

I'm thinking we could restore the original string field, mark it as deprecated, and ensure it makes it into the slice. It'll give users a grace period to resolve this. While we're likely to make breaking changes before v1, where possible it's nice to make the experience reasonably stable.

You may have already thought of this though and may have some thoughts on it?
FreddyDevelop: Yes, thats a good idea. I will add the original field back.
james-d-elliott: I'll take a closer look at some other libraries and merge it, but I'm 99% sure this is the correct method to handle this.

Patch:
diff --git a/protocol/assertion.go b/protocol/assertion.go
index 6cc2a71..45a4578 100644
--- a/protocol/assertion.go
+++ b/protocol/assertion.go
@@ -100,7 +100,7 @@ func ParseCredentialRequestResponseBody(body io.Reader) (*ParsedCredentialAssert
 // Follow the remaining steps outlined in §7.2 Verifying an authentication assertion
 // (https://www.w3.org/TR/webauthn/#verifying-assertion) and return an error if there
 // is a failure during each step.
-func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPartyID, relyingPartyOrigin, appID string, verifyUser bool, credentialBytes []byte) error {
+func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPartyID string, relyingPartyOrigins []string, appID string, verifyUser bool, credentialBytes []byte) error {
 
 	// Steps 4 through 6 in verifying the assertion data (https://www.w3.org/TR/webauthn/#verifying-assertion) are
 	// "assertive" steps, i.e "Let JSONtext be the result of running UTF-8 decode on the value of cData."
@@ -108,7 +108,7 @@ func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPa
 
 	// Handle steps 7 through 10 of assertion by verifying stored data against the Collected Client Data
 	// returned by the authenticator
-	validError := p.Response.CollectedClientData.Verify(storedChallenge, AssertCeremony, relyingPartyOrigin)
+	validError := p.Response.CollectedClientData.Verify(storedChallenge, AssertCeremony, relyingPartyOrigins)
 	if validError != nil {
 		return validError
 	}
diff --git a/protocol/client.go b/protocol/client.go
index 31e5c00..b33029e 100644
--- a/protocol/client.go
+++ b/protocol/client.go
@@ -50,16 +50,22 @@ const (
 	NotSupported TokenBindingStatus = "not-supported"
 )
 
-// Returns the origin per the HTML spec: (scheme)://(host)[:(port)]
-func FullyQualifiedOrigin(u *url.URL) string {
-	return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+// FullyQualifiedOrigin returns the origin per the HTML spec: (scheme)://(host)[:(port)]
+func FullyQualifiedOrigin(rawOrigin string) (fqOrigin string, err error) {
+	var origin *url.URL
+
+	if origin, err = url.Parse(rawOrigin); err != nil {
+		return "", err
+	}
+
+	return fmt.Sprintf("%s://%s", origin.Scheme, origin.Host), nil
 }
 
 // Handles steps 3 through 6 of verfying the registering client data of a
 // new credential and steps 7 through 10 of verifying an authentication assertion
 // See https://www.w3.org/TR/webauthn/#registering-a-new-credential
 // and https://www.w3.org/TR/webauthn/#verifying-assertion
-func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyType, relyingPartyOrigin string) error {
+func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyType, rpOrigins []string) error {
 
 	// Registration Step 3. Verify that the value of C.type is webauthn.create.
 
@@ -77,20 +83,30 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
 
 	challenge := c.Challenge
 	if subtle.ConstantTimeCompare([]byte(storedChallenge), []byte(challenge)) != 1 {
-		err := ErrVerification.WithDetails("Error validating challenge")
-		return err.WithInfo(fmt.Sprintf("Expected b Value: %#v\nReceived b: %#v\n", storedChallenge, challenge))
+		return ErrVerification.
+			WithDetails("Error validating challenge").
+			WithInfo(fmt.Sprintf("Expected b Value: %#v\nReceived b: %#v\n", storedChallenge, challenge))
 	}
 
 	// Registration Step 5 & Assertion Step 9. Verify that the value of C.origin matches
 	// the Relying Party's origin.
-	clientDataOrigin, err := url.Parse(c.Origin)
+	fqOrigin, err := FullyQualifiedOrigin(c.Origin)
 	if err != nil {
 		return ErrParsingData.WithDetails("Error decoding clientData origin as URL")
 	}
 
-	if !strings.EqualFold(FullyQualifiedOrigin(clientDataOrigin), relyingPartyOrigin) {
-		err := ErrVerification.WithDetails("Error validating origin")
-		return err.WithInfo(fmt.Sprintf("Expected Value: %s, Received: %s", relyingPartyOrigin, FullyQualifiedOrigin(clientDataOrigin)))
+	found := false
+	for _, origin := range rpOrigins {
+		if strings.EqualFold(fqOrigin, origin) {
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		return ErrVerification.
+			WithDetails("Error validating origin").
+			WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", rpOrigins, fqOrigin))
 	}
 
 	// Registration Step 6 and Assertion Step 10. Verify that the value of C.tokenBinding.status
@@ -102,7 +118,9 @@ func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyTy
 			return ErrParsingData.WithDetails("Error decoding clientData, token binding present without status")
 		}
 		if c.TokenBinding.Status != Present && c.TokenBinding.Status != Supported && c.TokenBinding.Status != NotSupported {
-			return ErrParsingData.WithDetails("Error decoding clientData, token binding present with invalid status").WithInfo(fmt.Sprintf("Got: %s", c.TokenBinding.Status))
+			return ErrParsingData.
+				WithDetails("Error decoding clientData, token binding present with invalid status").
+				WithInfo(fmt.Sprintf("Got: %s", c.TokenBinding.Status))
 		}
 	}
 	// Not yet fully implemented by the spec, browsers, and me.
diff --git a/protocol/credential.go b/protocol/credential.go
index 2ef8341..117c20d 100644
--- a/protocol/credential.go
+++ b/protocol/credential.go
@@ -106,10 +106,10 @@ func ParseCredentialCreationResponseBody(body io.Reader) (*ParsedCredentialCreat
 
 // Verifies the Client and Attestation data as laid out by §7.1. Registering a new credential
 // https://www.w3.org/TR/webauthn/#registering-a-new-credential
-func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUser bool, relyingPartyID, relyingPartyOrigin string) error {
+func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, verifyUser bool, relyingPartyID string, relyingPartyOrigins []string) error {
 
 	// Handles steps 3 through 6 - Verifying the Client Data against the Relying Party's stored data
-	verifyError := pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, relyingPartyOrigin)
+	verifyError := pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, relyingPartyOrigins)
 	if verifyError != nil {
 		return verifyError
 	}
diff --git a/webauthn/login.go b/webauthn/login.go
index babfb68..0728e5d 100644
--- a/webauthn/login.go
+++ b/webauthn/login.go
@@ -215,7 +215,7 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe
 	shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
 
 	rpID := webauthn.Config.RPID
-	rpOrigin := webauthn.Config.RPOrigin
+	rpOrigins := webauthn.Config.RPOrigins
 
 	appID, err := parsedResponse.GetAppID(session.Extensions, loginCredential.AttestationType)
 	if err != nil {
@@ -223,7 +223,7 @@ func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedRe
 	}
 
 	// Handle steps 4 through 16
-	validError := parsedResponse.Verify(session.Challenge, rpID, rpOrigin, appID, shouldVerifyUser, loginCredential.PublicKey)
+	validError := parsedResponse.Verify(session.Challenge, rpID, rpOrigins, appID, shouldVerifyUser, loginCredential.PublicKey)
 	if validError != nil {
 		return nil, validError
 	}
diff --git a/webauthn/main.go b/webauthn/main.go
index 5854c24..1e5c5e7 100644
--- a/webauthn/main.go
+++ b/webauthn/main.go
@@ -18,8 +18,10 @@ type WebAuthn struct {
 type Config struct {
 	RPDisplayName string
 	RPID          string
-	RPOrigin      string
-	RPIcon        string
+	// Deprecated: Use RPOrigins instead
+	RPOrigin  string
+	RPOrigins []string
+	RPIcon    string
 	// Defaults for generating options
 	AttestationPreference  protocol.ConveyancePreference
 	AuthenticatorSelection protocol.AuthenticatorSelection
@@ -47,14 +49,12 @@ func (config *Config) validate() error {
 		config.Timeout = defaultTimeout
 	}
 
-	if config.RPOrigin == "" {
-		config.RPOrigin = config.RPID
-	} else {
-		u, err := url.Parse(config.RPOrigin)
-		if err != nil {
-			return fmt.Errorf("RPOrigin not valid URL: %+v", err)
-		}
-		config.RPOrigin = protocol.FullyQualifiedOrigin(u)
+	if len(config.RPOrigin) > 0 {
+		config.RPOrigins = append(config.RPOrigins, config.RPOrigin)
+	}
+
+	if len(config.RPOrigins) == 0 {
+		return fmt.Errorf("missing at least one RPOrigin")
 	}
 
 	if config.AuthenticatorSelection.RequireResidentKey == nil {
diff --git a/webauthn/registration.go b/webauthn/registration.go
index 4d6aab1..75fbbb0 100644
--- a/webauthn/registration.go
+++ b/webauthn/registration.go
@@ -151,7 +151,7 @@ func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parse
 
 	shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
 
-	invalidErr := parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigin)
+	invalidErr := parsedResponse.Verify(session.Challenge, shouldVerifyUser, webauthn.Config.RPID, webauthn.Config.RPOrigins)
 	if invalidErr != nil {
 		return nil, invalidErr
 	}


Test patch:
diff --git a/protocol/assertion_test.go b/protocol/assertion_test.go
index 91c952f..2ccda77 100644
--- a/protocol/assertion_test.go
+++ b/protocol/assertion_test.go
@@ -153,7 +153,7 @@ func TestParsedCredentialAssertionData_Verify(t *testing.T) {
 	type args struct {
 		storedChallenge    URLEncodedBase64
 		relyingPartyID     string
-		relyingPartyOrigin string
+		relyingPartyOrigin []string
 		verifyUser         bool
 		credentialBytes    []byte
 	}
diff --git a/protocol/attestation_test.go b/protocol/attestation_test.go
index 8b8159d..cfe5e24 100644
--- a/protocol/attestation_test.go
+++ b/protocol/attestation_test.go
@@ -30,7 +30,7 @@ func TestAttestationVerify(t *testing.T) {
 			pcc.Response = *parsedAttestationResponse
 
 			// Test Base Verification
-			err = pcc.Verify(options.Response.Challenge.String(), false, options.Response.RelyingParty.ID, options.Response.RelyingParty.Name)
+			err = pcc.Verify(options.Response.Challenge.String(), false, options.Response.RelyingParty.ID, []string{options.Response.RelyingParty.Name})
 			if err != nil {
 				t.Fatalf("Not valid: %+v (%s)", err, err.(*Error).DevInfo)
 			}
diff --git a/protocol/client_test.go b/protocol/client_test.go
index e5039c3..cd9eda8 100644
--- a/protocol/client_test.go
+++ b/protocol/client_test.go
@@ -1,16 +1,17 @@
 package protocol
 
 import (
-	"net/url"
 	"testing"
 )
 
-func setupCollectedClientData(challenge URLEncodedBase64) *CollectedClientData {
-	return &CollectedClientData{
+func setupCollectedClientData(challenge URLEncodedBase64, origin string) *CollectedClientData {
+	ccd := &CollectedClientData{
 		Type:      CreateCeremony,
-		Origin:    "example.com",
+		Origin:    origin,
 		Challenge: challenge.String(),
 	}
+
+	return ccd
 }
 
 func TestVerifyCollectedClientData(t *testing.T) {
@@ -19,11 +20,10 @@ func TestVerifyCollectedClientData(t *testing.T) {
 		t.Fatalf("error creating challenge: %s", err)
 	}
 
-	ccd := setupCollectedClientData(newChallenge)
+	ccd := setupCollectedClientData(newChallenge, "http://example.com")
 	var storedChallenge = newChallenge
 
-	originURL, _ := url.Parse(ccd.Origin)
-	err = ccd.Verify(storedChallenge.String(), ccd.Type, FullyQualifiedOrigin(originURL))
+	err = ccd.Verify(storedChallenge.String(), ccd.Type, []string{ccd.Origin})
 	if err != nil {
 		t.Fatalf("error verifying challenge: expected %#v got %#v", ccd.Challenge, storedChallenge)
 	}
@@ -34,13 +34,45 @@ func TestVerifyCollectedClientDataIncorrectChallenge(t *testing.T) {
 	if err != nil {
 		t.Fatalf("error creating challenge: %s", err)
 	}
-	ccd := setupCollectedClientData(newChallenge)
+	ccd := setupCollectedClientData(newChallenge, "http://example.com")
 	bogusChallenge, err := CreateChallenge()
 	if err != nil {
 		t.Fatalf("error creating challenge: %s", err)
 	}
-	err = ccd.Verify(bogusChallenge.String(), ccd.Type, ccd.Origin)
+
+	err = ccd.Verify(bogusChallenge.String(), ccd.Type, []string{ccd.Origin})
 	if err == nil {
 		t.Fatalf("error expected but not received. expected %#v got %#v", ccd.Challenge, bogusChallenge)
 	}
 }
+
+func TestVerifyCollectedClientDataUnexpectedOrigin(t *testing.T) {
+	newChallenge, err := CreateChallenge()
+	if err != nil {
+		t.Fatalf("error creating challenge: %s", err)
+	}
+	ccd := setupCollectedClientData(newChallenge, "http://example.com")
+	storedChallenge := newChallenge
+
+	expectedOrigins := []string{"http://different.com"}
+	err = ccd.Verify(storedChallenge.String(), ccd.Type, expectedOrigins)
+	if err == nil {
+		t.Fatalf("error expected but not received. expected %#v got %#v", expectedOrigins, ccd.Origin)
+	}
+}
+
+func TestVerifyCollectedClientDataWithMultipleExpectedOrigins(t *testing.T) {
+	newChallenge, err := CreateChallenge()
+	if err != nil {
+		t.Fatalf("error creating challenge: %s", err)
+	}
+
+	ccd := setupCollectedClientData(newChallenge, "http://example.com")
+	var storedChallenge = newChallenge
+
+	expectedOrigins := []string{"https://exmaple.com", "9C:B4:AE:EF:05:53:6E:73:0E:C4:B8:02:E7:67:F6:7D:A4:E7:BC:26:D7:42:B5:27:FF:01:7D:68:2A:EB:FA:1D", ccd.Origin}
+	err = ccd.Verify(storedChallenge.String(), ccd.Type, expectedOrigins)
+	if err != nil {
+		t.Fatalf("error verifying challenge: expected %#v got %#v", expectedOrigins, ccd.Origin)
+	}
+}
diff --git a/protocol/credential_test.go b/protocol/credential_test.go
index 5beee5a..c06df12 100644
--- a/protocol/credential_test.go
+++ b/protocol/credential_test.go
@@ -165,7 +165,7 @@ func TestParsedCredentialCreationData_Verify(t *testing.T) {
 		storedChallenge    URLEncodedBase64
 		verifyUser         bool
 		relyingPartyID     string
-		relyingPartyOrigin string
+		relyingPartyOrigin []string
 	}
 	tests := []struct {
 		name    string
@@ -224,7 +224,7 @@ func TestParsedCredentialCreationData_Verify(t *testing.T) {
 				storedChallenge:    URLEncodedBase64(byteChallenge),
 				verifyUser:         false,
 				relyingPartyID:     `webauthn.io`,
-				relyingPartyOrigin: `https://webauthn.io`,
+				relyingPartyOrigin: []string{`https://webauthn.io`},
 			},
 			wantErr: false,
 		},