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
3// Returning to the page you came from after signing in (spec §9.4), and
4// the two sign-in behaviours that go with it: an already-signed-in
5// GET /signin goes straight back, and signing out lands on a page that
6// says so.
7//
8// With Home configured, the return path already rode the Home hand-off
9// (home_routes.go's signin remembers it in __Host-oficina-docs-home-
10// return). What did not honour it was the local email link: rastrillo's
11// auth.Verify always lands on its fixed SignedInPath, so the value cannot
12// ride the emailed link and lives in that same sealed, 600-second cookie
13// instead, read at /auth/local/complete.
14//
15// Standalone Docs (no Home) had nothing: rastrillo's RequireSession drops
16// the path (its refuseSession redirects to a bare /signin) and its
17// SignedInPath was "/". It now gets the same cookie, sealed under a key
18// derived from the instance key, the same /auth/local/complete landing,
19// and a guard that puts return_to on the sign-in redirect. (Spec §9.4
20// left standalone as "a small return cookie of its own, or lands on /";
21// this is the first, so the Playwright deep-link gate can run against a
22// local dev server.)
23//
24// auth_navigation.go's "never comes from return_to" governs the Keymail
25// authorize URL, which must be exactly https://…/oauth/authorize. The
26// path here is a separate value, validated by handoff.SafePath (a
27// same-origin path: no scheme, no host, no //, no control characters),
28// so it can never become an off-site destination.
29
30import (
31 "crypto/sha256"
32 "net/http"
33 "net/url"
34 "strings"
35 "time"
36
37 "amadan.net/oficina/home/handoff"
38 "amadan.net/oficina/home/handoff/seal"
39 rauth "amadan.net/rastrillo/rastrillo/auth"
40)
41
42// standaloneReturn builds the standalone instance's return memory: the
43// same sealed cookie localLinkNavigation keeps for Home instances, keyed
44// from the instance key rather than Home's DEK (standalone has none).
45func standaloneReturn(instanceKey, origin string) (*localLinkNavigation, error) {
46 key := sha256.Sum256([]byte("oficina/docs standalone return v1\x00" + instanceKey))
47 sealer, err := seal.New(key[:], "oficina/docs")
48 if err != nil {
49 return nil, err
50 }
51 u, err := url.Parse(origin)
52 if err != nil {
53 return nil, err
54 }
55 return &localLinkNavigation{sealer: sealer, origin: origin, host: u.Host, now: time.Now}, nil
56}
57
58// requestedReturn is the return_to a sign-in GET carries, when it is
59// exactly one same-origin path.
60func requestedReturn(r *http.Request) (string, bool) {
61 q, err := url.ParseQuery(r.URL.RawQuery)
62 if err != nil {
63 return "", false
64 }
65 values, ok := q["return_to"]
66 if !ok || len(values) != 1 || !handoff.SafePath(values[0]) {
67 return "", false
68 }
69 return values[0], true
70}
71
72// signinOutcome reports whether GET /signin is showing an outcome of the
73// email flow (?sent, ?err, ?force) rather than starting one.
74func signinOutcome(r *http.Request) bool {
75 q := r.URL.Query()
76 return q.Has("sent") || q.Has("err") || q.Has("force")
77}
78
79// alreadySignedIn sends a signed-in GET /signin back where it came
80// from, with no form (spec §9.2: 303 to return_to or /).
81func alreadySignedIn(w http.ResponseWriter, r *http.Request) {
82 target := "/"
83 if path, ok := requestedReturn(r); ok {
84 target = path
85 }
86 w.Header().Set("Cache-Control", "no-store")
87 http.Redirect(w, r, target, http.StatusSeeOther)
88}
89
90// admitted reports whether the signed-in routes would let this request's
91// session in right now, by running the same guard chain (h.requireCurrent:
92// session, identity, admission) around a probe. A session is not enough:
93// a guest whose last share was revoked still holds a valid one, admission
94// sends GET / to /signin, and skipping the form for them sent /signin
95// straight back to / — forever (review finding, Astra, #65).
96func (h *handlers) admitted(r *http.Request) bool {
97 if h.requireCurrent == nil {
98 return false
99 }
100 probe := r.Clone(r.Context())
101 probe.Method = http.MethodGet
102 probe.URL = &url.URL{Path: "/"}
103 probe.RequestURI = "/"
104 reached := false
105 h.requireCurrent(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })).
106 ServeHTTP(&signinResponse{header: make(http.Header)}, probe)
107 return reached
108}
109
110// standaloneSignin is GET /signin without Home: already signed in (and
111// admitted) goes back; a return_to is remembered for the landing; then
112// the form.
113func (h *handlers) standaloneSignin(n *localLinkNavigation) http.HandlerFunc {
114 return func(w http.ResponseWriter, r *http.Request) {
115 if !signinOutcome(r) && h.admitted(r) {
116 alreadySignedIn(w, r)
117 return
118 }
119 // Starting a sign-in remembers where to come back to, and a plain
120 // /signin remembers the list: a page remembered by an earlier,
121 // abandoned attempt must not win over the one being made now. The
122 // email flow's own outcomes (?sent, ?err) leave it alone. Home's
123 // /signin does the same (home_routes.go).
124 if !signinOutcome(r) {
125 path, ok := requestedReturn(r)
126 if !ok {
127 path = "/"
128 }
129 n.rememberReturn(w, path)
130 }
131 h.signin(w, r)
132 }
133}
134
135// completeStandalone is the standalone SignedInPath: land on the
136// remembered page, once, or on the list.
137func (n *localLinkNavigation) completeStandalone(w http.ResponseWriter, r *http.Request) {
138 if !n.guard(w, r, localLinkCompletePath) {
139 return
140 }
141 target := n.returnPath(r)
142 localNavigationCookie(w, homeReturnCookie, "", -1)
143 http.Redirect(w, r, target, http.StatusSeeOther)
144}
145
146// requireSessionReturning is rastrillo's RequireSession, except that a
147// signed-out page request is sent to /signin with return_to, as
148// rastrillo's own sessions.Require already does and auth.RequireSession
149// (its refuseSession) does not. Anything else is exactly what it was.
150func requireSessionReturning(a *rauth.Auth) func(http.Handler) http.Handler {
151 return func(next http.Handler) http.Handler {
152 guarded := a.RequireSession(next)
153 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154 if r.Method == http.MethodGet || r.Method == http.MethodHead {
155 if _, ok := a.SessionFrom(r); !ok {
156 http.Redirect(w, r, "/signin?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
157 return
158 }
159 }
160 guarded.ServeHTTP(w, r)
161 })
162 }
163}
164
165// signoutToSignedOut is rastrillo's Signout, landing on /signed-out ("You’re
166// signed out") rather than straight back on the sign-in form, which read
167// as though nothing had happened.
168func signoutToSignedOut(signout http.HandlerFunc) http.HandlerFunc {
169 return func(w http.ResponseWriter, r *http.Request) {
170 result := &signinResponse{header: make(http.Header)}
171 signout(result, r)
172 if result.status == http.StatusSeeOther && strings.HasPrefix(result.header.Get("Location"), "/signin") {
173 result.header.Set("Location", "/signed-out")
174 }
175 for name, values := range result.header {
176 for _, value := range values {
177 w.Header().Add(name, value)
178 }
179 }
180 if result.status == 0 {
181 result.status = http.StatusOK
182 }
183 w.WriteHeader(result.status)
184 _, _ = w.Write(result.body.Bytes())
185 }
186}
187