Skip to content

Commit b6db923

Browse files
feat(protocol): current user details signal constructor (#766)
NewSignalCurrentUserDetails completes the trio alongside NewSignalAllAcceptedCredentials and CredentialDescriptor.SignalUnknownCredential. It takes CurrentUserDetailsUser, a subset of webauthn.User, so the user value the ceremony methods already take satisfies it without an adapter, and a compile time assertion in the webauthn tests keeps that true. The Config.EncodeUserIDAsString comment now also records that the options it produces are not the PublicKeyCredentialCreationOptionsJSON form and are rejected by parseCreationOptionsFromJSON, since user.id must be a Base64URLString there.
1 parent db1e068 commit b6db923

4 files changed

Lines changed: 152 additions & 1 deletion

File tree

protocol/signals.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
11
package protocol
22

3+
import (
4+
"reflect"
5+
)
6+
7+
func signalUserIsNil(user any) bool {
8+
if user == nil {
9+
return true
10+
}
11+
12+
switch value := reflect.ValueOf(user); value.Kind() {
13+
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
14+
return value.IsNil()
15+
default:
16+
return false
17+
}
18+
}
19+
320
// NewSignalAllAcceptedCredentials creates a new SignalAllAcceptedCredentials struct that can simply be encoded with
421
// json.Marshal.
22+
//
23+
// A nil user, including a nil pointer carried in a non-nil interface, yields a nil result.
524
func NewSignalAllAcceptedCredentials(rpid string, user AllAcceptedCredentialsUser) *SignalAllAcceptedCredentials {
6-
if user == nil {
25+
if signalUserIsNil(user) {
726
return nil
827
}
928

@@ -29,6 +48,27 @@ type SignalAllAcceptedCredentials struct {
2948
UserID URLEncodedBase64 `json:"userId"`
3049
}
3150

51+
// NewSignalCurrentUserDetails creates a new SignalCurrentUserDetails struct that can simply be encoded with
52+
// json.Marshal. It is the counterpart of [NewSignalAllAcceptedCredentials] for the signal a Relying Party sends
53+
// after the name or display name of a user account changes.
54+
//
55+
// A [github.com/go-webauthn/webauthn/webauthn.User] satisfies [CurrentUserDetailsUser] as it stands, so the user
56+
// value the ceremony methods already take can be passed straight through.
57+
//
58+
// A nil user, including a nil pointer carried in a non-nil interface, yields a nil result.
59+
func NewSignalCurrentUserDetails(rpid string, user CurrentUserDetailsUser) *SignalCurrentUserDetails {
60+
if signalUserIsNil(user) {
61+
return nil
62+
}
63+
64+
return &SignalCurrentUserDetails{
65+
DisplayName: user.WebAuthnDisplayName(),
66+
Name: user.WebAuthnName(),
67+
RPID: rpid,
68+
UserID: user.WebAuthnID(),
69+
}
70+
}
71+
3272
// SignalCurrentUserDetails is a struct which represents the CDDL of the same name.
3373
type SignalCurrentUserDetails struct {
3474
DisplayName string `json:"displayName"`
@@ -49,3 +89,12 @@ type AllAcceptedCredentialsUser interface {
4989
WebAuthnID() []byte
5090
WebAuthnCredentialIDs() [][]byte
5191
}
92+
93+
// CurrentUserDetailsUser is an interface that can be implemented by a user to provide the details a Relying Party
94+
// signals after they change. It is a subset of [github.com/go-webauthn/webauthn/webauthn.User], which therefore
95+
// satisfies it without any additional method.
96+
type CurrentUserDetailsUser interface {
97+
WebAuthnID() []byte
98+
WebAuthnName() string
99+
WebAuthnDisplayName() string
100+
}

protocol/signals_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,85 @@ func TestNewSignalAllAcceptedCredentials(t *testing.T) {
5151
}
5252
}
5353

54+
func TestNewSignalCurrentUserDetails(t *testing.T) {
55+
testCases := []struct {
56+
name string
57+
rpid string
58+
have CurrentUserDetailsUser
59+
expected *SignalCurrentUserDetails
60+
expectedJSON string
61+
}{
62+
{
63+
"ShouldHandleNil",
64+
"example.com",
65+
nil,
66+
nil,
67+
"null",
68+
},
69+
{
70+
"ShouldHandleStandard",
71+
"example.com",
72+
&signalUser{
73+
id: []byte("123"),
74+
name: "alex",
75+
displayName: "Alex Müller",
76+
},
77+
&SignalCurrentUserDetails{
78+
DisplayName: "Alex Müller",
79+
Name: "alex",
80+
RPID: "example.com",
81+
UserID: []byte("123"),
82+
},
83+
`{"displayName":"Alex Müller","name":"alex","rpId":"example.com","userId":"MTIz"}`,
84+
},
85+
{
86+
"ShouldHandleEmptyDetails",
87+
"example.com",
88+
&signalUser{
89+
id: []byte("123"),
90+
},
91+
&SignalCurrentUserDetails{
92+
RPID: "example.com",
93+
UserID: []byte("123"),
94+
},
95+
`{"displayName":"","name":"","rpId":"example.com","userId":"MTIz"}`,
96+
},
97+
}
98+
99+
for _, tc := range testCases {
100+
t.Run(tc.name, func(t *testing.T) {
101+
actual := NewSignalCurrentUserDetails(tc.rpid, tc.have)
102+
103+
assert.Equal(t, tc.expected, actual)
104+
105+
data, err := json.Marshal(actual)
106+
assert.NoError(t, err)
107+
assert.Equal(t, tc.expectedJSON, string(data))
108+
})
109+
}
110+
}
111+
112+
func TestNewSignalTypedNilUser(t *testing.T) {
113+
var user *signalUser
114+
115+
t.Run("ShouldHandleAllAcceptedCredentials", func(t *testing.T) {
116+
assert.NotPanics(t, func() {
117+
assert.Nil(t, NewSignalAllAcceptedCredentials("example.com", user))
118+
})
119+
})
120+
121+
t.Run("ShouldHandleCurrentUserDetails", func(t *testing.T) {
122+
assert.NotPanics(t, func() {
123+
assert.Nil(t, NewSignalCurrentUserDetails("example.com", user))
124+
})
125+
})
126+
}
127+
54128
type signalUser struct {
55129
id []byte
56130
credentials [][]byte
131+
name string
132+
displayName string
57133
}
58134

59135
func (u *signalUser) WebAuthnID() []byte {
@@ -63,3 +139,11 @@ func (u *signalUser) WebAuthnID() []byte {
63139
func (u *signalUser) WebAuthnCredentialIDs() [][]byte {
64140
return u.credentials
65141
}
142+
143+
func (u *signalUser) WebAuthnName() string {
144+
return u.name
145+
}
146+
147+
func (u *signalUser) WebAuthnDisplayName() string {
148+
return u.displayName
149+
}

webauthn/types.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,22 @@ type Config struct {
104104
// EncodeUserIDAsString ensures the user.id value during registrations is encoded as a raw UTF8 string. This is
105105
// useful when you only use printable ASCII characters for the random user.id but the browser library does not
106106
// decode the URL Safe Base64 data.
107+
//
108+
// The resulting options are not the PublicKeyCredentialCreationOptionsJSON form, which requires user.id to be a
109+
// Base64URLString, so a client which passes them to PublicKeyCredential.parseCreationOptionsFromJSON() does one
110+
// of two things with them, neither of them what the Relying Party intended:
111+
//
112+
// - A value outside the base64url alphabet, such as "alice@example.com", fails to decode and the call throws.
113+
// So does one whose length is one more than a multiple of four, such as "a" or "alice", since that is not a
114+
// length any base64 encoding produces.
115+
// - A value which happens to be valid base64url is accepted and decoded into unrelated bytes. "user123" is
116+
// one such value, and arrives at the authenticator as the five bytes ba c7 ab d7 6d, so the user handle
117+
// stored against the credential is not the one that was sent.
118+
//
119+
// The second outcome is the dangerous one, since nothing reports it. Enable this only for a client which does
120+
// its own decoding, which is the situation it exists for.
121+
//
122+
// Specification: §5.1.8. Deserialize Registration Ceremony Options (https://www.w3.org/TR/webauthn-3/#sctn-parseCreationOptionsFromJSON)
107123
EncodeUserIDAsString bool
108124

109125
// Timeouts configures various timeouts.

webauthn/types_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,8 @@ type defaultUser struct {
562562

563563
var _ User = (*defaultUser)(nil)
564564

565+
var _ protocol.CurrentUserDetailsUser = (User)(nil)
566+
565567
func (user *defaultUser) WebAuthnID() []byte {
566568
return user.id
567569
}

0 commit comments

Comments
 (0)