| 1 | // Local sign-in is Keymail for an address whose domain runs Keymail and an |
| 2 | // emailed single-use link for every other address, decided by the domain |
| 3 | // alone (beginSignin). Optional Home sign-in resolves an explicitly |
| 4 | // connected existing account; it never creates a person. |
| 5 | package calendar |
| 6 | |
| 7 | import ( |
| 8 | "amadan.net/oficina/estilo.git/chrome" |
| 9 | "amadan.net/oficina/home/connect" |
| 10 | "context" |
| 11 | "database/sql" |
| 12 | "fmt" |
| 13 | "log/slog" |
| 14 | "net/url" |
| 15 | "regexp" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | |
| 19 | rauth "amadan.net/rastrillo/rastrillo/auth" |
| 20 | "amadan.net/rastrillo/rastrillo/mail" |
| 21 | ) |
| 22 | |
| 23 | // authSchema aliases rauth.Schema so BootSchema (migrations.go) reads |
| 24 | // as a list of subsystems rather than a mix of import aliases. Its |
| 25 | // position there is load-bearing: auth's own backfill migration reads |
| 26 | // the sessions table, so sessions.Schema must apply first. |
| 27 | var authSchema = rauth.Schema |
| 28 | |
| 29 | // devInstanceKey is resolveInstanceKey's development-only fallback. It |
| 30 | // is not a secret — it is a fixed, public string, exactly as dangerous |
| 31 | // and exactly as acceptable as leaving the key empty would be, except |
| 32 | // spelled out here rather than left implicit. It never seals anything |
| 33 | // outside a throwaway development database. |
| 34 | const devInstanceKey = "calendar-development-instance-key-not-a-secret" |
| 35 | |
| 36 | // resolveInstanceKey applies Config.InstanceKey's fail-closed rule: |
| 37 | // non-empty always wins; empty is fine only in development; anywhere |
| 38 | // else it is a startup refusal. |
| 39 | func resolveInstanceKey(cfg Config) (string, error) { |
| 40 | key := strings.TrimSpace(cfg.InstanceKey) |
| 41 | if key != "" { |
| 42 | return key, nil |
| 43 | } |
| 44 | if cfg.Env == "development" { |
| 45 | return devInstanceKey, nil |
| 46 | } |
| 47 | return "", fmt.Errorf("calendar: 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 $CALENDAR_INSTANCE_KEY", cfg.Env, "development") |
| 48 | } |
| 49 | |
| 50 | // buildMailer applies Config.Mailer's fail-closed rule: an explicit |
| 51 | // Mailer always wins; nil is fine only in development, where it falls |
| 52 | // back to rastrillo/mail's logging sender. |
| 53 | // |
| 54 | // In development the chosen sender is always wrapped in a |
| 55 | // *recordingMailer, whether or not Config.Mailer was already set: that |
| 56 | // is what lets TestSignIn (testing.go) read the last mailed link back |
| 57 | // without a mail server. The returned *recordingMailer is nil outside |
| 58 | // development — it holds the plaintext of the last link sent in |
| 59 | // memory, and a real deployment has no reason to carry that. |
| 60 | func buildMailer(cfg Config, logger *slog.Logger) (mail.Sender, *recordingMailer, error) { |
| 61 | sender := cfg.Mailer |
| 62 | if sender == nil { |
| 63 | if cfg.Env != "development" { |
| 64 | // Both prefixes named, in the order main.go tries them: |
| 65 | // the platform's own CARLOS_SMTP_* is what `carlos email |
| 66 | // enable` writes, and naming only the app's prefix sent |
| 67 | // the first deploy of this app into a wake loop against |
| 68 | // an instance whose mail was configured correctly. |
| 69 | return nil, nil, fmt.Errorf("calendar: mail is not configured (set $CARLOS_SMTP_HOST and $CARLOS_SMTP_FROM with `carlos email enable`, or $CALENDAR_SMTP_HOST and $CALENDAR_SMTP_FROM to override) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in, and a reminder that cannot be delivered is not a reminder", cfg.Env, "development") |
| 70 | } |
| 71 | sender = mail.Logged(logger) |
| 72 | } |
| 73 | if cfg.Env != "development" { |
| 74 | return sender, nil, nil |
| 75 | } |
| 76 | rec := &recordingMailer{next: sender} |
| 77 | return rec, rec, nil |
| 78 | } |
| 79 | |
| 80 | // recordingMailer wraps a mail.Sender, remembering the most recently |
| 81 | // sent body so TestSignIn can read the magic link back out of it. It |
| 82 | // still forwards every Send, so the ordinary dev behaviour — the link |
| 83 | // lands in the log, clearly marked — is unchanged; this only adds a |
| 84 | // second, in-memory place to find it. |
| 85 | type recordingMailer struct { |
| 86 | mu sync.Mutex |
| 87 | next mail.Sender |
| 88 | lastBody string |
| 89 | } |
| 90 | |
| 91 | func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error { |
| 92 | m.mu.Lock() |
| 93 | m.lastBody = body |
| 94 | m.mu.Unlock() |
| 95 | return m.next.Send(ctx, to, subject, body) |
| 96 | } |
| 97 | |
| 98 | func (m *recordingMailer) last() string { |
| 99 | m.mu.Lock() |
| 100 | defer m.mu.Unlock() |
| 101 | return m.lastBody |
| 102 | } |
| 103 | |
| 104 | // magicLinkPattern finds the emailed link inside the sign-in mail body. |
| 105 | // Anchored to this app's own callback path so it cannot mistake some |
| 106 | // other URL the body might one day carry for the sign-in link itself. |
| 107 | var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| 108 | |
| 109 | // normalizeAddress puts an address into the one form the allowlist |
| 110 | // both stores and compares: trimmed, lowercased. Shared by |
| 111 | // resolveAllowlist and allowlist.allows so the two can never drift |
| 112 | // into comparing differently-normalised forms of the same address. |
| 113 | func normalizeAddress(address string) string { |
| 114 | return strings.ToLower(strings.TrimSpace(address)) |
| 115 | } |
| 116 | |
| 117 | // allowlist is Config.AllowedAddresses, parsed and normalised. |
| 118 | // |
| 119 | // addrs == nil means "admit every address" — resolveAllowlist's |
| 120 | // development-only default, spelled out rather than left as an |
| 121 | // accidental nil. addrs != nil means "admit nothing but exactly this |
| 122 | // set". resolveAllowlist never returns a non-nil-but-empty map outside |
| 123 | // development, since an empty parse there is a startup refusal. |
| 124 | type allowlist struct { |
| 125 | addrs map[string]struct{} |
| 126 | } |
| 127 | |
| 128 | func (a allowlist) allows(address string) bool { |
| 129 | if a.addrs == nil { |
| 130 | return true |
| 131 | } |
| 132 | _, ok := a.addrs[normalizeAddress(address)] |
| 133 | return ok |
| 134 | } |
| 135 | |
| 136 | // resolveAllowlist applies Config.AllowedAddresses' fail-closed rule. |
| 137 | // |
| 138 | // Refusal, not "admit nobody" — a deliberate choice. An instance that |
| 139 | // silently admitted nobody would boot looking entirely healthy: |
| 140 | // /signin renders, POST /signin answers its ordinary 303 ?sent=1 on |
| 141 | // every submission (gateBegin's whole point is that a refused |
| 142 | // submission and a genuine one are byte-identical), and nothing would |
| 143 | // surface the mistake short of somebody noticing that no link ever |
| 144 | // arrives. A startup refusal is loud instead: the process does not |
| 145 | // come up and whoever is deploying it sees that immediately. The |
| 146 | // honest cost is that a misconfigured instance is down rather than |
| 147 | // merely closed — accepted here because "down" gets fixed the same |
| 148 | // day, while "quietly admits nobody" on a public hostname could sit |
| 149 | // unnoticed indefinitely. |
| 150 | func resolveAllowlist(cfg Config) (allowlist, error) { |
| 151 | addrs := make(map[string]struct{}) |
| 152 | for _, addr := range strings.Split(cfg.AllowedAddresses, ",") { |
| 153 | addr = normalizeAddress(addr) |
| 154 | if addr == "" { |
| 155 | continue |
| 156 | } |
| 157 | // Every entry must look like an address, because the separator |
| 158 | // is the one thing nobody checks. Split on "," alone turns |
| 159 | // "a@x.com;b@y.com" — or a space- or newline-separated list — |
| 160 | // into ONE entry that is nobody's address, len(addrs) > 0, and |
| 161 | // a boot that succeeds into precisely the "quietly admits |
| 162 | // nobody" state the refusal below exists to prevent. The |
| 163 | // instance then looks entirely healthy and no link ever |
| 164 | // arrives, which is the failure this app has already had once. |
| 165 | // |
| 166 | // Refusing at boot rather than dropping the bad entry: dropping |
| 167 | // it would admit whoever parsed cleanly and silently lock out |
| 168 | // the rest, which is a worse shape of the same bug. |
| 169 | if !strings.Contains(addr, "@") { |
| 170 | return allowlist{}, fmt.Errorf( |
| 171 | "calendar: $CALENDAR_ALLOWED_ADDRESSES has an entry with no %q in it — the list is comma-separated, and a %q or a space is not a separator; refusing to start rather than admit nobody", |
| 172 | "@", ";") |
| 173 | } |
| 174 | addrs[addr] = struct{}{} |
| 175 | } |
| 176 | if len(addrs) > 0 { |
| 177 | return allowlist{addrs: addrs}, nil |
| 178 | } |
| 179 | if cfg.Env == "development" { |
| 180 | return allowlist{}, nil // addrs stays nil: admit everyone |
| 181 | } |
| 182 | return allowlist{}, fmt.Errorf("calendar: $CALENDAR_ALLOWED_ADDRESSES is empty and Env is %q, not %q — refusing to start with no allowlist configured; set it to this instance's address(es)", cfg.Env, "development") |
| 183 | } |
| 184 | |
| 185 | // newAuthPlugin builds the one *rauth.Auth this process shares. One per |
| 186 | // process, never one per request: the underlying flow carries its |
| 187 | // rate-limiter budget on the value, so a plugin per request would get a |
| 188 | // fresh empty budget every time — no rate limiting at all, silently. |
| 189 | // |
| 190 | // Authorize is list.allows as well as mailQueue. The two defend |
| 191 | // different things: mailQueue stops the mail going out, Authorize |
| 192 | // stops a session being minted for a non-permitted address, whether |
| 193 | // Keymail verified it or a link was somehow minted for it (mailed |
| 194 | // before the allowlist changed, say). |
| 195 | // |
| 196 | // h supplies the two pages auth asks the app for: the door a verified |
| 197 | // but refused address is shown (handlers.refused) and the door an |
| 198 | // emailed link opens on (handlers.renderConfirm). |
| 199 | func newAuthPlugin(origin string, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger, homeEnabled bool, h *handlers) (*rauth.Auth, error) { |
| 200 | signin, signedIn := "/signin", "/" |
| 201 | if homeEnabled { |
| 202 | signin, signedIn = "/signin/local", connect.LocalCompletePath |
| 203 | } |
| 204 | return rauth.New(rauth.Config{ |
| 205 | DB: writer, |
| 206 | SigninPath: signin, |
| 207 | SignedInPath: signedIn, |
| 208 | Origin: origin, |
| 209 | InstanceKey: instanceKey, |
| 210 | Mailer: mailer, |
| 211 | Authorize: list.allows, |
| 212 | // A verified address the allowlist does not hold gets the door, |
| 213 | // with the refusal on it, in auth's own 403 (handlers.refused). |
| 214 | Refused: h.refused, |
| 215 | // GET /auth/verify spends nothing: it opens the door with one |
| 216 | // Sign in button that POSTs the link back, so a mail scanner's |
| 217 | // fetch cannot spend it (handlers.renderConfirm). |
| 218 | RenderConfirm: h.renderConfirm, |
| 219 | // No proof-of-work on the sign-in form. Calendar never had one: |
| 220 | // Begin's rate budgets and mailQueue's allowlist check are what |
| 221 | // stand between a stranger and the mailer, and the door is |
| 222 | // estilo's form, which renders no pow challenge. ProofOff keeps |
| 223 | // today's behaviour; wiring a pow.Guard is a decision of its own |
| 224 | // (schema, assets, a form that needs JavaScript). |
| 225 | ProofOff: true, |
| 226 | Subject: chrome.EmailSubject("Calendar"), |
| 227 | Logger: logger, |
| 228 | }) |
| 229 | } |
| 230 | |
| 231 | // linkPath extracts the request-target — path plus query, no scheme or |
| 232 | // host — from an emailed magic link. It exists so the test harness does |
| 233 | // not have to know the emailed link is absolute. |
| 234 | func linkPath(link string) (string, error) { |
| 235 | u, err := url.Parse(link) |
| 236 | if err != nil { |
| 237 | return "", fmt.Errorf("calendar: parse magic link %q: %w", link, err) |
| 238 | } |
| 239 | return u.RequestURI(), nil |
| 240 | } |
| 241 | |