mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-20 18:09:05 +02:00
feat: include passkey icons based on AAGUID (#1756)
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// Refreshes the AAGUID metadata and authenticator icons from the community AAGUID list
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceURL = "https://raw.githubusercontent.com/pocket-id/passkey-aaguids/refs/heads/main/combined_aaguid.json"
|
||||
metadataFile = "backend/resources/aaguids.json"
|
||||
iconDir = "backend/resources/aaguid-icons"
|
||||
svgDataPrefix = "data:image/svg+xml;base64,"
|
||||
iconFilePattern = "*.svg"
|
||||
iconDigestBytes = 8
|
||||
)
|
||||
|
||||
// authenticator is the subset of an upstream entry this script cares about
|
||||
// The icons are data URIs rather than links, so nothing beyond the combined JSON has to be fetched
|
||||
type authenticator struct {
|
||||
Name string `json:"name"`
|
||||
IconLight string `json:"icon_light"`
|
||||
IconDark string `json:"icon_dark"`
|
||||
}
|
||||
|
||||
// authenticatorMetadata is the generated runtime representation of an authenticator
|
||||
// Icon file names contain a 64-bit SHA-256 prefix of their decoded SVG so identical upstream icons share one asset
|
||||
type authenticatorMetadata struct {
|
||||
Name string `json:"name"`
|
||||
IconLight string `json:"icon_light,omitempty"`
|
||||
IconDark string `json:"icon_dark,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatalln("error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// run writes the metadata manifest and its content-addressed icons from one upstream snapshot so their references stay consistent
|
||||
func run() error {
|
||||
authenticators, err := fetchAuthenticators(sourceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch %s: %w", sourceURL, err)
|
||||
}
|
||||
if len(authenticators) == 0 {
|
||||
return errors.New("upstream AAGUID list is empty")
|
||||
}
|
||||
|
||||
metadata, icons, err := buildResources(authenticators)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build authenticator resources: %w", err)
|
||||
}
|
||||
|
||||
if err := writeIcons(icons); err != nil {
|
||||
return fmt.Errorf("failed to write icon files: %w", err)
|
||||
}
|
||||
|
||||
if err := writeAuthenticatorMetadata(metadata); err != nil {
|
||||
return fmt.Errorf("failed to write authenticator metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAuthenticators downloads the upstream authenticator list keyed by AAGUID
|
||||
func fetchAuthenticators(url string) (authenticators map[string]authenticator, err error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get authenticator JSON: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, resp.Body.Close())
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("expected status 200, got %s", resp.Status)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&authenticators); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JSON: %w", err)
|
||||
}
|
||||
|
||||
return authenticators, nil
|
||||
}
|
||||
|
||||
// buildResources converts upstream data URIs into metadata references and a set of unique decoded SVGs
|
||||
func buildResources(authenticators map[string]authenticator) (map[string]authenticatorMetadata, map[string][]byte, error) {
|
||||
metadata := make(map[string]authenticatorMetadata, len(authenticators))
|
||||
icons := make(map[string][]byte)
|
||||
|
||||
// The AAGUIDs are sorted so failures are deterministic when the upstream data contains an invalid icon
|
||||
for _, aaguid := range slices.Sorted(maps.Keys(authenticators)) {
|
||||
a := authenticators[aaguid]
|
||||
entry := authenticatorMetadata{Name: a.Name}
|
||||
|
||||
// Entries without a usable light SVG retain their display names but do not reference any icon
|
||||
lightName, err := addIcon(icons, a.IconLight)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to process light SVG for %s: %w", aaguid, err)
|
||||
}
|
||||
if lightName == "" {
|
||||
metadata[aaguid] = entry
|
||||
continue
|
||||
}
|
||||
entry.IconLight = lightName
|
||||
|
||||
// A missing or identical dark icon falls back to the light asset at runtime
|
||||
darkName, err := addIcon(icons, a.IconDark)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to process dark SVG for %s: %w", aaguid, err)
|
||||
}
|
||||
if darkName != "" && darkName != lightName {
|
||||
entry.IconDark = darkName
|
||||
}
|
||||
|
||||
metadata[aaguid] = entry
|
||||
}
|
||||
|
||||
return metadata, icons, nil
|
||||
}
|
||||
|
||||
// addIcon decodes one SVG data URI and returns the content-addressed file name stored in icons
|
||||
func addIcon(icons map[string][]byte, dataURI string) (string, error) {
|
||||
payload, ok := strings.CutPrefix(dataURI, svgDataPrefix)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
svg, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
|
||||
digest := sha256.Sum256(svg)
|
||||
name := fmt.Sprintf("%x.svg", digest[:iconDigestBytes])
|
||||
if existing, exists := icons[name]; exists && !bytes.Equal(existing, svg) {
|
||||
return "", fmt.Errorf("SHA-256 prefix collision for %s", name)
|
||||
}
|
||||
icons[name] = svg
|
||||
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// writeIcons replaces the icon directory contents so assets dropped upstream do not linger
|
||||
func writeIcons(icons map[string][]byte) error {
|
||||
if err := os.MkdirAll(iconDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create authenticator icons directory: %w", err)
|
||||
}
|
||||
|
||||
existing, err := filepath.Glob(filepath.Join(iconDir, iconFilePattern))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error gathering existing SVGs: %w", err)
|
||||
}
|
||||
for _, path := range existing {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("error clearing existing SVG: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range slices.Sorted(maps.Keys(icons)) {
|
||||
if err := os.WriteFile(filepath.Join(iconDir, name), icons[name], 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write SVG %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAuthenticatorMetadata writes the AAGUID manifest consumed by the backend
|
||||
func writeAuthenticatorMetadata(metadata map[string]authenticatorMetadata) (err error) {
|
||||
if err := os.MkdirAll(filepath.Dir(metadataFile), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create authenticator metadata directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(metadataFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create authenticator metadata file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, file.Close())
|
||||
}()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(metadata)
|
||||
}
|
||||
Reference in New Issue
Block a user