package connect import ( "context" "crypto/sha256" "encoding/base64" "errors" "net/http" "strings" "time" "amadan.net/oficina/home/connect/linkstore" "amadan.net/oficina/home/handoff" "amadan.net/oficina/home/handoff/seal" "amadan.net/rastrillo/rastrillo/assertion" ) const homeInitCookie = "__Host-oficina-home-init" const homeProofCookie = "__Host-oficina-home-proof" var errHomeBridge = errors.New("home connect: exchange unavailable") var errIdentityRefused = errors.New("home connect: identity refused") type Bridge struct { origin, issuer, sessionCookie string namespace string keyHash [32]byte links *linkstore.Store ordinary, fresh *handoff.Consumer now func() time.Time returnPath func(*http.Request) string readSession func(*http.Request) (*Session, error) mintSession func(http.ResponseWriter, *http.Request, Session) error localPerson func(context.Context, Session) (Person, error) render func(http.ResponseWriter, *http.Request, string, string, int) } type homeFinishKey struct{} type homeFinishResult struct { session *Session init string state string status int } // Both subsystem schemas must be migrated before construction. The consumers // share one replay store; choosing a different Finish cannot weaken freshness. func New(c Config) (*Bridge, error) { if c.DB == nil || c.ReadSession == nil || c.MintHomeSession == nil || c.LookupLocalSession == nil || c.LookupPerson == nil || c.Render == nil || !validSessionCookie(c.SessionCookie) { return nil, errHomeBridge } sealer, err := seal.New(c.DEK, c.Namespace) if err != nil { return nil, errHomeBridge } exchange, err := handoff.NewStore(c.DB, sealer, c.Issuer, c.Origin, c.Keys) if err != nil { return nil, errHomeBridge } linker, err := linkstore.New(c.DB, c.DEK, c.Origin, c.Issuer, c.Namespace, c.LookupPerson) if err != nil { return nil, errHomeBridge } now := c.Now if now == nil { now = time.Now } b := &Bridge{namespace: c.Namespace, keyHash: sha256.Sum256(c.DEK), origin: c.Origin, issuer: c.Issuer, sessionCookie: c.SessionCookie, links: linker, now: now, returnPath: c.ReturnPath, readSession: c.ReadSession, mintSession: c.MintHomeSession, localPerson: c.LookupLocalSession, render: c.Render} cfg := handoff.ConsumerConfig{Store: exchange, ConsumerID: c.ConsumerID, Complete: b.complete, Fail: b.fail, Now: func() time.Time { return b.now() }, CurrentSubject: func(r *http.Request) string { result, _ := r.Context().Value(homeFinishKey{}).(*homeFinishResult) if result != nil && result.session != nil && result.session.Method == HomeMethod { return result.session.Subject } return "" }} b.ordinary, err = handoff.NewConsumer(cfg) if err != nil { return nil, errHomeBridge } cfg.RequireFreshAuth = true cfg.BindStart = func(r *http.Request, nonce string) error { secret, present, ok := homeCookie(r, homeInitCookie) if !present || !ok { return errIdentityRefused } local, _, err := b.localBinding(r) if err != nil { return err } return b.links.BindNonce(r.Context(), secret, local, nonce, b.now()) } b.fresh, err = handoff.NewConsumer(cfg) if err != nil { return nil, errHomeBridge } if exchange.Sweep(context.Background(), b.now()) != nil || linker.Sweep(context.Background(), b.now()) != nil { return nil, errHomeBridge } return b, nil } // homeCookie distinguishes absent cookies from malformed or duplicate values. // Neither a duplicate nor an invalid link cookie may select ordinary sign-in. func homeCookie(r *http.Request, name string) (value string, present, valid bool) { count := 0 for _, c := range r.Cookies() { if c.Name == name { count++ value = c.Value } } if count == 0 { return "", false, true } if count != 1 || len(value) != 43 { return "", true, false } raw, err := base64.RawURLEncoding.Strict().DecodeString(value) return value, true, err == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == value } func setHomeCookie(w http.ResponseWriter, name, value string, seconds int) { http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", MaxAge: seconds, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode}) } func (b *Bridge) actualSession(r *http.Request) (*Session, error) { _, present, ok := homeCookie(r, b.sessionCookie) if !ok { return nil, errIdentityRefused } if !present { return nil, nil } session, err := b.readSession(r) if err != nil || session == nil { return nil, errIdentityRefused } return session, nil } func (b *Bridge) Start(w http.ResponseWriter, r *http.Request) { _, present, ok := homeCookie(r, homeInitCookie) if !ok { b.fail(w, r, http.StatusBadRequest) return } // Duplicate auth cookies must fail before a fresh hook or future mint. if _, err := b.actualSession(r); err != nil { b.fail(w, r, http.StatusForbidden) return } if present { b.fresh.Start(w, r) } else { b.ordinary.Start(w, r) } } func (b *Bridge) Callback(w http.ResponseWriter, r *http.Request) { b.ordinary.Callback(w, r) } func (b *Bridge) Finish(w http.ResponseWriter, r *http.Request) { init, _, ok := homeCookie(r, homeInitCookie) if !ok { b.fail(w, r, http.StatusBadRequest) return } _, _, ok = homeCookie(r, homeProofCookie) if !ok { b.fail(w, r, http.StatusBadRequest) return } session, err := b.actualSession(r) if err != nil { b.fail(w, r, http.StatusForbidden) return } result := &homeFinishResult{session: session, init: init} b.ordinary.Finish(w, r.WithContext(context.WithValue(r.Context(), homeFinishKey{}, result))) } func (b *Bridge) fail(w http.ResponseWriter, r *http.Request, status int) { state := "failed" if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok && result.state != "" { state, status = result.state, result.status } b.render(w, r, state, "", status) } func rejectHome(r *http.Request, state string, status int) error { if result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult); ok { result.state, result.status = state, status } return errIdentityRefused } func (b *Bridge) complete(w http.ResponseWriter, r *http.Request, claims assertion.Claims) error { result, ok := r.Context().Value(homeFinishKey{}).(*homeFinishResult) if !ok { return errHomeBridge } if result.init != "" { setHomeCookie(w, homeInitCookie, "", -1) local, _, err := b.localBinding(r) if err != nil { return rejectHome(r, "failed", http.StatusForbidden) } secret, err := b.links.Complete(r.Context(), result.init, local, linkstore.Home{Issuer: claims.Issuer, Subject: claims.Subject, Nonce: claims.Nonce, AuthTime: time.Unix(claims.AuthTime, 0)}, b.now()) if errors.Is(err, linkstore.ErrRefused) { return rejectHome(r, "failed", http.StatusForbidden) } if err != nil { return errHomeBridge } setHomeCookie(w, homeProofCookie, secret, 300) return nil } id, _, err := b.links.Resolve(r.Context(), claims.Issuer, claims.Subject) if errors.Is(err, linkstore.ErrRefused) { return rejectHome(r, "unknown", http.StatusForbidden) } if err != nil { return errHomeBridge } if result.session != nil { person, err := b.existingPerson(r, *result.session) if err != nil || person.ID != id { return rejectHome(r, "failed", http.StatusForbidden) } } if err := b.mintSession(w, r, Session{Subject: claims.Subject, Method: HomeMethod, AuthTime: time.Unix(claims.AuthTime, 0)}); err != nil { return errHomeBridge } return nil } // existingPerson never creates a person or samples, even during ordinary SSO. func (b *Bridge) existingPerson(r *http.Request, session Session) (Person, error) { if session.Method == HomeMethod { return b.Resolve(r.Context(), session.Subject) } if session.Method != "magiclink" && session.Method != "keymail" { return Person{}, errIdentityRefused } p, err := b.localPerson(r.Context(), session) if err != nil || p.ID <= 0 || p.Label == "" { return Person{}, errIdentityRefused } return p, nil } func (b *Bridge) localBinding(r *http.Request) (linkstore.Local, Person, error) { session, err := b.actualSession(r) if err != nil || session == nil { return linkstore.Local{}, Person{}, errIdentityRefused } at, fresh := FreshLocal(*session, b.now()) if !fresh { return linkstore.Local{}, Person{}, errIdentityRefused } person, err := b.existingPerson(r, *session) if err != nil { return linkstore.Local{}, Person{}, err } token, _, _ := homeCookie(r, b.sessionCookie) return linkstore.Local{PersonID: person.ID, SessionToken: token, AuthTime: at}, person, nil } func (b *Bridge) guard(w http.ResponseWriter, r *http.Request, method, path string) bool { w.Header().Set("Cache-Control", "no-store") if r.Host != strings.TrimPrefix(b.origin, "https://") || r.URL.Path != path { b.fail(w, r, http.StatusNotFound) return false } if r.Method != method { w.Header().Set("Allow", method) b.fail(w, r, http.StatusMethodNotAllowed) return false } if r.URL.RawQuery != "" { b.fail(w, r, http.StatusBadRequest) return false } return true } func validSessionCookie(name string) bool { if !strings.HasPrefix(name, "__Host-") || len(name) <= 7 || len(name) > 128 { return false } for _, c := range name { if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-') { return false } } return true }