Skip to content

Commit 039b569

Browse files
committed
feat(passkeys): add audit, metering, webauthn primitives
1 parent 0a5eb95 commit 039b569

5 files changed

Lines changed: 276 additions & 1 deletion

File tree

internal/api/passkey_webauthn.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package api
2+
3+
import (
4+
"github.com/go-webauthn/webauthn/protocol"
5+
"github.com/go-webauthn/webauthn/webauthn"
6+
"github.com/supabase/auth/internal/models"
7+
)
8+
9+
// getPasskeyWebAuthn creates a *webauthn.WebAuthn instance from the shared server-side WebAuthn configuration.
10+
func (a *API) getPasskeyWebAuthn() (*webauthn.WebAuthn, error) {
11+
rpConfig := a.config.WebAuthn
12+
13+
return webauthn.New(&webauthn.Config{
14+
RPDisplayName: rpConfig.RPDisplayName,
15+
RPID: rpConfig.RPID,
16+
RPOrigins: rpConfig.RPOrigins,
17+
AttestationPreference: protocol.PreferNoAttestation,
18+
AuthenticatorSelection: protocol.AuthenticatorSelection{
19+
// required to support discoverable credentials
20+
ResidentKey: protocol.ResidentKeyRequirementRequired,
21+
UserVerification: protocol.VerificationPreferred,
22+
},
23+
})
24+
}
25+
26+
// TODO(fm): webAuthnUser is a thin adapter that wraps a *models.User and returns passkey
27+
// credentials (from the webauthn_credentials table) instead of MFA factor
28+
// credentials. This is necessary because the existing User.WebAuthnCredentials()
29+
// method returns MFA WebAuthn factor credentials until they are consolidated.
30+
type webAuthnUser struct {
31+
user *models.User
32+
credentials []webauthn.Credential
33+
}
34+
35+
func newWebAuthnUser(user *models.User, passkeyCredentials []*models.WebAuthnCredential) *webAuthnUser {
36+
credentials := make([]webauthn.Credential, len(passkeyCredentials))
37+
38+
for i, pc := range passkeyCredentials {
39+
credentials[i] = pc.ToWebAuthnCredential()
40+
}
41+
42+
return &webAuthnUser{
43+
user: user,
44+
credentials: credentials,
45+
}
46+
}
47+
48+
func (u *webAuthnUser) WebAuthnID() []byte {
49+
return u.user.WebAuthnID()
50+
}
51+
52+
func (u *webAuthnUser) WebAuthnName() string {
53+
if email := u.user.GetEmail(); email != "" {
54+
return email
55+
}
56+
57+
return u.user.GetPhone()
58+
}
59+
60+
func (u *webAuthnUser) WebAuthnDisplayName() string {
61+
if meta := u.user.UserMetaData; meta != nil {
62+
if name, ok := meta["name"].(string); ok && name != "" {
63+
return name
64+
}
65+
}
66+
67+
return u.WebAuthnName()
68+
}
69+
70+
func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential {
71+
return u.credentials
72+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package api
2+
3+
import (
4+
"testing"
5+
6+
"github.com/go-webauthn/webauthn/protocol"
7+
"github.com/gofrs/uuid"
8+
"github.com/stretchr/testify/require"
9+
"github.com/stretchr/testify/suite"
10+
"github.com/supabase/auth/internal/conf"
11+
"github.com/supabase/auth/internal/models"
12+
)
13+
14+
type PasskeyWebAuthnTestSuite struct {
15+
suite.Suite
16+
API *API
17+
Config *conf.GlobalConfiguration
18+
}
19+
20+
func TestPasskeyWebAuthn(t *testing.T) {
21+
api, config, err := setupAPIForTest()
22+
require.NoError(t, err)
23+
ts := &PasskeyWebAuthnTestSuite{
24+
API: api,
25+
Config: config,
26+
}
27+
defer api.db.Close()
28+
suite.Run(t, ts)
29+
}
30+
31+
func (ts *PasskeyWebAuthnTestSuite) TestGetPasskeyWebAuthn() {
32+
ts.API.config.WebAuthn = conf.WebAuthnConfiguration{
33+
RPID: "example.com",
34+
RPDisplayName: "Example App",
35+
RPOrigins: []string{"https://example.com"},
36+
}
37+
38+
wn, err := ts.API.getPasskeyWebAuthn()
39+
ts.Require().NoError(err)
40+
ts.Require().NotNil(wn)
41+
}
42+
43+
func (ts *PasskeyWebAuthnTestSuite) TestGetPasskeyWebAuthnMultipleOrigins() {
44+
ts.API.config.WebAuthn = conf.WebAuthnConfiguration{
45+
RPID: "example.com",
46+
RPDisplayName: "Example App",
47+
RPOrigins: []string{"https://example.com", "https://app.example.com"},
48+
}
49+
50+
wn, err := ts.API.getPasskeyWebAuthn()
51+
ts.Require().NoError(err)
52+
ts.Require().NotNil(wn)
53+
}
54+
55+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnUserWithEmail() {
56+
userID := uuid.Must(uuid.NewV4())
57+
user := &models.User{
58+
ID: userID,
59+
}
60+
user.Email = "user@example.com"
61+
62+
wu := newWebAuthnUser(user, nil)
63+
64+
ts.Equal([]byte(userID.String()), wu.WebAuthnID())
65+
ts.Equal("user@example.com", wu.WebAuthnName())
66+
ts.Equal("user@example.com", wu.WebAuthnDisplayName())
67+
ts.Empty(wu.WebAuthnCredentials())
68+
}
69+
70+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnUserWithPhone() {
71+
userID := uuid.Must(uuid.NewV4())
72+
user := &models.User{
73+
ID: userID,
74+
}
75+
user.Phone = "+1234567890"
76+
77+
wu := newWebAuthnUser(user, nil)
78+
79+
ts.Equal("+1234567890", wu.WebAuthnName())
80+
ts.Equal("+1234567890", wu.WebAuthnDisplayName())
81+
}
82+
83+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnUserEmailTakesPrecedence() {
84+
user := &models.User{
85+
ID: uuid.Must(uuid.NewV4()),
86+
}
87+
user.Email = "user@example.com"
88+
user.Phone = "+1234567890"
89+
90+
wu := newWebAuthnUser(user, nil)
91+
92+
ts.Equal("user@example.com", wu.WebAuthnName())
93+
}
94+
95+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnDisplayNameFromMetadata() {
96+
user := &models.User{
97+
ID: uuid.Must(uuid.NewV4()),
98+
UserMetaData: map[string]any{
99+
"name": "John Doe",
100+
},
101+
}
102+
user.Email = "user@example.com"
103+
104+
wu := newWebAuthnUser(user, nil)
105+
106+
ts.Equal("user@example.com", wu.WebAuthnName())
107+
ts.Equal("John Doe", wu.WebAuthnDisplayName())
108+
}
109+
110+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnDisplayNameFallsBackToEmail() {
111+
user := &models.User{
112+
ID: uuid.Must(uuid.NewV4()),
113+
UserMetaData: map[string]any{},
114+
}
115+
user.Email = "user@example.com"
116+
117+
wu := newWebAuthnUser(user, nil)
118+
119+
ts.Equal("user@example.com", wu.WebAuthnDisplayName())
120+
}
121+
122+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnDisplayNameSkipsEmptyMetadataName() {
123+
user := &models.User{
124+
ID: uuid.Must(uuid.NewV4()),
125+
UserMetaData: map[string]any{
126+
"name": "",
127+
},
128+
}
129+
user.Email = "user@example.com"
130+
131+
wu := newWebAuthnUser(user, nil)
132+
133+
ts.Equal("user@example.com", wu.WebAuthnDisplayName())
134+
}
135+
136+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnDisplayNameNilMetadata() {
137+
user := &models.User{
138+
ID: uuid.Must(uuid.NewV4()),
139+
}
140+
user.Phone = "+1234567890"
141+
142+
wu := newWebAuthnUser(user, nil)
143+
144+
ts.Equal("+1234567890", wu.WebAuthnDisplayName())
145+
}
146+
147+
func (ts *PasskeyWebAuthnTestSuite) TestWebAuthnUserWithCredentials() {
148+
user := &models.User{
149+
ID: uuid.Must(uuid.NewV4()),
150+
}
151+
user.Email = "user@example.com"
152+
153+
creds := []*models.WebAuthnCredential{
154+
{
155+
ID: uuid.Must(uuid.NewV4()),
156+
UserID: user.ID,
157+
CredentialID: []byte("cred-1"),
158+
PublicKey: []byte("pk-1"),
159+
AttestationType: "none",
160+
SignCount: 5,
161+
Transports: models.WebAuthnTransports{protocol.USB},
162+
BackupEligible: true,
163+
BackedUp: false,
164+
},
165+
{
166+
ID: uuid.Must(uuid.NewV4()),
167+
UserID: user.ID,
168+
CredentialID: []byte("cred-2"),
169+
PublicKey: []byte("pk-2"),
170+
AttestationType: "none",
171+
SignCount: 0,
172+
Transports: models.WebAuthnTransports{protocol.Internal},
173+
BackupEligible: true,
174+
BackedUp: true,
175+
},
176+
}
177+
178+
wu := newWebAuthnUser(user, creds)
179+
180+
webauthnCreds := wu.WebAuthnCredentials()
181+
ts.Require().Len(webauthnCreds, 2)
182+
183+
ts.Equal([]byte("cred-1"), webauthnCreds[0].ID)
184+
ts.Equal([]byte("pk-1"), webauthnCreds[0].PublicKey)
185+
ts.Equal(uint32(5), webauthnCreds[0].Authenticator.SignCount)
186+
ts.True(webauthnCreds[0].Flags.BackupEligible)
187+
ts.False(webauthnCreds[0].Flags.BackupState)
188+
189+
ts.Equal([]byte("cred-2"), webauthnCreds[1].ID)
190+
ts.True(webauthnCreds[1].Flags.BackupEligible)
191+
ts.True(webauthnCreds[1].Flags.BackupState)
192+
}

internal/metering/record.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121
LoginTypePKCE LoginType = "pkce"
2222
LoginTypeToken LoginType = "token" // for refresh token flows, to be backward-compatible with existing data
2323
LoginTypeMFA LoginType = "mfa" // for MFA verifications
24+
LoginTypePasskey LoginType = "passkey"
2425
)
2526

2627
// Provider constants for consistent login analytics

internal/models/audit_log_entry.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ const (
4545
UpdateFactorAction AuditAction = "factor_updated"
4646
MFACodeLoginAction AuditAction = "mfa_code_login"
4747
IdentityUnlinkAction AuditAction = "identity_unlinked"
48+
PasskeyCreatedAction AuditAction = "passkey_created"
49+
PasskeyUpdatedAction AuditAction = "passkey_updated"
50+
PasskeyDeletedAction AuditAction = "passkey_deleted"
4851

4952
account auditLogType = "account"
5053
team auditLogType = "team"
@@ -77,6 +80,9 @@ var ActionLogTypeMap = map[AuditAction]auditLogType{
7780
UpdateFactorAction: factor,
7881
MFACodeLoginAction: factor,
7982
DeleteRecoveryCodesAction: recoveryCodes,
83+
PasskeyCreatedAction: user,
84+
PasskeyUpdatedAction: user,
85+
PasskeyDeletedAction: user,
8086
}
8187

8288
// AuditLogEntry is the database model for audit log entries.

internal/models/factor.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const (
5656
Anonymous
5757
Web3
5858
OAuthProviderAuthorizationCode
59+
PasskeyLogin
5960
)
6061

6162
func (authMethod AuthenticationMethod) String() string {
@@ -92,6 +93,8 @@ func (authMethod AuthenticationMethod) String() string {
9293
return "web3"
9394
case OAuthProviderAuthorizationCode:
9495
return "oauth_provider/authorization_code"
96+
case PasskeyLogin:
97+
return "passkey"
9598
}
9699
return ""
97100
}
@@ -131,7 +134,8 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error)
131134
return Web3, nil
132135
case "oauth_provider/authorization_code":
133136
return OAuthProviderAuthorizationCode, nil
134-
137+
case "passkey":
138+
return PasskeyLogin, nil
135139
}
136140
return 0, fmt.Errorf("unsupported authentication method %q", authMethod)
137141
}

0 commit comments

Comments
 (0)