Files
ghglance/internal/web/oauth.go
T
tiennm99 537c87c458 feat(web): show cards inline for quick viewing and accept the visitor's own token
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.
2026-10-07 19:15:15 +07:00

461 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}