| 1 | package main |
| 2 | |
| 3 | // π€ The OIDC provider table β Sign in with Apple's verifier, generalized |
| 4 | // (issue #1112, server half). SiwA (siwa.go) proved the shape: an RS256 |
| 5 | // identity token signed by a JWKS-published key, checked end to end, keyed |
| 6 | // into the store as HMAC(pepper, sub), answered exactly like a passkey |
| 7 | // ceremony. Google and Microsoft speak the same protocol with different |
| 8 | // constants, so what differs lives in one table row (oidcProvider) and |
| 9 | // everything else is shared: the JWKS cache, the signature + claims |
| 10 | // verification, resolve-or-create, link mode, and the web-state trampoline. |
| 11 | // Apple deliberately KEEPS its own routes, env vars, and store rows |
| 12 | // (/api/siwa/*, LCHAT_HOME_SIWA_*, siwa_anchors) β it shipped first and is |
| 13 | // wire-compatible with deployed clients; its verifier now delegates to the |
| 14 | // shared core here, so there is still exactly one verification path. |
| 15 | // |
| 16 | // Issuer quirks, the one place the providers genuinely differ: |
| 17 | // - Google's issuer is https://accounts.google.com, but Google documents a |
| 18 | // legacy form without the scheme β both are accepted, and both key the |
| 19 | // same account (the sub, not the issuer spelling, names the person). |
| 20 | // - Microsoft's v2.0 "common" endpoint (consumer AND work accounts) mints |
| 21 | // a PER-TENANT issuer β https://login.microsoftonline.com/{tenantid}/v2.0 |
| 22 | // β so no single string can be pinned. We accept that pattern, and |
| 23 | // conservatively require the token's `tid` claim (always present in v2.0 |
| 24 | // tokens) to equal the issuer's tenant segment: a mismatch is a forgery |
| 25 | // tell, not a variant to tolerate. |
| 26 | // |
| 27 | // Account keying is HMAC(pepper, sub) with a pepper PER PROVIDER |
| 28 | // (LCHAT_HOME_GOOGLE_PEPPER, LCHAT_HOME_MICROSOFT_PEPPER) β hashes can never |
| 29 | // collide across providers even for equal subs β and the store carries the |
| 30 | // provider name beside the hash (oidc_anchors rows; oidc/<provider>/<hash> |
| 31 | // pointers on S3), mirroring siwa_anchors exactly. Like the SiwA pepper, |
| 32 | // these are DURABLE: they name accounts, so there is no ephemeral fallback β |
| 33 | // unset means that door is off. Each provider gates exactly like SiwA: |
| 34 | // client ids + pepper both set β on; neither β its endpoints answer 404. |
| 35 | |
| 36 | import ( |
| 37 | "crypto" |
| 38 | "crypto/hmac" |
| 39 | "crypto/rsa" |
| 40 | "crypto/sha256" |
| 41 | "encoding/base64" |
| 42 | "encoding/hex" |
| 43 | "encoding/json" |
| 44 | "errors" |
| 45 | "math/big" |
| 46 | "net/http" |
| 47 | "net/url" |
| 48 | "strings" |
| 49 | "sync" |
| 50 | "time" |
| 51 | ) |
| 52 | |
| 53 | const ( |
| 54 | googleJWKSURL = "https://www.googleapis.com/oauth2/v3/certs" |
| 55 | googleIssuer = "https://accounts.google.com" |
| 56 | |
| 57 | microsoftJWKSURL = "https://login.microsoftonline.com/common/discovery/v2.0/keys" |
| 58 | // microsoftIssuerPrefix/Suffix frame the v2.0 issuer template, |
| 59 | // https://login.microsoftonline.com/{tenantid}/v2.0 β see the header |
| 60 | // comment for why a pattern, not a pinned string. |
| 61 | microsoftIssuerPrefix = "https://login.microsoftonline.com/" |
| 62 | microsoftIssuerSuffix = "/v2.0" |
| 63 | |
| 64 | // oidcReturnScheme is where the web-flow trampoline bounces the token for |
| 65 | // native apps β the sibling of siwa.go's elevenmessenger://home-siwa. |
| 66 | oidcReturnScheme = "elevenmessenger://home-oidc" |
| 67 | ) |
| 68 | |
| 69 | // oidcProviderNames is the fixed set of table rows β the store kinds that |
| 70 | // exist regardless of which doors are currently enabled (anchors of a |
| 71 | // disabled provider still list and remove in /api/anchors, exactly as siwa |
| 72 | // anchors do with SiwA off). Apple is deliberately absent: it stays on its |
| 73 | // own routes and its own siwa_anchors rows. |
| 74 | var oidcProviderNames = []string{"google", "microsoft"} |
| 75 | |
| 76 | // oidcAnchorKinds is the ANCHOR namespace over the oidc tables β the |
| 77 | // verifier-table providers plus GitHub, whose door is the code-exchange |
| 78 | // path (github.go) but whose anchors store identically. The listing and |
| 79 | // removal surfaces speak this set, so a github anchor is visible and |
| 80 | // removable like any other (its invisibility after Paul's first combine |
| 81 | // was exactly this list missing github). |
| 82 | var oidcAnchorKinds = append(append([]string{}, oidcProviderNames...), "github") |
| 83 | |
| 84 | var oidcHTTP = &http.Client{Timeout: 10 * time.Second} |
| 85 | |
| 86 | // oidcProvider is one table row: everything that differs between the doors. |
| 87 | type oidcProvider struct { |
| 88 | name string // route + store name ("google"); element of oidcProviderNames |
| 89 | display string // user-facing copy ("Google") |
| 90 | // checkIssuer validates the token's iss claim (and, for Microsoft, its |
| 91 | // tid consistency). Everything else about verification is shared. |
| 92 | checkIssuer func(c oidcClaims) error |
| 93 | clientIDs []string |
| 94 | pepper []byte |
| 95 | jwks *jwksCache |
| 96 | } |
| 97 | |
| 98 | // newOIDCProvider builds the table row for a known provider name; nil for an |
| 99 | // unknown one (main fails fast β the names come from the env-parsing table). |
| 100 | func newOIDCProvider(name string, clientIDs []string, pepper []byte) *oidcProvider { |
| 101 | switch name { |
| 102 | case "google": |
| 103 | return &oidcProvider{name: name, display: "Google", checkIssuer: googleIssuerOK, |
| 104 | clientIDs: clientIDs, pepper: pepper, jwks: &jwksCache{url: googleJWKSURL}} |
| 105 | case "microsoft": |
| 106 | return &oidcProvider{name: name, display: "Microsoft", checkIssuer: microsoftIssuerOK, |
| 107 | clientIDs: clientIDs, pepper: pepper, jwks: &jwksCache{url: microsoftJWKSURL}} |
| 108 | } |
| 109 | return nil |
| 110 | } |
| 111 | |
| 112 | // googleIssuerOK accepts Google's issuer in both documented spellings. |
| 113 | func googleIssuerOK(c oidcClaims) error { |
| 114 | if c.Iss != googleIssuer && c.Iss != "accounts.google.com" { |
| 115 | return errors.New("wrong issuer") |
| 116 | } |
| 117 | return nil |
| 118 | } |
| 119 | |
| 120 | // microsoftIssuerOK accepts the v2.0 issuer template and requires the tid |
| 121 | // claim to match the issuer's tenant segment (see the header comment). |
| 122 | func microsoftIssuerOK(c oidcClaims) error { |
| 123 | tenant, ok := strings.CutPrefix(c.Iss, microsoftIssuerPrefix) |
| 124 | if ok { |
| 125 | tenant, ok = strings.CutSuffix(tenant, microsoftIssuerSuffix) |
| 126 | } |
| 127 | if !ok || tenant == "" || strings.Contains(tenant, "/") { |
| 128 | return errors.New("wrong issuer") |
| 129 | } |
| 130 | if c.Tid == "" || c.Tid != tenant { |
| 131 | return errors.New("tenant mismatch") |
| 132 | } |
| 133 | return nil |
| 134 | } |
| 135 | |
| 136 | func (p *oidcProvider) subHash(sub string) string { |
| 137 | m := hmac.New(sha256.New, p.pepper) |
| 138 | m.Write([]byte(sub)) |
| 139 | return hex.EncodeToString(m.Sum(nil)) |
| 140 | } |
| 141 | |
| 142 | // jwksCache holds a provider's signing keys by kid. Unknown kids trigger a |
| 143 | // refetch (key rotation), floored at one fetch a minute so a garbage kid |
| 144 | // can't become a fetch amplifier. (Moved verbatim from siwa.go β one cache |
| 145 | // per provider, Apple's included.) |
| 146 | type jwksCache struct { |
| 147 | url string |
| 148 | |
| 149 | mu sync.Mutex |
| 150 | keys map[string]*rsa.PublicKey |
| 151 | fetched time.Time |
| 152 | } |
| 153 | |
| 154 | func (c *jwksCache) key(kid string) (*rsa.PublicKey, error) { |
| 155 | c.mu.Lock() |
| 156 | defer c.mu.Unlock() |
| 157 | if k, ok := c.keys[kid]; ok { |
| 158 | return k, nil |
| 159 | } |
| 160 | if c.keys != nil && time.Since(c.fetched) < time.Minute { |
| 161 | return nil, errors.New("unknown key id") |
| 162 | } |
| 163 | resp, err := oidcHTTP.Get(c.url) |
| 164 | if err != nil { |
| 165 | return nil, err |
| 166 | } |
| 167 | defer resp.Body.Close() |
| 168 | if resp.StatusCode != http.StatusOK { |
| 169 | return nil, errors.New("jwks fetch failed") |
| 170 | } |
| 171 | var doc struct { |
| 172 | Keys []struct { |
| 173 | Kty string `json:"kty"` |
| 174 | Kid string `json:"kid"` |
| 175 | N string `json:"n"` |
| 176 | E string `json:"e"` |
| 177 | } `json:"keys"` |
| 178 | } |
| 179 | if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 64<<10)).Decode(&doc); err != nil { |
| 180 | return nil, err |
| 181 | } |
| 182 | keys := make(map[string]*rsa.PublicKey, len(doc.Keys)) |
| 183 | for _, k := range doc.Keys { |
| 184 | if k.Kty != "RSA" { |
| 185 | continue |
| 186 | } |
| 187 | nb, err := base64.RawURLEncoding.DecodeString(k.N) |
| 188 | if err != nil { |
| 189 | continue |
| 190 | } |
| 191 | eb, err := base64.RawURLEncoding.DecodeString(k.E) |
| 192 | if err != nil { |
| 193 | continue |
| 194 | } |
| 195 | keys[k.Kid] = &rsa.PublicKey{ |
| 196 | N: new(big.Int).SetBytes(nb), |
| 197 | E: int(new(big.Int).SetBytes(eb).Int64()), |
| 198 | } |
| 199 | } |
| 200 | c.keys, c.fetched = keys, time.Now() |
| 201 | if k, ok := keys[kid]; ok { |
| 202 | return k, nil |
| 203 | } |
| 204 | return nil, errors.New("unknown key id") |
| 205 | } |
| 206 | |
| 207 | // oidcClaims is the decoded claim set shared verification reads. Aud stays |
| 208 | // raw (string or []string, per RFC 7519); Tid is Microsoft's tenant id, |
| 209 | // absent elsewhere. |
| 210 | type oidcClaims struct { |
| 211 | Iss string `json:"iss"` |
| 212 | Aud json.RawMessage `json:"aud"` |
| 213 | Exp int64 `json:"exp"` |
| 214 | Sub string `json:"sub"` |
| 215 | Nonce string `json:"nonce"` |
| 216 | Tid string `json:"tid"` |
| 217 | } |
| 218 | |
| 219 | // verifyIDToken checks an OIDC identity token end to end β RS256 signature |
| 220 | // against the provider's JWKS, then issuer (via checkIssuer), expiry, |
| 221 | // audience (one of the provider's client ids), and the nonce when the caller |
| 222 | // bound one β and returns the provider's stable `sub`. This IS the SiwA |
| 223 | // verifier, generalized verbatim; the check order is unchanged (signature β |
| 224 | // issuer β expiry β audience β nonce β sub), and it stays hand-rolled |
| 225 | // (split/decode/RSA-verify, the apns.go style) rather than a JWT dependency: |
| 226 | // ~sixty lines a person can read whole. |
| 227 | func verifyIDToken(jwks *jwksCache, token string, checkIssuer func(oidcClaims) error, clientIDs []string, nonce string, now time.Time) (string, error) { |
| 228 | parts := strings.Split(token, ".") |
| 229 | if len(parts) != 3 { |
| 230 | return "", errors.New("malformed token") |
| 231 | } |
| 232 | headB, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| 233 | if err != nil { |
| 234 | return "", errors.New("malformed header") |
| 235 | } |
| 236 | var head struct { |
| 237 | Alg string `json:"alg"` |
| 238 | Kid string `json:"kid"` |
| 239 | } |
| 240 | if err := json.Unmarshal(headB, &head); err != nil || head.Alg != "RS256" { |
| 241 | return "", errors.New("unsupported algorithm") |
| 242 | } |
| 243 | key, err := jwks.key(head.Kid) |
| 244 | if err != nil { |
| 245 | return "", err |
| 246 | } |
| 247 | sig, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| 248 | if err != nil { |
| 249 | return "", errors.New("malformed signature") |
| 250 | } |
| 251 | digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) |
| 252 | if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig); err != nil { |
| 253 | return "", errors.New("bad signature") |
| 254 | } |
| 255 | claimsB, err := base64.RawURLEncoding.DecodeString(parts[1]) |
| 256 | if err != nil { |
| 257 | return "", errors.New("malformed claims") |
| 258 | } |
| 259 | var claims oidcClaims |
| 260 | if err := json.Unmarshal(claimsB, &claims); err != nil { |
| 261 | return "", errors.New("malformed claims") |
| 262 | } |
| 263 | if err := checkIssuer(claims); err != nil { |
| 264 | return "", err |
| 265 | } |
| 266 | if now.Unix() >= claims.Exp { |
| 267 | return "", errors.New("expired") |
| 268 | } |
| 269 | var auds []string |
| 270 | var one string |
| 271 | if json.Unmarshal(claims.Aud, &one) == nil { |
| 272 | auds = []string{one} |
| 273 | } else if json.Unmarshal(claims.Aud, &auds) != nil { |
| 274 | return "", errors.New("malformed audience") |
| 275 | } |
| 276 | okAud := false |
| 277 | for _, a := range auds { |
| 278 | for _, c := range clientIDs { |
| 279 | if a == c { |
| 280 | okAud = true |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | if !okAud { |
| 285 | return "", errors.New("wrong audience") |
| 286 | } |
| 287 | if nonce != "" && claims.Nonce != nonce { |
| 288 | return "", errors.New("nonce mismatch") |
| 289 | } |
| 290 | if claims.Sub == "" { |
| 291 | return "", errors.New("no subject") |
| 292 | } |
| 293 | return claims.Sub, nil |
| 294 | } |
| 295 | |
| 296 | // oidcVerify is POST /api/oidc/{provider}/verify β the same one-verifier |
| 297 | // contract as /api/siwa/verify, per table row. Sign-in (default): |
| 298 | // resolve-or-create the account behind the peppered sub hash and answer |
| 299 | // exactly like finishCeremony (token + proof). Link (`"link":true` + |
| 300 | // X-Ceremony-Proof): attach the anchor to the proof's existing account, the |
| 301 | // same freshness bar as adding a passkey. An unknown or disabled provider is |
| 302 | // a 404 β an off door and a nonexistent one are indistinguishable, like SiwA. |
| 303 | func (s *Server) oidcVerify(w http.ResponseWriter, r *http.Request) { |
| 304 | p := s.oidc[r.PathValue("provider")] |
| 305 | if p == nil { |
| 306 | http.Error(w, "not found", http.StatusNotFound) |
| 307 | return |
| 308 | } |
| 309 | var req struct { |
| 310 | IdentityToken string `json:"identity_token"` |
| 311 | Nonce string `json:"nonce"` |
| 312 | Link bool `json:"link"` |
| 313 | } |
| 314 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { |
| 315 | http.Error(w, "bad request", http.StatusBadRequest) |
| 316 | return |
| 317 | } |
| 318 | sub, err := verifyIDToken(p.jwks, req.IdentityToken, p.checkIssuer, p.clientIDs, req.Nonce, time.Now()) |
| 319 | if err != nil { |
| 320 | http.Error(w, "That sign-in couldn't be confirmed", http.StatusUnauthorized) |
| 321 | return |
| 322 | } |
| 323 | subHash := p.subHash(sub) |
| 324 | |
| 325 | if req.Link { |
| 326 | uid, ok := s.ceremonyUser(r) |
| 327 | if !ok { |
| 328 | http.Error(w, "Confirm it's you first", http.StatusUnauthorized) |
| 329 | return |
| 330 | } |
| 331 | conflict, err := s.store.insertOidcAnchor(uid, p.name, subHash) |
| 332 | if err != nil { |
| 333 | http.Error(w, "db error", http.StatusInternalServerError) |
| 334 | return |
| 335 | } |
| 336 | if conflict { |
| 337 | http.Error(w, "That "+p.display+" account already signs in somewhere else", http.StatusConflict) |
| 338 | return |
| 339 | } |
| 340 | writeJSON(w, http.StatusOK, map[string]any{"linked": true}) |
| 341 | return |
| 342 | } |
| 343 | |
| 344 | uid, ok := s.store.oidcUser(p.name, subHash) |
| 345 | created := false |
| 346 | if !ok { |
| 347 | // The seamless front door, exactly as SiwA: an unknown identity |
| 348 | // becomes a fresh account on the spot β no separate registration |
| 349 | // ceremony. The vault starts empty; the client PUTs the first blob. |
| 350 | id, err := randToken() |
| 351 | if err != nil { |
| 352 | http.Error(w, "db error", http.StatusInternalServerError) |
| 353 | return |
| 354 | } |
| 355 | if err := s.store.createUser(id); err != nil { |
| 356 | http.Error(w, "db error", http.StatusInternalServerError) |
| 357 | return |
| 358 | } |
| 359 | if _, err := s.store.insertOidcAnchor(id, p.name, subHash); err != nil { |
| 360 | http.Error(w, "db error", http.StatusInternalServerError) |
| 361 | return |
| 362 | } |
| 363 | uid, created = id, true |
| 364 | } |
| 365 | tok, err := s.store.createLinkToken(uid) |
| 366 | if err != nil { |
| 367 | http.Error(w, "db error", http.StatusInternalServerError) |
| 368 | return |
| 369 | } |
| 370 | writeJSON(w, http.StatusOK, map[string]any{ |
| 371 | "token": tok, |
| 372 | "proof": s.keys.mintCeremonyProof(uid), |
| 373 | "created": created, |
| 374 | }) |
| 375 | } |
| 376 | |
| 377 | // oidcReturn is POST /oidc/return β where a provider's web flow form_posts, |
| 378 | // mirroring /siwa/return exactly: a TRAMPOLINE, nothing more. It bounces the |
| 379 | // fields onward without verifying anything (verifying here would mean two |
| 380 | // verifiers) and stays provider-blind β the page or app minted the state, so |
| 381 | // it already knows which door it opened; the fragment keeps the token out of |
| 382 | // request logs. Gated on any door being open, like siwaReturn on SiwA. |
| 383 | func (s *Server) oidcReturn(w http.ResponseWriter, r *http.Request) { |
| 384 | if len(s.oidc) == 0 { |
| 385 | http.Error(w, "not found", http.StatusNotFound) |
| 386 | return |
| 387 | } |
| 388 | r.Body = http.MaxBytesReader(w, r.Body, 64<<10) |
| 389 | if err := r.ParseForm(); err != nil { |
| 390 | http.Error(w, "bad request", http.StatusBadRequest) |
| 391 | return |
| 392 | } |
| 393 | idToken := r.PostFormValue("id_token") |
| 394 | state := r.PostFormValue("state") |
| 395 | if idToken == "" || len(idToken) > 8192 || len(state) > 512 { |
| 396 | http.Error(w, "bad request", http.StatusBadRequest) |
| 397 | return |
| 398 | } |
| 399 | // A "web."-prefixed state was minted by the home page itself: bounce back |
| 400 | // to the page β a relative redirect, same origin β and it picks the |
| 401 | // fragment up on load. Every other state is a native app's |
| 402 | // ASWebAuthenticationSession, which intercepts the custom scheme. |
| 403 | if strings.HasPrefix(state, "web.") { |
| 404 | http.Redirect(w, r, "/#oidc="+url.QueryEscape(idToken)+ |
| 405 | "&state="+url.QueryEscape(state), http.StatusSeeOther) |
| 406 | return |
| 407 | } |
| 408 | http.Redirect(w, r, oidcReturnScheme+"#state="+url.QueryEscape(state)+ |
| 409 | "&id_token="+url.QueryEscape(idToken), http.StatusSeeOther) |
| 410 | } |
| 411 | |
| 412 | // splitCommaList parses a comma-separated env list, trimming blanks. |
| 413 | func splitCommaList(v string) []string { |
| 414 | var out []string |
| 415 | for _, c := range strings.Split(v, ",") { |
| 416 | if c = strings.TrimSpace(c); c != "" { |
| 417 | out = append(out, c) |
| 418 | } |
| 419 | } |
| 420 | return out |
| 421 | } |
| 422 | |