package main // 🤖 Anchor management — the account's sign-in methods as one manageable list // (issue #1089; the settings surface that consumes this is #1098). Two // endpoints: // // GET /api/anchors list every way into the account // DELETE /api/anchors/{kind}/{id} remove one (ceremony-gated) // // The listing is metadata only: {kind, id, created_at}. The id is the removal // handle — the base64url credential id for passkeys; for siwa/email/oidc THE // STORED HASH itself. Handing the account's own client that hash is // deliberate: it is HMAC-peppered server-side data (reverses to nothing // without the pepper), already opaque and stable, and it's exactly the key // removal needs — a surrogate id would mean more store state for zero privacy // gain. What must never appear is anything reversible: no Apple subs, no // addresses (the server never holds those anyway), no public keys. The OIDC // provider doors (oidc.go) list under their provider name ("google", // "microsoft") — one kind per door, exactly as siwa is Apple's. // // Removal rule (the last-method guard): the LAST sign-in method overall — // passkeys, SiwA anchors, email anchors, and OIDC provider anchors counted // together — can never be // removed; an account must not be brickable from its own settings screen. // This REPLACES the old last-PASSKEY-only rule: the anchor model reversed the // passkey-only invariant (docs/accounts-and-recovery.md), so an account // holding a live email or Apple anchor may shed its final passkey. The guard // itself lives in the store (transaction/CAS-atomic with the delete it // protects); handlers only translate last=true into a 409. import "net/http" // anchorEntry is one listing row: an Anchor plus its kind, so the client can // group and route removals without a second lookup. type anchorEntry struct { Kind string `json:"kind"` ID string `json:"id"` CreatedAt int64 `json:"created_at"` } // listAnchorsHandler is GET /api/anchors — link-token gated like the devices // list: which ways in exist reveals nothing to a holder of the account's own // token, and the settings screen must render without demanding a ceremony. func (s *Server) listAnchorsHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.linkUser(r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } type anchorSource struct { kind string list func(string) ([]Anchor, error) } parts := []anchorSource{ {"passkey", s.store.listCredentialMeta}, {"siwa", s.store.listSiwaAnchors}, {"email", s.store.listEmailAnchors}, } // The OIDC-table kinds come from the fixed set (oidcAnchorKinds — the // verifier providers plus github), not the enabled doors: anchors of a // currently-disabled door still list (and remove below), exactly as // siwa anchors do with SiwA off. for _, name := range oidcAnchorKinds { parts = append(parts, anchorSource{name, func(uid string) ([]Anchor, error) { return s.store.listOidcAnchors(uid, name) }}) } anchors := []anchorEntry{} for _, part := range parts { rows, err := part.list(uid) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } for _, a := range rows { anchors = append(anchors, anchorEntry{Kind: part.kind, ID: a.ID, CreatedAt: a.CreatedAt}) } } writeJSON(w, http.StatusOK, map[string]any{"anchors": anchors}) } // deleteAnchorHandler is DELETE /api/anchors/{kind}/{id} — ceremony-gated // like credential deletion (removing a way in reduces access, so a stolen // link token must never be able to do it). The passkey kind routes to the // same store call as DELETE /api/credentials/{id}, so both doors enforce the // one last-method rule. func (s *Server) deleteAnchorHandler(w http.ResponseWriter, r *http.Request) { uid, ok := s.ceremonyUser(r) if !ok { http.Error(w, "Confirm it's you first", http.StatusUnauthorized) return } var deleted, last bool var err error switch kind, id := r.PathValue("kind"), r.PathValue("id"); kind { case "passkey": deleted, last, err = s.store.deleteCredential(uid, id) case "siwa": deleted, last, err = s.store.deleteSiwaAnchor(uid, id) case "email": deleted, last, err = s.store.deleteEmailAnchor(uid, id) case "google", "microsoft", "github": // the OIDC-table kinds (oidcAnchorKinds) deleted, last, err = s.store.deleteOidcAnchor(uid, kind, id) default: http.Error(w, "unknown sign-in method kind", http.StatusBadRequest) return } if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if last { http.Error(w, "that's the last way into this account — add another way to sign in before removing it", http.StatusConflict) return } if !deleted { http.Error(w, "no such sign-in method", http.StatusNotFound) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }