| 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. |
| 3 | package docs |
| 4 | |
| 5 | import ( |
| 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. |
| 30 | var 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. |
| 40 | const 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. |
| 47 | func 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. |
| 73 | func 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. |
| 98 | type 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 | |
| 119 | func (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. |
| 131 | var 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. |
| 138 | func 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. |
| 153 | type 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. |
| 193 | func (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. |
| 207 | func (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. |
| 232 | func (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. |
| 262 | func 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. |
| 289 | func 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. |
| 314 | func 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. |
| 351 | func 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. |
| 418 | func 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). |
| 450 | type 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). |
| 466 | func (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). |
| 477 | func (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). |
| 505 | func (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. |
| 555 | func (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. |
| 564 | func 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 | |