orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package web
2
3import (
4 "encoding/json"
5 "io"
6 "log/slog"
7 "net/http"
8 "net/url"
9 "strings"
10 "time"
11)
12
13// Single sign-on for every bythewood.me site, against auth.bythewood.me.
14//
15// The cookie is opaque, so a site cannot check it for itself and has to ask.
16// That is the point: a signed cookie is valid until it expires no matter what
17// the issuer says, and asking is what makes revoking a session take effect
18// everywhere on the next request rather than whenever the signature ages out.
19//
20// The answer is not cached. A call to another container on the bridge is well
21// under a millisecond and these are dashboards one person reads, so a cache
22// would buy nothing and would put a window on revocation, which is the feature
23// this exists for.
24//
25// The cost is real and worth stating: with orchard-auth down, every site behind
26// this is unreachable. That is why auth.bythewood.me has recovery codes and why
27// nothing public is behind it.
28const (
29 SessionCookie = "bw_session"
30
31 authVerifyURL = "http://orchard-auth:8000/verify"
32 authLoginURL = "https://auth.bythewood.me/login"
33
34 // Short. A dashboard that hangs because auth is slow is worse than one
35 // that says you are signed out.
36 authTimeout = 3 * time.Second
37)
38
39// Authenticator answers whether a request carries a live session.
40type Authenticator struct {
41 client *http.Client
42 verify string
43}
44
45func NewAuthenticator() *Authenticator { return NewAuthenticatorAt(authVerifyURL) }
46
47// NewAuthenticatorAt points at a different verifier, which is what the tests
48// use to stand one up without a running auth container.
49func NewAuthenticatorAt(verify string) *Authenticator {
50 return &Authenticator{
51 client: &http.Client{
52 Timeout: authTimeout,
53 // A verify call must never follow a redirect: the answer is the
54 // status code, and a 302 to somewhere else is not an answer.
55 CheckRedirect: func(*http.Request, []*http.Request) error {
56 return http.ErrUseLastResponse
57 },
58 },
59 verify: verify,
60 }
61}
62
63// Authenticated reports whether the caller is signed in. Every failure is a no,
64// including auth being unreachable, because the alternative is failing open.
65func (a *Authenticator) Authenticated(r *http.Request) bool {
66 // A site built without one is signed out rather than a panic, which matters
67 // because this is called from the page data every template renders.
68 if a == nil || a.client == nil {
69 return false
70 }
71
72 c, err := r.Cookie(SessionCookie)
73 if err != nil || c.Value == "" {
74 return false
75 }
76
77 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.verify, nil)
78 if err != nil {
79 return false
80 }
81 req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.Value})
82
83 resp, err := a.client.Do(req)
84 if err != nil {
85 slog.Error("verifying a session failed",
86 slog.String("component", "auth"),
87 slog.Any("err", err))
88 return false
89 }
90 defer resp.Body.Close()
91
92 var body struct {
93 OK bool `json:"ok"`
94 }
95 if err := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&body); err != nil {
96 return false
97 }
98 return resp.StatusCode == http.StatusOK && body.OK
99}
100
101// RequireAuth gates a handler, sending anyone without a session to the login
102// with a way back.
103func (a *Authenticator) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
104 return func(w http.ResponseWriter, r *http.Request) {
105 if !a.Authenticated(r) {
106 http.Redirect(w, r, LoginURL(r), http.StatusSeeOther)
107 return
108 }
109 next(w, r)
110 }
111}
112
113// RequireAuthJSON gates the endpoints a dashboard's own JavaScript calls, where
114// a redirect to an HTML login page would be parsed as data.
115func (a *Authenticator) RequireAuthJSON(next http.HandlerFunc) http.HandlerFunc {
116 return func(w http.ResponseWriter, r *http.Request) {
117 if !a.Authenticated(r) {
118 w.Header().Set("Content-Type", "application/json; charset=utf-8")
119 w.Header().Set("Cache-Control", "no-store")
120 w.WriteHeader(http.StatusUnauthorized)
121 _, _ = w.Write([]byte(`{"error":"not signed in"}`))
122 return
123 }
124 next(w, r)
125 }
126}
127
128// LoginURL is where a signed out visitor goes, carrying an absolute return
129// address because the login is on another host.
130//
131// The return address is never this site's own /login. That path is itself a
132// redirect to auth, so handing it back as the destination is an infinite loop:
133// auth sends you there, the stub sends you to auth, auth sees a live session and
134// sends you there again. An explicit ?next= on the stub is honoured instead, and
135// the site root is the fallback.
136func LoginURL(r *http.Request) string {
137 scheme := "https"
138 if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "http" {
139 scheme = "http"
140 }
141
142 target := r.URL.RequestURI()
143 if r.URL.Path == "/login" {
144 target = "/"
145 if n := r.URL.Query().Get("next"); strings.HasPrefix(n, "/") &&
146 !strings.HasPrefix(n, "//") && !strings.HasPrefix(n, "/\\") &&
147 n != "/login" {
148 target = n
149 }
150 }
151
152 back := scheme + "://" + r.Host + target
153 return authLoginURL + "?next=" + url.QueryEscape(back)
154}
155
156// LogoutURL ends the session for every site at once, which is the only sensible
157// meaning of signing out when one cookie covers all of them.
158func LogoutURL() string { return "https://auth.bythewood.me/" }