eleven / messenger Public

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>
Paul Campbell 3e8700fdc3e5903343b0a9b3f1e57e03bda9f39f parent ec82058
10 files changed, +1127 −161
  • home/anchors.go +22 −7
  • home/db.go +82 −8
  • home/main.go +34 −0
  • home/oidc.go +413 −0
  • home/oidc_test.go +359 −0
  • home/s3store.go +109 −1
  • home/ses.go +1 −1
  • home/siwa.go +12 −144
  • home/store.go +16 −0
  • home/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.
}