oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Instance-local sign-in uses Keymail OAuth when detected and email otherwise.
2// Credentials remain at Keymail; Docs never enrolls a per-instance passkey.
3package docs
4
5import (
6 "context"
7 "database/sql"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "net/url"
12 "regexp"
13 "strings"
14 "sync"
15
16 "amadan.net/oficina/estilo.git/brand"
17 "amadan.net/oficina/estilo.git/chrome"
18 "amadan.net/oficina/estilo.git/sky"
19 rauth "amadan.net/rastrillo/rastrillo/auth"
20 "amadan.net/rastrillo/rastrillo/mail"
21 "amadan.net/rastrillo/rastrillo/sessions"
22
23 "amadan.net/rastrillo/rastrillo"
24)
25
26// authSchema aliases rauth.Schema — auth's own migrations, merged into
27// BootSchema (app.go) ahead of this app's own Schema and after
28// sessions.Schema, per rauth's own doc comment: its backfill migration
29// reads the sessions table, so sessions.Schema must apply first.
30var authSchema = rauth.Schema
31
32// devInstanceKey is resolveInstanceKey's fallback, used only when Env
33// is "development" and Config.InstanceKey is empty (NewForTest's
34// case). It is not a secret: it is a fixed, public string, exactly as
35// dangerous — and exactly as acceptable — as rastrillo/auth's own
36// empty-key value would be, except spelled out here instead of left
37// implicit, and it never seals anything outside a throwaway
38// development database. resolveInstanceKey refuses to fall back to it
39// for any other Env.
40const devInstanceKey = "docs-development-instance-key-not-a-secret"
41
42// resolveInstanceKey applies Config.InstanceKey's own fail-closed
43// rule (see its doc comment on Config): non-empty always wins; empty
44// is fine only in development, where it defaults to devInstanceKey;
45// anywhere else it is a startup refusal, the same shape New already
46// uses for Origin.
47func resolveInstanceKey(cfg Config) (string, error) {
48 key := strings.TrimSpace(cfg.InstanceKey)
49 if key != "" {
50 return key, nil
51 }
52 if cfg.Env == "development" {
53 return devInstanceKey, nil
54 }
55 return "", fmt.Errorf("docs: Config.InstanceKey is empty and Env is %q, not %q — refusing to start with no seal for the sign-in link's pending blob; set $DOCS_INSTANCE_KEY", cfg.Env, "development")
56}
57
58// buildMailer applies Config.Mailer's fail-closed rule (see its own
59// doc comment): an explicit Mailer always wins; nil is fine only in
60// development, where it falls back to rastrillo/mail's logging
61// sender; anywhere else nil is a startup refusal — a sign-in that
62// cannot send its link is not sign-in, so this is the same fail-closed
63// shape New already uses for Origin, not merely a warning.
64//
65// In development the chosen sender is always wrapped in a
66// *recordingMailer, whether or not Config.Mailer was already set:
67// that is what lets TestSignIn (testing.go) read back the last
68// mailed link without a real mail server, keeping
69// scripts/run-dev.sh a one-command affair. The returned
70// *recordingMailer is nil outside development — nothing there needs
71// it, and it holds the plaintext of the last link sent in memory,
72// which a real deployment has no reason to carry.
73func buildMailer(cfg Config, logger *slog.Logger) (mail.Sender, *recordingMailer, error) {
74 sender := cfg.Mailer
75 if sender == nil {
76 if cfg.Env != "development" {
77 return nil, nil, fmt.Errorf("docs: mail is not configured (set $DOCS_SMTP_HOST and $DOCS_SMTP_FROM) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in", cfg.Env, "development")
78 }
79 // Loudly labelled by mail.Logged itself on every send — see
80 // its own doc comment; this is the dev/test-only fallback,
81 // never reached outside Env == "development".
82 sender = mail.Logged(logger)
83 }
84 if cfg.Env != "development" {
85 return sender, nil, nil
86 }
87 rec := &recordingMailer{next: sender}
88 return rec, rec, nil
89}
90
91// recordingMailer wraps a mail.Sender, remembering the most recently
92// sent message's body so TestSignIn (testing.go) can read the magic
93// link back out of it — the development/test convenience buildMailer
94// installs, never wired outside Env == "development". It still
95// forwards every Send to the wrapped sender, so the ordinary
96// dev-mode behavior (the link lands in the log, clearly marked) is
97// unchanged; this only adds a second, in-memory place to find it.
98type recordingMailer struct {
99 mu sync.Mutex
100 next mail.Sender
101 // lastTo is recorded alongside lastBody, and it is not decoration.
102 // The mail queue leaves a refused address with exactly the 303 ?sent=1 a
103 // real one gets — deliberately, so the endpoint is not a membership
104 // oracle — so a caller that reads only lastBody cannot tell "a link
105 // was mailed to this address" from "somebody else's link is still
106 // sitting here". TestSignIn used to make that mistake and reported
107 // success for an address the allowlist had refused.
108 lastTo string
109 lastBody string
110 // sends counts mails, and it is the field that makes the recipient
111 // check above actually work. Comparing only lastTo answers "was the
112 // most recent mail addressed here", which stays true for every later
113 // REFUSAL of the same address — the recorder simply keeps the earlier
114 // success. A caller has to know a NEW mail was sent, not that an old
115 // one is still lying there.
116 sends int
117}
118
119func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error {
120 m.mu.Lock()
121 m.lastTo, m.lastBody, m.sends = to, body, m.sends+1
122 m.mu.Unlock()
123 return m.next.Send(ctx, to, subject, body)
124}
125
126// magicLinkPattern finds the emailed link inside signin's default mail
127// body ("Open this link to sign in:\n\n<link>\n\n..." —
128// keymaildev/signin's flow.go). Anchored to the app's own callback
129// path so it cannot mistake some other URL the body might someday
130// carry for the sign-in link itself.
131var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`)
132
133// normalizeAddress puts address into the one form allowlist compares
134// and stores: trimmed of surrounding whitespace, lowercased. Shared
135// by resolveAllowlist (building the set) and allowlist.allows
136// (checking against it) so the two can never drift into comparing
137// differently-normalized forms of the same address.
138func normalizeAddress(address string) string {
139 return strings.ToLower(strings.TrimSpace(address))
140}
141
142// allowlist is Config.AllowedAddresses ($DOCS_ALLOWED_ADDRESSES),
143// parsed and normalized — see that field's own doc comment on Config
144// for what it gates and why it is wired into two different places.
145//
146// addrs == nil means "admit every address" — resolveAllowlist's
147// development-only default (the same shape devInstanceKey uses for
148// InstanceKey: spelled out here rather than left as an accidental
149// nil, and refused everywhere else). addrs != nil means "admit
150// nothing but exactly this set"; resolveAllowlist never returns a
151// non-nil-but-empty map outside development, since an empty parse
152// there is a startup refusal, not a value this type carries.
153type allowlist struct {
154 addrs map[string]struct{}
155
156 // granted widens admission to anybody holding a grant on a document
157 // here, and is nil when nothing has wired the store in (every test of
158 // the allowlist alone, and any construction before the store exists).
159 //
160 // It exists because $DOCS_ALLOWED_ADDRESSES gates who may receive a
161 // magic link at all, and suppressed delivery answers identically for
162 // a refused address so this endpoint is not a membership oracle.
163 // Without this clause, sharing a document to somebody outside the list
164 // produces a grant that can never be redeemed: the sharer sees success,
165 // the recipient gets a link that silently does nothing, and neither is
166 // told why. That is the worst available failure shape, because it looks
167 // like it worked.
168 //
169 // Deliberately one clause in allows(). Sharing widening who may sign in
170 // is a real decision about this instance's front door, and reversing it
171 // should be deleting two lines rather than unpicking a design.
172 granted func(address string) (bool, error)
173}
174
175// configured reports whether address is a MEMBER of this instance — on
176// $DOCS_ALLOWED_ADDRESSES, rather than admitted by a grant.
177//
178// The distinction the branch review found missing (finding 1). Suite §1
179// ruling 1 and Docs §8.2 both say an outside guest is a *guest* member;
180// the branch had no guest kind, so a grant was a full membership and
181// admission became transitive — Alice grants Bob, Bob creates and grants
182// Mallory, and the operator's allowlist has quietly stopped being the
183// membership list.
184//
185// A guest reads and writes what they were granted. Creating documents and
186// changing who reaches one are member powers, and this is the predicate
187// that says so.
188//
189// addrs == nil (development's admit-everyone) makes everybody a member,
190// which is the same latitude allows() already gives and the same reason:
191// a dev instance with no configuration should not need a membership list
192// to be usable.
193func (a allowlist) configured(address string) bool {
194 if a.addrs == nil {
195 return true
196 }
197 _, ok := a.addrs[normalizeAddress(address)]
198 return ok
199}
200
201// allows reports whether address may sign in, and whether the question
202// could be answered at all.
203//
204// The error is separate from the answer because the two demand different
205// responses on a live request: a definite "no" ejects, an unanswerable
206// question must not. See requireAdmission.
207func (a allowlist) allows(address string) (bool, error) {
208 if a.addrs == nil {
209 return true, nil
210 }
211 normalized := normalizeAddress(address)
212 if _, ok := a.addrs[normalized]; ok {
213 return true, nil
214 }
215 // A grant admits its holder, and nobody else — as a guest, not as a
216 // member: see configured() above for the powers that stay behind the
217 // configured list, and requireAdmission for why this is now re-asked on
218 // every request rather than only at sign-in.
219 if a.granted == nil {
220 return false, nil
221 }
222 return a.granted(normalized)
223}
224
225// admits is allows for callers that take a plain predicate — rauth's own
226// Authorize hook, which has no way to say "could not tell".
227//
228// A failed check refuses, which is the right direction at sign-in: there
229// is no unsaved work to lose, and a person who is genuinely admitted can
230// try again. requireAdmission is the caller that must NOT do this, and it
231// uses allows directly for exactly that reason.
232func (a allowlist) admits(address string) bool {
233 ok, err := a.allows(address)
234 return err == nil && ok
235}
236
237// resolveAllowlist applies Config.AllowedAddresses' own fail-closed
238// rule (see its doc comment on Config): a non-empty, comma-separated
239// list always wins, parsed and normalized; an empty one is fine only
240// in development, where it defaults to "admit everyone" so TestSignIn
241// and scripts/run-dev.sh need no setup of their own; anywhere else it
242// is a startup refusal.
243//
244// Refusal, not "admit nobody" — a real choice, made deliberately, not
245// the shape everything else in this file happens to use. An instance
246// that silently admitted nobody would boot looking entirely healthy:
247// /signin renders and email submissions answer 303 ?sent=1 whether
248// delivery was permitted or suppressed. Nothing would
249// ever surface the mistake short of a person noticing that no sign-in
250// link ever arrives — on an app whose entire premise is one
251// instance, one team, where "nobody configured" is exactly the state
252// a fresh, not-yet-configured deployment starts in. A startup refusal
253// is loud: the process does not come up, whoever is deploying it sees
254// that immediately, and cannot ship it half-configured by accident.
255// The honest cost is that a misconfigured instance is down rather
256// than merely closed — weighed and accepted here because "down" gets
257// fixed the same day, while "quietly admits nobody" on a public
258// hostname could sit unnoticed indefinitely. This is the same
259// tradeoff Origin, InstanceKey and Mailer already make in this file
260// and app.go: a misconfigured instance refuses to start, never
261// quietly does the wrong thing.
262func resolveAllowlist(cfg Config) (allowlist, error) {
263 addrs := make(map[string]struct{})
264 for _, addr := range strings.Split(cfg.AllowedAddresses, ",") {
265 addr = normalizeAddress(addr)
266 if addr == "" {
267 continue
268 }
269 addrs[addr] = struct{}{}
270 }
271 if len(addrs) > 0 || cfg.Team != nil {
272 return allowlist{addrs: addrs}, nil
273 }
274 if cfg.Env == "development" {
275 return allowlist{}, nil // addrs stays nil: admit everyone
276 }
277 return allowlist{}, fmt.Errorf("docs: $DOCS_ALLOWED_ADDRESSES is empty and Env is %q, not %q — refusing to start with no allowlist configured: an instance-per-team app admitting nobody would look identical to one working correctly (see resolveAllowlist's own doc comment), so this refuses rather than boots quietly wrong; set $DOCS_ALLOWED_ADDRESSES to this team's address(es)", cfg.Env, "development")
278}
279
280// listAdmits is the sign-in mail queue's admission check (mailqueue.go):
281// list.allows, with a failed grant lookup refusing, as a refusal always
282// has here. It runs on the queue's worker, after the response: every
283// address reaches classification, the same rate limits and the same
284// enqueue, so neither POST /signin's answer nor its timing says who is
285// admitted (Astra on oficina/slides #20: a send inside the request made
286// a permitted address wait for SMTP, 252 ms against 0.35 ms). Callback
287// admission remains authoritative, including for tokens minted before
288// an address was removed; a token never delivered is never redeemed.
289func listAdmits(list allowlist) func(string) bool {
290 return func(to string) bool {
291 admitted, err := list.allows(to)
292 return err == nil && admitted
293 }
294}
295
296// requireAdmission re-asks the front-door question on every request, and
297// answers a person who no longer passes it by ending them at /signin.
298//
299// It exists because allows() used to run only at sign-in. A session lasts
300// 30 days (rastrillo/auth.DefaultSessionTTL), so revoking somebody's last
301// grant took their document away and left them a working session for a
302// month — during which, before the guest rule above, they went on creating
303// documents and granting them to other people. "Stop sharing" was complete
304// for the document and silent about the instance. (Branch review, finding
305// 3.)
306//
307// Cheap for members: allows() checks the configured set first and returns
308// before touching the database. Only a guest pays a query, and a guest is
309// exactly who the answer can have changed for.
310//
311// It sits after RequireSession so it can rely on there being an identity;
312// with no identity it does nothing and lets RequireSession's own redirect
313// stand.
314func requireAdmission(list allowlist, signinPath string) func(http.Handler) http.Handler {
315 admission := requireAdmissionOf(list, signinPath, func(r *http.Request) (string, bool) {
316 if id, ok := currentIdentity(r); ok {
317 if id.Actor.VerifiedAddress == nil {
318 return "", false
319 }
320 return *id.Actor.VerifiedAddress, true
321 }
322 s, ok := sessions.Current(r)
323 return s.Subject, ok && s.Method != homeAssertionMethod
324 })
325 return func(next http.Handler) http.Handler {
326 allowed := admission(next)
327 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
328 // Missing adapter wiring fails closed even if an opaque subject happens
329 // to match an allowed address. It must not take the legacy fallback.
330 if s, ok := sessions.Current(r); ok && s.Method == homeAssertionMethod {
331 if _, resolved := currentIdentity(r); !resolved {
332 refuseIdentity(w, r, signinPath)
333 return
334 }
335 }
336 allowed.ServeHTTP(w, r)
337 })
338 }
339}
340
341// requireAdmissionOf is requireAdmission with the identity lookup passed
342// in, which is the only way this middleware can be tested at all: rauth
343// stores the identity under an unexported context key, so a test cannot
344// build a request that carries one.
345//
346// The seam is here rather than the test reaching through HTTP because an
347// end-to-end version of the "database is down" case never reaches this
348// code — rastrillo's own session layer refuses first, and the test would
349// have asserted on its 403 while proving nothing about this middleware.
350// That is the failure mode this branch has now produced four times.
351func requireAdmissionOf(list allowlist, signinPath string, identity func(*http.Request) (string, bool)) func(http.Handler) http.Handler {
352 return func(next http.Handler) http.Handler {
353 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
354 address, ok := identity(r)
355 if !ok {
356 next.ServeHTTP(w, r)
357 return
358 }
359 admitted, err := list.allows(address)
360 if err != nil {
361 // The question could not be answered, which is not the
362 // same as "no". 503 rather than 403: save-queue.mjs
363 // retries a 5xx and permanently drops a 4xx, so folding a
364 // database stall into a refusal would cost a guest their
365 // paragraph. Retryable, and honest.
366 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
367 return
368 }
369 if admitted {
370 next.ServeHTTP(w, r)
371 return
372 }
373
374 // A redirect answers "show me a page". It is never an answer
375 // to "store this".
376 //
377 // The first version of this middleware 303'd every method,
378 // which was the worst defect on this branch and was introduced
379 // by the fix for the previous one. fetch() follows redirects by
380 // default, so the editor's POST became a GET of the sign-in
381 // page, came back 200 with HTML, and save-queue.mjs read res.ok
382 // and reported the batch saved. A revoked writer typed into a
383 // page that told them everything was fine — strictly worse than
384 // the reader-editor bug, which at least lit "save failed".
385 //
386 // rastrillo's own refuseSession draws the line in exactly this
387 // place and for exactly this reason. (Re-review, finding 1.)
388 if r.Method == http.MethodGet || r.Method == http.MethodHead {
389 http.Redirect(w, r, signinPath, http.StatusSeeOther)
390 return
391 }
392 http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusForbidden)
393 })
394 }
395}
396
397// newAuthPlugin wires rastrillo/auth per its SKILL.md contract: one
398// *Auth built at boot (New — SKILL.md's own warning against building
399// a Flow per request, since the rate limiter's budget lives on the
400// value), Origin as both the emailed link's base and the cookie
401// decision, InstanceKey and Mailer already resolved and fail-closed
402// by resolveInstanceKey/buildMailer above. Authorize is list.admits —
403// the fail-closed predicate form, because rauth's hook has no way to
404// say "could not tell" and refusing is the safe answer here.
405// Delivery and callback admission are checked independently. No local passkey
406// second factor is enrolled; Keymail verifies credentials at its own origin.
407//
408// door draws the two pages rastrillo hands back to the app: Refused, a
409// verified address the allowlist does not hold, and RenderConfirm, the
410// page an emailed link opens (GET /auth/verify), whose one Sign in button
411// POSTs the token back to spend it. Both are the sign-in door.
412//
413// ProofOff: Docs has never put a proof-of-work front door on POST
414// /signin. Its abuse controls are rastrillo's per-address and per-IP rate
415// limits and the allowlist-gated mail queue, which never delivers to an
416// address it does not hold. rastrillo now refuses to start without the
417// choice being made, and this keeps today's behaviour.
418func newAuthPlugin(cfg Config, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, door signinDoor) (*rauth.Auth, error) {
419 // Both modes land on /auth/local/complete, which sends a person to
420 // the page they came from (signin_return.go), since Verify itself can
421 // only land on one fixed path.
422 return rauth.New(rauth.Config{
423 SignedInPath: localLinkCompletePath,
424 DB: writer,
425 Origin: cfg.Origin,
426 InstanceKey: instanceKey,
427 Mailer: mailer, // the sign-in mail queue (mailqueue.go)
428 Authorize: list.admits,
429 Logger: logger,
430 Subject: chrome.EmailSubject("Docs"),
431 ProofOff: true,
432 Refused: func(w http.ResponseWriter, r *http.Request, _ rauth.Identity) {
433 door.refused(w, r)
434 },
435 RenderConfirm: func(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) {
436 door.confirm(w, r, d)
437 },
438 })
439}
440
441// signinPage is GET /signin's view data. rauth.Auth's own handlers
442// (Begin, Callback, Verify) never render a page themselves — SKILL.md
443// §5/magic-links.md: "the sign-in page stays yours" — they report
444// outcomes by redirecting back here with a query this handler reads.
445//
446// Exactly one of Form, Sent, Confirm and ContinueURL is set: the form
447// (estilo's estilo-signin), the check-your-email state that replaces it
448// (estilo-signin-sent), the emailed link's confirm step
449// (auth_navigation.go), or the Keymail continuation (auth_navigation.go).
450type signinPage struct {
451 Frame Frame
452 Form *chrome.SignIn
453 Sent *chrome.SignInNotice
454 Confirm *signinConfirm
455 ContinueURL string
456 SubmitLabel string
457 // Door heads the continuation and confirm cards (Icon, App, Sky), so
458 // the moment between the form and Keymail, and the page an emailed
459 // link opens, look like the same door.
460 Door chrome.SignInNotice
461}
462
463// signinAgainPath is where "Use a different address" goes: the local
464// form, which with Home configured is /signin/local (plain /signin starts
465// a Home exchange).
466func (h *handlers) signinAgainPath() string {
467 if h.homeEnabled {
468 return "/signin/local"
469 }
470 return "/signin"
471}
472
473// signinAgainHref is "Use a different address": the form again, carrying
474// the page this sign-in was started from (spec §9.2: "keeping the
475// return"). A bare /signin is a fresh start, which remembers the list, so
476// the deep link would be lost (review finding, Astra, #65).
477func (h *handlers) signinAgainHref(r *http.Request) string {
478 href := h.signinAgainPath()
479 if h.remembered != nil {
480 if path := h.remembered(r); path != "/" {
481 href += "?return_to=" + url.QueryEscape(path)
482 }
483 }
484 return href
485}
486
487// signin serves GET /signin — the form itself, and the landing spot
488// for every outcome rauth.Auth's handlers redirect back to
489// (?sent=1, ?err=rate|address|expired|keymail|1, ?force=1). rate,
490// address and expired each get their own copy below; keymail and 1
491// (a raw Begin failure logged server-side, and Verify's own "unknown,
492// used and expired alike" — its own doc comment says rauth.Auth
493// refuses to be more specific than that, so there is nothing this
494// page could add by drawing a finer distinction of its own) share the
495// switch's default branch, docs.signin.error.unknown — never
496// docs.error.generic (fix round 1 on Task 3): every outcome on this
497// one page means the same operational thing, "sign-in did not
498// complete, ask for a new link," and docs.error.generic is authored
499// for a render failure with nowhere styled to land, not for this
500// page's own ordinary, recoverable outcomes.
501//
502// An address error goes in the field's own error slot (the form sets
503// novalidate, so the browser's bubble never shows); every other error is
504// a negative callout above the form (spec §9.2).
505func (h *handlers) signin(w http.ResponseWriter, r *http.Request) {
506 q := r.URL.Query()
507 icon := brand.Path("docs")
508 data := signinPage{}
509 var page string
510 if q.Get("sent") == "1" {
511 sent := chrome.SignInNotice{Icon: icon, App: "Docs", Sky: sky.For(r.Host)}.Sent(h.signinAgainHref(r))
512 data.Sent, page = &sent, sent.Page()
513 } else {
514 form := &chrome.SignIn{
515 Icon: icon,
516 App: "Docs",
517 Sky: sky.For(r.Host),
518 Action: "/signin",
519 }
520 page = form.Page()
521 if q.Get("force") != "" {
522 form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
523 }
524 switch q.Get("err") {
525 case "":
526 // nothing to report
527 case "rate":
528 form.Error = rastrillo.T(r, "docs.signin.error.rate")
529 case "address":
530 form.AddressError = rastrillo.T(r, "docs.signin.error.address")
531 case "expired":
532 form.Error = rastrillo.T(r, "docs.signin.error.expired")
533 default:
534 form.Error = rastrillo.T(r, "docs.signin.error.unknown")
535 }
536 if refusedHere(r) {
537 // Keymail or a link verified the address and the allowlist
538 // does not hold it, in this very response (Config.Refused,
539 // auth_navigation.go's refused).
540 // Never from the query. The form is the way on.
541 form.Error = rastrillo.T(r, "docs.signin.error.refused")
542 }
543 data.Form = form
544 }
545 data.Frame = h.frame(r, frameOptions{titleParts: []string{page}, signinPage: true})
546 if err := h.render.page(w, r, "signin", data); err != nil {
547 h.logger.Error("docs: render signin", "err", err)
548 h.fail(w, r, http.StatusInternalServerError, failStatus)
549 }
550}
551
552// signedOut is GET /signed-out, where Sign out lands: "You’re signed out",
553// and a Sign in button (spec §9.2). With Home configured it is the same
554// page (home_routes.go), which is why it takes the path to sign in at.
555func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) {
556 h.render.homeLink(w, r, "signedout", "", http.StatusOK)
557}
558
559// linkPath extracts the request-target (path plus query, no
560// scheme/host) from an emailed magic link — what
561// httptest.NewRequest, and a real browser following a same-origin
562// link, both just need. It exists so TestSignIn (testing.go) does not
563// have to know the emailed link is absolute.
564func linkPath(link string) (string, error) {
565 u, err := url.Parse(link)
566 if err != nil {
567 return "", fmt.Errorf("docs: parse magic link %q: %w", link, err)
568 }
569 return u.RequestURI(), nil
570}
571