Files

355 lines
12 KiB
Go

package editor
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
)
// Signing in is the platform's OAuth authorization-code flow with PKCE.
// The editor never sees a password. It asks for the person's profile and for
// repository access, which saving needs (pushing draft branches, opening and
// merging pull requests); the platform still decides, per repository, what
// that person may actually do. The state and PKCE verifier travel in a
// sealed, short-lived cookie rather than in server memory.
type endpoints struct{ authorize, token, user, scope string }
func (f *ForgeConfig) endpoints() endpoints {
b := f.base()
switch f.Kind {
case forge.GitHub:
api := "https://api.github.com"
if !strings.EqualFold(f.Host, "github.com") {
api = b + "/api/v3"
}
return endpoints{b + "/login/oauth/authorize", b + "/login/oauth/access_token", api + "/user", "read:user repo"}
case forge.GitLab:
return endpoints{b + "/oauth/authorize", b + "/oauth/token", b + "/api/v4/user", "read_user api"}
case forge.Bitbucket:
return endpoints{"https://bitbucket.org/site/oauth2/authorize", "https://bitbucket.org/site/oauth2/access_token", "https://api.bitbucket.org/2.0/user", ""}
default: // Gitea, Forgejo
return endpoints{b + "/login/oauth/authorize", b + "/login/oauth/access_token", b + "/api/v1/user", "read:user write:repository"}
}
}
type oauthState struct {
Forge string `json:"f"`
State string `json:"s"`
Verifier string `json:"v"`
Return string `json:"r"`
Expires time.Time `json:"e"`
}
func randomString(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func (s *Server) redirectURI() string { return s.cfg.PublicURL + "/auth/callback" }
// login sends the browser to the platform's consent page.
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
f := s.cfg.forge(r.URL.Query().Get("forge"))
if f == nil {
http.Error(w, "no such platform here", http.StatusBadRequest)
return
}
st := oauthState{Forge: f.Host, State: randomString(24), Verifier: randomString(48), Return: safeReturn(r.URL.Query().Get("return")), Expires: time.Now().Add(10 * time.Minute)}
if err := s.seal.setCookie(w, stateCookie, st, 10*time.Minute); err != nil {
http.Error(w, "couldn't start sign-in", http.StatusInternalServerError)
return
}
sum := sha256.Sum256([]byte(st.Verifier))
q := url.Values{
"client_id": {f.ClientID}, "redirect_uri": {s.redirectURI()}, "response_type": {"code"},
"state": {st.State}, "code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}, "code_challenge_method": {"S256"},
}
if sc := f.endpoints().scope; sc != "" {
q.Set("scope", sc)
}
// Adding a second account on a platform: GitHub can ask which account to
// use. Elsewhere the platform signs in whoever is signed in there.
if r.URL.Query().Get("select") == "1" && f.Kind == forge.GitHub {
q.Set("prompt", "select_account")
}
http.Redirect(w, r, f.endpoints().authorize+"?"+q.Encode(), http.StatusFound)
}
// safeReturn keeps the after-sign-in destination on the editor itself.
func safeReturn(p string) string {
// A path on the editor itself, nothing else. Browsers drop tabs and
// newlines from addresses and read a backslash as a slash, so "/\t/x"
// would become "//x", another site: refuse every control character,
// space and backslash before looking at the shape.
for _, r := range p {
if r < 0x21 || r == 0x7f || r == '\\' {
return "/"
}
}
u, err := url.Parse(p)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
return "/"
}
return p
}
// callback finishes sign-in: checks state, trades the code for a token,
// asks the platform who this is, and seals it all into the session cookie.
func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
c, err := s.seal.cookie(r, stateCookie)
var st oauthState
if err != nil || s.seal.open(stateCookie, c.Value, &st) != nil || time.Now().After(st.Expires) {
http.Error(w, "Sign-in expired or was started elsewhere. Start again from the editor.", http.StatusBadRequest)
return
}
s.seal.clearCookie(w, stateCookie)
q := r.URL.Query()
if e := q.Get("error"); e != "" {
http.Error(w, "The platform declined the sign-in: "+e, http.StatusForbidden)
return
}
if q.Get("state") == "" || q.Get("state") != st.State {
http.Error(w, "Sign-in state didn't match. Start again from the editor.", http.StatusBadRequest)
return
}
f := s.cfg.forge(st.Forge)
if f == nil {
http.Error(w, "no such platform here", http.StatusBadRequest)
return
}
ts, err := s.exchange(r.Context(), f, q.Get("code"), st.Verifier)
token := ts.Access
if err != nil {
s.log.Printf("sign-in with %s: %v", f.Host, err)
http.Error(w, "The platform didn't accept the sign-in. Try again.", http.StatusBadGateway)
return
}
login, name, email, err := s.whoami(r.Context(), f, token)
if err != nil {
s.log.Printf("sign-in with %s: %v", f.Host, err)
http.Error(w, "Couldn't read your profile from the platform.", http.StatusBadGateway)
return
}
sess := Session{Forge: f.Host, Login: login, Name: name, Email: email, Token: token, Expires: time.Now().Add(sessionTTL), Refresh: ts.Refresh, TokenExp: ts.Expires}
// The same account again refreshes its cookie; a new one takes a free slot.
slot := -1
used := map[int]bool{}
for _, a := range s.seal.accounts(r) {
used[a.slot] = true
if a.Forge == sess.Forge && a.Login == sess.Login {
slot = a.slot
}
}
for i := 0; i < maxAccounts && slot < 0; i++ {
if !used[i] {
slot = i
}
}
if slot < 0 {
http.Error(w, fmt.Sprintf("You're signed in to %d accounts, the most the editor holds. Sign one out, then add this one.", maxAccounts), http.StatusConflict)
return
}
if err := s.seal.setCookie(w, accountCookie(slot), sess, sessionTTL); err != nil {
http.Error(w, "couldn't start the session", http.StatusInternalServerError)
return
}
s.log.Printf("%s signed in with %s", login, f.Host)
http.Redirect(w, r, st.Return, http.StatusFound)
}
// tokenSet is what a platform's token endpoint hands back.
type tokenSet struct {
Access string
Refresh string
Expires time.Time // zero when the token doesn't expire
}
func (s *Server) exchange(ctx context.Context, f *ForgeConfig, code, verifier string) (tokenSet, error) {
return s.tokenRequest(ctx, f, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {s.redirectURI()}, "code_verifier": {verifier}})
}
// renew trades a refresh token for a new access token.
func (s *Server) renew(ctx context.Context, f *ForgeConfig, refresh string) (tokenSet, error) {
return s.tokenRequest(ctx, f, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refresh}})
}
func (s *Server) tokenRequest(ctx context.Context, f *ForgeConfig, form url.Values) (tokenSet, error) {
secret := os.Getenv(f.ClientSecretEnv)
if secret == "" {
return tokenSet{}, fmt.Errorf("%s is not set", f.ClientSecretEnv)
}
form.Set("client_id", f.ClientID)
if f.Kind != forge.Bitbucket {
form.Set("client_secret", secret)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.endpoints().token, strings.NewReader(form.Encode()))
if err != nil {
return tokenSet{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
if f.Kind == forge.Bitbucket {
req.SetBasicAuth(f.ClientID, secret)
}
res, err := s.http.Do(req)
if err != nil {
return tokenSet{}, err
}
defer res.Body.Close()
var d struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
Description string `json:"error_description"`
}
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err := json.Unmarshal(body, &d); err != nil {
return tokenSet{}, fmt.Errorf("token response: %s", res.Status)
}
if d.AccessToken == "" {
return tokenSet{}, fmt.Errorf("no token: %s %s", d.Error, d.Description)
}
ts := tokenSet{Access: d.AccessToken, Refresh: d.RefreshToken}
if d.ExpiresIn > 0 {
ts.Expires = time.Now().Add(time.Duration(d.ExpiresIn) * time.Second)
}
return ts, nil
}
// refreshAhead is how long before a token expires it's renewed.
const refreshAhead = 2 * time.Minute
type renewal struct {
ts tokenSet
err error
at time.Time
}
// freshen renews any account's token that is about to expire, and updates
// its cookie. Requests that arrive together with the same refresh token share
// one renewal, since some platforms accept a refresh token only once. An
// account whose renewal is refused is signed out; the others stay.
func (s *Server) freshen(w http.ResponseWriter, r *http.Request, accts []*Session) []*Session {
out := accts[:0:0]
for _, a := range accts {
if a.TokenExp.IsZero() || time.Until(a.TokenExp) > refreshAhead || a.Refresh == "" {
out = append(out, a)
continue
}
f := s.cfg.forge(a.Forge)
if f == nil {
continue
}
s.renewMu.Lock()
got, ok := s.renewed[a.Refresh]
if !ok || time.Since(got.at) > time.Minute {
ts, err := s.renew(r.Context(), f, a.Refresh)
got = renewal{ts: ts, err: err, at: time.Now()}
if s.renewed == nil || len(s.renewed) > 1024 {
s.renewed = map[string]renewal{}
}
s.renewed[a.Refresh] = got
}
s.renewMu.Unlock()
if got.err != nil {
s.log.Printf("%s on %s: renewing the token failed, signing out that account: %v", a.Login, a.Forge, got.err)
s.seal.clearCookie(w, accountCookie(a.slot))
continue
}
a.Token, a.TokenExp = got.ts.Access, got.ts.Expires
if got.ts.Refresh != "" {
a.Refresh = got.ts.Refresh
}
_ = s.seal.setCookie(w, accountCookie(a.slot), a, time.Until(a.Expires))
out = append(out, a)
}
return out
}
func (s *Server) whoami(ctx context.Context, f *ForgeConfig, token string) (login, name, email string, err error) {
var d struct {
Login string `json:"login"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Name string `json:"name"`
FullName string `json:"full_name"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
ID int64 `json:"id"`
}
if err := s.api(ctx, f, token, f.endpoints().user, &d); err != nil {
return "", "", "", err
}
login = firstNonEmpty(d.Login, d.Username, d.Nickname)
name = firstNonEmpty(d.FullName, d.Name, d.DisplayName, login)
if login == "" {
return "", "", "", errors.New("the platform didn't say who you are")
}
// The address commits are attributed to: the one the platform shows, or
// a no-reply address on the platform when the person keeps theirs private.
email = d.Email
if email == "" {
if f.Kind == forge.GitHub {
// GitHub's no-reply form with the account's number, which it
// ties to the account even after a rename.
email = login + "@users.noreply.github.com"
if d.ID > 0 {
email = strconv.FormatInt(d.ID, 10) + "+" + email
}
} else {
email = login + "@noreply." + f.Host
}
}
return login, name, email, nil
}
func firstNonEmpty(v ...string) string {
for _, s := range v {
if s != "" {
return s
}
}
return ""
}
// api GETs a platform API URL as the signed-in person and decodes JSON.
func (s *Server) api(ctx context.Context, f *ForgeConfig, token, u string, v any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "hotdog-cms-editor")
res, err := s.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode == http.StatusUnauthorized {
return errSignedOut
}
if res.StatusCode >= 300 {
return fmt.Errorf("%s: %s", u, res.Status)
}
return json.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(v)
}
var errSignedOut = errors.New("the platform no longer accepts this session")