oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// auth.go is sign-in: rastrillo/auth's plugin, the allowlist that gates
2// who is mailed and who is admitted, and the mapping from a verified
3// address to this app's own users row.
4//
5// Per spec §6.1 a single instance signs people in itself, with no
6// password and no credential enrolled here. How an address proves
7// itself depends on its domain alone, through rastrillo/auth's
8// classification: an address whose domain runs Keymail (keymail.dev
9// itself, or a domain delegating through a `_keymail` TXT record)
10// signs in at Keymail, where its passkey lives; every other address is
11// emailed a single-use link. Paul ruled on 2026-10-06 that Slides uses
12// Keymail. Never a passkey enrolled at this instance: its relying party
13// id would derive from this host, so a person on three team instances
14// would hold three credentials for one app. Keymail keeps the
15// credential at Keymail's own origin, and a magic link enrols nothing.
16//
17// Admission is a separate question from the ceremony, asked at two
18// places that cannot be observed from the form: the mail worker
19// (mailQueue — a non-permitted address is never mailed) and
20// rauth's Authorize, after Keymail or the link has verified the
21// address. Neither changes what POST /signin answers (gateBegin).
22//
23// This file is a port of oficina/docs' internal/docs/auth.go, adapted
24// to rastrillo v0.26.0 and to Config.AllowedAddresses being a slice
25// rather than a comma-separated string. Spec §6.1 names the second copy
26// as the extraction trigger: the next app to need this should get it
27// from rastrillo, not from a third copy.
28package slides
29
30import (
31 "bytes"
32 "context"
33 "crypto/sha256"
34 "database/sql"
35 "encoding/base64"
36 "encoding/json"
37 "errors"
38 "fmt"
39 "log/slog"
40 "math"
41 "net/http"
42 "net/url"
43 "strings"
44 "time"
45
46 "amadan.net/oficina/estilo.git/brand"
47 "amadan.net/oficina/estilo.git/chrome"
48 "amadan.net/oficina/estilo.git/sky"
49 "amadan.net/oficina/home/connect"
50 "amadan.net/oficina/home/connect/linkstore"
51 "amadan.net/oficina/home/handoff"
52 "amadan.net/oficina/home/handoff/seal"
53 "amadan.net/oficina/slides/internal/slides/present"
54 "amadan.net/rastrillo/rastrillo"
55 rauth "amadan.net/rastrillo/rastrillo/auth"
56 "amadan.net/rastrillo/rastrillo/mail"
57 "amadan.net/rastrillo/rastrillo/sessions"
58 "gorm.io/gorm"
59 "gorm.io/gorm/clause"
60)
61
62// signinPath is where the sign-in page lives. It must keep matching
63// rauth.Config's own default SigninPath, because newAuthPlugin never
64// overrides it: rauth redirects every outcome back to its own copy of
65// this literal, and app.go mounts the page at this one.
66const signinPath = "/signin"
67
68// devInstanceKey is resolveInstanceKey's fallback when Env is
69// development and Config.InstanceKey is empty. It is not a secret: it
70// is a fixed, public string, exactly as dangerous — and exactly as
71// acceptable — as rastrillo/auth's own refused empty key would be,
72// except spelled out here instead of left implicit, and it never seals
73// anything outside a throwaway development database.
74const devInstanceKey = "slides-development-instance-key-not-a-secret"
75
76// resolveInstanceKey applies Config.InstanceKey's fail-closed rule:
77// non-empty always wins; empty is fine only in development; anywhere
78// else it is a startup refusal.
79func resolveInstanceKey(cfg Config) (string, error) {
80 if key := strings.TrimSpace(cfg.InstanceKey); key != "" {
81 return key, nil
82 }
83 if cfg.development() {
84 return devInstanceKey, nil
85 }
86 return "", fmt.Errorf("slides: 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 $SLIDES_INSTANCE_KEY", cfg.Env, EnvDevelopment)
87}
88
89// resolveMailer applies Config.Mailer's fail-closed rule: an explicit
90// Mailer always wins; nil falls back to rastrillo/mail's logging sender
91// in development, and is a startup refusal anywhere else.
92func resolveMailer(cfg Config, logger *slog.Logger) (mail.Sender, error) {
93 if cfg.Mailer != nil {
94 return cfg.Mailer, nil
95 }
96 if !cfg.development() {
97 return nil, fmt.Errorf("slides: mail is not configured and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in. Set $SLIDES_SMTP_HOST and $SLIDES_SMTP_FROM, or run `carlos email enable` for this app and let $CARLOS_SMTP_HOST supply the relay", cfg.Env, EnvDevelopment)
98 }
99 // mail.Logged labels every send loudly itself — see its own doc
100 // comment. Never reached outside development.
101 return mail.Logged(logger), nil
102}
103
104// normalizeAddress puts address into the one form the allowlist
105// compares and the users table stores: trimmed, lowercased. Shared by
106// the allowlist and by ensureUser so the two can never drift into
107// comparing differently-normalized forms of the same address.
108func normalizeAddress(address string) string {
109 return strings.ToLower(strings.TrimSpace(address))
110}
111
112// allowlist is Config.AllowedAddresses, parsed and normalized.
113//
114// A nil addrs map means "admit every address" — resolveAllowlist's
115// development-only default, spelled out here rather than left as an
116// accidental nil. A non-nil one means "admit nothing but exactly this
117// set"; resolveAllowlist never returns a non-nil-but-empty map, since
118// an empty parse outside development is a refusal rather than a value.
119type allowlist struct {
120 addrs map[string]struct{}
121}
122
123// allows reports whether address may sign in.
124func (a allowlist) allows(address string) bool {
125 if a.addrs == nil {
126 return true
127 }
128 _, ok := a.addrs[normalizeAddress(address)]
129 return ok
130}
131
132// resolveAllowlist applies Config.AllowedAddresses' fail-closed rule.
133// See that field's own doc comment for why an empty list outside
134// development is a refusal rather than "admit nobody".
135func resolveAllowlist(cfg Config) (allowlist, error) {
136 addrs := make(map[string]struct{})
137 for _, addr := range cfg.AllowedAddresses {
138 if addr = normalizeAddress(addr); addr != "" {
139 addrs[addr] = struct{}{}
140 }
141 }
142 if len(addrs) > 0 {
143 return allowlist{addrs: addrs}, nil
144 }
145 if cfg.development() {
146 return allowlist{}, nil // addrs stays nil: admit everyone
147 }
148 return allowlist{}, fmt.Errorf("slides: Config.AllowedAddresses is empty and Env is %q, not %q — refusing to start with no allowlist: an instance admitting nobody is indistinguishable from one working correctly, since every POST /signin answers the same 303 either way; set $SLIDES_ALLOWED_ADDRESSES", cfg.Env, EnvDevelopment)
149}
150
151// gateBegin is POST /signin: rauth.Auth.Begin for EVERY address,
152// permitted or not, so what the form answers depends on the address's
153// domain and never on the allowlist. That is the whole guarantee, and
154// any difference at all would turn this endpoint into a membership
155// oracle, letting anyone learn who this instance admits by watching how
156// its answers differ.
157//
158// Every address therefore takes the same road through Begin: the same
159// per-IP and per-address budgets, the same classification, the same
160// ceremony.
161//
162// - A Keymail address — permitted or not — goes to Keymail. Begin
163// answers with a 303 to Keymail's authorize URL, which newBegin's
164// continuation turns into a page (Safari applies form-action to the
165// whole redirect chain of a form POST, and ours is 'self'). Whether
166// the address may sign in here is asked only after Keymail has
167// verified it, by rauth's Authorize, and a refusal is the sign-in
168// door saying so (refused, rauth's Refused hook) — told only to
169// somebody who has just proved they hold the address.
170// - Any other address is answered `303 /signin?sent=1`, and a link is
171// mailed only if it is permitted: mailQueue drops the rest, after
172// the response, so not even its timing differs (mailqueue.go).
173// So this instance's sending identity is no open relay for whatever
174// a stranger types, which is why a gate exists at all
175// (rauth.Config.Authorize alone runs only after a link was mailed
176// and clicked).
177//
178// force (any non-empty value) is passed through as the form sent it.
179// Only the recovery form carries it — the page rastrillo lands a failed
180// Keymail exchange on, `?force=1&err=keymail` — and it selects an
181// emailed link over Keymail, so a person whose Keymail failed is never
182// stuck. A stranger posting force for any address gets exactly the
183// plain-email answer above.
184//
185// No outcome of Begin depends on admission any more: both kinds of
186// address spend the same budgets and parse the same way, and the send
187// that only a permitted address reaches happens in mailQueue's worker,
188// after the response, where its failures are logged rather than
189// answered. sameAnswer still rewrites `?err=rate|address|1` to sent, as
190// it always has; giving rate and address back their own sentences is a
191// separate, safe change, not made here.
192func gateBegin(next http.HandlerFunc, logger *slog.Logger) http.HandlerFunc {
193 return func(w http.ResponseWriter, r *http.Request) {
194 next(&sameAnswer{ResponseWriter: w, logger: logger}, r)
195 }
196}
197
198// newBegin is the POST /signin handler both modes mount: gateBegin
199// under connect.OAuthContinuation, which ends the POST with a page
200// (renderKeymailContinuation) when Begin's answer is Keymail's
201// authorize URL, and passes every other answer through untouched.
202func (a *app) newBegin(begin http.HandlerFunc) http.HandlerFunc {
203 return connect.OAuthContinuation(a.cfg.Origin, gateBegin(begin, a.logger), a.renderKeymailContinuation)
204}
205
206// renderKeymailContinuation is the page between the form and Keymail:
207// the door's own card, saying where the person is going, with a meta
208// refresh that goes there by itself and a link that does when the
209// refresh is blocked. A navigation from a page, unlike a redirect from
210// a form POST, is outside form-action.
211func (a *app) renderKeymailContinuation(w http.ResponseWriter, r *http.Request, destination string) {
212 p := connect.OAuthPage(destination)
213 p.Title = rastrillo.T(r, "slides.signin.keymail.title")
214 p.ActionLabel = rastrillo.T(r, "slides.signin.keymail.action")
215 notice := chrome.SignInNotice{
216 Icon: brand.Path("slides"), App: "Slides", Sky: sky.For(r.Host),
217 Heading: p.Title, Body: rastrillo.T(r, "slides.signin.keymail.body"),
218 Link: &chrome.Link{Href: p.URL, Label: p.ActionLabel},
219 }
220 render(w, "signin", signinView{
221 shell: a.publicShell(r, frame{parts: []string{p.Title}, signinPage: true}),
222 Sent: &notice,
223 Continue: &p,
224 })
225}
226
227// refused is rauth's Refused hook: Keymail or an emailed link verified
228// the address and the allowlist does not hold it. It answers with the
229// sign-in door, "That address can't sign in to Slides" above the form,
230// in a 403. No session is minted, and rauth has already ended the
231// attempt.
232//
233// Rendered, not redirected to ?err=refused: a query parameter is
234// something anybody can type, and then the sentence that means "you
235// proved this address and it is not admitted" could be shown to anybody
236// about nothing (Astra on #20, round 3). Only this hook can say it, and
237// rauth calls it only after the address is proved, so it is no oracle.
238// It replaced doorOnRefusal, which buffered Callback and Verify and
239// matched rastrillo's bare 403 sentence until the hook existed (#63).
240func (a *app) refused(w http.ResponseWriter, r *http.Request, _ rauth.Identity) {
241 w.Header().Set("Cache-Control", "no-store")
242 form := a.door(r)
243 form.Action = signinPath
244 form.Error = rastrillo.T(r, "slides.signin.error.refused")
245 a.renderSignin(w, r, http.StatusForbidden, signinView{
246 shell: a.publicShell(r, frame{parts: []string{form.Page()}, signinPage: true}),
247 SignIn: form,
248 })
249}
250
251// renderConfirm is rauth's RenderConfirm: the page an emailed link opens
252// on. GET /auth/verify spends nothing, so a mail-security scanner that
253// fetches every link in an inbound message cannot spend this one; the
254// door's single Sign in button POSTs the token back to d.Action, and
255// that POST signs in (#64).
256//
257// It is the door with the address field taken away — the same mark,
258// sky, heading and Sign in label — so the person who clicked the link
259// sees Slides, not rastrillo's plain English fallback. estilo has no
260// confirm variant, so the form is signin.html's own. rauth has already
261// set no-store, Referrer-Policy: strict-origin (the URL is a live
262// credential) and the framing refusals; nothing here replaces them.
263// d.Token is untrusted query input and reaches the page only through
264// html/template.
265func (a *app) renderConfirm(w http.ResponseWriter, r *http.Request, d rauth.ConfirmPageData) {
266 c := signinConfirm{Door: a.door(r), Action: d.Action, Token: d.Token}
267 c.Door.Lede = rastrillo.T(r, "slides.signin.confirm.lede")
268 a.renderSignin(w, r, http.StatusOK, signinView{
269 shell: a.publicShell(r, frame{parts: []string{c.Door.Page()}, signinPage: true}),
270 Confirm: &c,
271 })
272}
273
274// signinConfirm is the confirm step's door: the chrome, and the form
275// that posts the emailed token back.
276type signinConfirm struct {
277 Door chrome.SignIn
278 Action, Token string
279}
280
281// door is the sign-in door's chrome for this instance: Slides' mark and
282// name on its sky.
283func (a *app) door(r *http.Request) chrome.SignIn {
284 return chrome.SignIn{
285 Icon: brand.Path("slides"),
286 App: "Slides",
287 Sky: sky.For(r.Host),
288 OpenSource: &chrome.Link{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")},
289 }
290}
291
292// renderSignin renders the signin page with status. render alone always
293// answers 200, which the refusal must not.
294func (a *app) renderSignin(w http.ResponseWriter, r *http.Request, status int, v signinView) {
295 var buf strings.Builder
296 if err := pages["signin"].ExecuteTemplate(&buf, "layout", v); err != nil {
297 a.logger.Error("slides: render sign-in page", "err", err)
298 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
299 return
300 }
301 w.Header().Set("Content-Type", "text/html; charset=utf-8")
302 w.WriteHeader(status)
303 _, _ = w.Write([]byte(buf.String()))
304}
305
306// sentLocation is the answer POST /signin gives every address that is
307// not going to Keymail — see gateBegin. Spelled once so the paths that
308// write it cannot drift apart by a character.
309const sentLocation = signinPath + "?sent=1"
310
311// sameAnswer is the ResponseWriter gateBegin hands to Begin: it
312// rewrites any `?err=…` outcome back to sentLocation before the status
313// line goes out, so a permitted address cannot be told apart from a
314// denied one by a failed send. See gateBegin's own doc comment for
315// which outcomes that still matters for.
316//
317// It hooks WriteHeader rather than the Location header itself because
318// that is the last moment a header is still settable: http.Redirect
319// sets Location and then calls WriteHeader, so by intercepting the
320// second we see the first already written and can still change it. The
321// body is not a concern on this route — http.Redirect writes its little
322// HTML body only for GET and HEAD, and this handler is mounted on POST
323// alone.
324type sameAnswer struct {
325 http.ResponseWriter
326 logger *slog.Logger
327}
328
329func (s *sameAnswer) WriteHeader(status int) {
330 if loc := s.Header().Get("Location"); loc != "" && loc != sentLocation && strings.HasPrefix(loc, signinPath+"?err=") {
331 // Logged, not swallowed: the person who submitted the form is
332 // told nothing, so the operator has to be told everything.
333 s.logger.Warn("slides: sign-in refused; answering as if sent, to keep the allowlist unobservable",
334 "outcome", loc)
335 s.Header().Set("Location", sentLocation)
336 }
337 s.ResponseWriter.WriteHeader(status)
338}
339
340// newAuthPlugin wires rastrillo/auth per SKILL.md §5: exactly one *Auth
341// per process (the rate limiter's budget lives on the value, so a plugin
342// per request would silently have no rate limiting at all), Origin as
343// both the emailed link's base and the cookie decision, InstanceKey and
344// Mailer already resolved and fail-closed above.
345//
346// Authorize is list.allows as well as mailQueue — two checks
347// defending two different things: mailQueue stops the mail,
348// Authorize stops a session being minted for a non-permitted address,
349// whether Keymail verified it or a link was somehow issued for it
350// (mailed before the allowlist changed, say). SecondFactor stays nil,
351// per this file's package comment.
352//
353// cfg must be the resolved Config (config.go's resolve), not a
354// caller's raw one: Origin, InstanceKey and Mailer are all required
355// here and all three may have been filled in by a development default.
356//
357// SigninPath is passed explicitly even though signinPath is already
358// rauth's own zero-value default. rauth redirects every outcome to its
359// copy of that literal and app.go mounts the page at ours; saying it
360// once, out loud, is what keeps the two from drifting silently if
361// either default ever changes.
362//
363// a supplies the two pages rauth asks of the app: the door a verified
364// but refused address is shown (app.refused) and the door an emailed
365// link opens on (app.renderConfirm). Only their method values are taken
366// here; newApp fills the rest of *a in after this returns.
367func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist, mailer *mailQueue, a *app) (*rauth.Auth, error) {
368 signedInPath := landedPath
369 if cfg.Home != nil {
370 signedInPath = connect.LocalCompletePath
371 }
372 return rauth.New(rauth.Config{
373 SignedInPath: signedInPath,
374 DB: writer,
375 Origin: cfg.Origin,
376 InstanceKey: cfg.InstanceKey,
377 Mailer: mailer,
378 Subject: chrome.EmailSubject("Slides"),
379 Authorize: list.allows,
380 // A verified address the allowlist does not hold gets the door,
381 // with the refusal on it, in rauth's own 403 (app.refused).
382 Refused: a.refused,
383 // GET /auth/verify spends nothing: it opens the door with one
384 // Sign in button that POSTs the link back, so a mail scanner's
385 // fetch cannot spend it (app.renderConfirm).
386 RenderConfirm: a.renderConfirm,
387 // No proof-of-work on the sign-in form. Slides never had one:
388 // rauth's per-IP and per-address budgets and mailQueue's
389 // allowlist check are what stand between a stranger and the
390 // mailer, and the door is estilo's form, which renders no pow
391 // challenge. ProofOff keeps today's behaviour; wiring a pow.Guard
392 // is a decision of its own (schema, assets, a form that needs
393 // JavaScript, which the door works without).
394 ProofOff: true,
395 SigninPath: signinPath,
396 Logger: cfg.Logger,
397 })
398}
399
400// presenceColours is the palette ensureUser assigns from — spec §3.2's
401// coloured cursor and block outline. Six, so a small team rarely
402// collides, and never the only carrier of who is who: the name rides
403// beside the colour everywhere it is drawn.
404var presenceColours = []string{
405 "#2f6f4f", "#8a4b2a", "#3a4f8a", "#7a2f5f", "#4a5a2a", "#2a5f7a",
406}
407
408// ensureUser maps a verified address to this app's own users row,
409// creating it on first sign-in. It is the mapping SKILL.md §5 insists
410// on: under rastrillo/auth the session Subject is the email, so
411// sessions.UserID answers (0, false) and scoping on it would file every
412// deck under user_id = 0.
413//
414// Read first, and only write when the read came back empty. This runs
415// on every authenticated request — currentUser calls it before any
416// handler does its own work — and SQLite has one writer: an
417// unconditional upsert here would open a write transaction per page
418// view, serialising every reader in the app behind a statement whose
419// answer is almost always "this row already exists". The read goes to
420// the reader pool (db.Open routes per statement), so the steady state
421// costs one indexed SELECT and no lock at all.
422//
423// The write, when it happens, is still ON CONFLICT DO NOTHING on the
424// unique Email index rather than a bare INSERT: the read above is not a
425// lock, so two requests arriving together on a person's very first
426// sign-in can both miss and both try to insert. DO NOTHING makes the
427// loser a no-op instead of an error, and the read-back below gives both
428// of them the same row.
429// The second return says whether THIS call created the row — what
430// makes a first sign-in distinguishable from every later request, and
431// so where the sample deck is seeded (sample.go). It is read off the
432// insert's own RowsAffected rather than off "we took the insert path",
433// because DO NOTHING means both racing requests take that path and only
434// one of them writes.
435func ensureUser(ctx context.Context, g *gorm.DB, address string) (User, bool, error) {
436 email := normalizeAddress(address)
437 if email == "" {
438 return User{}, false, errors.New("slides: cannot map an empty address to a user")
439 }
440
441 var found User
442 err := g.WithContext(ctx).Where("email = ?", email).Take(&found).Error
443 switch {
444 case err == nil:
445 return found, false, nil
446 case !errors.Is(err, gorm.ErrRecordNotFound):
447 return User{}, false, fmt.Errorf("slides: load user %q: %w", email, err)
448 }
449
450 // First sight of this address: create the row, then read it back
451 // rather than trusting the insert's own return — DO NOTHING leaves
452 // ID at zero when a concurrent request won the race.
453 created := User{Email: email, Colour: colourFor(email)}
454 res := g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&created)
455 if res.Error != nil {
456 return User{}, false, fmt.Errorf("slides: create user %q: %w", email, res.Error)
457 }
458 if err := g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil {
459 return User{}, false, fmt.Errorf("slides: load user %q: %w", email, err)
460 }
461 return found, res.RowsAffected == 1, nil
462}
463
464// colourFor picks a stable presence colour for an address: the same
465// person gets the same colour on every instance and after every
466// restart, which is what makes a colour worth recognising at all.
467func colourFor(email string) string {
468 var sum uint32
469 for _, b := range []byte(email) {
470 sum = sum*31 + uint32(b)
471 }
472 return presenceColours[int(sum%uint32(len(presenceColours)))]
473}
474
475// currentUser is the users row for whoever is signed in on r, created
476// on first sight. Past RequireSession the second return is always true;
477// it is false for a signed-out request, or when the row could not be
478// read (logged, never surfaced as a half-signed-in page).
479//
480// It is also where a first sign-in seeds the sample deck (sample.go) —
481// right beside the insert that makes the row, because "the users row
482// was just created" is the only place in the app that knows a person is
483// new. The seed is guarded by a second check for a deck this person
484// already owns, so no arrangement of races or restores can produce two
485// welcome decks, and a failure to build one never stops a sign-in.
486//
487// sessions.Current(r).Subject, never sessions.UserID — see ensureUser.
488//
489// IT IS ALSO THE ADMISSION RECHECK (spec §6.1, codex 6). The allowlist
490// used to gate only the minting of a session: past that, a session was
491// good until it expired, and rastrillo's RequireSession asks nothing
492// about admission. So taking an address out of
493// SLIDES_ALLOWED_ADDRESSES and restarting left every browser that
494// person was already signed in on working exactly as before — the one
495// control this instance has for saying "not any more" did not say it.
496//
497// It takes the ResponseWriter for that reason: the honest answer to a
498// session whose address is no longer admitted is to end it, here,
499// rather than to let the request through or to fail it as an internal
500// error. offboard does the rest.
501func (a *app) currentUser(w http.ResponseWriter, r *http.Request) (User, sessionOutcome) {
502 s, ok := sessions.Current(r)
503 if !ok || s.Subject == "" {
504 return User{}, sessionNone
505 }
506 if s.Method == connect.HomeMethod {
507 u, err := a.homeUser(r, s)
508 if err != nil {
509 if errors.Is(err, linkstore.ErrRefused) || errors.Is(err, errHomeIdentity) {
510 a.revokeHome(w, r, s.Subject)
511 return User{}, sessionOffboarded
512 }
513 a.logger.Error("slides: resolve Home user", "err", err)
514 return User{}, sessionNone
515 }
516 return u, sessionOK
517 }
518 if !a.allowed.allows(s.Subject) {
519 a.offboard(w, r, s.Subject)
520 return User{}, sessionOffboarded
521 }
522 u, created, err := ensureUser(r.Context(), a.db.G, s.Subject)
523 if err != nil {
524 a.logger.Error("slides: resolve current user", "err", err)
525 return User{}, sessionNone
526 }
527 if created {
528 if has, err := hasDeck(r.Context(), a.db.G, u); err != nil {
529 a.logger.Error("slides: check for an existing deck before seeding", "user", u.ID, "err", err)
530 } else if !has {
531 a.seedSampleDeckFor(r.Context(), u)
532 }
533 }
534 return u, sessionOK
535}
536
537// sessionOutcome is what currentUser concluded about a request, and it
538// is three answers rather than two because one of them means "this
539// request has already been answered".
540type sessionOutcome int
541
542const (
543 // sessionOK: there is a person, and the second return of
544 // currentUser is their row.
545 sessionOK sessionOutcome = iota
546 // sessionNone: no session, or the row could not be read. Never
547 // reached past RequireSession, and a bug if it is — the caller
548 // renders its own 500.
549 sessionNone
550 // sessionOffboarded: there was a session, and its address is no
551 // longer on the allowlist. offboard has ended it and written the
552 // answer; the caller must write nothing more.
553 sessionOffboarded
554)
555
556// offboard is what happens to somebody whose address is no longer on
557// the allowlist and who still has a live session — spec §6.1's
558// offboarding rule (codex 6).
559//
560// Three things, in this order, and the order is the point:
561//
562// - Every session for that address is DELETED, not just this
563// request's, and not just the cookie. A cleared cookie leaves the
564// token in the sessions table and valid, so a browser holding a
565// copy — a second tab restored from history, a phone — would carry
566// on. Real revocation, and for every device at once, because
567// offboarding is about the person and not about the browser they
568// happen to be using.
569// - Every presentation they had running is ENDED. A live session is
570// a public URL that reads the deck out, so leaving one up would
571// leave a removed person's talk readable by whoever has the
572// address. Their share links are NOT revoked: a share link belongs
573// to the deck, the deck belongs to the team, and revoking the
574// team's links because one member left would be an outage the
575// departure did not call for. The share page is where a link goes
576// away, deliberately by somebody who still has access.
577// - The cookie is cleared and the person is told, in words, on the
578// sign-in page. `?err=removed` rather than a silent redirect,
579// because "your access to this instance was removed" is a
580// different fact from "you are signed out" and a person who is not
581// told the first will simply ask for another link.
582//
583// A page request gets the redirect; anything else gets a 403, which is
584// rauth's own split (refuseSession) and the only one that makes sense
585// for a fetch or a form POST.
586func (a *app) offboard(w http.ResponseWriter, r *http.Request, address string) {
587 email := normalizeAddress(address)
588 if a.home != nil {
589 if err := a.db.G.WithContext(r.Context()).Exec("DELETE FROM sessions WHERE method = ? AND subject IN (SELECT l.subject FROM home_identity_links l JOIN users u ON u.id=l.person_id WHERE u.email=?)", connect.HomeMethod, email).Error; err != nil {
590 a.logger.Error("slides: revoke linked Home sessions", "err", err)
591 }
592 }
593 a.logger.Warn("slides: a signed-in address is no longer admitted; ending its sessions",
594 "address", email)
595
596 // rastrillo/sessions owns this table and offers no "revoke every
597 // session for a subject", so the statement is written here. The
598 // subject IS the address under rastrillo/auth (SKILL.md §5), which
599 // is what makes one statement enough.
600 if err := a.db.G.WithContext(r.Context()).
601 Exec("DELETE FROM sessions WHERE subject = ?", email).Error; err != nil {
602 a.logger.Error("slides: revoke the sessions of a removed address", "err", err)
603 }
604
605 var u User
606 if err := a.db.G.WithContext(r.Context()).
607 Where("email = ?", email).Take(&u).Error; err == nil {
608 a.endPresentationsOf(r, actorFor(u))
609 } else if !errors.Is(err, gorm.ErrRecordNotFound) {
610 a.logger.Error("slides: load a removed address's user row", "err", err)
611 }
612
613 if a.auth != nil {
614 http.SetCookie(w, &http.Cookie{
615 Name: a.auth.SessionCookie(), Value: "", Path: "/",
616 MaxAge: -1, HttpOnly: true,
617 SameSite: http.SameSiteLaxMode,
618 Secure: strings.HasPrefix(a.cfg.Origin, "https://"),
619 })
620 }
621 if r.Method == http.MethodGet || r.Method == http.MethodHead {
622 http.Redirect(w, r, signinPath+"?err=removed", http.StatusSeeOther)
623 return
624 }
625 http.Error(w, "this address is no longer admitted here", http.StatusForbidden)
626}
627
628// endPresentationsOf closes every live talk one actor still has open.
629// Best effort and logged: it is part of offboarding, and a failure to
630// end a talk must not stop the sign-out that is the main event.
631func (a *app) endPresentationsOf(r *http.Request, actor string) {
632 var live []Presentation
633 if err := a.db.G.WithContext(r.Context()).
634 Where("actor = ? AND ended_at IS NULL", actor).Find(&live).Error; err != nil {
635 a.logger.Error("slides: list a removed actor's presentations", "actor", actor, "err", err)
636 return
637 }
638 if len(live) == 0 {
639 return
640 }
641 now := time.Now().UTC()
642 if err := a.db.G.WithContext(r.Context()).Model(&Presentation{}).
643 Where("actor = ? AND ended_at IS NULL", actor).
644 Update("ended_at", now).Error; err != nil {
645 a.logger.Error("slides: end a removed actor's presentations", "actor", actor, "err", err)
646 return
647 }
648 // Every projector, phone and follower watching one of them, told at
649 // once — the same event the End button publishes.
650 for _, p := range live {
651 a.hub.Publish(p.Sqid, present.Event{Ended: true})
652 }
653}
654
655// signinView is the sign-in page's data: estilo's sign-in form, or, once
656// a link is on its way, the check-your-email state that replaces it, or
657// the confirm step an emailed link opens on.
658type signinView struct {
659 shell
660 SignIn chrome.SignIn
661 Sent *chrome.SignInNotice
662 // Continue is set on the page between the form and Keymail
663 // (renderKeymailContinuation): its meta refresh goes on to
664 // Keymail, and Sent holds the door's card saying so.
665 Continue *connect.OAuthData
666 // Confirm is set on the page an emailed link opens on
667 // (renderConfirm): the door with one Sign in button.
668 Confirm *signinConfirm
669}
670
671// signin serves GET /signin: the form, and the landing spot for every
672// outcome rauth redirects back to (?sent=1, ?err=rate|address|expired|
673// keymail|removed|1, ?force=1). rate, address, expired and removed each
674// get their own sentence (a refusal is refused's, never the query's); keymail and a bare failure
675// share the default, because both mean the same operational thing —
676// sign-in did not complete, ask for a new link — and rauth's Verify
677// deliberately refuses to be more specific than that. ?force=1 (with
678// err=keymail) makes the form ask for an emailed link.
679//
680// Already signed in, it skips the form (spec §9.2): 303 to return_to or
681// the deck list. The admission recheck runs FIRST (currentUser), so an
682// address taken off the allowlist is signed out and told so here rather
683// than sent to a page that would send it straight back: the loop Astra
684// found in Docs (#65, finding 1) and Sheets (#39, finding 2). In Home
685// mode this handler is /signin/local, where a Home session goes on
686// purpose to link an account, so it never skips there.
687//
688// return_to (spec §9.4): rastrillo/auth lands a magic link on a fixed
689// SignedInPath and never reads return_to, so the value can't ride the
690// emailed link. It is remembered in a short-lived cookie here (returnCookie)
691// and read back at /auth/landed.
692func (a *app) signin(w http.ResponseWriter, r *http.Request) {
693 q := r.URL.Query()
694 ret, hasReturn := safeReturn(q)
695 if a.home == nil || r.URL.Path == "/signin/local" {
696 if s, ok := a.sessions.From(r); ok {
697 _, outcome := a.currentUser(w, sessions.WithSession(r, s))
698 switch outcome {
699 case sessionOK:
700 dest := "/"
701 if hasReturn {
702 dest = ret
703 }
704 w.Header().Set("Cache-Control", "no-store")
705 http.Redirect(w, r, dest, http.StatusSeeOther)
706 return
707 case sessionOffboarded:
708 // currentUser has signed the address out and answered.
709 return
710 }
711 }
712 switch {
713 case hasReturn:
714 a.rememberReturn(w, ret)
715 case len(q) == 0:
716 // A plain /signin is a fresh start: a destination remembered
717 // by an earlier, abandoned sign-in must not hijack this one.
718 a.forgetReturn(w)
719 }
720 }
721 w.Header().Set("Cache-Control", "no-store")
722 if q.Get("sent") == "1" {
723 // "Use a different address" goes back to the form keeping the
724 // return (spec §9.2): the remembered page, not a bare /signin,
725 // which would forget it (Astra on Docs #65, finding 2).
726 again := signinPath
727 if a.home != nil {
728 again = "/signin/local"
729 }
730 if remembered, ok := a.rememberedReturn(r); ok {
731 again += "?return_to=" + url.QueryEscape(remembered)
732 }
733 sent := chrome.SignInNotice{Icon: brand.Path("slides"), App: "Slides", Sky: sky.For(r.Host)}.Sent(again)
734 render(w, "signin", signinView{
735 shell: a.publicShell(r, frame{parts: []string{sent.Page()}, signinPage: true}),
736 Sent: &sent,
737 })
738 return
739 }
740 form := a.door(r)
741 form.Action = signinPath
742 if q.Get("force") != "" {
743 // A failed Keymail exchange lands here as ?force=1&err=keymail.
744 // Posting force back asks for an emailed link instead, so the
745 // next attempt does not return to the Keymail that just failed.
746 form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
747 }
748 if hasReturn {
749 form.Return = ret
750 }
751 switch err := q.Get("err"); {
752 case err == "":
753 // nothing to report. (?err=refused is the unknown-error default
754 // below: the refusal's own sentence is refused's, told only in
755 // the refusal's response.)
756 case err == "rate":
757 form.Error = rastrillo.T(r, "slides.signin.error.rate")
758 case err == "address":
759 // An address error belongs to the field, not a banner.
760 form.AddressError = rastrillo.T(r, "slides.signin.error.address")
761 case err == "expired":
762 form.Error = rastrillo.T(r, "slides.signin.error.expired")
763 case err == "removed":
764 // Not "sign-in did not complete": this address had access and
765 // no longer does (offboard). Asking for another link will not
766 // help, and the sentence says so.
767 form.Error = rastrillo.T(r, "slides.signin.error.removed")
768 default:
769 form.Error = rastrillo.T(r, "slides.signin.error.unknown")
770 }
771 render(w, "signin", signinView{
772 shell: a.publicShell(r, frame{parts: []string{form.Page()}, signinPage: true}),
773 SignIn: form,
774 })
775}
776
777// homeSignin is GET /signin with Home configured: connect's own handler,
778// which remembers return_to for the Home exchange and, for a live
779// session, skips straight to the destination. Its skip reads the session
780// cookie and nothing else, so the admission recheck runs here first: a
781// Home user who has lost access is signed out and told so, rather than
782// bounced between /signin and a page that refuses them.
783func (a *app) homeSignin(w http.ResponseWriter, r *http.Request) {
784 if s, ok := a.sessions.From(r); ok {
785 if _, outcome := a.currentUser(w, sessions.WithSession(r, s)); outcome == sessionOffboarded {
786 return
787 }
788 }
789 a.homeRoutes.Signin(w, r)
790}
791
792// requireSession is rastrillo/auth's RequireSession with return_to
793// (spec §9.4): its refusal sends a page request to a bare /signin and
794// drops the path, so a signed-out visit to a deck link used to land on
795// the deck list after sign-in. A signed-out GET or HEAD is answered here
796// with 303 /signin?return_to=<the page>; everything else goes to the
797// guard as before (a POST still gets its 403).
798func (a *app) requireSession(guard func(http.Handler) http.Handler) func(http.Handler) http.Handler {
799 return func(next http.Handler) http.Handler {
800 guarded := guard(next)
801 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
802 if r.Method == http.MethodGet || r.Method == http.MethodHead {
803 if _, ok := a.sessions.From(r); !ok {
804 w.Header().Set("Cache-Control", "no-store")
805 http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
806 return
807 }
808 }
809 guarded.ServeHTTP(w, r)
810 })
811 }
812}
813
814// landedPath is rastrillo/auth's SignedInPath in standalone mode: where a
815// verified magic link lands, which then goes on to the remembered page.
816const landedPath = "/auth/landed"
817
818// landed serves GET /auth/landed: the remembered return_to, or the deck
819// list. The cookie is spent either way.
820func (a *app) landed(w http.ResponseWriter, r *http.Request) {
821 dest := "/"
822 if ret, ok := a.rememberedReturn(r); ok {
823 dest = ret
824 }
825 a.forgetReturn(w)
826 w.Header().Set("Cache-Control", "no-store")
827 http.Redirect(w, r, dest, http.StatusSeeOther)
828}
829
830// returnCookieName is the remembered return_to. Host-only, and
831// __Host- prefixed wherever the origin is https.
832func (a *app) returnCookieName() string {
833 if a.secure() {
834 return "__Host-slides_return"
835 }
836 return "slides_return"
837}
838
839func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://") }
840
841// returnCookieAge is how long a remembered page lasts: a link opened
842// after ten minutes, or in another browser, lands on the deck list.
843const returnCookieAge int64 = 600
844const returnCookieLimit = 4096
845
846type returnIntent struct {
847 Path string `json:"path"`
848 Issued int64 `json:"issued"`
849 Expires int64 `json:"expires"`
850}
851
852// newReturnSealer keeps the cookie independent from any other encrypted
853// value. The raw instance key need not be exactly 32 bytes; its domain-bound
854// hash is. A changed key invalidates old return cookies, which is safe.
855func newReturnSealer(instanceKey string) (*seal.Sealer, error) {
856 key := sha256.Sum256([]byte("oficina/slides return v1\x00" + instanceKey))
857 return seal.New(key[:], "oficina/slides")
858}
859
860func (a *app) returnPurpose() string { return "slides.return.v1:" + a.cfg.Origin }
861
862func (a *app) rememberReturn(w http.ResponseWriter, path string) {
863 if !handoff.SafePath(path) || a.returnSeal == nil || a.returnNow == nil {
864 return
865 }
866 now := a.returnNow().Unix()
867 if now <= 0 || now > math.MaxInt64-returnCookieAge {
868 return
869 }
870 raw, err := json.Marshal(returnIntent{Path: path, Issued: now, Expires: now + returnCookieAge})
871 if err != nil {
872 return
873 }
874 sealed, err := a.returnSeal.Seal(a.returnPurpose(), raw)
875 if err != nil {
876 return
877 }
878 value := base64.RawURLEncoding.EncodeToString(sealed)
879 if len(value) > returnCookieLimit {
880 return
881 }
882 http.SetCookie(w, &http.Cookie{
883 Name: a.returnCookieName(), Value: value, Path: "/",
884 MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(),
885 SameSite: http.SameSiteLaxMode,
886 })
887}
888
889func (a *app) forgetReturn(w http.ResponseWriter) {
890 http.SetCookie(w, &http.Cookie{
891 Name: a.returnCookieName(), Value: "", Path: "/",
892 MaxAge: -1, HttpOnly: true, Secure: a.secure(),
893 SameSite: http.SameSiteLaxMode,
894 })
895}
896
897// rememberedReturn reads the remembered page back, validated again: the
898// cookie is a request header like any other, and the value is only ever
899// a same-origin path (handoff.SafePath, which also refuses /signin and
900// /auth/*, so this can never loop).
901func (a *app) rememberedReturn(r *http.Request) (string, bool) {
902 if a.returnSeal == nil || a.returnNow == nil {
903 return "", false
904 }
905 count := 0
906 for _, c := range r.Cookies() {
907 if c.Name == a.returnCookieName() {
908 count++
909 }
910 }
911 if count != 1 {
912 return "", false
913 }
914 c, err := r.Cookie(a.returnCookieName())
915 if err != nil {
916 return "", false
917 }
918 if c.Value == "" || len(c.Value) > returnCookieLimit {
919 return "", false
920 }
921 sealed, err := base64.RawURLEncoding.Strict().DecodeString(c.Value)
922 if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != c.Value {
923 return "", false
924 }
925 raw, err := a.returnSeal.Open(a.returnPurpose(), sealed)
926 if err != nil || len(raw) > returnCookieLimit {
927 return "", false
928 }
929 var intent returnIntent
930 if json.Unmarshal(raw, &intent) != nil {
931 return "", false
932 }
933 canonical, _ := json.Marshal(intent)
934 now := a.returnNow().Unix()
935 if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now || intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || !handoff.SafePath(intent.Path) {
936 return "", false
937 }
938 return intent.Path, true
939}
940
941// safeReturn is the request's return_to, when there is exactly one and
942// it is a same-origin path (handoff.SafePath: no scheme, no host, no
943// "//", no control characters, not /signin or /auth/*).
944func safeReturn(q url.Values) (string, bool) {
945 values, ok := q["return_to"]
946 if !ok || len(values) != 1 || !handoff.SafePath(values[0]) {
947 return "", false
948 }
949 return values[0], true
950}
951
952// signedOutAfter wraps rastrillo/auth's Signout, which ends the session
953// and redirects to the sign-in form: a page that, with no word about
954// what just happened, read like an error (polish audit, sign-on). The
955// redirect goes to /signed-out instead, which says so.
956func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc {
957 return func(w http.ResponseWriter, r *http.Request) {
958 signout(&toSignedOut{ResponseWriter: w}, r)
959 }
960}
961
962// toSignedOut rewrites Signout's Location, at the last moment a header is
963// still settable (sameAnswer's technique).
964type toSignedOut struct{ http.ResponseWriter }
965
966func (t *toSignedOut) WriteHeader(status int) {
967 if t.Header().Get("Location") == signinPath {
968 t.Header().Set("Location", "/signed-out")
969 }
970 t.ResponseWriter.WriteHeader(status)
971}
972
973// signedOut serves GET /signed-out in standalone mode.
974func (a *app) signedOut(w http.ResponseWriter, r *http.Request) {
975 w.Header().Set("Cache-Control", "no-store")
976 a.renderSignedOut(w, r, http.StatusOK)
977}
978
979// signedOutView is the signed-out page's data.
980type signedOutView struct {
981 shell
982 Notice chrome.SignInNotice
983}
984
985// renderSignedOut is where Sign out lands, standalone and with Home
986// alike: "You're signed out", and a primary Sign in.
987func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, status int) {
988 notice := chrome.SignInNotice{Icon: brand.Path("slides"), App: "Slides", Sky: sky.For(r.Host)}.SignedOut(signinPath)
989 var buf strings.Builder
990 err := pages["signed_out"].ExecuteTemplate(&buf, "layout", signedOutView{
991 shell: a.publicShell(r, frame{parts: []string{notice.Page()}, signinPage: true}),
992 Notice: notice,
993 })
994 if err != nil {
995 a.logger.Error("slides: render signed-out page", "err", err)
996 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
997 return
998 }
999 w.Header().Set("Content-Type", "text/html; charset=utf-8")
1000 w.WriteHeader(status)
1001 _, _ = w.Write([]byte(buf.String()))
1002}
1003