158 lines
4.8 KiB
Go
158 lines
4.8 KiB
Go
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"net"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templateFS embed.FS
|
|
|
|
var (
|
|
errorTemplate = template.Must(template.ParseFS(templateFS, "templates/error.html"))
|
|
successTemplate = template.Must(template.ParseFS(templateFS, "templates/success.html"))
|
|
)
|
|
|
|
// callbackResult is delivered by the callback server once the browser redirect
|
|
// arrives. Exactly one of code or err is set.
|
|
type callbackResult struct {
|
|
code string
|
|
err error
|
|
}
|
|
|
|
// callbackServer is a short-lived local HTTP server that captures the
|
|
// authorization code from the OAuth redirect.
|
|
type callbackServer struct {
|
|
server *http.Server
|
|
listener net.Listener
|
|
redirect string
|
|
results chan callbackResult
|
|
}
|
|
|
|
// listenCallback binds the local callback listener.
|
|
//
|
|
// It binds to loopback (127.0.0.1) by default so the callback server is never
|
|
// exposed on other interfaces. bindAll is set only inside a container, where
|
|
// Docker's published-port DNAT delivers traffic to the container's eth0 rather
|
|
// than to loopback; host-side exposure is still constrained by the publish
|
|
// (e.g. -p 127.0.0.1:8085:8085). A native run — even with a fixed port — stays
|
|
// on loopback.
|
|
func listenCallback(port int, bindAll bool) (net.Listener, error) {
|
|
host := "127.0.0.1"
|
|
if bindAll {
|
|
host = "0.0.0.0"
|
|
}
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("starting callback listener on %s: %w", addr, err)
|
|
}
|
|
return listener, nil
|
|
}
|
|
|
|
// newCallbackServer starts a callback server on listener that validates state
|
|
// and reports the result on a buffered channel. The redirect URI always uses
|
|
// localhost so it matches the value registered on the OAuth/GitHub App.
|
|
func newCallbackServer(listener net.Listener, expectedState string) *callbackServer {
|
|
cs := &callbackServer{
|
|
server: &http.Server{ReadHeaderTimeout: 10 * time.Second}, // ReadHeaderTimeout guards against Slowloris.
|
|
listener: listener,
|
|
redirect: fmt.Sprintf("http://localhost:%d/callback", listener.Addr().(*net.TCPAddr).Port),
|
|
results: make(chan callbackResult, 1),
|
|
}
|
|
cs.server.Handler = cs.handler(expectedState)
|
|
|
|
go func() {
|
|
if err := cs.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
cs.report(callbackResult{err: fmt.Errorf("callback server: %w", err)})
|
|
}
|
|
}()
|
|
|
|
return cs
|
|
}
|
|
|
|
// handler renders the callback endpoint. It reports the outcome exactly once and
|
|
// always shows the user a friendly page.
|
|
func (cs *callbackServer) handler(expectedState string) http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
if errCode := q.Get("error"); errCode != "" {
|
|
msg := errCode
|
|
if desc := q.Get("error_description"); desc != "" {
|
|
msg = fmt.Sprintf("%s: %s", errCode, desc)
|
|
}
|
|
cs.report(callbackResult{err: fmt.Errorf("authorization failed: %s", msg)})
|
|
renderError(w, msg)
|
|
return
|
|
}
|
|
|
|
if q.Get("state") != expectedState {
|
|
cs.report(callbackResult{err: fmt.Errorf("state mismatch (possible CSRF)")})
|
|
renderError(w, "state mismatch")
|
|
return
|
|
}
|
|
|
|
code := q.Get("code")
|
|
if code == "" {
|
|
cs.report(callbackResult{err: fmt.Errorf("no authorization code in callback")})
|
|
renderError(w, "no authorization code received")
|
|
return
|
|
}
|
|
|
|
cs.report(callbackResult{code: code})
|
|
renderSuccess(w)
|
|
})
|
|
return mux
|
|
}
|
|
|
|
// report delivers the first outcome and drops later ones (the channel is
|
|
// buffered for one; subsequent redirect retries must not block the handler).
|
|
func (cs *callbackServer) report(res callbackResult) {
|
|
select {
|
|
case cs.results <- res:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// wait blocks for the callback outcome or ctx cancellation, then shuts the
|
|
// server down. It is safe to call once per server.
|
|
func (cs *callbackServer) wait(ctx context.Context) (string, error) {
|
|
defer cs.close()
|
|
select {
|
|
case res := <-cs.results:
|
|
return res.code, res.err
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (cs *callbackServer) close() {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
_ = cs.server.Shutdown(shutdownCtx)
|
|
_ = cs.listener.Close()
|
|
}
|
|
|
|
func renderSuccess(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := successTemplate.Execute(w, nil); err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// renderError shows the failure page. html/template auto-escapes msg, so a
|
|
// hostile error_description cannot inject markup.
|
|
func renderError(w http.ResponseWriter, msg string) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := errorTemplate.Execute(w, struct{ ErrorMessage string }{ErrorMessage: msg}); err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|