1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "encoding/base64"
8 "encoding/json"
9 "errors"
10 "io"
11 "mime"
12 "net/http"
13 "strconv"
14
15 "github.com/a-h/templ"
16 "github.com/bnema/zerowrap"
17
18 "bnema.dev/ermite/internal/clientlimit"
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21)
22
23// The owner's passkeys on the web (ADR 0026).
24//
25// The pages are GET: /-/login (sign in, anonymous) and /-/passkeys (the
26// owner's list). The ceremonies are JSON POSTs the script static/js/passkey.js
27// makes, each in two steps (begin, finish). Every POST passes the gate: an
28// Origin equal to the public origin, and a session, except the two login
29// endpoints, which are how a session starts. The finish endpoints also need
30// "Content-Type: application/json", which a cross-site form cannot send.
31
32const (
33 passkeyLoginPath = "/-/login"
34 passkeysPath = "/-/passkeys"
35
36 loginBeginPath = passkeysPath + "/login/begin"
37 loginFinishPath = passkeysPath + "/login/finish"
38
39 // maxCeremonyBytes bounds the body of a finish: a response of an
40 // authenticator is well under 10 KiB.
41 maxCeremonyBytes = 64 << 10
42)
43
44// passkeysOn tells that the passkey routes exist.
45func (s *Server) passkeysOn() bool { return s.cfg.Passkeys != nil && s.cfg.Sessions != nil }
46
47func (s *Server) passkeyRoutes(mux *http.ServeMux) {
48 mux.HandleFunc("GET "+passkeyLoginPath, s.passkeyLoginPage)
49 mux.HandleFunc("POST "+loginBeginPath, s.passkeyLoginBegin)
50 mux.HandleFunc("POST "+loginFinishPath, s.passkeyLoginFinish)
51 mux.HandleFunc("GET "+passkeysPath, s.page(s.passkeysPage))
52 mux.HandleFunc("POST "+passkeysPath+"/register/begin", s.passkeyRegisterBegin)
53 mux.HandleFunc("POST "+passkeysPath+"/register/finish", s.passkeyRegisterFinish)
54 mux.HandleFunc("POST "+passkeysPath+"/{id}/remove", s.passkeyRemove)
55}
56
57// isPasskeyLoginPath tells whether u is exactly one of the two endpoints an
58// anonymous browser may post to. Like isLoginPath it looks at the escaped path,
59// which is what the router matches.
60func isPasskeyLoginPath(path string) bool {
61 return path == loginBeginPath || path == loginFinishPath
62}
63
64// jsonReply writes a JSON answer that no cache keeps.
65func jsonReply(w http.ResponseWriter, status int, v any) {
66 h := w.Header()
67 h.Set("Content-Type", "application/json")
68 h.Set("Cache-Control", noStore)
69 w.WriteHeader(status)
70 _ = json.NewEncoder(w).Encode(v)
71}
72
73type jsonMessage struct {
74 Error string `json:"error"`
75}
76
77func jsonError(w http.ResponseWriter, status int, msg string) {
78 jsonReply(w, status, jsonMessage{Error: msg})
79}
80
81// jsonInternal answers a failure that is not the caller's to fix: an id for the
82// log, and no detail.
83func (s *Server) jsonInternal(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
84 id := requestID()
85 log := zerowrap.FromCtx(ctx)
86 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web passkey request failed")
87 jsonError(w, http.StatusInternalServerError, "internal error, request id "+id)
88}
89
90// jsonBody checks the content type of a finish and reads its body, bounded. It
91// answers 415 or 413 and returns false when it cannot.
92func jsonBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
93 mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
94 if err != nil || mt != "application/json" {
95 jsonError(w, http.StatusUnsupportedMediaType, "send application/json")
96 return nil, false
97 }
98 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxCeremonyBytes))
99 if err != nil {
100 var tooBig *http.MaxBytesError
101 if errors.As(err, &tooBig) {
102 jsonError(w, http.StatusRequestEntityTooLarge, "request too large")
103 } else {
104 jsonError(w, http.StatusBadRequest, "request could not be read")
105 }
106 return nil, false
107 }
108 return body, true
109}
110
111// ceremonyReply is the answer of a begin.
112type ceremonyReply struct {
113 Ceremony string `json:"ceremony"`
114 Options json.RawMessage `json:"options"`
115}
116
117// blocked answers 429 when the client has failed too many sign-ins.
118func (s *Server) blocked(w http.ResponseWriter, r *http.Request) (addr string, blocked bool) {
119 addr = clientlimit.ClientAddr(r, s.cfg.TrustedProxies)
120 if is, wait := s.cfg.Limiter.Blocked(r.Context(), addr); is {
121 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
122 jsonError(w, http.StatusTooManyRequests, "too many attempts")
123 return addr, true
124 }
125 return addr, false
126}
127
128// passkeyLoginPage is GET /-/login: the sign-in page. A signed-in visitor goes
129// home.
130func (s *Server) passkeyLoginPage(w http.ResponseWriter, r *http.Request) {
131 if SignedIn(r.Context()) {
132 seeOther(w, r, "/")
133 return
134 }
135 l := s.stateLayout("sign in", []Crumb{{Label: "sign in", Current: true}})
136 s.secretPage(w, r, http.StatusOK, PasskeyLogin(l, PasskeyLoginPage{
137 Title: "sign in",
138 Button: "Sign in with a passkey",
139 Lines: []string{"or run `ermite web login` in a terminal."},
140 Begin: loginBeginPath,
141 Finish: loginFinishPath,
142 }))
143}
144
145// passkeyLoginBegin is POST /-/passkeys/login/begin.
146func (s *Server) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
147 addr, blocked := s.blocked(w, r)
148 if blocked {
149 return
150 }
151 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
152 defer cancel()
153 ceremony, options, err := s.cfg.Passkeys.BeginLogin(ctx, clientlimit.Key(addr))
154 if err != nil {
155 s.jsonInternal(ctx, w, r, err)
156 return
157 }
158 jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
159}
160
161// finishRequest is the body of a finish. The name of a new passkey is given at
162// the begin of its registration, not here.
163type finishRequest struct {
164 Ceremony string `json:"ceremony"`
165 Credential json.RawMessage `json:"credential"`
166}
167
168// readFinish reads the body of a finish: JSON, bounded, with a ceremony and a
169// credential. It answers 415, 413 or 400 and returns false when it cannot. A
170// body that fails here is a bug of the client, not a guess at a passkey: the
171// callers do not count it as a failed sign-in.
172func readFinish(w http.ResponseWriter, r *http.Request) (finishRequest, bool) {
173 body, ok := jsonBody(w, r)
174 if !ok {
175 return finishRequest{}, false
176 }
177 var req finishRequest
178 if err := json.Unmarshal(body, &req); err != nil || req.Ceremony == "" || len(req.Credential) == 0 {
179 jsonError(w, http.StatusBadRequest, "malformed request")
180 return finishRequest{}, false
181 }
182 return req, true
183}
184
185// passkeyLoginFinish is POST /-/passkeys/login/finish: it checks the response
186// of the authenticator and sets the session cookie, the same as a login link.
187// A refusal counts as a failed authentication of the client, whatever the
188// reason.
189func (s *Server) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
190 // The limiter first: a client that is blocked gets 429 whatever it sends.
191 addr, blocked := s.blocked(w, r)
192 if blocked {
193 return
194 }
195 req, ok := readFinish(w, r)
196 if !ok {
197 return
198 }
199 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
200 defer cancel()
201 token, lifetime, err := s.cfg.Passkeys.FinishLogin(ctx, req.Ceremony, req.Credential)
202 switch {
203 case errors.Is(err, ports.ErrLinkRejected):
204 s.cfg.Limiter.Fail(ctx, addr)
205 jsonError(w, http.StatusUnauthorized, "passkey not accepted")
206 return
207 case err != nil:
208 s.jsonInternal(ctx, w, r, err)
209 return
210 }
211 h := w.Header()
212 h.Del("Set-Cookie") // a stale cookie cleared by withSession
213 http.SetCookie(w, s.cookie(token, int(lifetime.Seconds())))
214 h.Set("Cache-Control", noStore)
215 w.WriteHeader(http.StatusNoContent)
216}
217
218// passkeysPage is GET /-/passkeys: the owner's list. Anybody else gets the 404
219// page, as for any page that does not exist for them.
220func (s *Server) passkeysPage(ctx context.Context, _ *http.Request) (templ.Component, int, error) {
221 if !SignedIn(ctx) {
222 return nil, 0, errNotFound
223 }
224 list, err := s.cfg.Passkeys.List(ctx, principal(ctx))
225 if err != nil {
226 return nil, 0, err
227 }
228 now := s.cfg.Now()
229 rows := make([]PasskeyRow, 0, len(list))
230 for _, p := range list {
231 rows = append(rows, PasskeyRow{
232 Name: p.Name,
233 Created: ago(now, p.CreatedAt), CreatedAt: rfc3339(p.CreatedAt),
234 LastUsed: ago(now, p.LastUsedAt), LastUsedAt: rfc3339(p.LastUsedAt),
235 Synced: p.BackupState,
236 Remove: passkeysPath + "/" + base64.RawURLEncoding.EncodeToString(p.ID) + "/remove",
237 })
238 }
239 l := s.base()
240 l.Title = "passkeys · " + s.instance
241 l.Crumbs = []Crumb{{Label: "passkeys", Current: true}}
242 l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
243 return Passkeys(l, PasskeysPage{
244 Rows: rows, Full: len(list) >= domain.MaxPasskeys, Max: domain.MaxPasskeys,
245 Begin: passkeysPath + "/register/begin", Finish: passkeysPath + "/register/finish",
246 }), http.StatusOK, nil
247}
248
249// registerError answers a refused registration step.
250func (s *Server) registerError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
251 switch {
252 case errors.Is(err, context.Canceled):
253 // The browser is gone.
254 case errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrUnauthenticated) && !SignedIn(ctx):
255 jsonError(w, http.StatusForbidden, "forbidden")
256 case errors.Is(err, domain.ErrAlreadyExists):
257 jsonError(w, http.StatusConflict, "this passkey is already registered")
258 case errors.Is(err, domain.ErrResourceExhausted):
259 jsonError(w, http.StatusTooManyRequests, rule(err.Error(), "a limit is reached"))
260 case domain.IsInvalid(err):
261 jsonError(w, http.StatusBadRequest, rule(err.Error(), "the name is not valid"))
262 case errors.Is(err, domain.ErrUnauthenticated):
263 jsonError(w, http.StatusBadRequest, "passkey not accepted, try again")
264 default:
265 s.jsonInternal(ctx, w, r, err)
266 }
267}
268
269// passkeyRegisterBegin is POST /-/passkeys/register/begin. Like the finish
270// endpoints it needs "Content-Type: application/json".
271func (s *Server) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
272 body, ok := jsonBody(w, r)
273 if !ok {
274 return
275 }
276 // {"name": "..."}; an empty body is no name. The name is checked now, before
277 // the browser prompts the owner for a passkey that would then be refused.
278 var req struct {
279 Name string `json:"name"`
280 }
281 if len(body) > 0 {
282 if err := json.Unmarshal(body, &req); err != nil {
283 jsonError(w, http.StatusBadRequest, "malformed request")
284 return
285 }
286 }
287 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
288 defer cancel()
289 ceremony, options, err := s.cfg.Passkeys.BeginRegistration(ctx, principal(ctx), req.Name)
290 if err != nil {
291 s.registerError(ctx, w, r, err)
292 return
293 }
294 jsonReply(w, http.StatusOK, ceremonyReply{Ceremony: ceremony, Options: options})
295}
296
297// passkeyRegisterFinish is POST /-/passkeys/register/finish.
298func (s *Server) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
299 req, ok := readFinish(w, r)
300 if !ok {
301 return
302 }
303 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
304 defer cancel()
305 if err := s.cfg.Passkeys.FinishRegistration(ctx, principal(ctx), req.Ceremony, req.Credential); err != nil {
306 s.registerError(ctx, w, r, err)
307 return
308 }
309 w.Header().Set("Cache-Control", noStore)
310 w.WriteHeader(http.StatusNoContent)
311}
312
313// passkeyRemove is POST /-/passkeys/{id}/remove: it removes the passkey, which
314// signs every browser out, this one included: the answer sends it to the
315// sign-in page.
316func (s *Server) passkeyRemove(w http.ResponseWriter, r *http.Request) {
317 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
318 defer cancel()
319 id, err := base64.RawURLEncoding.DecodeString(r.PathValue("id"))
320 if err != nil || len(id) == 0 {
321 s.actionFailed(ctx, w, r, errNotFound, passkeysPath)
322 return
323 }
324 if err := s.cfg.Passkeys.Remove(ctx, principal(ctx), id); err != nil {
325 s.actionFailed(ctx, w, r, err, passkeysPath)
326 return
327 }
328 seeOther(w, r, passkeyLoginPath)
329}