Skip to content

Commit 0a5eb95

Browse files
committed
feat(passkeys): add configuration, error codes, and schemas
1 parent 40d07b5 commit 0a5eb95

14 files changed

Lines changed: 807 additions & 5 deletions

internal/api/apierrors/errorcode.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,15 @@ const (
112112

113113
ErrorCodeCustomProviderNotFound ErrorCode = "custom_provider_not_found"
114114
ErrorCodeOverCustomProviderQuota ErrorCode = "over_custom_provider_quota"
115+
116+
// Passkey feature-level errors
117+
ErrorCodePasskeyDisabled ErrorCode = "passkey_disabled"
118+
ErrorCodeTooManyPasskeys ErrorCode = "too_many_passkeys"
119+
120+
// WebAuthn protocol-level errors (shared between passkeys and MFA WebAuthn)
121+
ErrorCodeWebAuthnCredentialNotFound ErrorCode = "webauthn_credential_not_found"
122+
ErrorCodeWebAuthnChallengeNotFound ErrorCode = "webauthn_challenge_not_found"
123+
ErrorCodeWebAuthnChallengeExpired ErrorCode = "webauthn_challenge_expired"
124+
ErrorCodeWebAuthnVerificationFailed ErrorCode = "webauthn_verification_failed"
125+
ErrorCodeWebAuthnCredentialExists ErrorCode = "webauthn_credential_exists"
115126
)

internal/api/middleware.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,14 @@ func (a *API) requireManualLinkingEnabled(w http.ResponseWriter, req *http.Reque
355355
return ctx, nil
356356
}
357357

358+
func (a *API) requirePasskeyEnabled(w http.ResponseWriter, req *http.Request) (context.Context, error) {
359+
ctx := req.Context()
360+
if !a.config.Passkey.Enabled {
361+
return nil, apierrors.NewNotFoundError(apierrors.ErrorCodePasskeyDisabled, "Passkeys are disabled")
362+
}
363+
return ctx, nil
364+
}
365+
358366
func (a *API) databaseCleanup(cleanup models.Cleaner) func(http.Handler) http.Handler {
359367
return func(next http.Handler) http.Handler {
360368
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

internal/api/settings.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type Settings struct {
3838
PhoneAutoconfirm bool `json:"phone_autoconfirm"`
3939
SmsProvider string `json:"sms_provider"`
4040
SAMLEnabled bool `json:"saml_enabled"`
41+
PasskeysEnabled bool `json:"passkeys_enabled"`
4142
}
4243

4344
func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
@@ -77,5 +78,6 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
7778
PhoneAutoconfirm: config.Sms.Autoconfirm,
7879
SmsProvider: config.Sms.Provider,
7980
SAMLEnabled: config.SAML.Enabled,
81+
PasskeysEnabled: config.Passkey.Enabled,
8082
})
8183
}

internal/conf/configuration.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,50 @@ type MFAConfiguration struct {
177177
WebAuthn MFAFactorTypeConfiguration `split_words:"true"`
178178
}
179179

180+
type WebAuthnConfiguration struct {
181+
RPID string `json:"rp_id" split_words:"true" envconfig:"WEBAUTHN_RP_ID"`
182+
RPDisplayName string `json:"rp_display_name" split_words:"true" envconfig:"WEBAUTHN_RP_DISPLAY_NAME"`
183+
RPOrigins []string `json:"rp_origins" split_words:"true" envconfig:"WEBAUTHN_RP_ORIGINS"`
184+
ChallengeExpiryDuration time.Duration `json:"challenge_expiry_duration" split_words:"true" default:"5m"`
185+
}
186+
187+
func (w *WebAuthnConfiguration) Validate() error {
188+
if w.RPID == "" {
189+
return errors.New("conf: GOTRUE_WEBAUTHN_RP_ID is required when passkeys are enabled")
190+
}
191+
192+
if w.RPDisplayName == "" {
193+
return errors.New("conf: GOTRUE_WEBAUTHN_RP_DISPLAY_NAME is required when passkeys are enabled")
194+
}
195+
196+
if len(w.RPOrigins) == 0 {
197+
return errors.New("conf: GOTRUE_WEBAUTHN_RP_ORIGINS is required when passkeys are enabled")
198+
}
199+
200+
for _, origin := range w.RPOrigins {
201+
u, err := url.Parse(origin)
202+
if err != nil {
203+
return fmt.Errorf("conf: invalid WebAuthn RP origin %q: %w", origin, err)
204+
}
205+
206+
if u.Scheme == "http" {
207+
host := u.Hostname()
208+
if host != "localhost" && host != "127.0.0.1" {
209+
return fmt.Errorf("conf: WebAuthn RP origin %q must use HTTPS (http is only allowed for localhost/127.0.0.1)", origin)
210+
}
211+
} else if u.Scheme != "https" {
212+
return fmt.Errorf("conf: WebAuthn RP origin %q must use HTTPS", origin)
213+
}
214+
}
215+
216+
return nil
217+
}
218+
219+
type PasskeyConfiguration struct {
220+
Enabled bool `json:"enabled" default:"false"`
221+
MaxPasskeysPerUser int `json:"max_passkeys_per_user" split_words:"true" default:"10"`
222+
}
223+
180224
type APIConfiguration struct {
181225
Host string
182226
Port string `envconfig:"PORT" default:"8081"`
@@ -358,6 +402,8 @@ type GlobalConfiguration struct {
358402
Sessions SessionsConfiguration `json:"sessions"`
359403
MFA MFAConfiguration `json:"MFA"`
360404
SAML SAMLConfiguration `json:"saml"`
405+
WebAuthn WebAuthnConfiguration `json:"webauthn"`
406+
Passkey PasskeyConfiguration `json:"passkey"`
361407
CORS CORSConfiguration `json:"cors"`
362408
IndexWorker IndexWorkerConfiguration `json:"index_worker" split_words:"true"`
363409

@@ -1285,6 +1331,12 @@ func (c *GlobalConfiguration) Validate() error {
12851331
}
12861332
}
12871333

1334+
if c.Passkey.Enabled {
1335+
if err := c.WebAuthn.Validate(); err != nil {
1336+
return err
1337+
}
1338+
}
1339+
12881340
return nil
12891341
}
12901342

internal/conf/configuration_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,113 @@ func TestLoading(t *testing.T) {
12531253
}
12541254
}
12551255

1256+
func TestWebAuthnConfigurationValidate(t *testing.T) {
1257+
// Empty RPID → error
1258+
{
1259+
w := &WebAuthnConfiguration{
1260+
RPDisplayName: "Test",
1261+
RPOrigins: []string{"https://example.com"},
1262+
}
1263+
err := w.Validate()
1264+
require.Error(t, err)
1265+
require.Contains(t, err.Error(), "GOTRUE_WEBAUTHN_RP_ID is required")
1266+
}
1267+
1268+
// Empty RPDisplayName → error
1269+
{
1270+
w := &WebAuthnConfiguration{
1271+
RPID: "example.com",
1272+
RPOrigins: []string{"https://example.com"},
1273+
}
1274+
err := w.Validate()
1275+
require.Error(t, err)
1276+
require.Contains(t, err.Error(), "GOTRUE_WEBAUTHN_RP_DISPLAY_NAME is required")
1277+
}
1278+
1279+
// Empty RPOrigins → error
1280+
{
1281+
w := &WebAuthnConfiguration{
1282+
RPID: "example.com",
1283+
RPDisplayName: "Example",
1284+
}
1285+
err := w.Validate()
1286+
require.Error(t, err)
1287+
require.Contains(t, err.Error(), "GOTRUE_WEBAUTHN_RP_ORIGINS is required")
1288+
}
1289+
1290+
// HTTP origin (not localhost) → error
1291+
{
1292+
w := &WebAuthnConfiguration{
1293+
RPID: "example.com",
1294+
RPDisplayName: "Example",
1295+
RPOrigins: []string{"http://example.com"},
1296+
}
1297+
err := w.Validate()
1298+
require.Error(t, err)
1299+
require.Contains(t, err.Error(), "must use HTTPS")
1300+
}
1301+
1302+
// HTTP localhost is allowed
1303+
{
1304+
w := &WebAuthnConfiguration{
1305+
RPID: "localhost",
1306+
RPDisplayName: "Localhost",
1307+
RPOrigins: []string{"http://localhost:3000"},
1308+
}
1309+
err := w.Validate()
1310+
require.NoError(t, err)
1311+
}
1312+
1313+
// HTTP 127.0.0.1 is allowed
1314+
{
1315+
w := &WebAuthnConfiguration{
1316+
RPID: "localhost",
1317+
RPDisplayName: "Localhost",
1318+
RPOrigins: []string{"http://127.0.0.1:3000"},
1319+
}
1320+
err := w.Validate()
1321+
require.NoError(t, err)
1322+
}
1323+
1324+
// Valid HTTPS origins pass
1325+
{
1326+
w := &WebAuthnConfiguration{
1327+
RPID: "example.com",
1328+
RPDisplayName: "Example",
1329+
RPOrigins: []string{"https://example.com", "https://app.example.com"},
1330+
}
1331+
err := w.Validate()
1332+
require.NoError(t, err)
1333+
}
1334+
1335+
// Conditional validation: disabled passkey skips WebAuthn validation
1336+
{
1337+
cfg := &GlobalConfiguration{
1338+
SiteURL: "https://example.com",
1339+
API: APIConfiguration{ExternalURL: "https://example.com"},
1340+
JWT: JWTConfiguration{Secret: "a"},
1341+
}
1342+
cfg.Passkey.Enabled = false
1343+
require.NoError(t, cfg.ApplyDefaults())
1344+
err := cfg.Validate()
1345+
require.NoError(t, err)
1346+
}
1347+
1348+
// Conditional validation: enabled passkey requires WebAuthn config
1349+
{
1350+
cfg := &GlobalConfiguration{
1351+
SiteURL: "https://example.com",
1352+
API: APIConfiguration{ExternalURL: "https://example.com"},
1353+
JWT: JWTConfiguration{Secret: "a"},
1354+
}
1355+
cfg.Passkey.Enabled = true
1356+
require.NoError(t, cfg.ApplyDefaults())
1357+
err := cfg.Validate()
1358+
require.Error(t, err)
1359+
require.Contains(t, err.Error(), "GOTRUE_WEBAUTHN_RP_ID is required")
1360+
}
1361+
}
1362+
12561363
func toPtr[T any](v T) *T {
12571364
return &(&([1]T{T(v)}))[0]
12581365
}

internal/models/cleanup.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func NewCleanup(config *conf.GlobalConfiguration) *Cleanup {
4141
tableMFAChallenges := Challenge{}.TableName()
4242
tableMFAFactors := Factor{}.TableName()
4343
tableOAuthClientStates := OAuthClientState{}.TableName()
44+
tableWebAuthnChallenges := WebAuthnChallenge{}.TableName()
4445

4546
c := &Cleanup{}
4647

@@ -60,6 +61,7 @@ func NewCleanup(config *conf.GlobalConfiguration) *Cleanup {
6061
fmt.Sprintf("delete from %q where id in (select id from %q where created_at < now() - interval '24 hours' limit 100 for update skip locked);", tableOAuthClientStates, tableOAuthClientStates),
6162
fmt.Sprintf("delete from %q where id in (select id from %q where created_at < now() - interval '24 hours' limit 100 for update skip locked);", tableMFAChallenges, tableMFAChallenges),
6263
fmt.Sprintf("delete from %q where id in (select id from %q where created_at < now() - interval '24 hours' and status = 'unverified' limit 100 for update skip locked);", tableMFAFactors, tableMFAFactors),
64+
fmt.Sprintf("delete from %q where id in (select id from %q where expires_at < now() limit 100 for update skip locked);", tableWebAuthnChallenges, tableWebAuthnChallenges),
6365
)
6466

6567
if config.External.AnonymousUsers.Enabled {

internal/models/connection.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ func TruncateAll(conn *storage.Connection) error {
5151
(&pop.Model{Value: OneTimeToken{}}).TableName(),
5252
(&pop.Model{Value: OAuthServerClient{}}).TableName(),
5353
(&pop.Model{Value: CustomOAuthProvider{}}).TableName(),
54+
(&pop.Model{Value: WebAuthnCredential{}}).TableName(),
55+
(&pop.Model{Value: WebAuthnChallenge{}}).TableName(),
5456
}
5557

5658
for _, tableName := range tables {

internal/models/errors.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,25 @@ func (e CustomOAuthProviderNotFoundError) Error() string {
170170
func (e CustomOAuthProviderNotFoundError) Is(target error) bool {
171171
return target == errNotFound
172172
}
173+
174+
// WebAuthnCredentialNotFoundError represents when a WebAuthn credential can't be found.
175+
type WebAuthnCredentialNotFoundError struct{}
176+
177+
func (e WebAuthnCredentialNotFoundError) Error() string {
178+
return "WebAuthn credential not found"
179+
}
180+
181+
func (e WebAuthnCredentialNotFoundError) Is(target error) bool {
182+
return target == errNotFound
183+
}
184+
185+
// WebAuthnChallengeNotFoundError represents when a WebAuthn challenge can't be found.
186+
type WebAuthnChallengeNotFoundError struct{}
187+
188+
func (e WebAuthnChallengeNotFoundError) Error() string {
189+
return "WebAuthn challenge not found"
190+
}
191+
192+
func (e WebAuthnChallengeNotFoundError) Is(target error) bool {
193+
return target == errNotFound
194+
}

internal/models/factor.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,16 +150,16 @@ type Factor struct {
150150
Challenge []Challenge `json:"-" has_many:"challenges"`
151151
Phone storage.NullString `json:"phone" db:"phone"`
152152
LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"`
153-
WebAuthnCredential *WebAuthnCredential `json:"-" db:"web_authn_credential"`
153+
WebAuthnCredential *MFAWebAuthnCredential `json:"-" db:"web_authn_credential"`
154154
WebAuthnAAGUID *uuid.UUID `json:"web_authn_aaguid,omitempty" db:"web_authn_aaguid"`
155155
LastWebAuthnChallengeData *LastWebAuthnChallengeData `json:"last_webauthn_challenge_data,omitempty" db:"last_webauthn_challenge_data"`
156156
}
157157

158-
type WebAuthnCredential struct {
158+
type MFAWebAuthnCredential struct {
159159
webauthn.Credential
160160
}
161161

162-
func (wc *WebAuthnCredential) Value() (driver.Value, error) {
162+
func (wc *MFAWebAuthnCredential) Value() (driver.Value, error) {
163163
if wc == nil {
164164
return nil, nil
165165
}
@@ -200,7 +200,7 @@ func (lwcd *LastWebAuthnChallengeData) Scan(value interface{}) error {
200200
return json.Unmarshal(data, lwcd)
201201
}
202202

203-
func (wc *WebAuthnCredential) Scan(value interface{}) error {
203+
func (wc *MFAWebAuthnCredential) Scan(value interface{}) error {
204204
if value == nil {
205205
wc.Credential = webauthn.Credential{}
206206
return nil
@@ -283,7 +283,7 @@ func (f *Factor) GetSecret(decryptionKeys map[string]string, encrypt bool, encry
283283
}
284284

285285
func (f *Factor) SaveWebAuthnCredential(tx *storage.Connection, credential *webauthn.Credential) error {
286-
f.WebAuthnCredential = &WebAuthnCredential{
286+
f.WebAuthnCredential = &MFAWebAuthnCredential{
287287
Credential: *credential,
288288
}
289289

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package models
2+
3+
import (
4+
"database/sql"
5+
"time"
6+
7+
"github.com/gofrs/uuid"
8+
"github.com/pkg/errors"
9+
"github.com/supabase/auth/internal/storage"
10+
)
11+
12+
const (
13+
WebAuthnChallengeTypeSignup = "signup"
14+
WebAuthnChallengeTypeRegistration = "registration"
15+
WebAuthnChallengeTypeAuthentication = "authentication"
16+
)
17+
18+
// WebAuthnChallenge maps to the webauthn_challenges table.
19+
type WebAuthnChallenge struct {
20+
ID uuid.UUID `json:"id" db:"id"`
21+
UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"`
22+
ChallengeType string `json:"challenge_type" db:"challenge_type"`
23+
SessionData *WebAuthnSessionData `json:"session_data" db:"session_data"`
24+
CreatedAt time.Time `json:"created_at" db:"created_at"`
25+
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
26+
}
27+
28+
func (WebAuthnChallenge) TableName() string {
29+
return "webauthn_challenges"
30+
}
31+
32+
func NewWebAuthnChallenge(userID *uuid.UUID, challengeType string, sessionData *WebAuthnSessionData, expiresAt time.Time) *WebAuthnChallenge {
33+
id := uuid.Must(uuid.NewV4())
34+
return &WebAuthnChallenge{
35+
ID: id,
36+
UserID: userID,
37+
ChallengeType: challengeType,
38+
SessionData: sessionData,
39+
ExpiresAt: expiresAt,
40+
}
41+
}
42+
43+
func FindWebAuthnChallengeByID(conn *storage.Connection, id uuid.UUID) (*WebAuthnChallenge, error) {
44+
var challenge WebAuthnChallenge
45+
err := conn.Find(&challenge, id)
46+
if err != nil && errors.Cause(err) == sql.ErrNoRows {
47+
return nil, WebAuthnChallengeNotFoundError{}
48+
} else if err != nil {
49+
return nil, err
50+
}
51+
return &challenge, nil
52+
}
53+
54+
func (c *WebAuthnChallenge) IsExpired() bool {
55+
return time.Now().After(c.ExpiresAt)
56+
}
57+
58+
func (c *WebAuthnChallenge) Delete(tx *storage.Connection) error {
59+
return tx.Destroy(c)
60+
}

0 commit comments

Comments
 (0)