eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
36import (
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
53const (
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.
74var 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).
82var oidcAnchorKinds = append(append([]string{}, oidcProviderNames...), "github")
83
84var oidcHTTP = &http.Client{Timeout: 10 * time.Second}
85
86// oidcProvider is one table row: everything that differs between the doors.
87type 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).
100func 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.
113func 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).
122func 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
136func (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.)
146type jwksCache struct {
147 url string
148
149 mu sync.Mutex
150 keys map[string]*rsa.PublicKey
151 fetched time.Time
152}
153
154func (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.
210type 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.
227func 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.
303func (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.
383func (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.
413func 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