mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-15 14:16:29 +00:00
The web UI now runs every job on an OAuth token from "Sign in with GitHub". The ticked options decide the requested scopes (read:user, plus repo for private repos, plus read:org for org repos); a grant wider than requested is refused. The token lives only on the job and is revoked when the job ends, on every path including shutdown. The server no longer holds a GitHub token of its own, and the pasted-token field and /generate are gone. -serve requires -oauth-client-id, -oauth-client-secret and -public-url (GHGLANCE_OAUTH_* and GHGLANCE_PUBLIC_URL), and compose.yml requires them too. The CLI and the Action keep -token unchanged.
452 lines
15 KiB
Go
452 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.
|
||
func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
|
||
sub, form, ok := s.readSubmission(w, r)
|
||
if !ok {
|
||
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)
|
||
}
|
||
}
|