oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "database/sql"
5 "net/http"
6 "net/url"
7 "strings"
8 "time"
9
10 "amadan.net/oficina/home/handoff"
11 "amadan.net/oficina/home/handoff/seal"
12 "amadan.net/rastrillo/rastrillo"
13 rauth "amadan.net/rastrillo/rastrillo/auth"
14 "amadan.net/rastrillo/rastrillo/csrf"
15 "amadan.net/rastrillo/rastrillo/sessions"
16 "github.com/go-chi/chi/v5"
17)
18
19type homeRoutes struct {
20 bridge *homeBridge
21 navigation *localLinkNavigation
22 handlers *handlers
23 formPolicy string
24 issuer string
25}
26
27func newHomeRoutes(settings *homeSettings, origin string, db *sql.DB, auth *rauth.Auth, h *handlers) (*homeRoutes, error) {
28 b, err := newHomeBridge(settings, origin, db, auth, h.render, h.store)
29 if err != nil {
30 return nil, err
31 }
32 sealer, err := seal.New(settings.dek, "oficina/docs")
33 if err != nil {
34 return nil, errHomeConfig
35 }
36 navigation := newLocalLinkNavigation(sealer, b.sessions, origin)
37 if navigation == nil {
38 return nil, errHomeConfig
39 }
40 b.returnPath = navigation.returnPath
41 return &homeRoutes{bridge: b, navigation: navigation, handlers: h, formPolicy: homeFormPolicy(settings.issuer), issuer: settings.issuer}, nil
42}
43
44func cookieCount(r *http.Request, name string) int {
45 n := 0
46 for _, c := range r.Cookies() {
47 if c.Name == name {
48 n++
49 }
50 }
51 return n
52}
53
54// Only the assertion callback crosses origins. Its shared consumer validates
55// the exact issuer without consulting the browser's local session.
56func homeCSRF(origin string, home *homeRoutes) func(http.Handler) http.Handler {
57 protect := csrf.Protect(origin)
58 if home == nil {
59 return protect
60 }
61 return func(next http.Handler) http.Handler {
62 checked := protect(next)
63 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64 w.Header().Set("Content-Security-Policy", home.formPolicy)
65 if r.Host != strings.TrimPrefix(origin, "https://") {
66 http.NotFound(w, r)
67 return
68 }
69 if r.Method == http.MethodPost && r.URL.Path == handoff.CallbackPath {
70 next.ServeHTTP(w, r)
71 return
72 }
73 if cookieCount(r, home.bridge.sessionCookie) > 1 {
74 home.bridge.fail(w, r, http.StatusBadRequest)
75 return
76 }
77 checked.ServeHTTP(w, r)
78 })
79 }
80}
81
82// The local plugin resolves the first pending cookie, so reject ambiguity
83// before it can verify a gesture or rotate the existing session.
84func localAuthGuard(home *homeRoutes, next http.HandlerFunc) http.HandlerFunc {
85 if home == nil {
86 return next
87 }
88 return func(w http.ResponseWriter, r *http.Request) {
89 if cookieCount(r, home.bridge.sessionCookie) > 1 || cookieCount(r, "__Host-rastrillo_pending") > 1 {
90 home.bridge.fail(w, r, http.StatusBadRequest)
91 return
92 }
93 next(w, r)
94 }
95}
96
97func (h *homeRoutes) mountPublic(r chi.Router) {
98 r.Get("/signin", h.signin)
99 r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) {
100 h.handlers.render.homeLink(w, r, "signedout", "", http.StatusOK)
101 })
102 r.Get("/signin/local", h.handlers.signin)
103 r.Get(localLinkCompletePath, h.navigation.Complete)
104 r.Get(handoff.StartPath, h.bridge.Start)
105 r.Post(handoff.CallbackPath, h.bridge.Callback)
106 r.Get(handoff.FinishPath, h.bridge.Finish)
107}
108
109func (h *homeRoutes) signin(w http.ResponseWriter, r *http.Request) {
110 q, err := url.ParseQuery(r.URL.RawQuery)
111 if err != nil {
112 h.bridge.fail(w, r, http.StatusBadRequest)
113 return
114 }
115 // These are outcomes from the existing local email flow. Keep its form
116 // available even when the browser already has a Home session.
117 if q.Has("sent") || q.Has("err") || q.Has("force") {
118 h.handlers.signin(w, r)
119 return
120 }
121 // Already signed in to Docs: straight back, with no form and no new
122 // Home exchange (spec §9.2). A person with no session goes on to Home
123 // as before.
124 // Admitted, not merely holding a session: a refused identity must be
125 // able to reach sign-in, or the guard and this shortcut redirect to
126 // each other forever (h.handlers.admitted).
127 if _, ok := h.bridge.sessions.From(r); ok && h.handlers.admitted(r) {
128 alreadySignedIn(w, r)
129 return
130 }
131 path := "/"
132 if values, ok := q["return_to"]; ok && len(values) == 1 && handoff.SafePath(values[0]) {
133 path = values[0]
134 }
135 if !h.navigation.rememberReturn(w, path) {
136 h.bridge.fail(w, r, http.StatusServiceUnavailable)
137 return
138 }
139 w.Header().Set("Cache-Control", "no-store")
140 http.Redirect(w, r, handoff.StartPath+"?"+url.Values{"return_to": {path}}.Encode(), http.StatusSeeOther)
141}
142
143func (h *homeRoutes) mountAccount(r chi.Router) {
144 r.Get("/account/home", func(w http.ResponseWriter, r *http.Request) {
145 s, ok := sessions.Current(r)
146 if _, fresh := freshLocalAuthentication(s, time.Now()); !ok || !fresh {
147 h.navigation.Begin(w, r)
148 return
149 }
150 h.bridge.LinkPage(w, r)
151 })
152 r.Post("/account/home", func(w http.ResponseWriter, r *http.Request) {
153 s, ok := sessions.Current(r)
154 if _, fresh := freshLocalAuthentication(s, time.Now()); !ok || !fresh {
155 w.Header().Set("Cache-Control", "no-store")
156 http.Redirect(w, r, "/account/home", http.StatusSeeOther)
157 return
158 }
159 h.bridge.LinkStart(w, r)
160 })
161 r.Get("/account/home/confirm", h.bridge.ConfirmPage)
162 r.Post("/account/home/confirm", func(w http.ResponseWriter, r *http.Request) {
163 localNavigationCookie(w, homeReturnCookie, "", -1)
164 h.bridge.ConfirmPost(w, r)
165 })
166}
167
168func (h *handlers) homeLinkLabel(r *http.Request) string {
169 if !h.homeEnabled {
170 return ""
171 }
172 if id, ok := currentIdentity(r); ok && id.Session.Method == homeAssertionMethod {
173 return ""
174 }
175 return rastrillo.T(r, "docs.home.menu_action")
176}
177
178// signout ends the Docs session and clears the hand-off cookies, then hands
179// the same POST on to {issuer}/signout, which ends the id.* session too and
180// returns the browser to this origin's /signed-out (spec shared-chrome §9.5;
181// connect.Routes.Signout does the same for every other app). Ending only the
182// Docs session left "Sign in" on the signed-out page passing silently
183// through the live 30-day id.* session.
184//
185// - 307, not 303, so the hop stays a POST. id.* ignores a GET sign-out,
186// because any link or <img> can trigger one.
187// - The browser sends "Origin: null" on the redirected POST, so id.* admits
188// it by Referer. strict-origin-when-cross-origin makes the Referer
189// exactly this origin, which id.* checks against HOME_CONSUMERS.
190// - homeFormPolicy already names the issuer in form-action, which browsers
191// enforce on the redirect target of a form submission.
192//
193// /signed-out itself still never starts another exchange.
194func (h *homeRoutes) signout(w http.ResponseWriter, r *http.Request) {
195 h.bridge.sessions.SignOut(w, r)
196 for _, name := range []string{homeInitCookie, homeProofCookie, localLinkNavigationCookie, homeReturnCookie, "__Host-oficina-assertion-pending", "__Host-oficina-assertion-inbox"} {
197 localNavigationCookie(w, name, "", -1)
198 }
199 w.Header().Set("Cache-Control", "no-store")
200 w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
201 http.Redirect(w, r, h.issuer+"/signout", http.StatusTemporaryRedirect)
202}
203