| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/estilo.git/chrome" |
| 5 | "amadan.net/oficina/home/connect" |
| 6 | "database/sql" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "log/slog" |
| 10 | "net/url" |
| 11 | "os" |
| 12 | "strings" |
| 13 | |
| 14 | "amadan.net/rastrillo/rastrillo/auth" |
| 15 | "amadan.net/rastrillo/rastrillo/mail" |
| 16 | ) |
| 17 | |
| 18 | // AuthEnv is everything sign-in needs from the environment, already |
| 19 | // validated. Building it is the only place these variable names appear. |
| 20 | type AuthEnv struct { |
| 21 | Home *connect.Credentials |
| 22 | // Origin is the instance's external origin, scheme included. It is |
| 23 | // the base of every emailed link and the CSRF origin check, which |
| 24 | // makes a WRONG value the dangerous case rather than a missing one: |
| 25 | // a missing origin refuses at boot, while a wrong one boots happily |
| 26 | // and mints links pointing at a host nobody serves. Set it only |
| 27 | // after the route exists and answers. |
| 28 | Origin string |
| 29 | |
| 30 | // InstanceKey seals auth's pending blob. auth.New refuses an empty |
| 31 | // one, but it refuses at the wiring call with a framework message; |
| 32 | // this refuses at boot naming the variable an operator has to set. |
| 33 | InstanceKey string |
| 34 | |
| 35 | // Owners is the admission allowlist: the addresses that may hold a |
| 36 | // session on this instance. |
| 37 | Owners []string |
| 38 | |
| 39 | // Mailer delivers the links. |
| 40 | Mailer mail.Sender |
| 41 | } |
| 42 | |
| 43 | // loopback reports whether origin is a local development origin. |
| 44 | // |
| 45 | // This is the ONLY thing that relaxes the mail requirement, and it is |
| 46 | // deliberately a fact rather than a switch. A deployed instance's origin |
| 47 | // is its public https host, so it cannot take this branch; there is no |
| 48 | // flag or variable that turns "development" on, so there is nothing to |
| 49 | // set by accident in production. And the failure mode of lying about it |
| 50 | // is loud rather than quiet — an instance claiming a loopback origin |
| 51 | // mints links pointing at localhost, which nobody can click. |
| 52 | // The host is compared after parsing, never by prefix. A prefix test on |
| 53 | // "http://127.0.0.1" also accepts "http://127.0.0.1.attacker.example", |
| 54 | // which is a real domain someone else can own — caught here by |
| 55 | // TestLoopbackIsNotPrefixSpoofable, which failed on exactly that string |
| 56 | // when this was written with strings.HasPrefix. |
| 57 | func loopback(origin string) bool { |
| 58 | u, err := url.Parse(origin) |
| 59 | if err != nil || u.Scheme != "http" { |
| 60 | return false |
| 61 | } |
| 62 | switch u.Hostname() { |
| 63 | case "localhost", "127.0.0.1", "::1": |
| 64 | return true |
| 65 | } |
| 66 | return false |
| 67 | } |
| 68 | |
| 69 | // LoadAuthEnv reads and validates sign-in configuration, refusing rather |
| 70 | // than degrading. |
| 71 | // |
| 72 | // Every refusal here is a case where continuing would produce an app |
| 73 | // that looks like it works. An instance with no mailer logs live |
| 74 | // credentials to platform-held storage instead of emailing them; an |
| 75 | // instance with an empty allowlist admits anyone who can receive mail at |
| 76 | // any address. Both serve pages and answer /healthz. |
| 77 | func LoadAuthEnv(logger *slog.Logger) (AuthEnv, error) { |
| 78 | env := AuthEnv{ |
| 79 | Home: connect.CredentialsFromEnv("SHEETS"), |
| 80 | Origin: strings.TrimSpace(os.Getenv("SHEETS_ORIGIN")), |
| 81 | InstanceKey: strings.TrimSpace(os.Getenv("SHEETS_INSTANCE_KEY")), |
| 82 | Owners: splitOwners(os.Getenv("SHEETS_OWNERS")), |
| 83 | } |
| 84 | |
| 85 | var missing []string |
| 86 | if env.Origin == "" { |
| 87 | missing = append(missing, "SHEETS_ORIGIN") |
| 88 | } |
| 89 | if env.InstanceKey == "" { |
| 90 | missing = append(missing, "SHEETS_INSTANCE_KEY") |
| 91 | } |
| 92 | if len(env.Owners) == 0 { |
| 93 | missing = append(missing, "SHEETS_OWNERS") |
| 94 | } |
| 95 | if len(missing) > 0 { |
| 96 | return AuthEnv{}, fmt.Errorf("sign-in is not configured: %s not set", |
| 97 | strings.Join(missing, ", ")) |
| 98 | } |
| 99 | |
| 100 | // mail.FromEnv falls back to a logging sender with a warning when |
| 101 | // SMTP is absent. A warning is not enough: the emailed link is a |
| 102 | // live credential and the log is platform-held storage we cannot |
| 103 | // grep or redact. Detect the fallback ourselves and refuse. |
| 104 | if os.Getenv("CARLOS_SMTP_HOST") == "" || os.Getenv("CARLOS_SMTP_FROM") == "" { |
| 105 | if !loopback(env.Origin) { |
| 106 | return AuthEnv{}, errors.New( |
| 107 | "sign-in is not configured: CARLOS_SMTP_HOST and CARLOS_SMTP_FROM not set, " + |
| 108 | "so magic links would be written to the log instead of emailed. " + |
| 109 | "Run `carlos email enable --app sheets`") |
| 110 | } |
| 111 | logger.Warn("sign-in: no SMTP configured and origin is loopback — " + |
| 112 | "magic links will be LOGGED, NOT SENT") |
| 113 | } |
| 114 | if _, err := connect.DecodeCredentials(env.Home, env.Origin); err != nil { |
| 115 | return AuthEnv{}, errHome |
| 116 | } |
| 117 | env.Mailer = mail.FromEnv("CARLOS", logger) |
| 118 | |
| 119 | return env, nil |
| 120 | } |
| 121 | |
| 122 | // splitOwners parses the allowlist, lowercasing and dropping blanks so a |
| 123 | // trailing comma or stray space cannot admit or exclude anyone by |
| 124 | // accident. |
| 125 | func splitOwners(raw string) []string { |
| 126 | var out []string |
| 127 | for _, part := range strings.Split(raw, ",") { |
| 128 | if addr := strings.ToLower(strings.TrimSpace(part)); addr != "" { |
| 129 | out = append(out, addr) |
| 130 | } |
| 131 | } |
| 132 | return out |
| 133 | } |
| 134 | |
| 135 | // NewAuth wires the sign-in flow. Exactly one per process: auth's |
| 136 | // underlying flow carries rate-limiter state on the value, so one per |
| 137 | // request would get a fresh empty budget every time and rate-limit |
| 138 | // nothing, silently. |
| 139 | // |
| 140 | // app supplies the three things auth asks of the app at redemption: |
| 141 | // the admission gate (App.Admits), the door a verified but refused |
| 142 | // address is shown (App.refused), and the door an emailed link opens on |
| 143 | // (App.renderConfirm). Admission is the App's rather than |
| 144 | // Admits(env.Owners) because it is no longer a static list: a guest who |
| 145 | // has been granted a sheet must be able to sign in, and only the |
| 146 | // database knows who that is. |
| 147 | func NewAuth(db *sql.DB, env AuthEnv, logger *slog.Logger, app *App) (*auth.Auth, error) { |
| 148 | if app == nil { |
| 149 | // A nil Authorize admits every verified address, which on a |
| 150 | // public-hostname instance means anyone who can receive mail. |
| 151 | // Refusing here beats defaulting to the allowlist, because a |
| 152 | // caller that forgot to pass one has a bug either way and this |
| 153 | // is the version that says so. |
| 154 | return nil, errors.New("sign-in: refusing to wire auth with no admission gate") |
| 155 | } |
| 156 | authorize := app.Admits |
| 157 | queue := newMailQueue(env.Mailer, authorize, nil, logger) |
| 158 | if a, ok := env.Mailer.(queueAttacher); ok { |
| 159 | a.attachQueue(queue.wait) |
| 160 | } |
| 161 | signedInPath := "/sheets" |
| 162 | if env.Home != nil { |
| 163 | signedInPath = connect.LocalCompletePath |
| 164 | } |
| 165 | plugin, err := auth.New(auth.Config{ |
| 166 | DB: db, |
| 167 | Origin: env.Origin, |
| 168 | InstanceKey: env.InstanceKey, |
| 169 | // Begin runs for every address (handleBegin); the mail goes |
| 170 | // out after the response, and only to one authorize admits. |
| 171 | Mailer: queue, |
| 172 | Authorize: authorize, |
| 173 | // A verified address Authorize refuses gets the door, with the |
| 174 | // refusal on it, in auth's own 403 (App.refused). |
| 175 | Refused: app.refused, |
| 176 | // GET /auth/verify spends nothing: it opens the door with one |
| 177 | // Sign in button that POSTs the link back, so a mail scanner's |
| 178 | // fetch cannot spend it (App.renderConfirm). |
| 179 | RenderConfirm: app.renderConfirm, |
| 180 | // No proof-of-work on the sign-in form. Sheets never had one: |
| 181 | // handleBegin's rate budgets and mailQueue's admission check are |
| 182 | // what stand between a stranger and the mailer, and the door is |
| 183 | // estilo's form, which renders no pow challenge. ProofOff keeps |
| 184 | // today's behaviour; wiring a pow.Guard is a decision of its own |
| 185 | // (schema, assets, a form that needs JavaScript). |
| 186 | ProofOff: true, |
| 187 | SigninPath: "/signin", |
| 188 | // Where a fresh session lands. auth defaults this to "/", |
| 189 | // which is the anonymous entry page — so without this line |
| 190 | // every magic-link click arrives on a page that exists to |
| 191 | // offer sign-in, and has to be told it worked. |
| 192 | SignedInPath: signedInPath, |
| 193 | Subject: chrome.EmailSubject("Sheets"), |
| 194 | Logger: logger, |
| 195 | }) |
| 196 | if err != nil { |
| 197 | queue.close() |
| 198 | return nil, err |
| 199 | } |
| 200 | signinQueuesMu.Lock() |
| 201 | signinQueues[plugin] = queue |
| 202 | signinQueuesMu.Unlock() |
| 203 | return plugin, nil |
| 204 | } |
| 205 | |
| 206 | // admits builds the admission gate. |
| 207 | // |
| 208 | // A nil Authorize admits every verified address, which on a |
| 209 | // public-hostname instance means anyone who can receive mail. The |
| 210 | // allowlist is a slice-one stand-in for a membership table: it is |
| 211 | // deliberately the smallest thing that is fail-closed, not the shape |
| 212 | // sharing will eventually need. |
| 213 | // |
| 214 | // Comparison is on the lowercased address. That is not full |
| 215 | // normalisation — addresses are case-sensitive before the @ per RFC |
| 216 | // 5321, and no mainstream provider honours that — so this matches what |
| 217 | // operators expect when they type an allowlist. |
| 218 | func Admits(owners []string) func(string) bool { |
| 219 | allowed := make(map[string]struct{}, len(owners)) |
| 220 | for _, o := range owners { |
| 221 | allowed[o] = struct{}{} |
| 222 | } |
| 223 | return func(address string) bool { |
| 224 | _, ok := allowed[strings.ToLower(strings.TrimSpace(address))] |
| 225 | return ok |
| 226 | } |
| 227 | } |
| 228 | |