Skip to content

Commit

Permalink
feat(config): allow rpid to be defined at execution time (#234)
Browse files Browse the repository at this point in the history
This allows the Relying Party ID and Name to be configured at runtime rather than at configuration time.

Closes #165
  • Loading branch information
james-d-elliott authored Apr 29, 2024
1 parent f63fbc1 commit c673c3d
Show file tree
Hide file tree
Showing 7 changed files with 219 additions and 19 deletions.
1 change: 0 additions & 1 deletion webauthn/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
14 changes: 14 additions & 0 deletions webauthn/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"time"

"github.com/go-webauthn/webauthn/protocol"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
82 changes: 82 additions & 0 deletions webauthn/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package webauthn
import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/go-webauthn/webauthn/protocol"
)

Expand All @@ -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)
}
})
}
}
25 changes: 25 additions & 0 deletions webauthn/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"time"

"github.com/go-webauthn/webauthn/protocol"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down
92 changes: 91 additions & 1 deletion webauthn/registration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
14 changes: 4 additions & 10 deletions webauthn/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 3 additions & 7 deletions webauthn/user.go → webauthn/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
}

0 comments on commit c673c3d

Please sign in to comment.