home: the OIDC provider table — dark Google and Microsoft doors (#1112) (#1127)
* home: anchor management — list and remove sign-in methods (#1089)
GET /api/anchors (link token) lists every way into the account — passkeys,
SiwA anchors, email anchors — as {kind, id, created_at}; the id is the
removal handle (base64url credential id; the HMAC-peppered hash for
siwa/email, which reverses to nothing). DELETE /api/anchors/{kind}/{id}
(ceremony proof) removes one, refused with a 409 when it would leave ZERO
ways in: the guard evolved from last-passkey to last-METHOD, so an anchored
account may now shed its final passkey. The guard lives in the stores
(transaction/CAS-atomic), enforced identically by the legacy
/api/credentials/{id} door. Store additions (listing + deletion) land in
both SQLite and S3; the S3 side also sweeps stale anchor pointers so a
removed identifier can re-anchor a different account. #1098's settings UI
builds on this.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* home: the OIDC provider table — dark Google and Microsoft doors (#1112)
Generalizes the Sign in with Apple verifier into a provider table
(home/oidc.go). What differs per door lives in one oidcProvider row —
name, display, issuer check, client ids, pepper, JWKS cache — and
everything else is shared: the jwksCache (moved verbatim from siwa.go),
the hand-rolled RS256 verification (verifyIDToken, same check order),
resolve-or-create, link mode, and the form_post trampoline. Apple keeps
its exact routes, env vars, and siwa_anchors rows; verifySiwaToken is
now a thin wrapper over the shared core, so there is still one verifier
and /api/siwa/* is unchanged on the wire (its whole suite passes
untouched).
New per-provider surface: POST /api/oidc/{provider}/verify (the same
request/response contract as /api/siwa/verify) and POST /oidc/return
(the same web-state trampoline, provider-blind, fragment key #oidc=).
Gating rides the SiwA pattern per door: LCHAT_HOME_GOOGLE_CLIENT_IDS +
LCHAT_HOME_GOOGLE_PEPPER and LCHAT_HOME_MICROSOFT_CLIENT_IDS +
LCHAT_HOME_MICROSOFT_PEPPER, both-or-neither each, off = 404. Peppers
are per provider and durable — account keys are HMAC(provider pepper,
sub), and the store carries the provider name beside the hash
(oidc_anchors rows; oidc/<provider>/<hash> pointers on S3), so hashes
can never collide across providers.
Issuer decisions: Google accepts both documented spellings
(https://accounts.google.com and the legacy bare form — same account
either way). Microsoft uses the v2.0 common endpoint, so the issuer is
the per-tenant template https://login.microsoftonline.com/{tenantid}/v2.0
(consumer + work accounts); we accept the pattern and conservatively
require the token's tid claim to equal the issuer's tenant segment.
The anchors surface (#1089) gains the new kinds: GET /api/anchors lists
google/microsoft anchors (from the fixed table, so a disabled door's
anchors still list), DELETE /api/anchors/{google|microsoft}/{id}
removes them, and the last-method guard counts them in both stores.
Both doors ship DARK: no UI, and each stays 404 until its console
registration exists and the env pair is set (Paul's #1112 checklist).
No changelog entry for the same reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+1127
−161
home/anchors.go+22 −7home/db.go+82 −8home/main.go+34 −0home/oidc.go+413 −0home/oidc_test.go+359 −0home/s3store.go+109 −1home/ses.go+1 −1home/siwa.go+12 −144home/store.go+16 −0home/store_test.go+79 −0
diff --git a/home/anchors.go b/home/anchors.go| index 5e5816a..f814c10 100644 |
| --- a/home/anchors.go |
| +++ b/home/anchors.go |
| @@ -8,16 +8,19 @@ package main |
| // 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 THE |
| +// 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. |
| +// 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, and email anchors counted together — can never be |
| +// 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 |
| @@ -44,15 +47,25 @@ func (s *Server) listAnchorsHandler(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "unauthorized", http.StatusUnauthorized) |
| return |
| } |
| - anchors := []anchorEntry{} |
| - for _, part := range []struct { |
| + 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 provider kinds come from the fixed table (oidcProviderNames), |
| + // not the enabled set: anchors of a currently-disabled door still list |
| + // (and remove below), exactly as siwa anchors do with SiwA off. |
| + for _, name := range oidcProviderNames { |
| + 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) |
| @@ -85,6 +98,8 @@ func (s *Server) deleteAnchorHandler(w http.ResponseWriter, r *http.Request) { |
| deleted, last, err = s.store.deleteSiwaAnchor(uid, id) |
| case "email": |
| deleted, last, err = s.store.deleteEmailAnchor(uid, id) |
| + case "google", "microsoft": // the OIDC provider kinds (oidcProviderNames) |
| + deleted, last, err = s.store.deleteOidcAnchor(uid, kind, id) |
| default: |
| http.Error(w, "unknown sign-in method kind", http.StatusBadRequest) |
| return |
diff --git a/home/db.go b/home/db.go| index d10674b..2597085 100644 |
| --- a/home/db.go |
| +++ b/home/db.go |
| @@ -105,6 +105,17 @@ CREATE TABLE IF NOT EXISTS email_anchors ( |
| email_hash TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| +); |
| +-- OIDC provider anchors (oidc.go): siwa_anchors generalized — one row per |
| +-- (provider, HMAC(provider pepper, sub)). Apple deliberately STAYS in |
| +-- siwa_anchors (deployed wire compat); these rows are the newer doors |
| +-- (google, microsoft). |
| +CREATE TABLE IF NOT EXISTS oidc_anchors ( |
| + provider TEXT NOT NULL, |
| + sub_hash TEXT NOT NULL, |
| + user_id TEXT NOT NULL, |
| + created_at INTEGER NOT NULL, |
| + PRIMARY KEY (provider, sub_hash) |
| );`) |
| return err |
| } |
| @@ -175,10 +186,72 @@ func (s *sqliteStore) deleteEmailAnchor(userID, index string) (deleted, last boo |
| return s.deleteAnchorRow("email_anchors", "email_hash", userID, index) |
| } |
| +// The OIDC anchor methods mirror the siwa set with the provider name beside |
| +// the hash — the same row shape, one more key column. |
| + |
| +func (s *sqliteStore) oidcUser(provider, subHash string) (string, bool) { |
| + var uid string |
| + err := s.sql.QueryRow(`SELECT user_id FROM oidc_anchors WHERE provider = ? AND sub_hash = ?`, provider, subHash).Scan(&uid) |
| + if err != nil { |
| + return "", false |
| + } |
| + return uid, true |
| +} |
| + |
| +func (s *sqliteStore) insertOidcAnchor(uid, provider, subHash string) (bool, error) { |
| + // Same shape as insertSiwaAnchor: win the row or read who holds it. |
| + if _, err := s.sql.Exec( |
| + `INSERT INTO oidc_anchors (provider, sub_hash, user_id, created_at) VALUES (?, ?, ?, ?) |
| + ON CONFLICT (provider, sub_hash) DO NOTHING`, |
| + provider, subHash, uid, time.Now().Unix()); err != nil { |
| + return false, err |
| + } |
| + owner, ok := s.oidcUser(provider, subHash) |
| + if !ok { |
| + return false, errors.New("oidc anchor vanished") |
| + } |
| + return owner != uid, nil |
| +} |
| + |
| +func (s *sqliteStore) listOidcAnchors(userID, provider string) ([]Anchor, error) { |
| + return s.listAnchorRows("SELECT sub_hash, created_at FROM oidc_anchors WHERE user_id = ? AND provider = ? ORDER BY created_at", userID, provider) |
| +} |
| + |
| +// deleteOidcAnchor is deleteAnchorRow with the provider in the predicate — |
| +// the same transaction-atomic last-method guard. |
| +func (s *sqliteStore) deleteOidcAnchor(userID, provider, subHash string) (deleted, last bool, err error) { |
| + tx, err := s.sql.Begin() |
| + if err != nil { |
| + return false, false, err |
| + } |
| + defer tx.Rollback() |
| + var owned int |
| + e := tx.QueryRow("SELECT 1 FROM oidc_anchors WHERE user_id = ? AND provider = ? AND sub_hash = ?", |
| + userID, provider, subHash).Scan(&owned) |
| + if e == sql.ErrNoRows { |
| + return false, false, nil |
| + } |
| + if e != nil { |
| + return false, false, e |
| + } |
| + n, err := signInMethods(tx, userID) |
| + if err != nil { |
| + return false, false, err |
| + } |
| + if n <= 1 { |
| + return false, true, nil |
| + } |
| + if _, err := tx.Exec("DELETE FROM oidc_anchors WHERE user_id = ? AND provider = ? AND sub_hash = ?", |
| + userID, provider, subHash); err != nil { |
| + return false, false, err |
| + } |
| + return true, false, tx.Commit() |
| +} |
| + |
| // listAnchorRows runs one of the fixed (id, created_at) listing queries above |
| -// — the three anchor listings differ only in table and key column. |
| -func (s *sqliteStore) listAnchorRows(query, userID string) ([]Anchor, error) { |
| - rows, err := s.sql.Query(query, userID) |
| +// — the anchor listings differ only in table and key column(s). |
| +func (s *sqliteStore) listAnchorRows(query string, args ...any) ([]Anchor, error) { |
| + rows, err := s.sql.Query(query, args...) |
| if err != nil { |
| return nil, err |
| } |
| @@ -224,16 +297,17 @@ func (s *sqliteStore) deleteAnchorRow(table, keyCol, userID, hash string) (delet |
| return true, false, tx.Commit() |
| } |
| -// signInMethods counts every way into the account — passkeys plus SiwA and |
| -// email anchors — inside the caller's transaction, so the last-method guard |
| -// is atomic with the delete it protects. |
| +// signInMethods counts every way into the account — passkeys plus SiwA, |
| +// email, and OIDC-provider anchors — inside the caller's transaction, so the |
| +// last-method guard is atomic with the delete it protects. |
| func signInMethods(tx *sql.Tx, userID string) (int, error) { |
| var n int |
| err := tx.QueryRow(`SELECT |
| (SELECT COUNT(*) FROM credentials WHERE user_id = ?) |
| + (SELECT COUNT(*) FROM siwa_anchors WHERE user_id = ?) |
| - + (SELECT COUNT(*) FROM email_anchors WHERE user_id = ?)`, |
| - userID, userID, userID).Scan(&n) |
| + + (SELECT COUNT(*) FROM email_anchors WHERE user_id = ?) |
| + + (SELECT COUNT(*) FROM oidc_anchors WHERE user_id = ?)`, |
| + userID, userID, userID, userID).Scan(&n) |
| return n, err |
| } |
diff --git a/home/main.go b/home/main.go| index 110fcc4..a03ca6f 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -58,6 +58,11 @@ type Server struct { |
| siwaClientIDs []string |
| siwaPepper []byte |
| siwa *jwksCache |
| + // oidc is the generalized provider table (oidc.go) — the Google and |
| + // Microsoft doors, keyed by route name. Each row gates like SiwA (client |
| + // ids + pepper, both or neither); empty/nil → every /api/oidc/* and |
| + // /oidc/return answers 404. Apple stays on the dedicated fields above. |
| + oidc map[string]*oidcProvider |
| // The email anchor (email.go): the SES mailer, the blind-index pepper, |
| // and the in-memory sign-in codes. mail nil or pepper empty → off (404). |
| mail *sesMailer |
| @@ -172,6 +177,30 @@ func main() { |
| s.mail, s.emailPepper, s.emailCodes = mailer, []byte(emailPepper), newEmailCodes() |
| log.Printf("email sign-in on (from %s via SES %s)", emailFrom, sesRegion) |
| } |
| + // The OIDC provider table (oidc.go): each door rides the SiwA pattern — |
| + // LCHAT_HOME_<P>_CLIENT_IDS (comma-separated token audiences) turns it on |
| + // and LCHAT_HOME_<P>_PEPPER keys its accounts as HMAC(pepper, sub). The |
| + // peppers are per provider, env-only, and DURABLE (they name accounts; no |
| + // ephemeral fallback). Both or neither, per provider. Apple keeps its own |
| + // LCHAT_HOME_SIWA_* pair above. |
| + for _, pc := range []struct{ name, idsEnv, pepperEnv string }{ |
| + {"google", "LCHAT_HOME_GOOGLE_CLIENT_IDS", "LCHAT_HOME_GOOGLE_PEPPER"}, |
| + {"microsoft", "LCHAT_HOME_MICROSOFT_CLIENT_IDS", "LCHAT_HOME_MICROSOFT_PEPPER"}, |
| + } { |
| + ids, pepper := os.Getenv(pc.idsEnv), os.Getenv(pc.pepperEnv) |
| + if (ids == "") != (pepper == "") { |
| + log.Fatalf("sign in with %s needs BOTH %s and %s (or neither)", pc.name, pc.idsEnv, pc.pepperEnv) |
| + } |
| + if ids == "" { |
| + continue |
| + } |
| + p := newOIDCProvider(pc.name, splitCommaList(ids), []byte(pepper)) |
| + if s.oidc == nil { |
| + s.oidc = map[string]*oidcProvider{} |
| + } |
| + s.oidc[pc.name] = p |
| + log.Printf("sign in with %s on (%d client ids)", p.display, len(p.clientIDs)) |
| + } |
| if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil { |
| log.Printf("space provisioning via %s", s.cloud.url) |
| } |
| @@ -389,6 +418,11 @@ func (s *Server) handler() http.Handler { |
| // Apple's identity token to, and the web flow's form_post trampoline. |
| mux.HandleFunc("POST /api/siwa/verify", s.rateLimited(s.siwaVerify)) |
| mux.HandleFunc("POST /siwa/return", s.rateLimited(s.siwaReturn)) |
| + // The generalized OIDC doors (oidc.go): per-provider verify on the same |
| + // one-verifier contract as /api/siwa/verify, and the web flow's form_post |
| + // trampoline mirroring /siwa/return. Apple keeps its dedicated routes. |
| + mux.HandleFunc("POST /api/oidc/{provider}/verify", s.rateLimited(s.oidcVerify)) |
| + mux.HandleFunc("POST /oidc/return", s.rateLimited(s.oidcReturn)) |
| // The email anchor (email.go): request a sign-in code, redeem it. |
| mux.HandleFunc("POST /api/email/request", s.rateLimited(s.emailRequest)) |
| mux.HandleFunc("POST /api/email/verify", s.rateLimited(s.emailVerify)) |
diff --git a/home/oidc.go b/home/oidc.go| new file mode 100644 |
| index 0000000..410b576 |
| --- /dev/null |
| +++ b/home/oidc.go |
| @@ -0,0 +1,413 @@ |
| +package main |
| + |
| +// 🤖 The OIDC provider table — Sign in with Apple's verifier, generalized |
| +// (issue #1112, server half). SiwA (siwa.go) proved the shape: an RS256 |
| +// identity token signed by a JWKS-published key, checked end to end, keyed |
| +// into the store as HMAC(pepper, sub), answered exactly like a passkey |
| +// ceremony. Google and Microsoft speak the same protocol with different |
| +// constants, so what differs lives in one table row (oidcProvider) and |
| +// everything else is shared: the JWKS cache, the signature + claims |
| +// verification, resolve-or-create, link mode, and the web-state trampoline. |
| +// Apple deliberately KEEPS its own routes, env vars, and store rows |
| +// (/api/siwa/*, LCHAT_HOME_SIWA_*, siwa_anchors) — it shipped first and is |
| +// wire-compatible with deployed clients; its verifier now delegates to the |
| +// shared core here, so there is still exactly one verification path. |
| +// |
| +// Issuer quirks, the one place the providers genuinely differ: |
| +// - Google's issuer is https://accounts.google.com, but Google documents a |
| +// legacy form without the scheme — both are accepted, and both key the |
| +// same account (the sub, not the issuer spelling, names the person). |
| +// - Microsoft's v2.0 "common" endpoint (consumer AND work accounts) mints |
| +// a PER-TENANT issuer — https://login.microsoftonline.com/{tenantid}/v2.0 |
| +// — so no single string can be pinned. We accept that pattern, and |
| +// conservatively require the token's `tid` claim (always present in v2.0 |
| +// tokens) to equal the issuer's tenant segment: a mismatch is a forgery |
| +// tell, not a variant to tolerate. |
| +// |
| +// Account keying is HMAC(pepper, sub) with a pepper PER PROVIDER |
| +// (LCHAT_HOME_GOOGLE_PEPPER, LCHAT_HOME_MICROSOFT_PEPPER) — hashes can never |
| +// collide across providers even for equal subs — and the store carries the |
| +// provider name beside the hash (oidc_anchors rows; oidc/<provider>/<hash> |
| +// pointers on S3), mirroring siwa_anchors exactly. Like the SiwA pepper, |
| +// these are DURABLE: they name accounts, so there is no ephemeral fallback — |
| +// unset means that door is off. Each provider gates exactly like SiwA: |
| +// client ids + pepper both set → on; neither → its endpoints answer 404. |
| + |
| +import ( |
| + "crypto" |
| + "crypto/hmac" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "math/big" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "sync" |
| + "time" |
| +) |
| + |
| +const ( |
| + googleJWKSURL = "https://www.googleapis.com/oauth2/v3/certs" |
| + googleIssuer = "https://accounts.google.com" |
| + |
| + microsoftJWKSURL = "https://login.microsoftonline.com/common/discovery/v2.0/keys" |
| + // microsoftIssuerPrefix/Suffix frame the v2.0 issuer template, |
| + // https://login.microsoftonline.com/{tenantid}/v2.0 — see the header |
| + // comment for why a pattern, not a pinned string. |
| + microsoftIssuerPrefix = "https://login.microsoftonline.com/" |
| + microsoftIssuerSuffix = "/v2.0" |
| + |
| + // oidcReturnScheme is where the web-flow trampoline bounces the token for |
| + // native apps — the sibling of siwa.go's elevenmessenger://home-siwa. |
| + oidcReturnScheme = "elevenmessenger://home-oidc" |
| +) |
| + |
| +// oidcProviderNames is the fixed set of table rows — the store kinds that |
| +// exist regardless of which doors are currently enabled (anchors of a |
| +// disabled provider still list and remove in /api/anchors, exactly as siwa |
| +// anchors do with SiwA off). Apple is deliberately absent: it stays on its |
| +// own routes and its own siwa_anchors rows. |
| +var oidcProviderNames = []string{"google", "microsoft"} |
| + |
| +var oidcHTTP = &http.Client{Timeout: 10 * time.Second} |
| + |
| +// oidcProvider is one table row: everything that differs between the doors. |
| +type oidcProvider struct { |
| + name string // route + store name ("google"); element of oidcProviderNames |
| + display string // user-facing copy ("Google") |
| + // checkIssuer validates the token's iss claim (and, for Microsoft, its |
| + // tid consistency). Everything else about verification is shared. |
| + checkIssuer func(c oidcClaims) error |
| + clientIDs []string |
| + pepper []byte |
| + jwks *jwksCache |
| +} |
| + |
| +// newOIDCProvider builds the table row for a known provider name; nil for an |
| +// unknown one (main fails fast — the names come from the env-parsing table). |
| +func newOIDCProvider(name string, clientIDs []string, pepper []byte) *oidcProvider { |
| + switch name { |
| + case "google": |
| + return &oidcProvider{name: name, display: "Google", checkIssuer: googleIssuerOK, |
| + clientIDs: clientIDs, pepper: pepper, jwks: &jwksCache{url: googleJWKSURL}} |
| + case "microsoft": |
| + return &oidcProvider{name: name, display: "Microsoft", checkIssuer: microsoftIssuerOK, |
| + clientIDs: clientIDs, pepper: pepper, jwks: &jwksCache{url: microsoftJWKSURL}} |
| + } |
| + return nil |
| +} |
| + |
| +// googleIssuerOK accepts Google's issuer in both documented spellings. |
| +func googleIssuerOK(c oidcClaims) error { |
| + if c.Iss != googleIssuer && c.Iss != "accounts.google.com" { |
| + return errors.New("wrong issuer") |
| + } |
| + return nil |
| +} |
| + |
| +// microsoftIssuerOK accepts the v2.0 issuer template and requires the tid |
| +// claim to match the issuer's tenant segment (see the header comment). |
| +func microsoftIssuerOK(c oidcClaims) error { |
| + tenant, ok := strings.CutPrefix(c.Iss, microsoftIssuerPrefix) |
| + if ok { |
| + tenant, ok = strings.CutSuffix(tenant, microsoftIssuerSuffix) |
| + } |
| + if !ok || tenant == "" || strings.Contains(tenant, "/") { |
| + return errors.New("wrong issuer") |
| + } |
| + if c.Tid == "" || c.Tid != tenant { |
| + return errors.New("tenant mismatch") |
| + } |
| + return nil |
| +} |
| + |
| +func (p *oidcProvider) subHash(sub string) string { |
| + m := hmac.New(sha256.New, p.pepper) |
| + m.Write([]byte(sub)) |
| + return hex.EncodeToString(m.Sum(nil)) |
| +} |
| + |
| +// jwksCache holds a provider's signing keys by kid. Unknown kids trigger a |
| +// refetch (key rotation), floored at one fetch a minute so a garbage kid |
| +// can't become a fetch amplifier. (Moved verbatim from siwa.go — one cache |
| +// per provider, Apple's included.) |
| +type jwksCache struct { |
| + url string |
| + |
| + mu sync.Mutex |
| + keys map[string]*rsa.PublicKey |
| + fetched time.Time |
| +} |
| + |
| +func (c *jwksCache) key(kid string) (*rsa.PublicKey, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if k, ok := c.keys[kid]; ok { |
| + return k, nil |
| + } |
| + if c.keys != nil && time.Since(c.fetched) < time.Minute { |
| + return nil, errors.New("unknown key id") |
| + } |
| + resp, err := oidcHTTP.Get(c.url) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return nil, errors.New("jwks fetch failed") |
| + } |
| + var doc struct { |
| + Keys []struct { |
| + Kty string `json:"kty"` |
| + Kid string `json:"kid"` |
| + N string `json:"n"` |
| + E string `json:"e"` |
| + } `json:"keys"` |
| + } |
| + if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 64<<10)).Decode(&doc); err != nil { |
| + return nil, err |
| + } |
| + keys := make(map[string]*rsa.PublicKey, len(doc.Keys)) |
| + for _, k := range doc.Keys { |
| + if k.Kty != "RSA" { |
| + continue |
| + } |
| + nb, err := base64.RawURLEncoding.DecodeString(k.N) |
| + if err != nil { |
| + continue |
| + } |
| + eb, err := base64.RawURLEncoding.DecodeString(k.E) |
| + if err != nil { |
| + continue |
| + } |
| + keys[k.Kid] = &rsa.PublicKey{ |
| + N: new(big.Int).SetBytes(nb), |
| + E: int(new(big.Int).SetBytes(eb).Int64()), |
| + } |
| + } |
| + c.keys, c.fetched = keys, time.Now() |
| + if k, ok := keys[kid]; ok { |
| + return k, nil |
| + } |
| + return nil, errors.New("unknown key id") |
| +} |
| + |
| +// oidcClaims is the decoded claim set shared verification reads. Aud stays |
| +// raw (string or []string, per RFC 7519); Tid is Microsoft's tenant id, |
| +// absent elsewhere. |
| +type oidcClaims struct { |
| + Iss string `json:"iss"` |
| + Aud json.RawMessage `json:"aud"` |
| + Exp int64 `json:"exp"` |
| + Sub string `json:"sub"` |
| + Nonce string `json:"nonce"` |
| + Tid string `json:"tid"` |
| +} |
| + |
| +// verifyIDToken checks an OIDC identity token end to end — RS256 signature |
| +// against the provider's JWKS, then issuer (via checkIssuer), expiry, |
| +// audience (one of the provider's client ids), and the nonce when the caller |
| +// bound one — and returns the provider's stable `sub`. This IS the SiwA |
| +// verifier, generalized verbatim; the check order is unchanged (signature → |
| +// issuer → expiry → audience → nonce → sub), and it stays hand-rolled |
| +// (split/decode/RSA-verify, the apns.go style) rather than a JWT dependency: |
| +// ~sixty lines a person can read whole. |
| +func verifyIDToken(jwks *jwksCache, token string, checkIssuer func(oidcClaims) error, clientIDs []string, nonce string, now time.Time) (string, error) { |
| + parts := strings.Split(token, ".") |
| + if len(parts) != 3 { |
| + return "", errors.New("malformed token") |
| + } |
| + headB, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| + if err != nil { |
| + return "", errors.New("malformed header") |
| + } |
| + var head struct { |
| + Alg string `json:"alg"` |
| + Kid string `json:"kid"` |
| + } |
| + if err := json.Unmarshal(headB, &head); err != nil || head.Alg != "RS256" { |
| + return "", errors.New("unsupported algorithm") |
| + } |
| + key, err := jwks.key(head.Kid) |
| + if err != nil { |
| + return "", err |
| + } |
| + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| + if err != nil { |
| + return "", errors.New("malformed signature") |
| + } |
| + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) |
| + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig); err != nil { |
| + return "", errors.New("bad signature") |
| + } |
| + claimsB, err := base64.RawURLEncoding.DecodeString(parts[1]) |
| + if err != nil { |
| + return "", errors.New("malformed claims") |
| + } |
| + var claims oidcClaims |
| + if err := json.Unmarshal(claimsB, &claims); err != nil { |
| + return "", errors.New("malformed claims") |
| + } |
| + if err := checkIssuer(claims); err != nil { |
| + return "", err |
| + } |
| + if now.Unix() >= claims.Exp { |
| + return "", errors.New("expired") |
| + } |
| + var auds []string |
| + var one string |
| + if json.Unmarshal(claims.Aud, &one) == nil { |
| + auds = []string{one} |
| + } else if json.Unmarshal(claims.Aud, &auds) != nil { |
| + return "", errors.New("malformed audience") |
| + } |
| + okAud := false |
| + for _, a := range auds { |
| + for _, c := range clientIDs { |
| + if a == c { |
| + okAud = true |
| + } |
| + } |
| + } |
| + if !okAud { |
| + return "", errors.New("wrong audience") |
| + } |
| + if nonce != "" && claims.Nonce != nonce { |
| + return "", errors.New("nonce mismatch") |
| + } |
| + if claims.Sub == "" { |
| + return "", errors.New("no subject") |
| + } |
| + return claims.Sub, nil |
| +} |
| + |
| +// oidcVerify is POST /api/oidc/{provider}/verify — the same one-verifier |
| +// contract as /api/siwa/verify, per table row. Sign-in (default): |
| +// resolve-or-create the account behind the peppered sub hash and answer |
| +// exactly like finishCeremony (token + proof). Link (`"link":true` + |
| +// X-Ceremony-Proof): attach the anchor to the proof's existing account, the |
| +// same freshness bar as adding a passkey. An unknown or disabled provider is |
| +// a 404 — an off door and a nonexistent one are indistinguishable, like SiwA. |
| +func (s *Server) oidcVerify(w http.ResponseWriter, r *http.Request) { |
| + p := s.oidc[r.PathValue("provider")] |
| + if p == nil { |
| + http.Error(w, "not found", http.StatusNotFound) |
| + return |
| + } |
| + var req struct { |
| + IdentityToken string `json:"identity_token"` |
| + Nonce string `json:"nonce"` |
| + Link bool `json:"link"` |
| + } |
| + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { |
| + http.Error(w, "bad request", http.StatusBadRequest) |
| + return |
| + } |
| + sub, err := verifyIDToken(p.jwks, req.IdentityToken, p.checkIssuer, p.clientIDs, req.Nonce, time.Now()) |
| + if err != nil { |
| + http.Error(w, "that sign-in couldn't be confirmed", http.StatusUnauthorized) |
| + return |
| + } |
| + subHash := p.subHash(sub) |
| + |
| + if req.Link { |
| + uid, ok := s.ceremonyUser(r) |
| + if !ok { |
| + http.Error(w, "confirm it's you first", http.StatusUnauthorized) |
| + return |
| + } |
| + conflict, err := s.store.insertOidcAnchor(uid, p.name, subHash) |
| + if err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + if conflict { |
| + http.Error(w, "that "+p.display+" account already signs in somewhere else", http.StatusConflict) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"linked": true}) |
| + return |
| + } |
| + |
| + uid, ok := s.store.oidcUser(p.name, subHash) |
| + created := false |
| + if !ok { |
| + // The seamless front door, exactly as SiwA: an unknown identity |
| + // becomes a fresh account on the spot — no separate registration |
| + // ceremony. The vault starts empty; the client PUTs the first blob. |
| + id, err := randToken() |
| + if err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + if err := s.store.createUser(id); err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + if _, err := s.store.insertOidcAnchor(id, p.name, subHash); err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + uid, created = id, true |
| + } |
| + tok, err := s.store.createLinkToken(uid) |
| + if err != nil { |
| + http.Error(w, "db error", http.StatusInternalServerError) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "token": tok, |
| + "proof": s.keys.mintCeremonyProof(uid), |
| + "created": created, |
| + }) |
| +} |
| + |
| +// oidcReturn is POST /oidc/return — where a provider's web flow form_posts, |
| +// mirroring /siwa/return exactly: a TRAMPOLINE, nothing more. It bounces the |
| +// fields onward without verifying anything (verifying here would mean two |
| +// verifiers) and stays provider-blind — the page or app minted the state, so |
| +// it already knows which door it opened; the fragment keeps the token out of |
| +// request logs. Gated on any door being open, like siwaReturn on SiwA. |
| +func (s *Server) oidcReturn(w http.ResponseWriter, r *http.Request) { |
| + if len(s.oidc) == 0 { |
| + http.Error(w, "not found", http.StatusNotFound) |
| + return |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) |
| + if err := r.ParseForm(); err != nil { |
| + http.Error(w, "bad request", http.StatusBadRequest) |
| + return |
| + } |
| + idToken := r.PostFormValue("id_token") |
| + state := r.PostFormValue("state") |
| + if idToken == "" || len(idToken) > 8192 || len(state) > 512 { |
| + http.Error(w, "bad request", http.StatusBadRequest) |
| + return |
| + } |
| + // A "web."-prefixed state was minted by the home page itself: bounce back |
| + // to the page — a relative redirect, same origin — and it picks the |
| + // fragment up on load. Every other state is a native app's |
| + // ASWebAuthenticationSession, which intercepts the custom scheme. |
| + if strings.HasPrefix(state, "web.") { |
| + http.Redirect(w, r, "/#oidc="+url.QueryEscape(idToken)+ |
| + "&state="+url.QueryEscape(state), http.StatusSeeOther) |
| + return |
| + } |
| + http.Redirect(w, r, oidcReturnScheme+"#state="+url.QueryEscape(state)+ |
| + "&id_token="+url.QueryEscape(idToken), http.StatusSeeOther) |
| +} |
| + |
| +// splitCommaList parses a comma-separated env list, trimming blanks. |
| +func splitCommaList(v string) []string { |
| + var out []string |
| + for _, c := range strings.Split(v, ",") { |
| + if c = strings.TrimSpace(c); c != "" { |
| + out = append(out, c) |
| + } |
| + } |
| + return out |
| +} |
diff --git a/home/oidc_test.go b/home/oidc_test.go| new file mode 100644 |
| index 0000000..5942a10 |
| --- /dev/null |
| +++ b/home/oidc_test.go |
| @@ -0,0 +1,359 @@ |
| +package main |
| + |
| +// 🤖 OIDC provider-table tests (oidc.go, issue #1112): the Google and |
| +// Microsoft doors against a fake IdP — the fakeApple rig from siwa_test.go, |
| +// which is provider-agnostic already (one JWKS httptest server + self-minted |
| +// RS256 tokens); these helpers just re-default the claims per door. Apple's |
| +// own suite (siwa_test.go) is the proof the shared core didn't move under |
| +// it — nothing here re-tests /api/siwa/*. |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// msTenant is Microsoft's consumer-accounts tenant id — what a personal |
| +// account's v2.0 token carries in both the issuer and tid. |
| +const msTenant = "9188040d-6c67-4c5b-b112-36a304b66dad" |
| + |
| +func googleToken(t *testing.T, idp *fakeApple, overrides map[string]any) string { |
| + t.Helper() |
| + claims := map[string]any{"iss": googleIssuer, "aud": "google-client-1", "sub": "google-sub-1"} |
| + for k, v := range overrides { |
| + claims[k] = v |
| + } |
| + return idp.token(t, claims) |
| +} |
| + |
| +func microsoftToken(t *testing.T, idp *fakeApple, overrides map[string]any) string { |
| + t.Helper() |
| + claims := map[string]any{ |
| + "iss": microsoftIssuerPrefix + msTenant + microsoftIssuerSuffix, |
| + "tid": msTenant, |
| + "aud": "microsoft-client-1", |
| + "sub": "microsoft-sub-1", |
| + } |
| + for k, v := range overrides { |
| + claims[k] = v |
| + } |
| + return idp.token(t, claims) |
| +} |
| + |
| +// oidcServer is newTestServer with both provider doors enabled against one |
| +// fake IdP (each door keeps its OWN jwksCache, as in production — they just |
| +// share the test JWKS URL). |
| +func oidcServer(t *testing.T) (*httptest.Server, *Server, *fakeApple) { |
| + t.Helper() |
| + ts, s := newTestServer(t) |
| + idp := newFakeApple(t) |
| + s.oidc = map[string]*oidcProvider{} |
| + for _, name := range oidcProviderNames { |
| + p := newOIDCProvider(name, []string{name + "-client-1", name + "-client-2"}, []byte(name+"-pepper")) |
| + p.jwks = &jwksCache{url: idp.ts.URL} |
| + s.oidc[name] = p |
| + } |
| + return ts, s, idp |
| +} |
| + |
| +func oidcVerifyReq(t *testing.T, ts *httptest.Server, provider string, headers map[string]string, body map[string]any) *http.Response { |
| + t.Helper() |
| + b, _ := json.Marshal(body) |
| + return do(t, "POST", ts.URL+"/api/oidc/"+provider+"/verify", "", headers, b) |
| +} |
| + |
| +func TestOidcDisabledIs404(t *testing.T) { |
| + // No table at all: every door — and the trampoline — answers 404. |
| + ts, _ := newTestServer(t) |
| + for _, provider := range append([]string(nil), oidcProviderNames...) { |
| + resp := oidcVerifyReq(t, ts, provider, nil, map[string]any{"identity_token": "x"}) |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("disabled %s: got %d, want 404", provider, resp.StatusCode) |
| + } |
| + } |
| + if resp := do(t, "POST", ts.URL+"/oidc/return", "", nil, nil); resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("disabled /oidc/return: got %d, want 404", resp.StatusCode) |
| + } |
| + |
| + // One door on, the other off: gating is per provider, and an unknown |
| + // provider name — Apple included, it lives on /api/siwa/* — is the same |
| + // indistinguishable 404. |
| + ts2, s2, idp := oidcServer(t) |
| + delete(s2.oidc, "microsoft") |
| + if resp := oidcVerifyReq(t, ts2, "microsoft", nil, map[string]any{"identity_token": microsoftToken(t, idp, nil)}); resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("disabled microsoft beside enabled google: got %d, want 404", resp.StatusCode) |
| + } |
| + for _, provider := range []string{"apple", "carrier-pigeon"} { |
| + if resp := oidcVerifyReq(t, ts2, provider, nil, map[string]any{"identity_token": "x"}); resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("unknown provider %q: got %d, want 404", provider, resp.StatusCode) |
| + } |
| + } |
| + if resp := oidcVerifyReq(t, ts2, "google", nil, map[string]any{"identity_token": googleToken(t, idp, nil)}); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("enabled google: got %d, want 200", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestOidcSignInCreatesThenResolvesAccount(t *testing.T) { |
| + ts, _, idp := oidcServer(t) |
| + for _, tc := range []struct { |
| + provider string |
| + token, second string |
| + }{ |
| + {"google", googleToken(t, idp, nil), |
| + // Google's legacy no-scheme issuer form must resolve the SAME |
| + // account: the sub, not the issuer spelling, names the person. |
| + googleToken(t, idp, map[string]any{"iss": "accounts.google.com"})}, |
| + {"microsoft", microsoftToken(t, idp, nil), microsoftToken(t, idp, nil)}, |
| + } { |
| + t.Run(tc.provider, func(t *testing.T) { |
| + resp := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.token}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("first sign-in: got %d", resp.StatusCode) |
| + } |
| + var first struct { |
| + Token string `json:"token"` |
| + Proof string `json:"proof"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&first); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if first.Token == "" || first.Proof == "" || !first.Created { |
| + t.Fatalf("first sign-in: token=%q proof=%q created=%v", first.Token, first.Proof, first.Created) |
| + } |
| + |
| + // The minted token is a real device link token: it writes the vault. |
| + put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed-"+tc.provider)) |
| + if put.StatusCode != http.StatusOK { |
| + t.Fatalf("blob put: got %d", put.StatusCode) |
| + } |
| + |
| + // Second sign-in, same sub: the SAME account (its blob is readable). |
| + resp2 := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.second}) |
| + var second struct { |
| + Token string `json:"token"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp2.Body).Decode(&second); err != nil { |
| + t.Fatalf("decode second: %v", err) |
| + } |
| + if second.Created { |
| + t.Fatalf("second sign-in claims a fresh account") |
| + } |
| + get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil) |
| + if b, _ := io.ReadAll(get.Body); string(b) != "sealed-"+tc.provider { |
| + t.Fatalf("second sign-in reads a different vault: %q", b) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestOidcRejectsBadTokens(t *testing.T) { |
| + ts, s, idp := oidcServer(t) |
| + // The matrix below fires more requests than the shared per-IP burst (10) |
| + // allows; widen it — the limiter has its own tests, this one is about |
| + // verification. |
| + s.limiter = newRateLimiter(1000, 1000) |
| + cases := map[string]struct { |
| + provider string |
| + token string |
| + }{ |
| + "google wrong issuer": {"google", googleToken(t, idp, map[string]any{"iss": appleIssuer})}, |
| + "google wrong audience": {"google", googleToken(t, idp, map[string]any{"aud": "com.other.app"})}, |
| + "google expired": {"google", googleToken(t, idp, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})}, |
| + "google empty sub": {"google", googleToken(t, idp, map[string]any{"sub": ""})}, |
| + "google garbage": {"google", "not.a.jwt"}, |
| + // A provider's token never opens another provider's door: the aud |
| + // (and, for microsoft, the issuer) belongs to the wrong row. |
| + "google token at microsoft door": {"microsoft", googleToken(t, idp, nil)}, |
| + |
| + "microsoft apple issuer": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": appleIssuer})}, |
| + "microsoft empty tenant": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": microsoftIssuerPrefix + microsoftIssuerSuffix[1:]})}, |
| + "microsoft v1 issuer": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": "https://sts.windows.net/" + msTenant + "/"})}, |
| + "microsoft slash tenant": {"microsoft", microsoftToken(t, idp, map[string]any{"iss": microsoftIssuerPrefix + "a/b" + microsoftIssuerSuffix})}, |
| + "microsoft tid mismatch": {"microsoft", microsoftToken(t, idp, map[string]any{"tid": "11111111-2222-3333-4444-555555555555"})}, |
| + "microsoft missing tid": {"microsoft", microsoftToken(t, idp, map[string]any{"tid": ""})}, |
| + "microsoft wrong aud": {"microsoft", microsoftToken(t, idp, map[string]any{"aud": "com.other.app"})}, |
| + "microsoft expired": {"microsoft", microsoftToken(t, idp, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()})}, |
| + } |
| + for name, tc := range cases { |
| + resp := oidcVerifyReq(t, ts, tc.provider, nil, map[string]any{"identity_token": tc.token}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Errorf("%s: got %d, want 401", name, resp.StatusCode) |
| + } |
| + } |
| + // Tampered signature: valid token, one flipped payload byte — per door. |
| + for _, provider := range oidcProviderNames { |
| + tok := googleToken(t, idp, nil) |
| + if provider == "microsoft" { |
| + tok = microsoftToken(t, idp, nil) |
| + } |
| + tampered := tok[:len(tok)-40] + "AAAA" + tok[len(tok)-36:] |
| + resp := oidcVerifyReq(t, ts, provider, nil, map[string]any{"identity_token": tampered}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Errorf("%s tampered: got %d, want 401", provider, resp.StatusCode) |
| + } |
| + } |
| +} |
| + |
| +func TestOidcNonceBinding(t *testing.T) { |
| + ts, _, idp := oidcServer(t) |
| + tok := googleToken(t, idp, map[string]any{"nonce": "expected-nonce"}) |
| + resp := oidcVerifyReq(t, ts, "google", nil, map[string]any{"identity_token": tok, "nonce": "expected-nonce"}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("matching nonce: got %d", resp.StatusCode) |
| + } |
| + resp = oidcVerifyReq(t, ts, "google", nil, map[string]any{"identity_token": tok, "nonce": "different"}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("mismatched nonce: got %d, want 401", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestOidcLinkToExistingAccount(t *testing.T) { |
| + ts, s, idp := oidcServer(t) |
| + uid, token := seedUser(t, s) |
| + proof := s.keys.mintCeremonyProof(uid) |
| + |
| + // Link needs a fresh ceremony proof — same bar as adding a passkey. |
| + resp := oidcVerifyReq(t, ts, "google", nil, map[string]any{ |
| + "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("link without proof: got %d, want 401", resp.StatusCode) |
| + } |
| + resp = oidcVerifyReq(t, ts, "google", map[string]string{"X-Ceremony-Proof": proof}, map[string]any{ |
| + "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("link with proof: got %d", resp.StatusCode) |
| + } |
| + |
| + // Signing in with that Google account now lands in the seeded account: |
| + // its vault is the one the seeded link token wrote. |
| + put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded-vault")) |
| + if put.StatusCode != http.StatusOK { |
| + t.Fatalf("seed blob: got %d", put.StatusCode) |
| + } |
| + resp = oidcVerifyReq(t, ts, "google", nil, map[string]any{ |
| + "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"})}) |
| + var signin struct { |
| + Token string `json:"token"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if signin.Created { |
| + t.Fatalf("sign-in after link created a fresh account") |
| + } |
| + get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil) |
| + if b, _ := io.ReadAll(get.Body); string(b) != "seeded-vault" { |
| + t.Fatalf("linked sign-in reads a different vault: %q", b) |
| + } |
| + |
| + // The same Google account cannot anchor a SECOND account. |
| + uid2, _ := seedUser(t, s) |
| + proof2 := s.keys.mintCeremonyProof(uid2) |
| + resp = oidcVerifyReq(t, ts, "google", map[string]string{"X-Ceremony-Proof": proof2}, map[string]any{ |
| + "identity_token": googleToken(t, idp, map[string]any{"sub": "link-sub"}), "link": true}) |
| + if resp.StatusCode != http.StatusConflict { |
| + t.Fatalf("second-account link: got %d, want 409", resp.StatusCode) |
| + } |
| + |
| + // Cross-provider isolation: the SAME sub string through the Microsoft |
| + // door is a different identity entirely (different provider, different |
| + // pepper) — it mints a fresh account, never the linked one. |
| + resp = oidcVerifyReq(t, ts, "microsoft", nil, map[string]any{ |
| + "identity_token": microsoftToken(t, idp, map[string]any{"sub": "link-sub"})}) |
| + var ms struct { |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&ms); err != nil { |
| + t.Fatalf("decode microsoft: %v", err) |
| + } |
| + if !ms.Created { |
| + t.Fatalf("microsoft door with a google-linked sub resolved an existing account") |
| + } |
| +} |
| + |
| +func TestOidcReturnTrampoline(t *testing.T) { |
| + ts, _, _ := oidcServer(t) |
| + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { |
| + return http.ErrUseLastResponse |
| + }} |
| + // A "web."-prefixed state (minted by the home page) bounces back to the |
| + // page itself — token in the fragment, provider known to the page already |
| + // (it minted the state when it opened the door). |
| + resp, err := client.PostForm(ts.URL+"/oidc/return", map[string][]string{ |
| + "id_token": {"tok.en.value"}, "state": {"web.abc123"}}) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusSeeOther { |
| + t.Fatalf("web trampoline: got %d, want 303", resp.StatusCode) |
| + } |
| + if loc, want := resp.Header.Get("Location"), "/#oidc=tok.en.value&state=web.abc123"; loc != want { |
| + t.Fatalf("web trampoline location: got %q, want %q", loc, want) |
| + } |
| + // Any other state is a native app's ASWebAuthenticationSession, which |
| + // intercepts the custom scheme. |
| + resp, err = client.PostForm(ts.URL+"/oidc/return", map[string][]string{ |
| + "id_token": {"tok.en.value"}, "state": {"native state"}}) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + if loc, want := resp.Header.Get("Location"), oidcReturnScheme+"#state=native+state&id_token=tok.en.value"; loc != want { |
| + t.Fatalf("native trampoline location: got %q, want %q", loc, want) |
| + } |
| +} |
| + |
| +func TestAnchorsListingIncludesOidcKinds(t *testing.T) { |
| + ts, s := newTestServer(t) |
| + uid, tok := seedUser(t, s) |
| + if _, err := s.store.insertOidcAnchor(uid, "google", "google-hash-1"); err != nil { |
| + t.Fatalf("insertOidcAnchor google: %v", err) |
| + } |
| + if _, err := s.store.insertOidcAnchor(uid, "microsoft", "microsoft-hash-1"); err != nil { |
| + t.Fatalf("insertOidcAnchor microsoft: %v", err) |
| + } |
| + |
| + // The listing carries the new kinds beside the existing ones — note the |
| + // doors are DISABLED on this server (no s.oidc): stored anchors still |
| + // list and remove, exactly as siwa anchors do with SiwA off. |
| + anchors := listAnchors(t, ts.URL, tok) |
| + kinds := map[string]string{} |
| + for _, a := range anchors { |
| + kinds[a.Kind] = a.ID |
| + if a.CreatedAt == 0 { |
| + t.Fatalf("%s created_at missing: %+v", a.Kind, a) |
| + } |
| + } |
| + if len(anchors) != 3 || kinds["google"] != "google-hash-1" || kinds["microsoft"] != "microsoft-hash-1" { |
| + t.Fatalf("listing kinds: %+v", anchors) |
| + } |
| + |
| + // Removal routes through the same ceremony-gated door… |
| + proof := s.keys.mintCeremonyProof(uid) |
| + if r := do(t, "DELETE", ts.URL+"/api/anchors/google/google-hash-1", tok, nil, nil); r.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("delete with link token: want 401, got %d", r.StatusCode) |
| + } |
| + if r := do(t, "DELETE", ts.URL+"/api/anchors/google/google-hash-1", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK { |
| + t.Fatalf("delete google anchor: want 200, got %d", r.StatusCode) |
| + } |
| + if _, ok := s.store.oidcUser("google", "google-hash-1"); ok { |
| + t.Fatalf("removed google anchor still resolves") |
| + } |
| + |
| + // …and the last-method guard counts the new kinds: the passkey may go |
| + // while the microsoft anchor holds the account, and then that anchor — |
| + // the last way in — is refused. |
| + creds, _ := s.store.credentialsFor(uid) |
| + credID := b64CredID(string(creds[0].ID)) |
| + if r := do(t, "DELETE", ts.URL+"/api/anchors/passkey/"+credID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK { |
| + t.Fatalf("shed passkey beside oidc anchor: want 200, got %d", r.StatusCode) |
| + } |
| + if r := do(t, "DELETE", ts.URL+"/api/anchors/microsoft/microsoft-hash-1", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("delete last oidc anchor: want 409, got %d", r.StatusCode) |
| + } |
| +} |
diff --git a/home/s3store.go b/home/s3store.go| index 24ec44b..b7c47f2 100644 |
| --- a/home/s3store.go |
| +++ b/home/s3store.go |
| @@ -186,6 +186,13 @@ func (s *s3Store) tokenKey(h string) string { return s.prefix + "tokens/" + h } |
| func (s *s3Store) siwaKey(h string) string { return s.prefix + "siwa/" + h } |
| func (s *s3Store) emailKey(h string) string { return s.prefix + "emails/" + h } |
| +// oidcKey namespaces per provider — the pointer-prefix twin of the |
| +// oidc_anchors (provider, sub_hash) key. provider is always an in-code name |
| +// (oidcProviderNames), never input. |
| +func (s *s3Store) oidcKey(provider, h string) string { |
| + return s.prefix + "oidc/" + provider + "/" + h |
| +} |
| + |
| // ---- the blob ----------------------------------------------------------------- |
| func (s *s3Store) getBlob(uid string) ([]byte, string, bool, error) { |
| @@ -369,6 +376,11 @@ type s3UserDoc struct { |
| SiwaAnchors []s3SiwaAnchor `json:"siwa_anchors,omitempty"` |
| // EmailAnchors mirrors SiwaAnchors for the email blind indexes. |
| EmailAnchors []s3EmailAnchor `json:"email_anchors,omitempty"` |
| + // OidcAnchors mirrors SiwaAnchors for the newer provider doors (google, |
| + // microsoft — oidc.go), with the provider name beside each hash; the |
| + // pointer half lives at oidc/<provider>/<hash>. Apple stays in |
| + // SiwaAnchors. |
| + OidcAnchors []s3OidcAnchor `json:"oidc_anchors,omitempty"` |
| } |
| // s3Credential wraps webauthn.Credential with its enrolment time — the |
| @@ -391,6 +403,12 @@ type s3EmailAnchor struct { |
| CreatedAt int64 `json:"created_at"` |
| } |
| +type s3OidcAnchor struct { |
| + Provider string `json:"provider"` |
| + SubHash string `json:"sub_hash"` |
| + CreatedAt int64 `json:"created_at"` |
| +} |
| + |
| type s3Device struct { |
| ID string `json:"id"` |
| TokenHash string `json:"token_hash"` |
| @@ -660,7 +678,7 @@ func (s *s3Store) deleteCredential(uid, credID string) (deleted, last bool, err |
| // signInMethodsDoc counts every way into the account within one loaded user |
| // doc — the doc-CAS twin of SQLite's in-transaction signInMethods. |
| func signInMethodsDoc(doc *s3UserDoc) int { |
| - return len(doc.Credentials) + len(doc.SiwaAnchors) + len(doc.EmailAnchors) |
| + return len(doc.Credentials) + len(doc.SiwaAnchors) + len(doc.EmailAnchors) + len(doc.OidcAnchors) |
| } |
| // ---- device link tokens -------------------------------------------------------- |
| @@ -901,6 +919,96 @@ func (s *s3Store) deleteEmailAnchor(uid, index string) (deleted, last bool, err |
| return deleted, last, err |
| } |
| +// The OIDC anchor methods (oidc.go's provider table) mirror the siwa set |
| +// exactly — pointer plus doc entry, pointer never decides, stale-pointer |
| +// sweep on insert, one-CAS last-method guard on delete — with the provider |
| +// name carried beside the hash in both halves. |
| + |
| +func (s *s3Store) oidcUser(provider, subHash string) (string, bool) { |
| + uid, found, err := s.getPointer(s.oidcKey(provider, subHash)) |
| + if err != nil || !found { |
| + return "", false |
| + } |
| + doc, _, found, err := s.loadUser(uid) |
| + if err != nil || !found { |
| + return "", false |
| + } |
| + for _, a := range doc.OidcAnchors { |
| + if a.Provider == provider && a.SubHash == subHash { |
| + return uid, true |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +func (s *s3Store) insertOidcAnchor(uid, provider, subHash string) (bool, error) { |
| + resolve := func(h string) (string, bool) { return s.oidcUser(provider, h) } |
| + if err := s.sweepAnchorPointer(s.oidcKey(provider, subHash), uid, resolve, subHash); err != nil { |
| + if err == errAnchorTaken { |
| + return true, nil |
| + } |
| + return false, err |
| + } |
| + if err := s.putPointer(s.oidcKey(provider, subHash), uid); err != nil { |
| + return false, err |
| + } |
| + now := time.Now().Unix() |
| + err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { |
| + for _, a := range doc.OidcAnchors { |
| + if a.Provider == provider && a.SubHash == subHash { |
| + return false, nil // already listed — idempotent |
| + } |
| + } |
| + doc.OidcAnchors = append(doc.OidcAnchors, s3OidcAnchor{Provider: provider, SubHash: subHash, CreatedAt: now}) |
| + return true, nil |
| + }) |
| + if err != nil { |
| + return false, err |
| + } |
| + return false, nil |
| +} |
| + |
| +func (s *s3Store) listOidcAnchors(uid, provider string) ([]Anchor, error) { |
| + doc, _, _, err := s.loadUser(uid) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := []Anchor{} |
| + for _, a := range doc.OidcAnchors { |
| + if a.Provider == provider { |
| + out = append(out, Anchor{ID: a.SubHash, CreatedAt: a.CreatedAt}) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +func (s *s3Store) deleteOidcAnchor(uid, provider, subHash string) (deleted, last bool, err error) { |
| + err = s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { |
| + deleted, last = false, false |
| + idx := -1 |
| + for i, a := range doc.OidcAnchors { |
| + if a.Provider == provider && a.SubHash == subHash { |
| + idx = i |
| + break |
| + } |
| + } |
| + if idx < 0 { |
| + return false, nil |
| + } |
| + if signInMethodsDoc(doc) == 1 { |
| + last = true |
| + return false, nil |
| + } |
| + doc.OidcAnchors = append(doc.OidcAnchors[:idx], doc.OidcAnchors[idx+1:]...) |
| + deleted = true |
| + return true, nil |
| + }) |
| + if err == nil && deleted { |
| + _ = s.deletePointer(s.oidcKey(provider, subHash)) // best-effort; see deleteSiwaAnchor |
| + } |
| + return deleted, last, err |
| +} |
| + |
| // linkTokenUser resolves tokens/<hash> and verifies the device is still in |
| // the user doc (revocation = removal from the doc; the pointer never decides). |
| // last_seen bumps only when more than lastSeenGranularity stale — that |
diff --git a/home/ses.go b/home/ses.go| index 4027a51..2fbe57d 100644 |
| --- a/home/ses.go |
| +++ b/home/ses.go |
| @@ -72,7 +72,7 @@ func (m *sesMailer) send(to, subject, text string) error { |
| req.Header.Set("X-Amz-Date", now.UTC().Format("20060102T150405Z")) |
| req.Header.Set("X-Amz-Content-Sha256", hex.EncodeToString(digest[:])) |
| awsSignV4(req, m.accessKey, m.secretKey, m.region, "ses", now) |
| - resp, err := siwaHTTP.Do(req) |
| + resp, err := oidcHTTP.Do(req) |
| if err != nil { |
| return err |
| } |
diff --git a/home/siwa.go b/home/siwa.go| index 4a62ea7..3a825e2 100644 |
| --- a/home/siwa.go |
| +++ b/home/siwa.go |
| @@ -21,22 +21,21 @@ package main |
| // pepper (LCHAT_HOME_SIWA_PEPPER) must be DURABLE: unlike the session key it |
| // keys accounts, so there is no ephemeral fallback — unset means SiwA is off. |
| // Verification is hand-rolled (split/decode/RSA-verify, the apns.go style) |
| -// rather than a JWT dependency: ~sixty lines a person can read whole. |
| +// rather than a JWT dependency — and since #1112 that hand-rolled verifier is |
| +// the shared OIDC core in oidc.go (verifyIDToken + jwksCache), with Apple's |
| +// row supplying only its issuer check. Apple deliberately keeps its own |
| +// routes, env vars, and siwa_anchors store rows: deployed clients speak |
| +// /api/siwa/*, and nothing here may change on the wire. |
| import ( |
| - "crypto" |
| "crypto/hmac" |
| - "crypto/rsa" |
| "crypto/sha256" |
| - "encoding/base64" |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| - "math/big" |
| "net/http" |
| "net/url" |
| "strings" |
| - "sync" |
| "time" |
| ) |
| @@ -49,149 +48,18 @@ const ( |
| siwaReturnScheme = "elevenmessenger://home-siwa" |
| ) |
| -var siwaHTTP = &http.Client{Timeout: 10 * time.Second} |
| - |
| -// jwksCache holds Apple's signing keys by kid. Unknown kids trigger a |
| -// refetch (key rotation), floored at one fetch a minute so a garbage kid |
| -// can't become a fetch amplifier. |
| -type jwksCache struct { |
| - url string |
| - |
| - mu sync.Mutex |
| - keys map[string]*rsa.PublicKey |
| - fetched time.Time |
| -} |
| - |
| -func (c *jwksCache) key(kid string) (*rsa.PublicKey, error) { |
| - c.mu.Lock() |
| - defer c.mu.Unlock() |
| - if k, ok := c.keys[kid]; ok { |
| - return k, nil |
| - } |
| - if c.keys != nil && time.Since(c.fetched) < time.Minute { |
| - return nil, errors.New("unknown key id") |
| - } |
| - resp, err := siwaHTTP.Get(c.url) |
| - if err != nil { |
| - return nil, err |
| - } |
| - defer resp.Body.Close() |
| - if resp.StatusCode != http.StatusOK { |
| - return nil, errors.New("jwks fetch failed") |
| - } |
| - var doc struct { |
| - Keys []struct { |
| - Kty string `json:"kty"` |
| - Kid string `json:"kid"` |
| - N string `json:"n"` |
| - E string `json:"e"` |
| - } `json:"keys"` |
| - } |
| - if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 64<<10)).Decode(&doc); err != nil { |
| - return nil, err |
| - } |
| - keys := make(map[string]*rsa.PublicKey, len(doc.Keys)) |
| - for _, k := range doc.Keys { |
| - if k.Kty != "RSA" { |
| - continue |
| - } |
| - nb, err := base64.RawURLEncoding.DecodeString(k.N) |
| - if err != nil { |
| - continue |
| - } |
| - eb, err := base64.RawURLEncoding.DecodeString(k.E) |
| - if err != nil { |
| - continue |
| - } |
| - keys[k.Kid] = &rsa.PublicKey{ |
| - N: new(big.Int).SetBytes(nb), |
| - E: int(new(big.Int).SetBytes(eb).Int64()), |
| - } |
| - } |
| - c.keys, c.fetched = keys, time.Now() |
| - if k, ok := keys[kid]; ok { |
| - return k, nil |
| - } |
| - return nil, errors.New("unknown key id") |
| -} |
| - |
| // verifySiwaToken checks an Apple identity token end to end — signature |
| // against Apple's JWKS, then issuer, audience (one of our client ids: the |
| // iOS bundle id or the web Services ID), expiry, and the nonce when the |
| -// caller bound one — and returns Apple's stable `sub`. |
| +// caller bound one — and returns Apple's stable `sub`. The mechanics live in |
| +// the shared OIDC core (oidc.go); Apple's row is just its fixed issuer. |
| func verifySiwaToken(jwks *jwksCache, token string, clientIDs []string, nonce string, now time.Time) (string, error) { |
| - parts := strings.Split(token, ".") |
| - if len(parts) != 3 { |
| - return "", errors.New("malformed token") |
| - } |
| - headB, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| - if err != nil { |
| - return "", errors.New("malformed header") |
| - } |
| - var head struct { |
| - Alg string `json:"alg"` |
| - Kid string `json:"kid"` |
| - } |
| - if err := json.Unmarshal(headB, &head); err != nil || head.Alg != "RS256" { |
| - return "", errors.New("unsupported algorithm") |
| - } |
| - key, err := jwks.key(head.Kid) |
| - if err != nil { |
| - return "", err |
| - } |
| - sig, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| - if err != nil { |
| - return "", errors.New("malformed signature") |
| - } |
| - digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) |
| - if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig); err != nil { |
| - return "", errors.New("bad signature") |
| - } |
| - claimsB, err := base64.RawURLEncoding.DecodeString(parts[1]) |
| - if err != nil { |
| - return "", errors.New("malformed claims") |
| - } |
| - var claims struct { |
| - Iss string `json:"iss"` |
| - Aud json.RawMessage `json:"aud"` // string or []string, per RFC 7519 |
| - Exp int64 `json:"exp"` |
| - Sub string `json:"sub"` |
| - Nonce string `json:"nonce"` |
| - } |
| - if err := json.Unmarshal(claimsB, &claims); err != nil { |
| - return "", errors.New("malformed claims") |
| - } |
| - if claims.Iss != appleIssuer { |
| - return "", errors.New("wrong issuer") |
| - } |
| - if now.Unix() >= claims.Exp { |
| - return "", errors.New("expired") |
| - } |
| - var auds []string |
| - var one string |
| - if json.Unmarshal(claims.Aud, &one) == nil { |
| - auds = []string{one} |
| - } else if json.Unmarshal(claims.Aud, &auds) != nil { |
| - return "", errors.New("malformed audience") |
| - } |
| - okAud := false |
| - for _, a := range auds { |
| - for _, c := range clientIDs { |
| - if a == c { |
| - okAud = true |
| - } |
| + return verifyIDToken(jwks, token, func(c oidcClaims) error { |
| + if c.Iss != appleIssuer { |
| + return errors.New("wrong issuer") |
| } |
| - } |
| - if !okAud { |
| - return "", errors.New("wrong audience") |
| - } |
| - if nonce != "" && claims.Nonce != nonce { |
| - return "", errors.New("nonce mismatch") |
| - } |
| - if claims.Sub == "" { |
| - return "", errors.New("no subject") |
| - } |
| - return claims.Sub, nil |
| + return nil |
| + }, clientIDs, nonce, now) |
| } |
| func (s *Server) siwaEnabled() bool { |
diff --git a/home/store.go b/home/store.go| index cfd42b3..06d3a4e 100644 |
| --- a/home/store.go |
| +++ b/home/store.go |
| @@ -141,6 +141,22 @@ type vaultStore interface { |
| listEmailAnchors(uid string) ([]Anchor, error) |
| deleteEmailAnchor(uid, index string) (deleted, last bool, err error) |
| + // ---- OIDC provider anchors (oidc/<provider>/<subHash> pointers) ---- |
| + // |
| + // The siwa set generalized (oidc.go): provider is a fixed in-code name |
| + // ("google", "microsoft" — oidcProviderNames), never input, and the hash |
| + // is HMAC(that provider's pepper, sub). Apple deliberately stays on the |
| + // siwa methods above — deployed data and wire behavior are untouched. |
| + // Semantics match the siwa set one for one: insert is idempotent for the |
| + // same user and conflict=true for a different one; a removed anchor's |
| + // identifier may re-anchor a different account later; delete sits under |
| + // the same last-method guard as every other sign-in method. |
| + |
| + oidcUser(provider, subHash string) (uid string, ok bool) |
| + insertOidcAnchor(uid, provider, subHash string) (conflict bool, err error) |
| + listOidcAnchors(uid, provider string) ([]Anchor, error) |
| + deleteOidcAnchor(uid, provider, subHash string) (deleted, last bool, err error) |
| + |
| // Ceremony proofs are deliberately NOT here: they became stateless HMAC |
| // tokens (stateless.go) — a store that persists nothing needs no methods. |
| } |
diff --git a/home/store_test.go b/home/store_test.go| index 348dcb3..b454f73 100644 |
| --- a/home/store_test.go |
| +++ b/home/store_test.go |
| @@ -455,6 +455,85 @@ func testVaultStore(t *testing.T, newStore func(t *testing.T) vaultStore) { |
| } |
| }) |
| + t.Run("OidcAnchors", func(t *testing.T) { |
| + // The provider-table anchors (#1112): the siwa semantics with the |
| + // provider name beside the hash — provider-scoped resolution and |
| + // listing, the same idempotence/conflict rules, the same last-method |
| + // guard, the same re-anchor-after-removal freedom. |
| + st := newStore(t) |
| + st.createUser("u1") |
| + st.insertCredential("u1", mkCred("cred-one")) |
| + if conflict, err := st.insertOidcAnchor("u1", "google", "ghash-1"); conflict || err != nil { |
| + t.Fatalf("insertOidcAnchor: conflict=%v err=%v", conflict, err) |
| + } |
| + if uid, ok := st.oidcUser("google", "ghash-1"); !ok || uid != "u1" { |
| + t.Fatalf("oidcUser: want (u1, true), got (%q, %v)", uid, ok) |
| + } |
| + // The SAME hash under the other provider is a different anchor |
| + // entirely — nothing resolves, and claiming it conflicts with no one. |
| + if _, ok := st.oidcUser("microsoft", "ghash-1"); ok { |
| + t.Fatalf("hash resolves across providers") |
| + } |
| + // Idempotent for the same user; conflict for a different one. |
| + if conflict, err := st.insertOidcAnchor("u1", "google", "ghash-1"); conflict || err != nil { |
| + t.Fatalf("re-insert: conflict=%v err=%v", conflict, err) |
| + } |
| + st.createUser("u2") |
| + if conflict, err := st.insertOidcAnchor("u2", "google", "ghash-1"); !conflict || err != nil { |
| + t.Fatalf("cross-user insert: want conflict, got conflict=%v err=%v", conflict, err) |
| + } |
| + |
| + // Listing is provider-scoped, (id, created_at) shaped. |
| + st.insertOidcAnchor("u1", "microsoft", "mhash-1") |
| + g, err := st.listOidcAnchors("u1", "google") |
| + if err != nil || len(g) != 1 || g[0].ID != "ghash-1" || g[0].CreatedAt == 0 { |
| + t.Fatalf("listOidcAnchors google: %+v err=%v", g, err) |
| + } |
| + if m, err := st.listOidcAnchors("u1", "microsoft"); err != nil || len(m) != 1 || m[0].ID != "mhash-1" { |
| + t.Fatalf("listOidcAnchors microsoft: %+v err=%v", m, err) |
| + } |
| + if rows, err := st.listOidcAnchors("u2", "google"); err != nil || len(rows) != 0 { |
| + t.Fatalf("other user's oidc anchors: %+v err=%v", rows, err) |
| + } |
| + |
| + // Unknown and cross-user (and cross-provider) deletes are (false,false). |
| + if deleted, last, err := st.deleteOidcAnchor("u1", "google", "nope"); deleted || last || err != nil { |
| + t.Fatalf("delete unknown: got (%v,%v,%v)", deleted, last, err) |
| + } |
| + if deleted, last, err := st.deleteOidcAnchor("u2", "google", "ghash-1"); deleted || last || err != nil { |
| + t.Fatalf("delete cross-user: got (%v,%v,%v)", deleted, last, err) |
| + } |
| + if deleted, last, err := st.deleteOidcAnchor("u1", "microsoft", "ghash-1"); deleted || last || err != nil { |
| + t.Fatalf("delete cross-provider: got (%v,%v,%v)", deleted, last, err) |
| + } |
| + |
| + // Three methods on u1 (passkey + google + microsoft): the google |
| + // anchor may go, stops resolving, and its identifier is genuinely |
| + // free — the same (provider, sub) may anchor a different account. |
| + if deleted, last, err := st.deleteOidcAnchor("u1", "google", "ghash-1"); !deleted || last || err != nil { |
| + t.Fatalf("delete google anchor: got (%v,%v,%v)", deleted, last, err) |
| + } |
| + if _, ok := st.oidcUser("google", "ghash-1"); ok { |
| + t.Fatalf("removed oidc anchor still resolves") |
| + } |
| + if conflict, err := st.insertOidcAnchor("u2", "google", "ghash-1"); conflict || err != nil { |
| + t.Fatalf("re-anchor removed hash: conflict=%v err=%v", conflict, err) |
| + } |
| + if uid, ok := st.oidcUser("google", "ghash-1"); !ok || uid != "u2" { |
| + t.Fatalf("re-anchored hash: want (u2, true), got (%q, %v)", uid, ok) |
| + } |
| + |
| + // The guard counts oidc anchors: u1's passkey may go while the |
| + // microsoft anchor holds the account, and then that anchor — the |
| + // last way in — is refused. |
| + if deleted, last, err := st.deleteCredential("u1", b64CredID("cred-one")); !deleted || last || err != nil { |
| + t.Fatalf("shed passkey beside oidc anchor: got (%v,%v,%v)", deleted, last, err) |
| + } |
| + if deleted, last, err := st.deleteOidcAnchor("u1", "microsoft", "mhash-1"); deleted || !last || err != nil { |
| + t.Fatalf("delete last oidc anchor: want (false,true), got (%v,%v,%v)", deleted, last, err) |
| + } |
| + }) |
| + |
| // Ceremony proofs deliberately have no contract entry: they left the store |
| // entirely — stateless HMAC tokens, tested in stateless_test.go. |
| } |