eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package 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
15import (
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.
31var (
32 githubTokenURL = "https://github.com/login/oauth/access_token"
33 githubUserURL = "https://api.github.com/user"
34)
35
36type githubDoor struct {
37 clientID string
38 secret string
39 pepper []byte
40}
41
42func (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.
50func (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.
107func (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.
181func (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