package main // 🤖 Sign in with GitHub — the code-exchange door (#1112). GitHub mints no // OIDC id_tokens, so unlike the Google/Microsoft table (oidc.go) the // exchange happens HERE, with the client secret this server holds. The // return trampoline stays blind: GET /oidc/return bounces the single-use // ?code= to the page fragment (worthless without the secret), the page // posts it to /api/oidc/github/verify, and this handler exchanges it, // reads the numeric user id from /user, and feeds HMAC(pepper, id) into // the SAME anchor machinery as every other provider — the access token // and profile are discarded on the spot; only the peppered hash survives. // Anchors store under provider name "github" in the oidc tables, so the // listing, removal, and merge paths need no new cases. import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "net/url" "strconv" "strings" "time" ) // Test seams — the exchange and user endpoints, swappable to an httptest rig. var ( githubTokenURL = "https://github.com/login/oauth/access_token" githubUserURL = "https://api.github.com/user" ) type githubDoor struct { clientID string secret string pepper []byte } func (g *githubDoor) subHash(sub string) string { m := hmac.New(sha256.New, g.pepper) m.Write([]byte(sub)) return hex.EncodeToString(m.Sum(nil)) } // resolveSub runs the whole GitHub side: code → access token → /user → the // stable numeric id. Everything transient dies with this call. func (g *githubDoor) resolveSub(ctx context.Context, code string) (string, error) { client := &http.Client{Timeout: 10 * time.Second} form := url.Values{ "client_id": {g.clientID}, "client_secret": {g.secret}, "code": {code}, } req, err := http.NewRequestWithContext(ctx, "POST", githubTokenURL, strings.NewReader(form.Encode())) if err != nil { return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") res, err := client.Do(req) if err != nil { return "", err } defer res.Body.Close() var tok struct { AccessToken string `json:"access_token"` } if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&tok); err != nil { return "", err } if tok.AccessToken == "" { return "", errors.New("no access token") // bad/expired/replayed code } ureq, err := http.NewRequestWithContext(ctx, "GET", githubUserURL, nil) if err != nil { return "", err } ureq.Header.Set("Authorization", "Bearer "+tok.AccessToken) ureq.Header.Set("Accept", "application/vnd.github+json") ures, err := client.Do(ureq) if err != nil { return "", err } defer ures.Body.Close() var user struct { ID int64 `json:"id"` } if err := json.NewDecoder(io.LimitReader(ures.Body, 64<<10)).Decode(&user); err != nil { return "", err } if user.ID == 0 { return "", errors.New("no user id") } // The numeric id is the stable subject (logins rename; ids don't). return strconv.FormatInt(user.ID, 10), nil } // githubVerify is POST /api/oidc/github/verify — the code-flow sibling of // oidcVerify, answering the identical contract (token + proof + created; // link:true + X-Ceremony-Proof attaches; conflict is the combine 409). An // unconfigured door is a 404, indistinguishable from a nonexistent one. func (s *Server) githubVerify(w http.ResponseWriter, r *http.Request) { if s.github == nil { http.Error(w, "not found", http.StatusNotFound) return } var req struct { Code string `json:"code"` Link bool `json:"link"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil || req.Code == "" || len(req.Code) > 512 { http.Error(w, "bad request", http.StatusBadRequest) return } sub, err := s.github.resolveSub(r.Context(), req.Code) if err != nil { http.Error(w, "That sign-in couldn't be confirmed", http.StatusUnauthorized) return } subHash := s.github.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, "github", subHash) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if conflict { http.Error(w, "That GitHub account already signs in somewhere else", http.StatusConflict) return } writeJSON(w, http.StatusOK, map[string]any{"linked": true}) return } uid, ok := s.store.oidcUser("github", subHash) created := false if !ok { 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, "github", 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, }) } // oauthCodeReturn is GET /oidc/return — the code-flow half of the return // trampoline (the POST half stays form_post for the id_token providers). // Provider-blind like its sibling: it bounces ?code= onward in the fragment // and never touches it; only the page (which minted the state) knows which // door was opened. Gated on the code-flow door existing at all. func (s *Server) oauthCodeReturn(w http.ResponseWriter, r *http.Request) { if s.github == nil { http.Error(w, "not found", http.StatusNotFound) return } code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") if code == "" || len(code) > 512 || len(state) > 512 { http.Error(w, "bad request", http.StatusBadRequest) return } if strings.HasPrefix(state, "web.") { http.Redirect(w, r, "/#oauth="+url.QueryEscape(code)+ "&state="+url.QueryEscape(state), http.StatusSeeOther) return } http.Redirect(w, r, oidcReturnScheme+"#state="+url.QueryEscape(state)+ "&code="+url.QueryEscape(code), http.StatusSeeOther) }