Skip to content

Commit 492898e

Browse files
committed
feat(passkeys): add management endpoints
1 parent b88418e commit 492898e

5 files changed

Lines changed: 405 additions & 1 deletion

File tree

internal/api/api.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
315315
r.Post("/options", api.PasskeyRegistrationOptions)
316316
r.Post("/verify", api.PasskeyRegistrationVerify)
317317
})
318+
319+
r.With(api.requireAuthentication).Get("/", api.PasskeyList)
320+
r.With(api.requireAuthentication).Route("/{passkey_id}", func(r *router) {
321+
r.Patch("/", api.PasskeyUpdate)
322+
r.Delete("/", api.PasskeyDelete)
323+
})
318324
})
319325

320326
r.Route("/sso", func(r *router) {
@@ -433,7 +439,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
433439
})
434440

435441
corsHandler := cors.New(cors.Options{
436-
AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
442+
AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete},
437443
AllowedHeaders: globalConfig.CORS.AllAllowedHeaders([]string{"Accept", "Authorization", "Content-Type", "X-Client-IP", "X-Client-Info", audHeaderName, useCookieHeader, APIVersionHeaderName}),
438444
ExposedHeaders: []string{"X-Total-Count", "Link", APIVersionHeaderName},
439445
AllowCredentials: true,

internal/api/passkey_manage.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"time"
7+
8+
"github.com/go-chi/chi/v5"
9+
"github.com/gofrs/uuid"
10+
"github.com/supabase/auth/internal/api/apierrors"
11+
"github.com/supabase/auth/internal/models"
12+
"github.com/supabase/auth/internal/storage"
13+
"github.com/supabase/auth/internal/utilities"
14+
)
15+
16+
// PasskeyListItem is the response shape for a single passkey in the list and management endpoints.
17+
// Only non-sensitive, user-relevant fields are exposed.
18+
type PasskeyListItem struct {
19+
ID string `json:"id"`
20+
FriendlyName string `json:"friendly_name,omitempty"`
21+
CreatedAt time.Time `json:"created_at"`
22+
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
23+
}
24+
25+
// PasskeyUpdateParams is the request body for PATCH /passkeys/{passkey_id}.
26+
type PasskeyUpdateParams struct {
27+
FriendlyName string `json:"friendly_name"`
28+
}
29+
30+
// TODO(fm): we should not allow any of the following operations on credentials used for
31+
// MFA webauthn factors — in particular, the deletion operation.
32+
33+
// PasskeyList handles GET /passkeys/.
34+
// Requires authentication. Returns all passkeys for the authenticated user.
35+
func (a *API) PasskeyList(w http.ResponseWriter, r *http.Request) error {
36+
ctx := r.Context()
37+
user := getUser(ctx)
38+
db := a.db.WithContext(ctx)
39+
40+
creds, err := models.FindWebAuthnCredentialsByUserID(db, user.ID)
41+
if err != nil {
42+
return apierrors.NewInternalServerError("Database error loading passkeys").WithInternalError(err)
43+
}
44+
45+
items := make([]PasskeyListItem, len(creds))
46+
for i, cred := range creds {
47+
items[i] = toPasskeyListItem(cred)
48+
}
49+
50+
return sendJSON(w, http.StatusOK, items)
51+
}
52+
53+
// PasskeyUpdate handles PATCH /passkeys/{passkey_id}.
54+
// Requires authentication. Updates the friendly_name of a passkey owned by the authenticated user.
55+
func (a *API) PasskeyUpdate(w http.ResponseWriter, r *http.Request) error {
56+
ctx := r.Context()
57+
config := a.config
58+
user := getUser(ctx)
59+
db := a.db.WithContext(ctx)
60+
61+
passkeyID, err := uuid.FromString(chi.URLParam(r, "passkey_id"))
62+
if err != nil {
63+
return apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "Passkey not found")
64+
}
65+
66+
params := &PasskeyUpdateParams{}
67+
body, err := utilities.GetBodyBytes(r)
68+
if err != nil {
69+
return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Could not read request body")
70+
}
71+
if err := json.Unmarshal(body, params); err != nil {
72+
return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Could not parse request body as JSON: %v", err)
73+
}
74+
75+
if params.FriendlyName == "" {
76+
return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "friendly_name is required")
77+
}
78+
79+
cred, err := models.FindWebAuthnCredentialByIDAndUserID(db, passkeyID, user.ID)
80+
if err != nil {
81+
if models.IsNotFoundError(err) {
82+
return apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "Passkey not found")
83+
}
84+
return apierrors.NewInternalServerError("Database error loading passkey").WithInternalError(err)
85+
}
86+
87+
err = db.Transaction(func(tx *storage.Connection) error {
88+
if terr := cred.UpdateFriendlyName(tx, params.FriendlyName); terr != nil {
89+
return terr
90+
}
91+
92+
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.PasskeyUpdatedAction, utilities.GetIPAddress(r), map[string]any{
93+
"passkey_id": cred.ID,
94+
}); terr != nil {
95+
return terr
96+
}
97+
98+
return nil
99+
})
100+
if err != nil {
101+
return apierrors.NewInternalServerError("Database error updating passkey").WithInternalError(err)
102+
}
103+
104+
return sendJSON(w, http.StatusOK, toPasskeyListItem(cred))
105+
}
106+
107+
// PasskeyDelete handles DELETE /passkeys/{passkey_id}.
108+
// Requires authentication. Deletes a passkey owned by the authenticated user.
109+
func (a *API) PasskeyDelete(w http.ResponseWriter, r *http.Request) error {
110+
ctx := r.Context()
111+
config := a.config
112+
user := getUser(ctx)
113+
db := a.db.WithContext(ctx)
114+
115+
passkeyID, err := uuid.FromString(chi.URLParam(r, "passkey_id"))
116+
if err != nil {
117+
return apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "Passkey not found")
118+
}
119+
120+
cred, err := models.FindWebAuthnCredentialByIDAndUserID(db, passkeyID, user.ID)
121+
if err != nil {
122+
if models.IsNotFoundError(err) {
123+
return apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "Passkey not found")
124+
}
125+
return apierrors.NewInternalServerError("Database error loading passkey").WithInternalError(err)
126+
}
127+
128+
err = db.Transaction(func(tx *storage.Connection) error {
129+
if terr := cred.Delete(tx); terr != nil {
130+
return terr
131+
}
132+
133+
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.PasskeyDeletedAction, utilities.GetIPAddress(r), map[string]any{
134+
"passkey_id": cred.ID,
135+
}); terr != nil {
136+
return terr
137+
}
138+
139+
return nil
140+
})
141+
if err != nil {
142+
return apierrors.NewInternalServerError("Database error deleting passkey").WithInternalError(err)
143+
}
144+
145+
return sendJSON(w, http.StatusOK, toPasskeyListItem(cred))
146+
}
147+
148+
func toPasskeyListItem(cred *models.WebAuthnCredential) PasskeyListItem {
149+
return PasskeyListItem{
150+
ID: cred.ID.String(),
151+
FriendlyName: cred.FriendlyName,
152+
CreatedAt: cred.CreatedAt,
153+
LastUsedAt: cred.LastUsedAt,
154+
}
155+
}

0 commit comments

Comments
 (0)