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 "amadan.net/oficina/estilo.git/brand"
5 "amadan.net/oficina/estilo.git/chrome"
6 "amadan.net/oficina/estilo.git/sky"
7 "bytes"
8 "context"
9 "net/http"
10 "net/url"
11 "strings"
12
13 "amadan.net/rastrillo/rastrillo"
14 rauth "amadan.net/rastrillo/rastrillo/auth"
15)
16
17// End the POST with a document before navigating to an identity provider.
18// Safari applies form-action to a redirect chain, including intermediate GETs.
19// The continuation keeps the original CSP and works without JavaScript.
20func (h *handlers) beginSignin(next http.HandlerFunc) http.HandlerFunc {
21 return func(w http.ResponseWriter, r *http.Request) {
22 result := &signinResponse{header: make(http.Header)}
23 next(result, r)
24 destination := result.header.Get("Location")
25 external := destination != "" && !strings.HasPrefix(destination, "/")
26 if result.status == http.StatusSeeOther && external {
27 if !keymailDestination(destination, h.origin) {
28 h.fail(w, r, http.StatusBadGateway, failStatus)
29 return
30 }
31 for _, cookie := range result.header.Values("Set-Cookie") {
32 w.Header().Add("Set-Cookie", cookie)
33 }
34 w.Header().Set("Cache-Control", "no-store")
35 w.Header().Set("Referrer-Policy", "no-referrer")
36 data := signinPage{
37 Frame: h.frame(r, frameOptions{titleParts: []string{chrome.SignIn{}.Page()}, signinPage: true}),
38 SubmitLabel: rastrillo.T(r, "docs.signin.keymail"),
39 ContinueURL: destination,
40 Door: chrome.SignInNotice{Icon: brand.Path("docs"), App: "Docs", Sky: sky.For(r.Host)},
41 }
42 if err := h.render.page(w, r, "signin", data); err != nil {
43 h.logger.Error("docs: render Keymail continuation", "err", err)
44 h.fail(w, r, http.StatusInternalServerError, failStatus)
45 }
46 return
47 }
48 for name, values := range result.header {
49 for _, value := range values {
50 w.Header().Add(name, value)
51 }
52 }
53 if result.status == 0 {
54 result.status = http.StatusOK
55 }
56 w.WriteHeader(result.status)
57 _, _ = w.Write(result.body.Bytes())
58 }
59}
60
61// The destination comes only from the identity plugin's server-generated
62// redirect, never from return_to or another submitted URL.
63func keymailDestination(destination, origin string) bool {
64 u, err := url.Parse(destination)
65 if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Path != "/oauth/authorize" || u.RawPath != "" {
66 return false
67 }
68 q, err := url.ParseQuery(u.RawQuery)
69 if err != nil {
70 return false
71 }
72 for _, key := range []string{"client_id", "redirect_uri", "scope", "code_challenge_method", "state", "code_challenge"} {
73 if len(q[key]) != 1 || q.Get(key) == "" {
74 return false
75 }
76 }
77 origin = strings.TrimSuffix(origin, "/")
78 return q.Get("client_id") == origin && q.Get("redirect_uri") == origin+"/auth/callback" && q.Get("scope") == "identify" && q.Get("code_challenge_method") == "S256"
79}
80
81type signinResponse struct {
82 header http.Header
83 status int
84 body bytes.Buffer
85}
86
87func (w *signinResponse) Header() http.Header { return w.header }
88func (w *signinResponse) WriteHeader(status int) {
89 if w.status == 0 {
90 w.status = status
91 }
92}
93func (w *signinResponse) Write(p []byte) (int, error) {
94 if w.status == 0 {
95 w.WriteHeader(http.StatusOK)
96 }
97 return w.body.Write(p)
98}
99
100// signinDoor is what rastrillo/auth calls back into for the two pages it
101// hands to the app (newAuthPlugin). The plugin is built before the
102// handlers, so these are bound late, by New.
103type signinDoor struct {
104 refused func(http.ResponseWriter, *http.Request)
105 confirm func(http.ResponseWriter, *http.Request, rauth.ConfirmPageData)
106}
107
108// signinRefused is Config.Refused: Keymail or an emailed link verified
109// an address the allowlist does not hold. It answers with the door, in a
110// 403, with the refusal marked on the request context (refusedHere), so
111// the form is the way on. rastrillo has already set any cookies the
112// attempt clears.
113//
114// Rendered, not redirected to ?err=refused: a query parameter is
115// something anybody can type, and the sentence means "you proved this
116// address and it is not admitted". Only someone who has just verified
117// the address reaches it, so it is no oracle.
118func (h *handlers) signinRefused(w http.ResponseWriter, r *http.Request) {
119 w.Header().Set("Cache-Control", "no-store")
120 h.signin(&forcedStatus{ResponseWriter: w, status: http.StatusForbidden},
121 r.WithContext(context.WithValue(r.Context(), refusedKey{}, true)))
122}
123
124// signinConfirm is the emailed link's confirm step: the door with one
125// Sign in button that POSTs the token back to /auth/verify, where
126// rastrillo spends it. The GET spends nothing, so a mail scanner that
127// fetches every link cannot sign anybody in.
128type signinConfirm struct {
129 Heading, Submit string
130 Action, Token string
131}
132
133// confirmSignin is Config.RenderConfirm. rastrillo has already set
134// no-store, Referrer-Policy strict-origin (the page's URL is a live
135// credential) and the framing refusals; nothing here may loosen them.
136// The words are estilo's door copy: the heading and the button are the
137// form's own "Sign in", so the step reads as the door, not a new page.
138func (h *handlers) confirmSignin(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) {
139 door := chrome.SignIn{}
140 data := signinPage{
141 Frame: h.frame(r, frameOptions{titleParts: []string{door.Page()}, signinPage: true}),
142 Confirm: &signinConfirm{Heading: door.Heading(), Submit: door.Submit(), Action: d.Action, Token: d.Token},
143 Door: chrome.SignInNotice{Icon: brand.Path("docs"), App: "Docs", Sky: sky.For(r.Host)},
144 }
145 if err := h.render.page(w, r, "signin", data); err != nil {
146 h.logger.Error("docs: render sign-in confirm", "err", err)
147 h.fail(w, r, http.StatusInternalServerError, failStatus)
148 }
149}
150
151// refusedKey marks a request signinRefused is answering with the door.
152type refusedKey struct{}
153
154// refusedHere reports whether r is a refusal signinRefused is rendering.
155func refusedHere(r *http.Request) bool {
156 v, _ := r.Context().Value(refusedKey{}).(bool)
157 return v
158}
159
160// forcedStatus answers with status whatever status the page writes.
161type forcedStatus struct {
162 http.ResponseWriter
163 status int
164 wrote bool
165}
166
167func (f *forcedStatus) WriteHeader(int) {
168 if !f.wrote {
169 f.wrote = true
170 f.ResponseWriter.WriteHeader(f.status)
171 }
172}
173
174func (f *forcedStatus) Write(p []byte) (int, error) {
175 f.WriteHeader(f.status)
176 return f.ResponseWriter.Write(p)
177}
178