mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +00:00
The web UI is for a quick look at a profile, not for hosting embeddable images. Cards are inlined into /u/<user> as data: images, the per-card route is gone, and the page no longer offers copy links or Markdown snippets. The page itself stays shareable. Visitors can again paste their own token, with a button that opens GitHub's new-token page with the needed scopes ticked. A pasted token takes precedence over sign-in, follows the same ownership, privacy and cooldown rules, is never stored or logged, and is not revoked. The server still uses no token of its own.
461 lines
15 KiB
Go
461 lines
15 KiB
Go
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"cmp"
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// GitHub's OAuth endpoints. Tests point OAuthConfig at an httptest server.
|
||
const (
|
||
defaultOAuthWebURL = "https://github.com"
|
||
defaultOAuthAPIURL = "https://api.github.com"
|
||
)
|
||
|
||
const (
|
||
// loginTTL is how long a started sign-in waits for GitHub's callback;
|
||
// GitHub's own authorization codes expire after ten minutes too.
|
||
loginTTL = 10 * time.Minute
|
||
// maxPendingLogins caps sign-ins waiting for a callback. The submission
|
||
// rate limit already bounds one client; this bounds them all.
|
||
maxPendingLogins = 1000
|
||
// oauthCookie binds the browser that started a sign-in to its state.
|
||
// Over https it carries the __Host- prefix, so a sibling subdomain
|
||
// cannot plant one; a plain-http public URL cannot use the prefix and
|
||
// gets the weaker unprefixed name.
|
||
oauthCookie = "__Host-ghglance_oauth"
|
||
oauthCookieInsecure = "ghglance_oauth"
|
||
// oauthTimeout bounds one call to GitHub's token or revoke endpoint.
|
||
oauthTimeout = 15 * time.Second
|
||
)
|
||
|
||
// OAuthConfig configures "Sign in with GitHub" through a GitHub OAuth App.
|
||
// ClientID, ClientSecret and PublicURL are all required.
|
||
type OAuthConfig struct {
|
||
ClientID string
|
||
ClientSecret string
|
||
// PublicURL is the site's external origin; the callback GitHub
|
||
// redirects to is PublicURL + "/auth/callback" and must match the one
|
||
// registered on the OAuth App exactly.
|
||
PublicURL string
|
||
// WebURL and APIURL override github.com and api.github.com.
|
||
WebURL string
|
||
APIURL string
|
||
}
|
||
|
||
// missing names the required values that are empty, in the order client
|
||
// ID, client secret, public URL.
|
||
func (c OAuthConfig) missing() []string {
|
||
var missing []string
|
||
if c.ClientID == "" {
|
||
missing = append(missing, "client ID")
|
||
}
|
||
if c.ClientSecret == "" {
|
||
missing = append(missing, "client secret")
|
||
}
|
||
if c.PublicURL == "" {
|
||
missing = append(missing, "public URL")
|
||
}
|
||
return missing
|
||
}
|
||
|
||
// oauthApp talks to GitHub on behalf of the OAuth App. It never logs a
|
||
// token, a code or the client secret.
|
||
type oauthApp struct {
|
||
clientID string
|
||
clientSecret string
|
||
redirectURI string
|
||
secureCookie bool
|
||
cookieName string
|
||
webURL string
|
||
apiURL string
|
||
client *http.Client
|
||
}
|
||
|
||
func newOAuthApp(c OAuthConfig) (*oauthApp, error) {
|
||
if missing := c.missing(); len(missing) > 0 {
|
||
return nil, fmt.Errorf("sign in with GitHub is required but not configured: missing OAuth %s", strings.Join(missing, ", "))
|
||
}
|
||
pub, err := url.Parse(strings.TrimRight(c.PublicURL, "/"))
|
||
if err != nil || (pub.Scheme != "http" && pub.Scheme != "https") || pub.Host == "" || pub.RawQuery != "" || pub.Fragment != "" {
|
||
return nil, fmt.Errorf("public URL %q must be an absolute http(s) URL such as https://ghglance.example.com", c.PublicURL)
|
||
}
|
||
app := &oauthApp{
|
||
clientID: c.ClientID,
|
||
clientSecret: c.ClientSecret,
|
||
redirectURI: pub.String() + "/auth/callback",
|
||
secureCookie: pub.Scheme == "https",
|
||
cookieName: oauthCookieInsecure,
|
||
webURL: strings.TrimRight(cmp.Or(c.WebURL, defaultOAuthWebURL), "/"),
|
||
apiURL: strings.TrimRight(cmp.Or(c.APIURL, defaultOAuthAPIURL), "/"),
|
||
client: &http.Client{Timeout: oauthTimeout},
|
||
}
|
||
if app.secureCookie {
|
||
app.cookieName = oauthCookie
|
||
}
|
||
return app, nil
|
||
}
|
||
|
||
// webOrigin is the scheme and host of the authorize page, which the page
|
||
// CSP must allow as a form-submission redirect target.
|
||
func (a *oauthApp) webOrigin() string {
|
||
u, err := url.Parse(a.webURL)
|
||
if err != nil {
|
||
return defaultOAuthWebURL
|
||
}
|
||
return u.Scheme + "://" + u.Host
|
||
}
|
||
|
||
// oauthScopes derives the scopes a sign-in asks for from the ticked
|
||
// options, never more: public data needs only read:user, private repos
|
||
// need repo (GitHub has no read-only private scope), and org repos add
|
||
// read:org on top of private access.
|
||
func oauthScopes(o Options) string {
|
||
if !o.IncludePrivate {
|
||
return "read:user"
|
||
}
|
||
if o.IncludeOrgRepos {
|
||
return "repo read:user read:org"
|
||
}
|
||
return "repo read:user"
|
||
}
|
||
|
||
// scopeSet splits GitHub's comma- or space-separated scope list.
|
||
func scopeSet(scopes string) map[string]bool {
|
||
has := map[string]bool{}
|
||
for _, s := range strings.FieldsFunc(scopes, func(r rune) bool { return r == ',' || r == ' ' }) {
|
||
has[s] = true
|
||
}
|
||
return has
|
||
}
|
||
|
||
// grantScopes narrows o to the scopes GitHub actually granted, which the
|
||
// user may have reduced on the consent screen. It reports which option was
|
||
// dropped ("" when none): "private" or "org".
|
||
func grantScopes(o Options, granted string) (Options, string) {
|
||
has := scopeSet(granted)
|
||
if o.IncludePrivate && !has["repo"] {
|
||
o.IncludePrivate, o.IncludeOrgRepos = false, false
|
||
return o, "private"
|
||
}
|
||
if o.IncludePrivate && o.IncludeOrgRepos && !has["read:org"] && !has["write:org"] && !has["admin:org"] {
|
||
o.IncludeOrgRepos = false
|
||
return o, "org"
|
||
}
|
||
return o, ""
|
||
}
|
||
|
||
// extraScopes lists the granted scopes the ticked options did not ask
|
||
// for. GitHub folds every scope a user ever granted the app into each new
|
||
// token and skips the consent screen, so a public-only sign-in can come
|
||
// back with repo from an earlier private one. A job never runs with a
|
||
// token wider than the ticks.
|
||
func extraScopes(o Options, granted string) []string {
|
||
asked := scopeSet(oauthScopes(o))
|
||
var extra []string
|
||
for s := range scopeSet(granted) {
|
||
if !asked[s] {
|
||
extra = append(extra, s)
|
||
}
|
||
}
|
||
slices.Sort(extra)
|
||
return extra
|
||
}
|
||
|
||
// authorizeURL is GitHub's consent page for one sign-in.
|
||
func (a *oauthApp) authorizeURL(state, challenge, scope, login string) string {
|
||
q := url.Values{
|
||
"client_id": {a.clientID},
|
||
"redirect_uri": {a.redirectURI},
|
||
"scope": {scope},
|
||
"state": {state},
|
||
"code_challenge": {challenge},
|
||
"code_challenge_method": {"S256"},
|
||
"login": {login},
|
||
}
|
||
return a.webURL + "/login/oauth/authorize?" + q.Encode()
|
||
}
|
||
|
||
var errExchange = errors.New("GitHub sign-in failed, try again")
|
||
|
||
// exchange trades an authorization code and its PKCE verifier for a token
|
||
// and the scopes GitHub granted.
|
||
func (a *oauthApp) exchange(ctx context.Context, code, verifier string) (token, scope string, err error) {
|
||
ctx, cancel := context.WithTimeout(ctx, oauthTimeout)
|
||
defer cancel()
|
||
form := url.Values{
|
||
"client_id": {a.clientID},
|
||
"client_secret": {a.clientSecret},
|
||
"code": {code},
|
||
"redirect_uri": {a.redirectURI},
|
||
"code_verifier": {verifier},
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.webURL+"/login/oauth/access_token", 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")
|
||
resp, err := a.client.Do(req)
|
||
if err != nil {
|
||
log.Printf("oauth: token exchange failed: %v", err)
|
||
return "", "", errExchange
|
||
}
|
||
defer resp.Body.Close()
|
||
var body struct {
|
||
AccessToken string `json:"access_token"`
|
||
Scope string `json:"scope"`
|
||
Error string `json:"error"`
|
||
}
|
||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&body); err != nil || resp.StatusCode != http.StatusOK {
|
||
log.Printf("oauth: token exchange failed: status %d", resp.StatusCode)
|
||
return "", "", errExchange
|
||
}
|
||
if body.Error != "" || !tokenRE.MatchString(body.AccessToken) {
|
||
log.Printf("oauth: token exchange refused: %s", strconv.Quote(body.Error))
|
||
return "", "", errExchange
|
||
}
|
||
return body.AccessToken, body.Scope, nil
|
||
}
|
||
|
||
// revoke deletes the grant's token on GitHub. It is best effort: a token
|
||
// that cannot be revoked still never leaves the server, and the user can
|
||
// revoke it under Settings > Applications.
|
||
func (a *oauthApp) revoke(token string) {
|
||
if token == "" {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), oauthTimeout)
|
||
defer cancel()
|
||
body, _ := json.Marshal(map[string]string{"access_token": token})
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
|
||
a.apiURL+"/applications/"+url.PathEscape(a.clientID)+"/token", bytes.NewReader(body))
|
||
if err != nil {
|
||
log.Printf("oauth: revoke failed: %v", err)
|
||
return
|
||
}
|
||
req.SetBasicAuth(a.clientID, a.clientSecret)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Accept", "application/vnd.github+json")
|
||
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||
resp, err := a.client.Do(req)
|
||
if err != nil {
|
||
log.Printf("oauth: revoke failed: %v", err)
|
||
return
|
||
}
|
||
io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||
resp.Body.Close()
|
||
if resp.StatusCode != http.StatusNoContent {
|
||
log.Printf("oauth: revoke returned status %d", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
// pendingLogin is a submission waiting for GitHub's callback.
|
||
type pendingLogin struct {
|
||
sub submission
|
||
form formValues
|
||
verifier string
|
||
expires time.Time
|
||
}
|
||
|
||
// pendingLogins holds started sign-ins by state, in memory only.
|
||
type pendingLogins struct {
|
||
now func() time.Time
|
||
|
||
mu sync.Mutex
|
||
entries map[string]*pendingLogin
|
||
}
|
||
|
||
func newPendingLogins() *pendingLogins {
|
||
return &pendingLogins{now: time.Now, entries: map[string]*pendingLogin{}}
|
||
}
|
||
|
||
var errTooManyLogins = errors.New("too many sign-ins are in progress, try again in a few minutes")
|
||
|
||
// add stores p under a fresh random state and returns the state.
|
||
func (l *pendingLogins) add(p *pendingLogin) (string, error) {
|
||
state, err := randomString()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
now := l.now()
|
||
if len(l.entries) >= maxPendingLogins {
|
||
for k, e := range l.entries {
|
||
if !now.Before(e.expires) {
|
||
delete(l.entries, k)
|
||
}
|
||
}
|
||
}
|
||
if len(l.entries) >= maxPendingLogins {
|
||
return "", errTooManyLogins
|
||
}
|
||
p.expires = now.Add(loginTTL)
|
||
l.entries[state] = p
|
||
return state, nil
|
||
}
|
||
|
||
// take removes and returns the sign-in for state, or nil when it is
|
||
// unknown, already used or expired. A state is good for one callback.
|
||
func (l *pendingLogins) take(state string) *pendingLogin {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
p, ok := l.entries[state]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
delete(l.entries, state)
|
||
if !l.now().Before(p.expires) {
|
||
return nil
|
||
}
|
||
return p
|
||
}
|
||
|
||
// randomString is 256 random bits, base64url-encoded: 43 characters, which
|
||
// also fits PKCE's 43–128 character verifier.
|
||
func randomString() (string, error) {
|
||
b := make([]byte, 32)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return "", err
|
||
}
|
||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||
}
|
||
|
||
// pkceChallenge is the S256 code challenge for verifier.
|
||
func pkceChallenge(verifier string) string {
|
||
sum := sha256.Sum256([]byte(verifier))
|
||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||
}
|
||
|
||
// handleAuthStart validates the generation form, parks it under a random
|
||
// state and sends the browser to GitHub's consent page. A pasted token
|
||
// takes precedence over signing in, whichever button sent the form (Enter
|
||
// in the token field presses the first one, the sign-in button): the job
|
||
// is queued on that token straight away, under the same ownership,
|
||
// privacy and cooldown rules, and the token is never revoked.
|
||
func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
|
||
sub, form, ok := s.readSubmission(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if sub.Pasted {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
s.enqueue(w, r, sub, form, "")
|
||
return
|
||
}
|
||
if s.queue.Status(sub.Login).Active() {
|
||
http.Redirect(w, r, "/u/"+url.PathEscape(userKey(sub.Login))+"?notice=pending", http.StatusSeeOther)
|
||
return
|
||
}
|
||
verifier, err := randomString()
|
||
if err != nil {
|
||
s.render(w, http.StatusInternalServerError, "index", pageData{Title: "ghglance", Error: capitalize(errExchange.Error()) + ".", Form: form})
|
||
return
|
||
}
|
||
state, err := s.logins.add(&pendingLogin{sub: sub, form: form, verifier: verifier})
|
||
if err != nil {
|
||
status, msg := http.StatusInternalServerError, errExchange
|
||
if errors.Is(err, errTooManyLogins) {
|
||
status, msg = http.StatusServiceUnavailable, errTooManyLogins
|
||
}
|
||
s.render(w, status, "index", pageData{Title: "ghglance", Error: capitalize(msg.Error()) + ".", Form: form})
|
||
return
|
||
}
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: s.oauth.cookieName,
|
||
Value: state,
|
||
Path: "/",
|
||
MaxAge: int(loginTTL.Seconds()),
|
||
HttpOnly: true,
|
||
Secure: s.oauth.secureCookie,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
http.Redirect(w, r, s.oauth.authorizeURL(state, pkceChallenge(verifier), oauthScopes(sub.Options), sub.Login), http.StatusFound)
|
||
}
|
||
|
||
// handleAuthCallback finishes a sign-in: it checks the state against the
|
||
// browser's cookie, trades the code for a token and queues the job, which
|
||
// enforces the ownership, privacy and cooldown rules once it knows whose
|
||
// token it holds. The token is revoked when the job ends.
|
||
func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: s.oauth.cookieName, Value: "", Path: "/", MaxAge: -1,
|
||
HttpOnly: true, Secure: s.oauth.secureCookie, SameSite: http.SameSiteLaxMode,
|
||
})
|
||
q := r.URL.Query()
|
||
state := q.Get("state")
|
||
c, err := r.Cookie(s.oauth.cookieName)
|
||
if state == "" || err != nil || subtle.ConstantTimeCompare([]byte(c.Value), []byte(state)) != 1 {
|
||
s.renderMessage(w, http.StatusBadRequest, "Sign-in failed",
|
||
"This sign-in was not started in this browser, or it was already used. Start again from the form.")
|
||
return
|
||
}
|
||
p := s.logins.take(state)
|
||
if p == nil {
|
||
s.renderMessage(w, http.StatusBadRequest, "Sign-in expired",
|
||
"This sign-in expired or was already used. Start again from the form.")
|
||
return
|
||
}
|
||
|
||
if e := q.Get("error"); e != "" {
|
||
msg := "GitHub sign-in failed. Your options are kept below; try again."
|
||
if e == "access_denied" {
|
||
msg = "GitHub sign-in was cancelled, so nothing was generated. Your options are kept below."
|
||
} else {
|
||
log.Printf("oauth: callback error %s", strconv.Quote(e))
|
||
}
|
||
s.render(w, http.StatusOK, "index", pageData{Title: "ghglance", Error: msg, Form: p.form})
|
||
return
|
||
}
|
||
code := q.Get("code")
|
||
if code == "" {
|
||
s.render(w, http.StatusBadRequest, "index", pageData{Title: "ghglance", Error: capitalize(errExchange.Error()) + ".", Form: p.form})
|
||
return
|
||
}
|
||
token, granted, err := s.oauth.exchange(r.Context(), code, p.verifier)
|
||
if err != nil {
|
||
s.render(w, http.StatusBadGateway, "index", pageData{Title: "ghglance", Error: capitalize(err.Error()) + ".", Form: p.form})
|
||
return
|
||
}
|
||
|
||
if extra := extraScopes(p.sub.Options, granted); len(extra) > 0 {
|
||
s.oauth.revoke(token)
|
||
s.render(w, http.StatusOK, "index", pageData{Title: "ghglance", Form: p.form, Error: fmt.Sprintf(
|
||
"GitHub returned a token with more access than your ticks ask for (%s), because you granted it to ghglance before. "+
|
||
"Nothing was generated and the token was revoked. Tick the matching options, or revoke ghglance under GitHub "+
|
||
"Settings > Applications > Authorized OAuth Apps and sign in again.", strings.Join(extra, ", "))})
|
||
return
|
||
}
|
||
|
||
sub := p.sub
|
||
sub.Token = token
|
||
var dropped string
|
||
sub.Options, dropped = grantScopes(sub.Options, granted)
|
||
notice := ""
|
||
if dropped != "" {
|
||
notice = "granted-" + dropped
|
||
}
|
||
if !s.enqueue(w, r, sub, p.form, notice) {
|
||
s.oauth.revoke(token)
|
||
}
|
||
}
|