package calls import ( "bytes" "context" "database/sql" "errors" "fmt" "net/http" "strconv" "amadan.net/oficina/home/connect" "amadan.net/oficina/home/connect/linkstore" "amadan.net/oficina/home/handoff" "amadan.net/rastrillo/rastrillo/csrf" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/sessions" ) var errHomeIdentity = errors.New("calls: Home identity refused") // errHomeRemoved is the definite subset of errHomeIdentity: the Home // link is refused, the person row is gone, or the person is no longer a // member. A database that could not be read is errHomeIdentity alone, // and never ends a session. var errHomeRemoved = fmt.Errorf("%w: removed", errHomeIdentity) type homeIntegration struct { bridge *connect.Bridge routes *connect.Routes sessions *sessions.Sessions } func (a *app) wireHome() error { h := a.cfg.home if h == nil { return nil } if _, err := migrate.Apply(context.Background(), a.db, migrate.Merge(migrate.MustFromFS(handoff.Migrations, "oficina_handoff"), migrate.MustFromFS(linkstore.Migrations, linkstore.SchemaNamespace))); err != nil { return errHomeIdentity } sess, err := sessions.New(sessions.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, SigninPath: signinPath, Logger: a.logger}) if err != nil || sess.CookieName() != a.auth.SessionCookie() { return errHomeIdentity } read := func(r *http.Request) (*connect.Session, error) { s, ok := sess.From(r) if !ok { return nil, nil } return &connect.Session{Subject: s.Subject, Method: s.Method, AuthTime: s.AuthTime, At: s.At}, nil } b, err := connect.New(connect.Config{DB: a.db.Writer(), Origin: a.cfg.Origin, Issuer: h.Issuer, ConsumerID: h.ConsumerID, Namespace: "oficina/calls", SessionCookie: sess.CookieName(), Keys: h.Keys, DEK: h.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 } p, err := a.store.existingHomePerson(ctx, s.Subject) if err != nil || !a.isMember(p) { return connect.Person{}, errHomeIdentity } return connect.Person{ID: p.ID, Label: p.Email}, nil }, LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) { p, err := a.store.homePersonByID(ctx, q, id) if err != nil { return "", err } if !a.isMember(p) { return "", sql.ErrNoRows } return p.Email, nil }, Render: a.renderHome}) if err != nil { return errHomeIdentity } routes, err := connect.NewRoutes(b, connect.RoutesConfig{LocalLandingPath: "/continue", Namespace: "oficina/calls", Origin: a.cfg.Origin, Issuer: h.Issuer, FormPolicy: homeFormPolicy(h.Issuer), SessionCookie: sess.CookieName(), DEK: h.DEK, ReadSession: read, SignOut: sess.SignOut, LocalSignin: a.localSignin, Protect: csrf.Protect(a.cfg.Origin), Render: a.renderHome}) if err != nil { return errHomeIdentity } a.home = &homeIntegration{b, routes, sess} return nil } func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person, error) { if s.Method == "guest" { id, err := strconv.ParseInt(s.Subject, 10, 64) if err != nil { return Person{}, errHomeIdentity } p, ok, err := a.store.personByID(ctx, id) if err != nil || !ok || !p.Anonymous { return Person{}, errHomeIdentity } return p, nil } if s.Method == connect.HomeMethod { if a.home == nil { return Person{}, errHomeIdentity } linked, err := a.home.bridge.Resolve(ctx, s.Subject) if errors.Is(err, linkstore.ErrRefused) { return Person{}, errHomeRemoved } if err != nil { return Person{}, errHomeIdentity } p, err := a.store.homePersonByID(ctx, a.db.Writer(), linked.ID) if errors.Is(err, sql.ErrNoRows) { return Person{}, errHomeRemoved } if err != nil { return Person{}, errHomeIdentity } if !a.isMember(p) { return Person{}, errHomeRemoved } return p, nil } if s.Method != "magiclink" && s.Method != "keymail" { return Person{}, errHomeIdentity } return a.store.ensurePerson(ctx, s.Subject) } // signin is GET /signin. A session that no longer resolves to a person // who may be here is ended first and told so: with Home configured, // connect's Signin skips straight to the destination for any live // session cookie, and the destination would refuse it again — the loop // Astra found in Docs (#65, finding 1) and Sheets (#39, finding 2). func (a *app) signin(w http.ResponseWriter, r *http.Request) { if id, ok := a.auth.SessionFrom(r); ok { if _, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)}); errors.Is(err, errHomeRemoved) { a.endRemovedSession(w, r) return } } if a.home != nil { if _, pending := a.readJoinCookie(r); !pending { a.home.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(w, r) return } } a.localSignin(w, r) } // endRemovedSession ends this request's session and sends a page request // to the sign-in form with the reason; anything else gets the 403 a // fetch reads as signed out (room.js's readRefusal). func (a *app) endRemovedSession(w http.ResponseWriter, r *http.Request) { a.nativeAuth.sessions.SignOut(w, r) if r.Method != http.MethodGet && r.Method != http.MethodHead { plainError(w, http.StatusForbidden) return } w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, a.signinAgain(r, "err=removed"), http.StatusSeeOther) } func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) { home := connect.Page(state, label) if state == "signedout" { // Where Sign out lands: the Oficina door, in the door's words // (connect's name the hub); its Sign in goes to connect's action. a.renderSignedOut(w, r, home.Action, status) return } var body bytes.Buffer if err := pages["home"].ExecuteTemplate(&body, "layout", a.page(r, titled(home.Title), map[string]any{"Home": home})); err != nil { a.ErrorPage(w, r, 500, "") return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _, _ = w.Write(body.Bytes()) } func (a *app) showHomeConnection(r *http.Request) bool { s, ok := sessions.Current(r) return a.home != nil && ok && (s.Method == "magiclink" || s.Method == "keymail") }