Skip to content

Commit 61ae2aa

Browse files
committed
feat(passkeys): progressive enrollment flow
1 parent 039b569 commit 61ae2aa

5 files changed

Lines changed: 837 additions & 0 deletions

File tree

internal/api/api.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,15 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
303303
})
304304
})
305305

306+
r.Route("/passkeys", func(r *router) {
307+
r.Use(api.requirePasskeyEnabled)
308+
309+
r.With(api.requireAuthentication).With(api.requireNotAnonymous).Route("/registration", func(r *router) {
310+
r.Post("/options", api.PasskeyRegistrationOptions)
311+
r.Post("/verify", api.PasskeyRegistrationVerify)
312+
})
313+
})
314+
306315
r.Route("/sso", func(r *router) {
307316
r.Use(api.requireSAMLEnabled)
308317
r.With(api.limitHandler(api.limiterOpts.SSO)).
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"time"
7+
8+
"github.com/go-webauthn/webauthn/protocol"
9+
"github.com/go-webauthn/webauthn/webauthn"
10+
"github.com/gofrs/uuid"
11+
"github.com/supabase/auth/internal/api/apierrors"
12+
"github.com/supabase/auth/internal/models"
13+
"github.com/supabase/auth/internal/storage"
14+
"github.com/supabase/auth/internal/utilities"
15+
)
16+
17+
// PasskeyRegistrationOptionsParams is the request body for POST /passkeys/registration/options.
18+
type PasskeyRegistrationOptionsParams struct{}
19+
20+
// PasskeyRegistrationOptionsResponse is the response body for POST /passkeys/registration/options.
21+
type PasskeyRegistrationOptionsResponse struct {
22+
ChallengeID string `json:"challenge_id"`
23+
Options *protocol.CredentialCreation `json:"options"`
24+
ExpiresAt int64 `json:"expires_at"`
25+
}
26+
27+
// PasskeyRegistrationVerifyParams is the request body for POST /passkeys/registration/verify.
28+
type PasskeyRegistrationVerifyParams struct {
29+
ChallengeID string `json:"challenge_id"`
30+
CredentialResponse json.RawMessage `json:"credential_response"`
31+
}
32+
33+
// PasskeyMetadataResponse is the response body for successful passkey creation.
34+
type PasskeyMetadataResponse struct {
35+
ID string `json:"id"`
36+
FriendlyName string `json:"friendly_name,omitempty"`
37+
CreatedAt time.Time `json:"created_at"`
38+
BackupEligible bool `json:"backup_eligible"`
39+
BackedUp bool `json:"backed_up"`
40+
Transports []protocol.AuthenticatorTransport `json:"transports"`
41+
}
42+
43+
// PasskeyRegistrationOptions handles POST /passkeys/registration/options.
44+
// Requires authentication. Generates WebAuthn registration options for adding a passkey to an existing account.
45+
func (a *API) PasskeyRegistrationOptions(w http.ResponseWriter, r *http.Request) error {
46+
ctx := r.Context()
47+
config := a.config
48+
user := getUser(ctx)
49+
db := a.db.WithContext(ctx)
50+
51+
if user.IsSSOUser {
52+
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeValidationFailed, "SSO users cannot register passkeys")
53+
}
54+
55+
// Check passkey limit
56+
count, err := models.CountWebAuthnCredentialsByUserID(db, user.ID)
57+
if err != nil {
58+
return apierrors.NewInternalServerError("Database error counting passkeys").WithInternalError(err)
59+
}
60+
if count >= config.Passkey.MaxPasskeysPerUser {
61+
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeTooManyPasskeys, "Maximum number of passkeys reached")
62+
}
63+
64+
// Load existing passkeys to build exclusion list
65+
existingCreds, err := models.FindWebAuthnCredentialsByUserID(db, user.ID)
66+
if err != nil {
67+
return apierrors.NewInternalServerError("Database error loading passkeys").WithInternalError(err)
68+
}
69+
70+
excludeList := make([]protocol.CredentialDescriptor, len(existingCreds))
71+
for i, cred := range existingCreds {
72+
excludeList[i] = protocol.CredentialDescriptor{
73+
Type: protocol.PublicKeyCredentialType,
74+
CredentialID: cred.CredentialID,
75+
}
76+
}
77+
78+
webAuthn, err := a.getPasskeyWebAuthn()
79+
if err != nil {
80+
return apierrors.NewInternalServerError("Failed to initialize WebAuthn").WithInternalError(err)
81+
}
82+
83+
webAuthnUser := newWebAuthnUser(user, existingCreds)
84+
options, session, err := webAuthn.BeginRegistration(webAuthnUser, webauthn.WithExclusions(excludeList))
85+
if err != nil {
86+
return apierrors.NewInternalServerError("Failed to generate WebAuthn registration options").WithInternalError(err)
87+
}
88+
89+
expiresAt := time.Now().Add(config.WebAuthn.ChallengeExpiryDuration)
90+
challenge := models.NewWebAuthnChallenge(
91+
&user.ID,
92+
models.WebAuthnChallengeTypeRegistration,
93+
&models.WebAuthnSessionData{SessionData: session},
94+
expiresAt,
95+
)
96+
97+
if err := db.Create(challenge); err != nil {
98+
return apierrors.NewInternalServerError("Database error storing challenge").WithInternalError(err)
99+
}
100+
101+
return sendJSON(w, http.StatusOK, &PasskeyRegistrationOptionsResponse{
102+
ChallengeID: challenge.ID.String(),
103+
Options: options,
104+
ExpiresAt: expiresAt.Unix(),
105+
})
106+
}
107+
108+
// PasskeyRegistrationVerify handles POST /passkeys/registration/verify.
109+
// Requires authentication. Verifies the WebAuthn credential and creates a passkey for the authenticated user.
110+
func (a *API) PasskeyRegistrationVerify(w http.ResponseWriter, r *http.Request) error {
111+
ctx := r.Context()
112+
config := a.config
113+
user := getUser(ctx)
114+
db := a.db.WithContext(ctx)
115+
116+
if user.IsSSOUser {
117+
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeValidationFailed, "SSO users cannot register passkeys")
118+
}
119+
120+
params := &PasskeyRegistrationVerifyParams{}
121+
body, err := utilities.GetBodyBytes(r)
122+
if err != nil {
123+
return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Could not read request body")
124+
}
125+
if err := json.Unmarshal(body, params); err != nil {
126+
return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Could not parse request body as JSON: %v", err)
127+
}
128+
129+
if params.ChallengeID == "" {
130+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "challenge_id is required")
131+
}
132+
if params.CredentialResponse == nil {
133+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "credential_response is required")
134+
}
135+
136+
challengeID, err := uuid.FromString(params.ChallengeID)
137+
if err != nil {
138+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "challenge_id must be a valid UUID")
139+
}
140+
141+
// Load and validate challenge
142+
challenge, err := models.FindWebAuthnChallengeByID(db, challengeID)
143+
if err != nil {
144+
if models.IsNotFoundError(err) {
145+
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnChallengeNotFound, "Challenge not found")
146+
}
147+
148+
return apierrors.NewInternalServerError("Database error loading challenge").WithInternalError(err)
149+
}
150+
151+
if challenge.IsExpired() {
152+
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnChallengeExpired, "Challenge has expired")
153+
}
154+
155+
if challenge.ChallengeType != models.WebAuthnChallengeTypeRegistration {
156+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid challenge type")
157+
}
158+
159+
// Verify the challenge belongs to the authenticated user
160+
if challenge.UserID == nil || *challenge.UserID != user.ID {
161+
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnChallengeNotFound, "Challenge not found")
162+
}
163+
164+
// Parse the credential creation response from the JSON params
165+
parsedResponse, err := parseCredentialCreationResponse(params.CredentialResponse)
166+
if err != nil {
167+
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnVerificationFailed, "Invalid credential response").WithInternalError(err)
168+
}
169+
170+
webAuthn, err := a.getPasskeyWebAuthn()
171+
if err != nil {
172+
return apierrors.NewInternalServerError("Failed to initialize WebAuthn").WithInternalError(err)
173+
}
174+
175+
// Load existing passkeys for the user adapter
176+
existingCreds, err := models.FindWebAuthnCredentialsByUserID(db, user.ID)
177+
if err != nil {
178+
return apierrors.NewInternalServerError("Database error loading passkeys").WithInternalError(err)
179+
}
180+
181+
webAuthnUser := newWebAuthnUser(user, existingCreds)
182+
sessionData := *challenge.SessionData.SessionData
183+
184+
credential, err := webAuthn.CreateCredential(webAuthnUser, sessionData, parsedResponse)
185+
if err != nil {
186+
return apierrors.NewBadRequestError(apierrors.ErrorCodeWebAuthnVerificationFailed, "Credential verification failed").WithInternalError(err)
187+
}
188+
189+
// TODO(fm): fallback to AAGUID -> UA -> "Passkey" for friendly name
190+
passkeyCredential := models.NewWebAuthnCredential(user.ID, credential, "")
191+
192+
err = db.Transaction(func(tx *storage.Connection) error {
193+
if terr := tx.Create(passkeyCredential); terr != nil {
194+
if models.IsUniqueConstraintViolatedError(terr) {
195+
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeWebAuthnCredentialExists, "This credential is already registered")
196+
}
197+
198+
return terr
199+
}
200+
201+
if terr := challenge.Delete(tx); terr != nil {
202+
return terr
203+
}
204+
205+
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.PasskeyCreatedAction, utilities.GetIPAddress(r), map[string]any{
206+
"passkey_id": passkeyCredential.ID,
207+
}); terr != nil {
208+
return terr
209+
}
210+
211+
return nil
212+
})
213+
if err != nil {
214+
return apierrors.NewInternalServerError("Database error creating passkey").WithInternalError(err)
215+
}
216+
217+
return sendJSON(w, http.StatusOK, &PasskeyMetadataResponse{
218+
ID: passkeyCredential.ID.String(),
219+
FriendlyName: passkeyCredential.FriendlyName,
220+
CreatedAt: passkeyCredential.CreatedAt,
221+
BackupEligible: passkeyCredential.BackupEligible,
222+
BackedUp: passkeyCredential.BackedUp,
223+
Transports: []protocol.AuthenticatorTransport(passkeyCredential.Transports),
224+
})
225+
}
226+
227+
// parseCredentialCreationResponse parses a WebAuthn credential creation response from raw JSON.
228+
func parseCredentialCreationResponse(raw json.RawMessage) (*protocol.ParsedCredentialCreationData, error) {
229+
var ccr protocol.CredentialCreationResponse
230+
if err := json.Unmarshal(raw, &ccr); err != nil {
231+
return nil, err
232+
}
233+
234+
return ccr.Parse()
235+
}

0 commit comments

Comments
 (0)