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// // 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"} // oidcAnchorKinds is the ANCHOR namespace over the oidc tables — the // verifier-table providers plus GitHub, whose door is the code-exchange // path (github.go) but whose anchors store identically. The listing and // removal surfaces speak this set, so a github anchor is visible and // removable like any other (its invisibility after Paul's first combine // was exactly this list missing github). var oidcAnchorKinds = append(append([]string{}, oidcProviderNames...), "github") 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 }