| 1 | package main |
| 2 | |
| 3 | // π€ Sign in with GitHub β the code-exchange door (#1112). GitHub mints no |
| 4 | // OIDC id_tokens, so unlike the Google/Microsoft table (oidc.go) the |
| 5 | // exchange happens HERE, with the client secret this server holds. The |
| 6 | // return trampoline stays blind: GET /oidc/return bounces the single-use |
| 7 | // ?code= to the page fragment (worthless without the secret), the page |
| 8 | // posts it to /api/oidc/github/verify, and this handler exchanges it, |
| 9 | // reads the numeric user id from /user, and feeds HMAC(pepper, id) into |
| 10 | // the SAME anchor machinery as every other provider β the access token |
| 11 | // and profile are discarded on the spot; only the peppered hash survives. |
| 12 | // Anchors store under provider name "github" in the oidc tables, so the |
| 13 | // listing, removal, and merge paths need no new cases. |
| 14 | |
| 15 | import ( |
| 16 | "context" |
| 17 | "crypto/hmac" |
| 18 | "crypto/sha256" |
| 19 | "encoding/hex" |
| 20 | "encoding/json" |
| 21 | "errors" |
| 22 | "io" |
| 23 | "net/http" |
| 24 | "net/url" |
| 25 | "strconv" |
| 26 | "strings" |
| 27 | "time" |
| 28 | ) |
| 29 | |
| 30 | // Test seams β the exchange and user endpoints, swappable to an httptest rig. |
| 31 | var ( |
| 32 | githubTokenURL = "https://github.com/login/oauth/access_token" |
| 33 | githubUserURL = "https://api.github.com/user" |
| 34 | ) |
| 35 | |
| 36 | type githubDoor struct { |
| 37 | clientID string |
| 38 | secret string |
| 39 | pepper []byte |
| 40 | } |
| 41 | |
| 42 | func (g *githubDoor) subHash(sub string) string { |
| 43 | m := hmac.New(sha256.New, g.pepper) |
| 44 | m.Write([]byte(sub)) |
| 45 | return hex.EncodeToString(m.Sum(nil)) |
| 46 | } |
| 47 | |
| 48 | // resolveSub runs the whole GitHub side: code β access token β /user β the |
| 49 | // stable numeric id. Everything transient dies with this call. |
| 50 | func (g *githubDoor) resolveSub(ctx context.Context, code string) (string, error) { |
| 51 | client := &http.Client{Timeout: 10 * time.Second} |
| 52 | |
| 53 | form := url.Values{ |
| 54 | "client_id": {g.clientID}, |
| 55 | "client_secret": {g.secret}, |
| 56 | "code": {code}, |
| 57 | } |
| 58 | req, err := http.NewRequestWithContext(ctx, "POST", githubTokenURL, strings.NewReader(form.Encode())) |
| 59 | if err != nil { |
| 60 | return "", err |
| 61 | } |
| 62 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 63 | req.Header.Set("Accept", "application/json") |
| 64 | res, err := client.Do(req) |
| 65 | if err != nil { |
| 66 | return "", err |
| 67 | } |
| 68 | defer res.Body.Close() |
| 69 | var tok struct { |
| 70 | AccessToken string `json:"access_token"` |
| 71 | } |
| 72 | if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&tok); err != nil { |
| 73 | return "", err |
| 74 | } |
| 75 | if tok.AccessToken == "" { |
| 76 | return "", errors.New("no access token") // bad/expired/replayed code |
| 77 | } |
| 78 | |
| 79 | ureq, err := http.NewRequestWithContext(ctx, "GET", githubUserURL, nil) |
| 80 | if err != nil { |
| 81 | return "", err |
| 82 | } |
| 83 | ureq.Header.Set("Authorization", "Bearer "+tok.AccessToken) |
| 84 | ureq.Header.Set("Accept", "application/vnd.github+json") |
| 85 | ures, err := client.Do(ureq) |
| 86 | if err != nil { |
| 87 | return "", err |
| 88 | } |
| 89 | defer ures.Body.Close() |
| 90 | var user struct { |
| 91 | ID int64 `json:"id"` |
| 92 | } |
| 93 | if err := json.NewDecoder(io.LimitReader(ures.Body, 64<<10)).Decode(&user); err != nil { |
| 94 | return "", err |
| 95 | } |
| 96 | if user.ID == 0 { |
| 97 | return "", errors.New("no user id") |
| 98 | } |
| 99 | // The numeric id is the stable subject (logins rename; ids don't). |
| 100 | return strconv.FormatInt(user.ID, 10), nil |
| 101 | } |
| 102 | |
| 103 | // githubVerify is POST /api/oidc/github/verify β the code-flow sibling of |
| 104 | // oidcVerify, answering the identical contract (token + proof + created; |
| 105 | // link:true + X-Ceremony-Proof attaches; conflict is the combine 409). An |
| 106 | // unconfigured door is a 404, indistinguishable from a nonexistent one. |
| 107 | func (s *Server) githubVerify(w http.ResponseWriter, r *http.Request) { |
| 108 | if s.github == nil { |
| 109 | http.Error(w, "not found", http.StatusNotFound) |
| 110 | return |
| 111 | } |
| 112 | var req struct { |
| 113 | Code string `json:"code"` |
| 114 | Link bool `json:"link"` |
| 115 | } |
| 116 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil || req.Code == "" || len(req.Code) > 512 { |
| 117 | http.Error(w, "bad request", http.StatusBadRequest) |
| 118 | return |
| 119 | } |
| 120 | sub, err := s.github.resolveSub(r.Context(), req.Code) |
| 121 | if err != nil { |
| 122 | http.Error(w, "That sign-in couldn't be confirmed", http.StatusUnauthorized) |
| 123 | return |
| 124 | } |
| 125 | subHash := s.github.subHash(sub) |
| 126 | |
| 127 | if req.Link { |
| 128 | uid, ok := s.ceremonyUser(r) |
| 129 | if !ok { |
| 130 | http.Error(w, "Confirm it's you first", http.StatusUnauthorized) |
| 131 | return |
| 132 | } |
| 133 | conflict, err := s.store.insertOidcAnchor(uid, "github", subHash) |
| 134 | if err != nil { |
| 135 | http.Error(w, "db error", http.StatusInternalServerError) |
| 136 | return |
| 137 | } |
| 138 | if conflict { |
| 139 | http.Error(w, "That GitHub account already signs in somewhere else", http.StatusConflict) |
| 140 | return |
| 141 | } |
| 142 | writeJSON(w, http.StatusOK, map[string]any{"linked": true}) |
| 143 | return |
| 144 | } |
| 145 | |
| 146 | uid, ok := s.store.oidcUser("github", subHash) |
| 147 | created := false |
| 148 | if !ok { |
| 149 | id, err := randToken() |
| 150 | if err != nil { |
| 151 | http.Error(w, "db error", http.StatusInternalServerError) |
| 152 | return |
| 153 | } |
| 154 | if err := s.store.createUser(id); err != nil { |
| 155 | http.Error(w, "db error", http.StatusInternalServerError) |
| 156 | return |
| 157 | } |
| 158 | if _, err := s.store.insertOidcAnchor(id, "github", subHash); err != nil { |
| 159 | http.Error(w, "db error", http.StatusInternalServerError) |
| 160 | return |
| 161 | } |
| 162 | uid, created = id, true |
| 163 | } |
| 164 | tok, err := s.store.createLinkToken(uid) |
| 165 | if err != nil { |
| 166 | http.Error(w, "db error", http.StatusInternalServerError) |
| 167 | return |
| 168 | } |
| 169 | writeJSON(w, http.StatusOK, map[string]any{ |
| 170 | "token": tok, |
| 171 | "proof": s.keys.mintCeremonyProof(uid), |
| 172 | "created": created, |
| 173 | }) |
| 174 | } |
| 175 | |
| 176 | // oauthCodeReturn is GET /oidc/return β the code-flow half of the return |
| 177 | // trampoline (the POST half stays form_post for the id_token providers). |
| 178 | // Provider-blind like its sibling: it bounces ?code= onward in the fragment |
| 179 | // and never touches it; only the page (which minted the state) knows which |
| 180 | // door was opened. Gated on the code-flow door existing at all. |
| 181 | func (s *Server) oauthCodeReturn(w http.ResponseWriter, r *http.Request) { |
| 182 | if s.github == nil { |
| 183 | http.Error(w, "not found", http.StatusNotFound) |
| 184 | return |
| 185 | } |
| 186 | code := r.URL.Query().Get("code") |
| 187 | state := r.URL.Query().Get("state") |
| 188 | if code == "" || len(code) > 512 || len(state) > 512 { |
| 189 | http.Error(w, "bad request", http.StatusBadRequest) |
| 190 | return |
| 191 | } |
| 192 | if strings.HasPrefix(state, "web.") { |
| 193 | http.Redirect(w, r, "/#oauth="+url.QueryEscape(code)+ |
| 194 | "&state="+url.QueryEscape(state), http.StatusSeeOther) |
| 195 | return |
| 196 | } |
| 197 | http.Redirect(w, r, oidcReturnScheme+"#state="+url.QueryEscape(state)+ |
| 198 | "&code="+url.QueryEscape(code), http.StatusSeeOther) |
| 199 | } |
| 200 | |