oficina / slides Public

Integrate Home sign-in with existing Slides identities

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 433e39cdb19fb6f4052472d125eaa05c1d9b52f3 parent bd2d16d
16 files changed, +725 −29
  • cmd/slides/main.go +7 −1
  • go.mod +3 −1
  • go.sum +6 −2
  • internal/slides/app.go +46 −10
  • internal/slides/auth.go +26 −7
  • internal/slides/config.go +2 −0
  • internal/slides/decks.go +2 −0
  • internal/slides/exports.go +7 −3
  • internal/slides/handlers.go +9 −4
  • internal/slides/home.go +151 −0
  • internal/slides/home_test.go +411 −0
  • internal/slides/locales/en.toml +38 −0
  • internal/slides/presentation.go +9 −0
  • internal/slides/render.go +5 −1
  • internal/slides/templates/account.html +1 −0
  • internal/slides/templates/home_link.html +2 −0

CI passed — run details

diff --git a/cmd/slides/main.go b/cmd/slides/main.go
index 09f4dfe..a832ee4 100644
--- a/cmd/slides/main.go
+++ b/cmd/slides/main.go
@@ -3,6 +3,7 @@
package main
import (
+ "amadan.net/oficina/home/connect"
"fmt"
"log/slog"
"os"
@@ -222,7 +223,12 @@ func main() {
// Every empty value below is a startup refusal inside slides.New
// unless env is "development" — see config.go's package comment.
- mux, a, err := slides.New(d, slides.Config{
+ var homeConfig *connect.Credentials
+ hc := connect.Credentials{Issuer: os.Getenv("SLIDES_HOME_ISSUER"), ConsumerID: os.Getenv("SLIDES_HOME_CONSUMER_ID"), KeysBase64: os.Getenv("SLIDES_HOME_KEYS_BASE64"), DEKBase64: os.Getenv("SLIDES_HOME_DEK_BASE64")}
+ if hc.Issuer != "" || hc.ConsumerID != "" || hc.KeysBase64 != "" || hc.DEKBase64 != "" {
+ homeConfig = &hc
+ }
+ mux, a, err := slides.New(d, slides.Config{Home: homeConfig,
Origin: origin,
Env: env,
InstanceKey: os.Getenv("SLIDES_INSTANCE_KEY"),
diff --git a/go.mod b/go.mod
index 05e4cf6..8f3df20 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,8 @@ module amadan.net/oficina/slides
go 1.25.0
require (
- amadan.net/rastrillo/rastrillo v0.26.0
+ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
+ amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
github.com/go-chi/chi/v5 v5.3.2
@@ -14,6 +15,7 @@ require (
)
require (
+ amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index 44caa20..646882f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,9 @@
-amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo=
-amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f h1:DLcotgutTVa/LeYiCTmBH+MO0O8EEJ2RN97+5zFxndE=
+amadan.net/oficina/home/connect v0.0.0-20260909231415-9a780c687d4f/go.mod h1:jsd1+E2JaW2m1Afqh7CHjSbdxYSRk2UPAEbxeSQ8zyo=
+amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
+amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
diff --git a/internal/slides/app.go b/internal/slides/app.go
index 92d0dd1..b2cda4b 100644
--- a/internal/slides/app.go
+++ b/internal/slides/app.go
@@ -1,6 +1,8 @@
package slides
import (
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/handoff"
"context"
"net/http"
@@ -49,19 +51,40 @@ func New(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
// is born covered. Origin-checking, not tokens — nothing to mint
// or forget in a form. a.cfg.Origin, not the caller's cfg.Origin:
// what this protects has to be the origin the app resolved.
- r.Use(csrf.Protect(a.cfg.Origin))
+ if a.homeRoutes != nil {
+ r.Use(a.homeRoutes.Middleware)
+ r.Use(a.homeMenu)
+ } else {
+ r.Use(csrf.Protect(a.cfg.Origin))
+ }
// Unauthenticated: the front door and rauth's four handlers. The
// sign-in page has to be reachable signed out or nobody could ever
// get in.
- r.Get(signinPath, a.signin)
- // gateBegin, not authPlugin.Begin directly — see its own doc
- // comment: the allowlist has to sit in FRONT of Begin, because
- // Begin is what sends the mail.
- r.Post(signinPath, gateBegin(authPlugin.Begin, a.allowed, a.logger))
- r.Get("/auth/callback", authPlugin.Callback)
- r.Get("/auth/verify", authPlugin.Verify)
- r.Post("/signout", authPlugin.Signout)
+ if a.homeRoutes == nil {
+ r.Get(signinPath, a.signin)
+ r.Post(signinPath, gateBegin(authPlugin.Begin, a.allowed, a.logger))
+ r.Get("/auth/callback", authPlugin.Callback)
+ r.Get("/auth/verify", authPlugin.Verify)
+ r.Post("/signout", authPlugin.Signout)
+ } else {
+ h := a.homeRoutes
+ r.Get(signinPath, h.Signin)
+ r.Get("/signin/local", a.signin)
+ r.Post(signinPath, h.LocalGuard(gateBegin(authPlugin.Begin, a.allowed, a.logger)))
+ r.Get("/auth/callback", h.LocalGuard(authPlugin.Callback))
+ r.Get("/auth/verify", h.LocalGuard(authPlugin.Verify))
+ r.Post("/signout", h.Signout)
+ r.Get("/signed-out", h.SignedOut)
+ r.Get(connect.LocalCompletePath, h.LocalComplete)
+ r.Get(handoff.StartPath, a.home.Start)
+ r.Post(handoff.CallbackPath, a.home.Callback)
+ r.Get(handoff.FinishPath, a.home.Finish)
+ r.Get("/account/home", h.Account)
+ r.Post("/account/home", h.LinkStart)
+ r.Get("/account/home/confirm", a.home.ConfirmPage)
+ r.Post("/account/home/confirm", h.ConfirmPost)
+ }
// The share link, and the one screen this app serves to somebody
// with no session at all (spec §5). Outside the group below on
@@ -234,7 +257,15 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
// while Schema (migrations.go) stays just this app's own so the
// generator and its check never see a subsystem's tables and try
// to drop them.
- migrated, err := migrate.Apply(context.Background(), d, BootSchema)
+ homeConfig, err := connect.DecodeCredentials(cfg.Home, cfg.Origin)
+ if err != nil {
+ return nil, nil, err
+ }
+ boot := BootSchema
+ if homeConfig != nil {
+ boot = migrate.Merge(BootSchema, homeSchema)
+ }
+ migrated, err := migrate.Apply(context.Background(), d, boot)
if err != nil {
return nil, nil, err
}
@@ -276,6 +307,11 @@ func newApp(d *db.DB, cfg Config) (*app, *rauth.Auth, error) {
pdf: &pdfRuntime{},
}
+ if err := a.initHome(homeConfig); err != nil {
+ rt.Close(context.Background())
+ return nil, nil, err
+ }
+
// The export's status page, its polled fragment and its SSE stream
// (exports.go). rastrillo/jobs owns the lifecycle and the
// owner-only lookup; the two renderers are this app's, so the page
diff --git a/internal/slides/auth.go b/internal/slides/auth.go
index 4a2e3fa..b018d5e 100644
--- a/internal/slides/auth.go
+++ b/internal/slides/auth.go
@@ -18,6 +18,7 @@
package slides
import (
+ "amadan.net/oficina/home/connect"
"context"
"database/sql"
"errors"
@@ -258,14 +259,19 @@ func (s *sameAnswer) WriteHeader(status int) {
// once, out loud, is what keeps the two from drifting silently if
// either default ever changes.
func newAuthPlugin(cfg Config, writer *sql.DB, list allowlist) (*rauth.Auth, error) {
+ signedInPath := "/"
+ if cfg.Home != nil {
+ signedInPath = connect.LocalCompletePath
+ }
return rauth.New(rauth.Config{
- DB: writer,
- Origin: cfg.Origin,
- InstanceKey: cfg.InstanceKey,
- Mailer: cfg.Mailer,
- Authorize: list.allows,
- SigninPath: signinPath,
- Logger: cfg.Logger,
+ SignedInPath: signedInPath,
+ DB: writer,
+ Origin: cfg.Origin,
+ InstanceKey: cfg.InstanceKey,
+ Mailer: cfg.Mailer,
+ Authorize: list.allows,
+ SigninPath: signinPath,
+ Logger: cfg.Logger,
})
}
@@ -375,6 +381,14 @@ func (a *app) currentUser(w http.ResponseWriter, r *http.Request) (User, session
if !ok || s.Subject == "" {
return User{}, sessionNone
}
+ if s.Method == connect.HomeMethod {
+ u, err := a.homeUser(r, s)
+ if err != nil {
+ a.revokeHome(w, r, s.Subject)
+ return User{}, sessionOffboarded
+ }
+ return u, sessionOK
+ }
if !a.allowed.allows(s.Subject) {
a.offboard(w, r, s.Subject)
return User{}, sessionOffboarded
@@ -445,6 +459,11 @@ const (
// for a fetch or a form POST.
func (a *app) offboard(w http.ResponseWriter, r *http.Request, address string) {
email := normalizeAddress(address)
+ if a.home != nil {
+ 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 {
+ a.logger.Error("slides: revoke linked Home sessions", "err", err)
+ }
+ }
a.logger.Warn("slides: a signed-in address is no longer admitted; ending its sessions",
"address", email)
diff --git a/internal/slides/config.go b/internal/slides/config.go
index b71e399..867da56 100644
--- a/internal/slides/config.go
+++ b/internal/slides/config.go
@@ -13,6 +13,7 @@
package slides
import (
+ "amadan.net/oficina/home/connect"
"fmt"
"log/slog"
"net/url"
@@ -40,6 +41,7 @@ const DefaultOrigin = "http://localhost:8091"
// Config is what New needs to build the app. cmd/slides/main.go fills
// it from the environment; the test harness fills it directly.
type Config struct {
+ Home *connect.Credentials
// Origin is this instance's external origin, scheme included —
// "https://slides.oficina.works". It is three things at once:
// csrf.Protect's yardstick for the legacy Origin/Referer fallback
diff --git a/internal/slides/decks.go b/internal/slides/decks.go
index 47fb5b4..156ada2 100644
--- a/internal/slides/decks.go
+++ b/internal/slides/decks.go
@@ -527,6 +527,7 @@ func (a *app) ownedDeckFor(w http.ResponseWriter, r *http.Request, u User) (Deck
// struct, so the menu is one partial rather than a block redefined in
// every template.
type shell struct {
+ HomeLabel string
Person string
AgentsLabel string
SignoutLabel string
@@ -539,6 +540,7 @@ func shellFor(r *http.Request, u User) shell {
}
return shell{
Person: person,
+ HomeLabel: homeMenuLabel(r),
AgentsLabel: rastrillo.T(r, "slides.account.agents"),
SignoutLabel: rastrillo.T(r, "slides.account.signout"),
}
diff --git a/internal/slides/exports.go b/internal/slides/exports.go
index 583a704..472fb06 100644
--- a/internal/slides/exports.go
+++ b/internal/slides/exports.go
@@ -596,7 +596,7 @@ func (a *app) exportJobRoute(w http.ResponseWriter, r *http.Request) {
a.renderExportPage(w, r, d)
return
}
- a.jobPages.StatusPage(w, r)
+ a.jobPages.StatusPage(w, a.exportPrincipal(r))
}
// exportFragment and exportEvents are the polled twins of the page,
@@ -609,7 +609,7 @@ func (a *app) exportFragment(w http.ResponseWriter, r *http.Request) {
a.renderExportFragment(w, r, d)
return
}
- a.jobPages.Fragment(w, r)
+ a.jobPages.Fragment(w, a.exportPrincipal(r))
}
// exportFallback is what the status page and its fragment answer with
@@ -639,6 +639,7 @@ func (a *app) exportFragment(w http.ResponseWriter, r *http.Request) {
// missing one is jobs.StatusPage's 403 to write, not a 500 here on top
// of it.
func (a *app) exportFallback(r *http.Request) (jobs.PageData, bool) {
+ r = a.exportPrincipal(r)
s, ok := sessions.Current(r)
if !ok || s.Subject == "" {
return jobs.PageData{}, false
@@ -669,7 +670,7 @@ func (a *app) exportEvents(w http.ResponseWriter, r *http.Request) {
if !a.exportJobIsHere(w, r) {
return
}
- a.jobPages.Events(w, r)
+ a.jobPages.Events(w, a.exportPrincipal(r))
}
// exportJobIsHere is the deck check rastrillo/jobs cannot make: it
@@ -892,6 +893,7 @@ type exportStatus struct {
// alternatives rather than a wrapper around a partial: the shim keeps
// the first element of what it fetches and drops the rest.
func (a *app) renderExportPage(w http.ResponseWriter, r *http.Request, d jobs.PageData) {
+ r = restoreExportPrincipal(r)
u, ok := a.viewer(w, r)
if !ok {
return
@@ -902,6 +904,7 @@ func (a *app) renderExportPage(w http.ResponseWriter, r *http.Request, d jobs.Pa
}
func (a *app) renderExportFragment(w http.ResponseWriter, r *http.Request, d jobs.PageData) {
+ r = restoreExportPrincipal(r)
renderFragment(w, "export_status", "export-body", a.exportStatusFor(r, d))
}
@@ -1134,6 +1137,7 @@ func (a *app) apiExportStatus(w http.ResponseWriter, r *http.Request) {
// about sessions.UserID): jobs.Get answers the owner and nobody else,
// and the owner it compares against is this string.
func (a *app) sessionSubject(w http.ResponseWriter, r *http.Request) (string, bool) {
+ r = a.exportPrincipal(r)
s, ok := sessions.Current(r)
if !ok || s.Subject == "" {
a.fail(w, r, "no session subject past the session gate", nil)
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go
index 728173f..b499397 100644
--- a/internal/slides/handlers.go
+++ b/internal/slides/handlers.go
@@ -1,6 +1,8 @@
package slides
import (
+ "amadan.net/oficina/home/connect"
+ "amadan.net/rastrillo/rastrillo/sessions"
"context"
"log/slog"
@@ -23,10 +25,13 @@ import (
// The handlers themselves live beside what they serve: sign-in in
// auth.go, the deck screens in decks.go.
type app struct {
- db *db.DB
- logger *slog.Logger
- cfg Config
- allowed allowlist
+ home *connect.Bridge
+ homeRoutes *connect.Routes
+ homeSessions *sessions.Sessions
+ db *db.DB
+ logger *slog.Logger
+ cfg Config
+ allowed allowlist
// core is the compiled deck core, shared by every request.
// Compiling the wasm module is expensive and instantiating it is
// not, so there is exactly one of these per process and a Handle
diff --git a/internal/slides/home.go b/internal/slides/home.go
new file mode 100644
index 0000000..a3ef86f
--- /dev/null
+++ b/internal/slides/home.go
@@ -0,0 +1,151 @@
+package slides
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/connect/linkstore"
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+var homeSchema = migrate.Merge(migrate.MustFromFS(handoff.Migrations, "home_handoff"), migrate.MustFromFS(linkstore.Migrations, "home_connect"))
+var errHomeIdentity = errors.New("slides: Home identity refused")
+
+func neutralSession(s sessions.Session) connect.Session {
+ return connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}
+}
+
+func (a *app) initHome(c *connect.DecodedCredentials) error {
+ if c == nil {
+ return nil
+ }
+ sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Logger: a.logger})
+ if err != nil {
+ return err
+ }
+ a.homeSessions = sess
+ read := func(r *http.Request) (*connect.Session, error) {
+ s, ok := sess.From(r)
+ if !ok {
+ return nil, nil
+ }
+ n := neutralSession(s)
+ return &n, nil
+ }
+ b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: c.Issuer, ConsumerID: c.ConsumerID, Namespace: "oficina/slides", SessionCookie: sess.CookieName(), Keys: c.Keys, DEK: c.DEK, ReadSession: read,
+ MintHomeSession: func(w http.ResponseWriter, r *http.Request, s connect.Session) error {
+ return sess.SignIn(w, r, sessions.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime})
+ },
+ LookupLocalSession: func(ctx context.Context, s connect.Session) (connect.Person, error) {
+ if s.Method != "magiclink" && s.Method != "keymail" {
+ return connect.Person{}, errHomeIdentity
+ }
+ var u User
+ if err := a.db.G.WithContext(ctx).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) {
+ return connect.Person{}, errHomeIdentity
+ }
+ return connect.Person{ID: u.ID, Label: u.Email}, nil
+ }, LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
+ var email string
+ if err := q.QueryRowContext(ctx, "SELECT email FROM users WHERE id = ?", id).Scan(&email); err != nil || !a.allowed.allows(email) {
+ return "", errHomeIdentity
+ }
+ return email, nil
+ }, Render: a.renderHome})
+ if err != nil {
+ return err
+ }
+ a.home = b
+ a.homeRoutes, err = connect.NewRoutes(b, connect.RoutesConfig{Namespace: "oficina/slides", Origin: a.cfg.Origin, Issuer: c.Issuer, DEK: c.DEK, SessionCookie: sess.CookieName(), ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.signin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome, FormPolicy: connect.FormPolicy(c.Issuer)})
+ return err
+}
+func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ page := connect.Page(state, label, func(key string) string { return rastrillo.T(r, "slides.home."+key) })
+ var buf bytes.Buffer
+ if err := pages["home_link"].ExecuteTemplate(&buf, "layout", page); err != nil {
+ a.logger.Error("slides: render Home account page", "err", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ buf.WriteTo(w)
+}
+func (a *app) homeUser(r *http.Request, s sessions.Session) (User, error) {
+ if a.home == nil || s.Method != connect.HomeMethod {
+ return User{}, errHomeIdentity
+ }
+ person, err := a.home.Resolve(r.Context(), s.Subject)
+ if err != nil {
+ return User{}, err
+ }
+ var u User
+ if err := a.db.G.WithContext(r.Context()).Where("id = ?", person.ID).Take(&u).Error; err != nil || !a.allowed.allows(u.Email) {
+ return User{}, errHomeIdentity
+ }
+ return u, nil
+}
+
+type homeMenuKey struct{}
+
+func homeMenuLabel(r *http.Request) string {
+ if enabled, _ := r.Context().Value(homeMenuKey{}).(bool); enabled {
+ return rastrillo.T(r, "slides.home.menu_action")
+ }
+ return ""
+}
+func (a *app) homeMenu(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), homeMenuKey{}, true)))
+ })
+}
+
+func (a *app) revokeHome(w http.ResponseWriter, r *http.Request, subject string) {
+ var u User
+ if a.home != nil && a.db.G.WithContext(r.Context()).Raw("SELECT u.* FROM users u JOIN home_identity_links l ON l.person_id=u.id WHERE l.issuer=? AND l.subject=?", a.cfg.Home.Issuer, subject).Scan(&u).Error == nil && u.ID != 0 && !a.allowed.allows(u.Email) {
+ a.offboard(w, r, u.Email)
+ return
+ }
+ if err := a.db.G.WithContext(r.Context()).Exec("DELETE FROM sessions WHERE method=? AND subject=?", connect.HomeMethod, subject).Error; err != nil {
+ a.logger.Error("slides: revoke Home session", "err", err)
+ }
+ if a.homeSessions != nil {
+ a.homeSessions.SignOut(w, r)
+ }
+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
+ http.Redirect(w, r, "/signin?err=removed", http.StatusSeeOther)
+ } else {
+ w.WriteHeader(http.StatusForbidden)
+ }
+}
+
+type exportSessionKey struct{}
+
+func (a *app) exportPrincipal(r *http.Request) *http.Request {
+ s, ok := sessions.Current(r)
+ if !ok || s.Method != connect.HomeMethod {
+ return r
+ }
+ u, err := a.homeUser(r, s)
+ if err != nil {
+ return r
+ }
+ original := s
+ s.Subject = u.Email
+ return sessions.WithSession(r.WithContext(context.WithValue(r.Context(), exportSessionKey{}, original)), s)
+}
+func restoreExportPrincipal(r *http.Request) *http.Request {
+ if s, ok := r.Context().Value(exportSessionKey{}).(sessions.Session); ok {
+ return sessions.WithSession(r, s)
+ }
+ return r
+}
diff --git a/internal/slides/home_test.go b/internal/slides/home_test.go
new file mode 100644
index 0000000..608df90
--- /dev/null
+++ b/internal/slides/home_test.go
@@ -0,0 +1,411 @@
+package slides
+
+import (
+ "amadan.net/rastrillo/rastrillo/assertion"
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/connect"
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo"
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+const homeTestOrigin = "https://slides.example.com"
+
+func homeFixture(t *testing.T) (*http.ServeMux, *app, User) {
+ mux, a, u, _ := homeFixtureKey(t)
+ return mux, a, u
+}
+func homeFixtureKey(t *testing.T) (*http.ServeMux, *app, User, *assertioncrypto.Keypair) {
+ t.Helper()
+ d := testDB(t)
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := json.Marshal(map[string]string{"test": base64.StdEncoding.EncodeToString(key.SignPub())})
+ cfg := Config{Mailer: &homeTestMail{}, Env: EnvDevelopment, Origin: homeTestOrigin, Logger: quietLogger(), AllowedAddresses: []string{"owner@example.com"}, Home: &connect.Credentials{Issuer: "https://home.example.com", ConsumerID: "slides-test", KeysBase64: base64.StdEncoding.EncodeToString(raw), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))}}
+ mux, a, err := New(d, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { a.Close() })
+ u := User{ID: 72, Email: "owner@example.com", Name: "Existing owner", Colour: "#112233"}
+ if err := d.G.Create(&u).Error; err != nil {
+ t.Fatal(err)
+ }
+ return mux, a, u, key
+}
+func homeSession(t *testing.T, a *app, subject, method string) *http.Cookie {
+ t.Helper()
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
+ if err := a.homeSessions.SignIn(w, r, sessions.Session{Subject: subject, Method: method, AuthTime: time.Now()}); err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range w.Result().Cookies() {
+ if c.Name == a.homeSessions.CookieName() {
+ return c
+ }
+ }
+ t.Fatal("missing session cookie")
+ return nil
+}
+func homeLinked(t *testing.T, a *app, u User) {
+ t.Helper()
+ if err := a.db.G.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", a.cfg.Home.Issuer, "home-person", u.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+}
+func TestHomeExistingIdentityNoCreationAndStableActors(t *testing.T) {
+ mux, a, u := homeFixture(t)
+ homeLinked(t, a, u)
+ cookie := homeSession(t, a, "home-person", connect.HomeMethod)
+ r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
+ r.AddCookie(cookie)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ if w.Code != 200 {
+ t.Fatalf("home root: %d %s", w.Code, w.Body.String())
+ }
+ var users, decks int64
+ a.db.G.Model(&User{}).Count(&users)
+ a.db.G.Model(&Deck{}).Count(&decks)
+ if users != 1 || decks != 0 {
+ t.Fatalf("Home created rows users=%d decks=%d", users, decks)
+ }
+ if got := a.presenterActor(r); got != actorFor(u) {
+ t.Fatalf("actor %q", got)
+ }
+ s, _ := a.homeSessions.From(r)
+ r = sessions.WithSession(r, s)
+ principal, ok := a.sessionSubject(httptest.NewRecorder(), r)
+ if !ok || principal != u.Email {
+ t.Fatal("export owner changed")
+ }
+ jobsRequest := a.exportPrincipal(r)
+ jobsSession, _ := sessions.Current(jobsRequest)
+ if jobsSession.Subject != u.Email {
+ t.Fatal("job lookup owner changed")
+ }
+ restored, _ := sessions.Current(restoreExportPrincipal(jobsRequest))
+ if restored.Subject != "home-person" || restored.Method != connect.HomeMethod {
+ t.Fatal("job renderer changed verified identity")
+ }
+}
+func TestHomeUnknownSubjectNeverMatchesEmailOrSeeds(t *testing.T) {
+ mux, a, u := homeFixture(t)
+ r := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
+ r.AddCookie(homeSession(t, a, u.Email, connect.HomeMethod))
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ if w.Code == 200 {
+ t.Fatal("Home subject matched existing email without explicit link")
+ }
+ var n int64
+ a.db.G.Model(&User{}).Count(&n)
+ if n != 1 {
+ t.Fatal("Home created user")
+ }
+ a.db.G.Model(&Deck{}).Count(&n)
+ if n != 0 {
+ t.Fatal("Home seeded content")
+ }
+}
+func TestHomeOffboardingRevokesBothMethodsAndEndsPresentation(t *testing.T) {
+ mux, a, u := homeFixture(t)
+ homeLinked(t, a, u)
+ home := homeSession(t, a, "home-person", connect.HomeMethod)
+ _ = homeSession(t, a, u.Email, "magiclink")
+ p := Presentation{Sqid: "testtalk", Actor: actorFor(u), StartedAt: time.Now()}
+ if err := a.db.G.Create(&p).Error; err != nil {
+ t.Fatal(err)
+ }
+ a.allowed = allowlist{addrs: map[string]struct{}{}}
+ r := httptest.NewRequest("POST", homeTestOrigin+"/decks", strings.NewReader("Title=unexpected"))
+ r.Header.Set("Origin", homeTestOrigin)
+ r.AddCookie(home)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ if w.Code != 403 {
+ t.Fatalf("removed POST: %d", w.Code)
+ }
+ var n int64
+ a.db.G.Table("sessions").Count(&n)
+ if n != 0 {
+ t.Fatal("offboarding retained sessions", n)
+ }
+ a.db.G.First(&p, p.ID)
+ if p.EndedAt == nil {
+ t.Fatal("removed presenter stayed live")
+ }
+ a.db.G.Model(&User{}).Count(&n)
+ if n != 1 {
+ t.Fatal("offboarding deleted account")
+ }
+}
+func TestHomeFrontDoorLocalRecoveryAndCallbackGuard(t *testing.T) {
+ mux, a, _ := homeFixture(t)
+ for _, tc := range []struct {
+ method, path string
+ status int
+ }{{"GET", "/signin?return_to=%2Fd%2Fexisting", 303}, {"GET", "/signin/local", 200}, {"GET", "/account/home", 303}, {"POST", "/account/home", 403}, {"POST", handoff.CallbackPath, 400}, {"GET", "/signed-out", 200}} {
+ r := httptest.NewRequest(tc.method, homeTestOrigin+tc.path, nil)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ if w.Code != tc.status {
+ t.Errorf("%s %s status %d body %s", tc.method, tc.path, w.Code, w.Body.String())
+ }
+ if tc.path == "/signin/local" && !strings.Contains(w.Body.String(), "slides-signin-form") {
+ t.Error("local form missing")
+ }
+ }
+ r := httptest.NewRequest("GET", homeTestOrigin+"/signin/local", nil)
+ c := homeSession(t, a, "home-person", connect.HomeMethod)
+ r.AddCookie(c)
+ r.AddCookie(c)
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ if w.Code != 400 {
+ t.Fatal("duplicate session bypassed guard")
+ }
+ if !strings.Contains(w.Header().Get("Content-Security-Policy"), "form-action 'self' https://home.example.com") {
+ t.Fatal("Home POST continuation omitted from CSP")
+ }
+}
+func TestHomeInvalidConfigurationDoesNotMigrate(t *testing.T) {
+ d := testDB(t)
+ _, _, err := New(d, Config{Env: EnvDevelopment, Origin: homeTestOrigin, Home: &connect.Credentials{Issuer: "https://home.example.com"}})
+ if err == nil {
+ t.Fatal("partial config accepted")
+ }
+ var n int
+ if err := d.Writer().QueryRowContext(context.Background(), "SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&n); err != nil || n != 0 {
+ t.Fatal("invalid configuration changed database", n, err)
+ }
+}
+
+func TestHomeRealSignedExchangeExplicitLinkAndReplay(t *testing.T) {
+ mux, a, u, key := homeFixtureKey(t)
+ jar := map[string]*http.Cookie{}
+ local := homeSession(t, a, u.Email, "magiclink")
+ jar[local.Name] = local
+ call := func(method, path, origin, form string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form))
+ if origin != "" {
+ r.Header.Set("Origin", origin)
+ }
+ if form != "" {
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ for _, c := range jar {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ for _, c := range w.Result().Cookies() {
+ if c.MaxAge < 0 {
+ delete(jar, c.Name)
+ } else {
+ jar[c.Name] = c
+ }
+ }
+ return w
+ }
+ start := call("POST", "/account/home", homeTestOrigin, "")
+ if start.Code != 303 {
+ t.Fatal("start", start.Code, start.Body.String())
+ }
+ auth := call("GET", start.Header().Get("Location"), "", "")
+ target, err := url.Parse(auth.Header().Get("Location"))
+ if err != nil || target.Query().Get("max_age") != "300" {
+ t.Fatal("fresh Home request absent", auth.Code, auth.Header())
+ }
+ id, _ := assertion.RandomID()
+ now := time.Now().Unix()
+ token, err := assertion.Sign(key, assertion.Claims{Version: 1, Issuer: a.cfg.Home.Issuer, Audience: homeTestOrigin, Subject: "linked-home-person", KeyID: "test", ID: id, Nonce: target.Query().Get("nonce"), IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ callback := call("POST", handoff.CallbackPath, a.cfg.Home.Issuer, url.Values{"assertion": {token}}.Encode())
+ if callback.Code != 303 {
+ t.Fatal("signed callback", callback.Code, callback.Body.String())
+ }
+ finish := call("GET", handoff.FinishPath, "", "")
+ if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" {
+ t.Fatal("finish", finish.Code, finish.Header(), finish.Body.String())
+ }
+ var links int64
+ a.db.G.Table("home_identity_links").Count(&links)
+ if links != 0 {
+ t.Fatal("link before explicit confirmation")
+ }
+ confirm := call("POST", "/account/home/confirm", homeTestOrigin, "")
+ if confirm.Code != 303 {
+ t.Fatal("confirm", confirm.Code, confirm.Body.String())
+ }
+ person, err := a.home.Resolve(context.Background(), "linked-home-person")
+ if err != nil || person.ID != u.ID {
+ t.Fatal("existing identity lost", person, err)
+ }
+ if jar[a.homeSessions.CookieName()].Value != local.Value {
+ t.Fatal("link silently replaced local session")
+ }
+ replay := call("POST", handoff.CallbackPath, a.cfg.Home.Issuer, url.Values{"assertion": {token}}.Encode())
+ if replay.Code < 400 {
+ t.Fatal("assertion replay accepted", replay.Code)
+ }
+ var users, decks int64
+ a.db.G.Model(&User{}).Count(&users)
+ a.db.G.Model(&Deck{}).Count(&decks)
+ if users != 1 || decks != 0 {
+ t.Fatal("link created data", users, decks)
+ }
+}
+
+func TestHomeDeletedLocalIDCannotBeReused(t *testing.T) {
+ _, a, u := homeFixture(t)
+ homeLinked(t, a, u)
+ if err := a.db.G.Delete(&u).Error; err != nil {
+ t.Fatal(err)
+ }
+ replacement := User{Email: u.Email}
+ if err := a.db.G.Create(&replacement).Error; err != nil {
+ t.Fatal(err)
+ }
+ if replacement.ID <= u.ID {
+ t.Fatal("user IDs may be reused", replacement.ID, u.ID)
+ }
+ if _, err := a.home.Resolve(context.Background(), "home-person"); err == nil {
+ t.Fatal("deleted user's link reassigned to replacement")
+ }
+}
+func TestHomePolicyTracksActualFrameworkAndStandalone(t *testing.T) {
+ bare := http.NewServeMux()
+ bare.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
+ baseline, closeBase, err := rastrillo.Handler(rastrillo.Options{Mux: bare})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeBase()
+ w := httptest.NewRecorder()
+ baseline.ServeHTTP(w, httptest.NewRequest("GET", homeTestOrigin+"/", nil))
+ policy := w.Header().Get("Content-Security-Policy")
+ mux, a, _ := homeFixture(t)
+ want := strings.Replace(policy, "form-action 'self'", "form-action 'self' "+a.cfg.Home.Issuer, 1)
+ if want == policy {
+ t.Fatal("framework form policy changed")
+ }
+ wrapped, closeHome, err := rastrillo.Handler(rastrillo.Options{Mux: mux})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeHome()
+ for _, path := range []string{"/signin/local", "/account/home", "/auth/start", "/signed-out"} {
+ rec := httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+path, nil))
+ if got := rec.Header().Get("Content-Security-Policy"); got != want {
+ t.Fatal("Home CSP drift", path, got, want)
+ }
+ }
+ plain, app, err := New(testDB(t), Config{Env: EnvDevelopment, Origin: homeTestOrigin})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer app.Close()
+ standalone, closePlain, err := rastrillo.Handler(rastrillo.Options{Mux: plain})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closePlain()
+ rec := httptest.NewRecorder()
+ standalone.ServeHTTP(rec, httptest.NewRequest("GET", homeTestOrigin+"/signin", nil))
+ if rec.Header().Get("Content-Security-Policy") != policy {
+ t.Fatal("standalone CSP changed")
+ }
+}
+
+type homeTestMail struct{ body string }
+
+func (m *homeTestMail) Send(_ context.Context, _, _ string, body string) error {
+ m.body = body
+ return nil
+}
+func TestHomeLocalPluginRecoveryRotatesThenReturnsToLink(t *testing.T) {
+ mux, a, u := homeFixture(t)
+ old := homeSession(t, a, "unlinked-home", connect.HomeMethod)
+ jar := []*http.Cookie{old}
+ run := func(method, path, form string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(form))
+ for _, c := range jar {
+ r.AddCookie(c)
+ }
+ if method == "POST" {
+ r.Header.Set("Origin", homeTestOrigin)
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, r)
+ return w
+ }
+ begin := run("GET", "/account/home", "")
+ if begin.Code != 303 || begin.Header().Get("Location") != "/signin/local" {
+ t.Fatal("recovery did not begin", begin.Code)
+ }
+ for _, c := range begin.Result().Cookies() {
+ if c.MaxAge > 0 {
+ jar = append(jar, c)
+ }
+ }
+ sent := run("POST", "/signin", url.Values{"address": {u.Email}}.Encode())
+ if sent.Code != 303 {
+ t.Fatal(sent.Code)
+ }
+ body := a.cfg.Mailer.(*homeTestMail).body
+ link := regexp.MustCompile(`https://[^\s<>]+/auth/verify\?token=[A-Za-z0-9_-]+`).FindString(body)
+ if link == "" {
+ t.Fatal("local fixture link missing")
+ }
+ parsed, _ := url.Parse(link)
+ verify := run("GET", parsed.RequestURI(), "")
+ if verify.Code != 303 || verify.Header().Get("Location") != connect.LocalCompletePath {
+ t.Fatal("local verify did not reach continuation", verify.Code, verify.Header())
+ }
+ for _, c := range verify.Result().Cookies() {
+ if c.Name == old.Name && c.MaxAge >= 0 {
+ jar[0] = c
+ }
+ }
+ if jar[0].Value == old.Value {
+ t.Fatal("local verification retained old session")
+ }
+ stale := httptest.NewRequest("GET", homeTestOrigin+"/", nil)
+ stale.AddCookie(old)
+ if _, ok := a.homeSessions.From(stale); ok {
+ t.Fatal("old session not revoked")
+ }
+ done := run("GET", connect.LocalCompletePath, "")
+ if done.Code != 303 || done.Header().Get("Location") != "/account/home" {
+ t.Fatal("recovery lost link navigation", done.Code, done.Header())
+ }
+ var n int64
+ a.db.G.Model(&User{}).Count(&n)
+ if n != 1 {
+ t.Fatal("recovery created user")
+ }
+ a.db.G.Model(&Deck{}).Count(&n)
+ if n != 0 {
+ t.Fatal("recovery seeded deck")
+ }
+}
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml
index 1911882..32b52be 100644
--- a/internal/slides/locales/en.toml
+++ b/internal/slides/locales/en.toml
@@ -547,3 +547,41 @@ slides.export.error.open = "This deck couldn't be opened. Try again in a moment.
slides.export.error.render = "The PDF couldn't be made. Try again, and tell us if it keeps happening."
slides.export.error.toobig = "This PDF is larger than we can hold. Export it without the notes, or split the deck."
slides.export.error.store = "The PDF was made but couldn't be stored. Try again."
+
+slides.home.signedout_title = "Signed out"
+
+slides.home.signedout_body = "You can sign in again with Home."
+
+slides.home.signedout_action = "Sign in with Home"
+
+slides.home.start_title = "Connect your account to Home"
+
+slides.home.start_body = "Use Home to sign in to this Slides account. Your content and sharing settings stay as they are."
+
+slides.home.start_action = "Continue to Home"
+
+slides.home.cancel_action = "Back"
+
+slides.home.confirm_title = "Confirm account connection"
+
+slides.home.confirm_body = "Connect the account below to the Home account you just signed into."
+
+slides.home.confirm_action = "Connect accounts"
+
+slides.home.unknown_title = "Home account not connected"
+
+slides.home.unknown_body = "Sign in to your existing Slides account, then connect Home from the account menu."
+
+slides.home.local_action = "Sign in to your existing Slides account"
+
+slides.home.failed_title = "Could not connect accounts"
+
+slides.home.failed_body = "Return to Slides and start again."
+
+slides.home.success_title = "Accounts connected"
+
+slides.home.success_body = "You can now use Home to sign in to this Slides account."
+
+slides.home.documents_action = "Back to Slides"
+
+slides.home.menu_action = "Connect Home account"
diff --git a/internal/slides/presentation.go b/internal/slides/presentation.go
index ed8ef5a..6002043 100644
--- a/internal/slides/presentation.go
+++ b/internal/slides/presentation.go
@@ -42,6 +42,7 @@
package slides
import (
+ "amadan.net/oficina/home/connect"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
@@ -250,6 +251,14 @@ func (a *app) presenterActor(r *http.Request) string {
if a.auth == nil {
return ""
}
+ if a.home != nil && a.homeSessions != nil {
+ if session, ok := a.homeSessions.From(r); ok && session.Method == connect.HomeMethod {
+ if u, err := a.homeUser(r, session); err == nil {
+ return actorFor(u)
+ }
+ return ""
+ }
+ }
id, ok := a.auth.SessionFrom(r)
if !ok || id.Address == "" {
return ""
diff --git a/internal/slides/render.go b/internal/slides/render.go
index d770b54..16212ab 100644
--- a/internal/slides/render.go
+++ b/internal/slides/render.go
@@ -1,6 +1,7 @@
package slides
import (
+ "amadan.net/oficina/home/connect"
"bytes"
"embed"
"html/template"
@@ -49,7 +50,7 @@ var pages = map[string]*template.Template{}
// publicPages are the screens a signed-out request can reach. They get
// no account menu, because there is no account to show.
-var publicPages = []string{"signin", "errors"}
+var publicPages = []string{"signin", "errors", "home_link"}
// signedInPages are the screens behind the session gate. They are built
// with templates/account.html parsed after the layout, so the shell's
@@ -101,6 +102,9 @@ var capabilityPages = map[string][]string{
func init() {
for _, name := range publicPages {
pages[name] = buildPage(name)
+ if name == "home_link" {
+ pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html"))
+ }
}
for _, name := range signedInPages {
pages[name] = buildPage(name, "templates/account.html")
diff --git a/internal/slides/templates/account.html b/internal/slides/templates/account.html
index 0976a08..a357b68 100644
--- a/internal/slides/templates/account.html
+++ b/internal/slides/templates/account.html
@@ -37,6 +37,7 @@
person's decks. A link and not a form — it is a screen, and
minting is a decision made on that screen rather than from a
menu. */}}
+{{if .HomeLabel}}<a href="/account/home">{{.HomeLabel}}</a>{{end}}
<a href="/agents">{{.AgentsLabel}}</a>
{{/* Sign-out is a POST, so it is a form and not a link: csrf.Protect
guards state-changing methods, and a GET that ends a session is one
diff --git a/internal/slides/templates/home_link.html b/internal/slides/templates/home_link.html
new file mode 100644
index 0000000..9000fcd
--- /dev/null
+++ b/internal/slides/templates/home_link.html
@@ -0,0 +1,2 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}{{template "home-connect" .}}{{end}}