Skip to content

Commit f000931

Browse files
committed
feat(passkeys): add admin endpoints to list and delete passkeys
1 parent 30b3aeb commit f000931

File tree

3 files changed

+216
-0
lines changed

3 files changed

+216
-0
lines changed

internal/api/api.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,13 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne
359359
})
360360
})
361361

362+
r.Route("/passkeys", func(r *router) {
363+
r.Get("/", api.AdminPasskeyList)
364+
r.Route("/{passkey_id}", func(r *router) {
365+
r.Delete("/", api.AdminPasskeyDelete)
366+
})
367+
})
368+
362369
r.Get("/", api.adminUserGet)
363370
r.Put("/", api.adminUserUpdate)
364371
r.Delete("/", api.adminUserDelete)

internal/api/passkey_admin.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/go-chi/chi/v5"
7+
"github.com/gofrs/uuid"
8+
"github.com/supabase/auth/internal/api/apierrors"
9+
"github.com/supabase/auth/internal/models"
10+
"github.com/supabase/auth/internal/storage"
11+
"github.com/supabase/auth/internal/utilities"
12+
)
13+
14+
// AdminPasskeyList handles GET /admin/users/{user_id}/passkeys.
15+
// Requires admin credentials. Returns all passkeys for the specified user.
16+
func (a *API) AdminPasskeyList(w http.ResponseWriter, r *http.Request) error {
17+
ctx := r.Context()
18+
user := getUser(ctx)
19+
db := a.db.WithContext(ctx)
20+
21+
creds, err := models.FindWebAuthnCredentialsByUserID(db, user.ID)
22+
if err != nil {
23+
return apierrors.NewInternalServerError("Database error loading passkeys").WithInternalError(err)
24+
}
25+
26+
items := make([]PasskeyListItem, len(creds))
27+
for i, cred := range creds {
28+
items[i] = toPasskeyListItem(cred)
29+
}
30+
31+
return sendJSON(w, http.StatusOK, items)
32+
}
33+
34+
// AdminPasskeyDelete handles DELETE /admin/users/{user_id}/passkeys/{passkey_id}.
35+
// Requires admin credentials. Deletes the specified passkey.
36+
func (a *API) AdminPasskeyDelete(w http.ResponseWriter, r *http.Request) error {
37+
ctx := r.Context()
38+
config := a.config
39+
user := getUser(ctx)
40+
adminUser := getAdminUser(ctx)
41+
db := a.db.WithContext(ctx)
42+
43+
passkeyID, err := uuid.FromString(chi.URLParam(r, "passkey_id"))
44+
if err != nil {
45+
return apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "Passkey not found")
46+
}
47+
48+
cred, err := models.FindWebAuthnCredentialByIDAndUserID(db, passkeyID, user.ID)
49+
if err != nil {
50+
if models.IsNotFoundError(err) {
51+
return apierrors.NewNotFoundError(apierrors.ErrorCodeValidationFailed, "Passkey not found")
52+
}
53+
return apierrors.NewInternalServerError("Database error loading passkey").WithInternalError(err)
54+
}
55+
56+
err = db.Transaction(func(tx *storage.Connection) error {
57+
if terr := cred.Delete(tx); terr != nil {
58+
return terr
59+
}
60+
61+
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, adminUser, models.PasskeyDeletedAction, utilities.GetIPAddress(r), map[string]any{
62+
"user_id": user.ID,
63+
"passkey_id": cred.ID,
64+
}); terr != nil {
65+
return terr
66+
}
67+
68+
return nil
69+
})
70+
if err != nil {
71+
return apierrors.NewInternalServerError("Database error deleting passkey").WithInternalError(err)
72+
}
73+
74+
w.WriteHeader(http.StatusNoContent)
75+
return nil
76+
}

internal/api/passkey_admin_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/gofrs/uuid"
9+
jwt "github.com/golang-jwt/jwt/v5"
10+
"github.com/stretchr/testify/require"
11+
"github.com/supabase/auth/internal/models"
12+
)
13+
14+
// generateAdminToken creates a JWT with the supabase_admin role for admin endpoint tests.
15+
func (ts *PasskeyTestSuite) generateAdminToken() string {
16+
claims := &AccessTokenClaims{
17+
Role: "supabase_admin",
18+
}
19+
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
20+
require.NoError(ts.T(), err)
21+
return token
22+
}
23+
24+
func (ts *PasskeyTestSuite) TestAdminPasskeyListEmpty() {
25+
adminToken := ts.generateAdminToken()
26+
w := ts.makeRequest(http.MethodGet, fmt.Sprintf("http://localhost/admin/users/%s/passkeys", ts.TestUser.ID), nil, withBearerToken(adminToken))
27+
28+
ts.Equal(http.StatusOK, w.Code)
29+
30+
var items []PasskeyListItem
31+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&items))
32+
ts.Empty(items)
33+
}
34+
35+
func (ts *PasskeyTestSuite) TestAdminPasskeyListWithPasskeys() {
36+
pk1 := ts.createTestPasskey(ts.TestUser.ID, "Admin View Key 1")
37+
pk2 := ts.createTestPasskey(ts.TestUser.ID, "Admin View Key 2")
38+
39+
adminToken := ts.generateAdminToken()
40+
w := ts.makeRequest(http.MethodGet, fmt.Sprintf("http://localhost/admin/users/%s/passkeys", ts.TestUser.ID), nil, withBearerToken(adminToken))
41+
42+
ts.Equal(http.StatusOK, w.Code)
43+
44+
var items []PasskeyListItem
45+
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&items))
46+
ts.Require().Len(items, 2)
47+
48+
ts.Equal(pk1.ID.String(), items[0].ID)
49+
ts.Equal("Admin View Key 1", items[0].FriendlyName)
50+
51+
ts.Equal(pk2.ID.String(), items[1].ID)
52+
ts.Equal("Admin View Key 2", items[1].FriendlyName)
53+
}
54+
55+
func (ts *PasskeyTestSuite) TestAdminPasskeyListUserNotFound() {
56+
adminToken := ts.generateAdminToken()
57+
fakeUserID := uuid.Must(uuid.NewV4())
58+
w := ts.makeRequest(http.MethodGet, fmt.Sprintf("http://localhost/admin/users/%s/passkeys", fakeUserID), nil, withBearerToken(adminToken))
59+
60+
ts.Equal(http.StatusNotFound, w.Code)
61+
}
62+
63+
func (ts *PasskeyTestSuite) TestAdminPasskeyDelete() {
64+
cred := ts.createTestPasskey(ts.TestUser.ID, "To Delete By Admin")
65+
66+
adminToken := ts.generateAdminToken()
67+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/admin/users/%s/passkeys/%s", ts.TestUser.ID, cred.ID), nil, withBearerToken(adminToken))
68+
69+
ts.Equal(http.StatusNoContent, w.Code)
70+
ts.Empty(w.Body.Bytes())
71+
72+
// Verify deleted from database
73+
_, err := models.FindWebAuthnCredentialByID(ts.API.db, cred.ID)
74+
ts.True(models.IsNotFoundError(err))
75+
}
76+
77+
func (ts *PasskeyTestSuite) TestAdminPasskeyDeleteNotFound() {
78+
adminToken := ts.generateAdminToken()
79+
fakePasskeyID := uuid.Must(uuid.NewV4())
80+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/admin/users/%s/passkeys/%s", ts.TestUser.ID, fakePasskeyID), nil, withBearerToken(adminToken))
81+
82+
ts.Equal(http.StatusNotFound, w.Code)
83+
}
84+
85+
func (ts *PasskeyTestSuite) TestAdminPasskeyDeleteWrongUser() {
86+
// Create another user with a passkey
87+
otherUser, err := models.NewUser("", "otheradmin@example.com", "password", ts.Config.JWT.Aud, nil)
88+
require.NoError(ts.T(), err)
89+
require.NoError(ts.T(), ts.API.db.Create(otherUser))
90+
otherCred := ts.createTestPasskey(otherUser.ID, "Other User Key")
91+
92+
// Try to delete otherUser's passkey via TestUser's admin route
93+
adminToken := ts.generateAdminToken()
94+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/admin/users/%s/passkeys/%s", ts.TestUser.ID, otherCred.ID), nil, withBearerToken(adminToken))
95+
96+
ts.Equal(http.StatusNotFound, w.Code)
97+
98+
// Verify the passkey still exists
99+
_, err = models.FindWebAuthnCredentialByID(ts.API.db, otherCred.ID)
100+
ts.NoError(err)
101+
}
102+
103+
func (ts *PasskeyTestSuite) TestAdminPasskeyListNonAdminForbidden() {
104+
// Use a regular user token instead of admin token
105+
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
106+
w := ts.makeRequest(http.MethodGet, fmt.Sprintf("http://localhost/admin/users/%s/passkeys", ts.TestUser.ID), nil, withBearerToken(token))
107+
108+
ts.Equal(http.StatusForbidden, w.Code)
109+
}
110+
111+
func (ts *PasskeyTestSuite) TestAdminPasskeyDeleteNonAdminForbidden() {
112+
cred := ts.createTestPasskey(ts.TestUser.ID, "Protected Key")
113+
114+
token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
115+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/admin/users/%s/passkeys/%s", ts.TestUser.ID, cred.ID), nil, withBearerToken(token))
116+
117+
ts.Equal(http.StatusForbidden, w.Code)
118+
119+
// Verify the passkey still exists
120+
_, err := models.FindWebAuthnCredentialByID(ts.API.db, cred.ID)
121+
ts.NoError(err)
122+
}
123+
124+
func (ts *PasskeyTestSuite) TestAdminPasskeyListUnauthenticated() {
125+
w := ts.makeRequest(http.MethodGet, fmt.Sprintf("http://localhost/admin/users/%s/passkeys", ts.TestUser.ID), nil)
126+
ts.Equal(http.StatusUnauthorized, w.Code)
127+
}
128+
129+
func (ts *PasskeyTestSuite) TestAdminPasskeyDeleteUnauthenticated() {
130+
cred := ts.createTestPasskey(ts.TestUser.ID, "Key")
131+
w := ts.makeRequest(http.MethodDelete, fmt.Sprintf("http://localhost/admin/users/%s/passkeys/%s", ts.TestUser.ID, cred.ID), nil)
132+
ts.Equal(http.StatusUnauthorized, w.Code)
133+
}

0 commit comments

Comments
 (0)