// WebAuthn gRPC handlers for listing and removing WebAuthn credentials. // These are admin-only operations that mirror the REST handlers in // internal/server/handlers_webauthn.go. package grpcserver import ( "context" "fmt" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1" "git.wntrmute.dev/kyle/mcias/internal/model" ) // ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials. // Requires: admin JWT in metadata. // // Security: credential material (IDs, public keys) is never included in the // response — only metadata (name, sign count, timestamps, etc.). func (a *authServiceServer) ListWebAuthnCredentials(ctx context.Context, req *mciasv1.ListWebAuthnCredentialsRequest) (*mciasv1.ListWebAuthnCredentialsResponse, error) { if err := a.s.requireAdmin(ctx); err != nil { return nil, err } if req.AccountId == "" { return nil, status.Error(codes.InvalidArgument, "account_id is required") } acct, err := a.s.db.GetAccountByUUID(req.AccountId) if err != nil { return nil, status.Error(codes.NotFound, "account not found") } creds, err := a.s.db.GetWebAuthnCredentials(acct.ID) if err != nil { a.s.logger.Error("list webauthn credentials", "error", err, "account_id", acct.ID) return nil, status.Error(codes.Internal, "internal error") } resp := &mciasv1.ListWebAuthnCredentialsResponse{ Credentials: make([]*mciasv1.WebAuthnCredentialInfo, 0, len(creds)), } for _, c := range creds { info := &mciasv1.WebAuthnCredentialInfo{ Id: c.ID, Name: c.Name, Aaguid: c.AAGUID, SignCount: c.SignCount, Discoverable: c.Discoverable, Transports: c.Transports, CreatedAt: timestamppb.New(c.CreatedAt), } if c.LastUsedAt != nil { info.LastUsedAt = timestamppb.New(*c.LastUsedAt) } resp.Credentials = append(resp.Credentials, info) } return resp, nil } // RemoveWebAuthnCredential removes a specific WebAuthn credential. // Requires: admin JWT in metadata. func (a *authServiceServer) RemoveWebAuthnCredential(ctx context.Context, req *mciasv1.RemoveWebAuthnCredentialRequest) (*mciasv1.RemoveWebAuthnCredentialResponse, error) { if err := a.s.requireAdmin(ctx); err != nil { return nil, err } if req.AccountId == "" { return nil, status.Error(codes.InvalidArgument, "account_id is required") } if req.CredentialId == 0 { return nil, status.Error(codes.InvalidArgument, "credential_id is required") } acct, err := a.s.db.GetAccountByUUID(req.AccountId) if err != nil { return nil, status.Error(codes.NotFound, "account not found") } // DeleteWebAuthnCredentialAdmin bypasses ownership checks (admin operation). if err := a.s.db.DeleteWebAuthnCredentialAdmin(req.CredentialId); err != nil { a.s.logger.Error("delete webauthn credential", "error", err, "credential_id", req.CredentialId) return nil, status.Error(codes.Internal, "internal error") } a.s.db.WriteAuditEvent(model.EventWebAuthnRemoved, nil, &acct.ID, peerIP(ctx), //nolint:errcheck fmt.Sprintf(`{"credential_id":%d}`, req.CredentialId)) return &mciasv1.RemoveWebAuthnCredentialResponse{}, nil }