oficina / home Public

Harden local identity bindings and share Keymail continuation

Paul Campbell pushed by paul@keymail.dev 868f189c5c7776979c88cce1ae8b7fa426b131a9 parent 9a780c6
13 files changed, +483 −48
  • connect/README.md +34 −3
  • connect/api.go +5 −0
  • connect/linkstore/flows.go +39 −26
  • connect/linkstore/migrations/0002_person_binding.sql +1 −0
  • connect/linkstore/store.go +24 −5
  • connect/linkstore/store_test.go +68 −11
  • connect/navigation.go +10 −2
  • connect/navigation_negative_test.go +126 −0
  • connect/navigation_test.go +30 −0
  • connect/oauth.go +91 −0
  • connect/oauth_test.go +48 −0
  • connect/routes.go +2 −1
  • connect/templates/oauth.html +5 −0

CI cancelled — superseded by a newer push — run details

diff --git a/connect/README.md b/connect/README.md
index f64b797..6cc18bf 100644
--- a/connect/README.md
+++ b/connect/README.md
@@ -9,6 +9,9 @@ with `DecodeCredentials(settings, origin)` once you know the app's origin.
The variables end in `_HOME_ISSUER`, `_HOME_CONSUMER_ID`, `_HOME_KEYS_BASE64`
and `_HOME_DEK_BASE64`. All empty means disabled; a partial configuration fails.
Use a dedicated random 32-byte storage key and a distinct namespace per app.
+Keep this key stable: it protects proof state and keyed local-person bindings.
+Replacing it without a migration makes existing links unusable; rotation requires
+an explicit migration with both keys, which this package does not yet provide.
Apply `handoff.Migrations` and `linkstore.Migrations` using your framework's
migration package. The latter's namespace is `linkstore.SchemaNamespace`.
@@ -20,7 +23,20 @@ Construct `Bridge` with the hooks in `Config`. Read the actual session cookie;
the supplied authentication time. `LookupLocalSession` reads an existing
local account. `LookupPerson` uses its supplied SQL query object and rechecks
current admission; return `sql.ErrNoRows` for missing or disallowed accounts.
-Neither hook creates accounts, samples or identity mappings.
+Neither hook creates accounts, samples or identity mappings. The returned label
+must be the canonical verified local address, never an editable display name.
+Local IDs must never be reused, even for a new account with the same address.
+Delete the account's `home_identity_links` rows in the same transaction as the
+local account. The shared schema cannot impose a foreign key on app-owned tables.
+A keyed binding detects reuse with a different address at every proof stage and
+resolution. Address changes require explicit removal and fresh linking; old or
+unbound links fail closed, rather than adopting the current account.
+
+Session cookie values must be exactly 43 characters of canonical unpadded
+base64url encoding of 32 bytes. `Session.AuthTime` is the method's actual
+verification time. `Session.At` is immutable verified-session creation time,
+never last-seen time or request time: legacy magic-link sessions can use it when
+`AuthTime` is absent. Keymail sessions cannot use that fallback.
On every authenticated app request, resolve Home sessions through
`Bridge.ResolveSession`. Use the returned local ID for ownership and grants.
@@ -31,11 +47,24 @@ existing behavior in the app's local authentication path.
`Routes` supplies shared navigation, local reauthentication markers and the
narrow callback exception to your app's CSRF middleware. Mount its methods
alongside `Bridge.Start`, `Callback`, `Finish` and `ConfirmPage`. The local
-plugin's successful return must be `LocalCompletePath`. Render shared pages
+plugin's successful return must be `LocalCompletePath`. Its pending cookie must
+be `__Host-rastrillo_pending`, and the local entry is `/signin/local`.
+Mount exactly `Bridge.Callback` at `POST /auth/assertion`: this path alone bypasses
+CSRF protection because the bridge verifies its browser-bound assertion.
+`LocalLandingPath` optionally sets a validated local destination after ordinary
+local sign-in (default `/`); explicit account-link navigation takes precedence. Render shared pages
inside the app's existing Estilo layout; all paths work without JavaScript.
Apps with additional content-security directives must supply their existing
policy with the validated Home issuer added to `form-action`.
+Wrap the trusted local plugin's Begin handler with `OAuthContinuation` when it
+can redirect to Keymail. Render the supplied destination with `OAuthPage` and the
+shared `home-oauth-head` and `home-oauth` templates inside your existing layout.
+The helper validates the client origin, `/auth/callback`, scope and PKCE method,
+then ends the POST with a no-store continuation page so Safari does not apply
+form-action to Keymail's redirect chain. It does not discover or trust arbitrary
+request-provided destinations. Keep the existing local plugin callback route.
+
## Extraction record
The link proof protocol, bridge, freshness rules and navigation originate in
@@ -49,7 +78,9 @@ Changes are the neutral session DTO and app lookup hooks, new `home_*` link
tables, app-selected encryption namespace, and shared rendering/navigation.
The local-person foreign key becomes a transaction-scoped existence/admission
lookup, because apps use different local identity tables. Every link write
-and subsequent resolution still checks the current local account.
+and subsequent resolution still checks the current local account. Additive
+migration 0002 adds a keyed person binding; earlier unbound rows fail closed.
+OAuth continuation also derives from Docs `internal/docs/auth_navigation.go`.
The original Docs state-machine tests were ported with synthetic session and
local-account hooks. The app-owned content-preservation test now checks an
diff --git a/connect/api.go b/connect/api.go
index 8868425..91aee15 100644
--- a/connect/api.go
+++ b/connect/api.go
@@ -13,10 +13,15 @@ import (
const HomeMethod = "home-assertion"
+// Session times must come from the verified stored session, never request or last-seen time.
+// AuthTime is the method verification time; At is immutable session creation time.
type Session struct {
Subject, Method string
AuthTime, At time.Time
}
+
+// Person.Label is the canonical verified local address, never a display name.
+// Local IDs must never be reused, including for a new account with the same address.
type Person struct {
ID int64
Label string
diff --git a/connect/linkstore/flows.go b/connect/linkstore/flows.go
index 91e579b..567fa3c 100644
--- a/connect/linkstore/flows.go
+++ b/connect/linkstore/flows.go
@@ -3,6 +3,7 @@ package linkstore
import (
"bytes"
"context"
+ "crypto/hmac"
"database/sql"
"encoding/json"
"errors"
@@ -13,16 +14,17 @@ import (
)
type flow struct {
- Version int `json:"v"`
- PersonID int64 `json:"person"`
- SessionHash string `json:"session"`
- LocalAuth int64 `json:"local_auth"`
- Issuer string `json:"issuer"`
- NonceHash string `json:"nonce,omitempty"`
- Subject string `json:"subject,omitempty"`
- HomeAuth int64 `json:"home_auth,omitempty"`
- ReturnPath string `json:"return"`
- Expires int64 `json:"expires"`
+ PersonBinding string `json:"person_binding"`
+ Version int `json:"v"`
+ PersonID int64 `json:"person"`
+ SessionHash string `json:"session"`
+ LocalAuth int64 `json:"local_auth"`
+ Issuer string `json:"issuer"`
+ NonceHash string `json:"nonce,omitempty"`
+ Subject string `json:"subject,omitempty"`
+ HomeAuth int64 `json:"home_auth,omitempty"`
+ ReturnPath string `json:"return"`
+ Expires int64 `json:"expires"`
}
func (s *Store) purpose(stage, id string) string {
@@ -83,15 +85,18 @@ func (s *Store) load(ctx context.Context, tx *sql.Tx, stage, id string, high int
func (s *Store) matches(f flow, local Local, now, high int64) bool {
return validLocal(local, now, high) && f.PersonID == local.PersonID && f.SessionHash == hash(local.SessionToken) && f.LocalAuth == local.AuthTime.Unix() && fresh(f.LocalAuth, now, high)
}
-func (s *Store) personExists(ctx context.Context, tx *sql.Tx, id int64) (bool, error) {
+func (s *Store) currentBinding(ctx context.Context, tx *sql.Tx, id int64) (string, error) {
label, err := s.lookup(ctx, tx, id)
if errors.Is(err, sql.ErrNoRows) {
- return false, nil
+ return "", nil
}
if err != nil {
- return false, ErrStorage
+ return "", ErrStorage
+ }
+ if label == "" {
+ return "", nil
}
- return label != "", nil
+ return s.personBinding(id, label), nil
}
// Start is called only by an explicit same-origin local-account link POST.
@@ -108,11 +113,11 @@ func (s *Store) Start(ctx context.Context, local Local, returnPath string, now t
if !validLocal(local, now.Unix(), high) {
return "", refuse(tx)
}
- exists, err := s.personExists(ctx, tx, local.PersonID)
+ binding, err := s.currentBinding(ctx, tx, local.PersonID)
if err != nil {
return "", err
}
- if !exists {
+ if binding == "" {
return "", refuse(tx)
}
var count int
@@ -130,7 +135,7 @@ func (s *Store) Start(ctx context.Context, local Local, returnPath string, now t
if expires <= high {
return "", refuse(tx)
}
- f := flow{Version: 1, PersonID: local.PersonID, SessionHash: hash(local.SessionToken), LocalAuth: local.AuthTime.Unix(), Issuer: s.issuer, ReturnPath: safePath(returnPath), Expires: expires}
+ f := flow{PersonBinding: binding, Version: 1, PersonID: local.PersonID, SessionHash: hash(local.SessionToken), LocalAuth: local.AuthTime.Unix(), Issuer: s.issuer, ReturnPath: safePath(returnPath), Expires: expires}
if err := s.put(ctx, tx, "init", hash(secret), f); err != nil {
return "", err
}
@@ -162,6 +167,13 @@ func (s *Store) BindNonce(ctx context.Context, secret string, local Local, nonce
if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(nonce) || f.NonceHash != "" {
return refuse(tx)
}
+ binding, err := s.currentBinding(ctx, tx, local.PersonID)
+ if err != nil {
+ return err
+ }
+ if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
+ return refuse(tx)
+ }
f.NonceHash = hash(nonce)
if err := s.put(ctx, tx, "init", id, f); err != nil {
return err
@@ -191,11 +203,11 @@ func (s *Store) Complete(ctx context.Context, secret string, local Local, home H
if !s.matches(f, local, now.Unix(), high) || !handoff.ValidNonce(home.Nonce) || f.NonceHash != hash(home.Nonce) || home.Issuer != s.issuer || !validSubject(home.Subject) || !fresh(home.AuthTime.Unix(), now.Unix(), high) {
return "", refuse(tx)
}
- exists, err := s.personExists(ctx, tx, local.PersonID)
+ binding, err := s.currentBinding(ctx, tx, local.PersonID)
if err != nil {
return "", err
}
- if !exists {
+ if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
return "", refuse(tx)
}
pending, err := newSecret()
@@ -245,11 +257,11 @@ func (s *Store) Pending(ctx context.Context, secret string, local Local, now tim
if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
return Pending{}, refuse(tx)
}
- exists, err := s.personExists(ctx, tx, local.PersonID)
+ binding, err := s.currentBinding(ctx, tx, local.PersonID)
if err != nil {
return Pending{}, err
}
- if !exists {
+ if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
return Pending{}, refuse(tx)
}
if err := s.put(ctx, tx, "pending", id, f); err != nil {
@@ -284,26 +296,27 @@ func (s *Store) Confirm(ctx context.Context, secret string, local Local, now tim
if !s.matches(f, local, now.Unix(), high) || !validPending(f, now.Unix(), high) {
return Pending{}, refuse(tx)
}
- exists, err := s.personExists(ctx, tx, local.PersonID)
+ binding, err := s.currentBinding(ctx, tx, local.PersonID)
if err != nil {
return Pending{}, err
}
- if !exists {
+ if binding == "" || !hmac.Equal([]byte(binding), []byte(f.PersonBinding)) {
return Pending{}, refuse(tx)
}
- _, err = tx.ExecContext(ctx, "INSERT INTO home_identity_links (issuer, subject, person_id) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", s.issuer, f.Subject, f.PersonID)
+ _, err = tx.ExecContext(ctx, "INSERT INTO home_identity_links (issuer, subject, person_id, person_binding) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", s.issuer, f.Subject, f.PersonID, f.PersonBinding)
if err != nil {
return Pending{}, ErrStorage
}
var person int64
- err = tx.QueryRowContext(ctx, "SELECT person_id FROM home_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person)
+ var storedBinding string
+ err = tx.QueryRowContext(ctx, "SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?", s.issuer, f.Subject).Scan(&person, &storedBinding)
if errors.Is(err, sql.ErrNoRows) {
return Pending{}, refuse(tx)
}
if err != nil {
return Pending{}, ErrStorage
}
- if person != f.PersonID {
+ if person != f.PersonID || !hmac.Equal([]byte(storedBinding), []byte(f.PersonBinding)) {
return Pending{}, refuse(tx)
}
if err := commit(tx); err != nil {
diff --git a/connect/linkstore/migrations/0002_person_binding.sql b/connect/linkstore/migrations/0002_person_binding.sql
new file mode 100644
index 0000000..e9c78cd
--- /dev/null
+++ b/connect/linkstore/migrations/0002_person_binding.sql
@@ -0,0 +1 @@
+ALTER TABLE home_identity_links ADD COLUMN person_binding TEXT NOT NULL DEFAULT '';
diff --git a/connect/linkstore/store.go b/connect/linkstore/store.go
index edd5593..544c1ff 100644
--- a/connect/linkstore/store.go
+++ b/connect/linkstore/store.go
@@ -4,11 +4,13 @@ package linkstore
import (
"context"
+ "crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"embed"
"encoding/base64"
+ "encoding/binary"
"encoding/hex"
"errors"
"net/url"
@@ -64,6 +66,8 @@ type Store struct {
seal *seal.Sealer
origin, issuer string
lookup LookupPerson
+ bindingKey []byte
+ namespace string
}
// New uses a dedicated handoff/storage DEK, never an application content key.
@@ -76,7 +80,7 @@ func New(db *sql.DB, dek []byte, origin, issuer, namespace string, lookup Lookup
if err != nil {
return nil, ErrRefused
}
- return &Store{db: db, seal: sealer, origin: origin, issuer: issuer, lookup: lookup}, nil
+ return &Store{db: db, seal: sealer, origin: origin, issuer: issuer, lookup: lookup, bindingKey: append([]byte(nil), dek...), namespace: namespace}, nil
}
func validOrigin(v string) bool {
@@ -178,8 +182,8 @@ func (s *Store) Resolve(ctx context.Context, issuer, subject string) (int64, str
return 0, "", ErrRefused
}
var id int64
- var address string
- err := s.db.QueryRowContext(ctx, `SELECT person_id FROM home_identity_links WHERE issuer = ? AND subject = ?`, issuer, subject).Scan(&id)
+ var address, binding string
+ err := s.db.QueryRowContext(ctx, `SELECT person_id, person_binding FROM home_identity_links WHERE issuer = ? AND subject = ?`, issuer, subject).Scan(&id, &binding)
if errors.Is(err, sql.ErrNoRows) {
return 0, "", ErrRefused
}
@@ -193,7 +197,7 @@ func (s *Store) Resolve(ctx context.Context, issuer, subject string) (int64, str
if err != nil {
return 0, "", ErrStorage
}
- if id <= 0 || address == "" {
+ if id <= 0 || address == "" || !hmac.Equal([]byte(binding), []byte(s.personBinding(id, address))) {
return 0, "", ErrRefused
}
return id, address, nil
@@ -211,7 +215,22 @@ type Queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
-// LookupPerson returns a label for an existing local person. It must never
+// LookupPerson returns the canonical verified local address, never a display name.
+// IDs must never be reused; deletion must remove links in the same transaction. It must never
// create one, and must use q rather than opening a separate transaction.
// Missing or currently inadmissible people return sql.ErrNoRows.
type LookupPerson func(ctx context.Context, q Queryer, id int64) (string, error)
+
+// personBinding prevents a stale numeric link from resolving a different address.
+// Length-prefixed fields keep the namespace, origin, ID and address unambiguous.
+func (s *Store) personBinding(id int64, address string) string {
+ m := hmac.New(sha256.New, s.bindingKey)
+ for _, field := range []string{"home-connect/person-binding/v1", s.namespace, s.origin, s.issuer} {
+ _ = binary.Write(m, binary.BigEndian, uint64(len(field)))
+ _, _ = m.Write([]byte(field))
+ }
+ _ = binary.Write(m, binary.BigEndian, id)
+ _ = binary.Write(m, binary.BigEndian, uint64(len(address)))
+ _, _ = m.Write([]byte(address))
+ return hex.EncodeToString(m.Sum(nil))
+}
diff --git a/connect/linkstore/store_test.go b/connect/linkstore/store_test.go
index ca1c5fe..f965b76 100644
--- a/connect/linkstore/store_test.go
+++ b/connect/linkstore/store_test.go
@@ -36,12 +36,14 @@ func fixture(t *testing.T) (*Store, string) {
if err != nil {
t.Fatal(err)
}
- migration, err := migrations.ReadFile("migrations/0001_links.sql")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := d.Exec(string(migration)); err != nil {
- t.Fatal(err)
+ for _, name := range []string{"0001_links.sql", "0002_person_binding.sql"} {
+ migration, err := migrations.ReadFile("migrations/" + name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.Exec(string(migration)); err != nil {
+ t.Fatal(err)
+ }
}
s, err := New(d, bytes.Repeat([]byte{42}, 32), origin, issuer, "oficina/docs", testLookup)
if err != nil {
@@ -125,10 +127,8 @@ func TestExplicitLinkPreservesPeopleAndContent(t *testing.T) {
if _, err := s.db.Exec("UPDATE people SET email = 'new@example.com' WHERE id = 1"); err != nil {
t.Fatal(err)
}
- _, address, err := s.Resolve(ctx, issuer, "opaque-home-b")
- if err != nil || address != "new@example.com" {
- t.Fatalf("stale address %q %v", address, err)
- }
+ _, _, err := s.Resolve(ctx, issuer, "opaque-home-b")
+ refused(t, err)
_, _, err = s.Resolve(ctx, "https://other.example", "opaque-home-b")
refused(t, err)
if flowCount(t, s) != 0 {
@@ -280,7 +280,7 @@ func TestConflictConsumedWithoutReplacingEitherMapping(t *testing.T) {
}
_, err = s.Confirm(ctx, cookie, tc.local, testNow)
refused(t, err)
- if _, err := s.db.Exec("INSERT INTO home_identity_links VALUES (?,?,?)", issuer, "subject-one", 1); err != nil {
+ if _, err := s.db.Exec("INSERT INTO home_identity_links VALUES (?,?,?,?)", issuer, "subject-one", 1, s.personBinding(1, "one@example.com")); err != nil {
t.Fatal(err)
}
}
@@ -588,3 +588,60 @@ func TestResolveRechecksAppAdmissionAndDoesNotRevealLookupErrors(t *testing.T) {
t.Fatal("lookup error escaped", err)
}
}
+
+func TestPersonBindingRefusesReusedIDAtEveryStage(t *testing.T) {
+ for _, stage := range []string{"bind", "complete", "pending", "confirm", "resolve"} {
+ t.Run(stage, func(t *testing.T) {
+ s, _ := fixture(t)
+ l := local(1)
+ secret, err := s.Start(ctx, l, "/", testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ nonce := nonce(42)
+ if stage != "bind" {
+ if err := s.BindNonce(ctx, secret, l, nonce, testNow); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if stage != "bind" && stage != "complete" {
+ secret, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ if stage == "resolve" {
+ if _, err := s.Confirm(ctx, secret, l, testNow); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := s.db.Exec("DELETE FROM people WHERE id=1; INSERT INTO people VALUES(1,'replacement@example.com')"); err != nil {
+ t.Fatal(err)
+ }
+ switch stage {
+ case "bind":
+ err = s.BindNonce(ctx, secret, l, nonce, testNow)
+ case "complete":
+ _, err = s.Complete(ctx, secret, l, Home{Issuer: issuer, Subject: "person", Nonce: nonce, AuthTime: testNow}, testNow)
+ case "pending":
+ _, err = s.Pending(ctx, secret, l, testNow)
+ case "confirm":
+ _, err = s.Confirm(ctx, secret, l, testNow)
+ case "resolve":
+ _, _, err = s.Resolve(ctx, issuer, "person")
+ }
+ refused(t, err)
+ if stage != "resolve" && flowCount(t, s) != 0 {
+ t.Fatal("rejected proof was not consumed")
+ }
+ })
+ }
+}
+func TestUnboundLegacyMappingFailsClosed(t *testing.T) {
+ s, _ := fixture(t)
+ if _, err := s.db.Exec("INSERT INTO home_identity_links(issuer,subject,person_id) VALUES(?,?,?)", issuer, "legacy", 1); err != nil {
+ t.Fatal(err)
+ }
+ _, _, err := s.Resolve(ctx, issuer, "legacy")
+ refused(t, err)
+}
diff --git a/connect/navigation.go b/connect/navigation.go
index 23e4319..c3adc89 100644
--- a/connect/navigation.go
+++ b/connect/navigation.go
@@ -25,6 +25,7 @@ type localLinkMarker struct {
}
type localLinkNavigation struct {
+ landingPath string
sealer handoff.Sealer
sessionCookie string
readSession func(*http.Request) (*Session, error)
@@ -39,7 +40,14 @@ func newLocalLinkNavigation(sealer handoff.Sealer, c RoutesConfig) *localLinkNav
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || sealer == nil || c.ReadSession == nil {
return nil
}
- return &localLinkNavigation{sealer: sealer, sessionCookie: c.SessionCookie, readSession: c.ReadSession, namespace: c.Namespace, origin: c.Origin, host: u.Host, now: time.Now, fail: func(w http.ResponseWriter, r *http.Request, status int) { c.Render(w, r, "failed", "", status) }}
+ landing := c.LocalLandingPath
+ if landing == "" {
+ landing = "/"
+ }
+ if !handoff.SafePath(landing) {
+ return nil
+ }
+ return &localLinkNavigation{landingPath: landing, sealer: sealer, sessionCookie: c.SessionCookie, readSession: c.ReadSession, namespace: c.Namespace, origin: c.Origin, host: u.Host, now: time.Now, fail: func(w http.ResponseWriter, r *http.Request, status int) { c.Render(w, r, "failed", "", status) }}
}
func localNavigationCookie(w http.ResponseWriter, name, value string, age int) {
@@ -136,7 +144,7 @@ func (n *localLinkNavigation) Complete(w http.ResponseWriter, r *http.Request) {
return
}
now := n.now()
- target := "/"
+ target := n.landingPath
if n.marker(r, now.Unix()) {
count := 0
for _, c := range r.Cookies() {
diff --git a/connect/navigation_negative_test.go b/connect/navigation_negative_test.go
new file mode 100644
index 0000000..05d2e4b
--- /dev/null
+++ b/connect/navigation_negative_test.go
@@ -0,0 +1,126 @@
+package connect
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func navigationRequest(path string) *http.Request {
+ return httptest.NewRequest("GET", navigationOrigin+path, nil)
+}
+func navigationMarker(t *testing.T, n *localLinkNavigation) *http.Cookie {
+ t.Helper()
+ w := httptest.NewRecorder()
+ n.Begin(w, navigationRequest("/account/home"))
+ for _, c := range w.Result().Cookies() {
+ if c.Name == localLinkNavigationCookie && c.MaxAge > 0 {
+ return c
+ }
+ }
+ t.Fatal("marker missing")
+ return nil
+}
+func assertNavigation(t *testing.T, n *localLinkNavigation, r *http.Request, want string) {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ n.Complete(rec, r)
+ if rec.Code != 303 || rec.Header().Get("Location") != want || rec.Header().Get("Cache-Control") != "no-store" {
+ t.Fatalf("navigation status=%d target=%q wanted%q", rec.Code, rec.Header().Get("Location"), want)
+ }
+ cleared := false
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == localLinkNavigationCookie && c.MaxAge < 0 {
+ cleared = true
+ }
+ }
+ if !cleared {
+ t.Fatal("marker not cleared")
+ }
+}
+
+func TestLocalNavigationRejectsInvalidMarkersAndOtherHosts(t *testing.T) {
+ local, n := navigationFixture(t)
+ now := time.Now().Truncate(time.Second)
+ local.At = now
+ n.now = func() time.Time { return now }
+ cookie := &http.Cookie{Name: n.sessionCookie, Value: "verified"}
+ valid := navigationMarker(t, n).Value
+ positive := navigationRequest(localLinkCompletePath)
+ positive.AddCookie(cookie)
+ positive.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid})
+ assertNavigation(t, n, positive, "/account/home")
+ plain := func(m localLinkMarker) string { b, _ := json.Marshal(m); return string(b) }
+ base := localLinkMarker{1, "link-start", now.Unix(), now.Unix() + 600}
+ badJSON := []string{
+ "{}", `{"v":1,"v":1,"action":"link-start","issued":1,"expires":2}`,
+ plain(base) + " {}", strings.TrimSuffix(plain(base), "}") + `,"unknown":true}`,
+ }
+ for _, m := range []localLinkMarker{{2, "link-start", now.Unix(), now.Unix() + 600}, {1, "other", now.Unix(), now.Unix() + 600}, {1, "link-start", 0, now.Unix() + 600}, {1, "link-start", now.Unix() + 1, now.Unix() + 600}, {1, "link-start", now.Unix() - 600, now.Unix()}, {1, "link-start", now.Unix(), now.Unix() + 601}} {
+ badJSON = append(badJSON, plain(m))
+ }
+ badValues := []string{"", strings.Repeat("a", 1025), valid + "=", "%" + valid, valid[:len(valid)-2]}
+ for _, p := range badJSON {
+ sealed, err := n.sealer.Seal(n.purpose(), []byte(p))
+ if err != nil {
+ t.Fatal(err)
+ }
+ badValues = append(badValues, base64.RawURLEncoding.EncodeToString(sealed))
+ }
+ otherPurpose, err := n.sealer.Seal(n.namespace+".local-link-navigation.v1:https://other.example", []byte(plain(base)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ badValues = append(badValues, base64.RawURLEncoding.EncodeToString(otherPurpose))
+ for _, value := range badValues {
+ r := navigationRequest(localLinkCompletePath)
+ r.AddCookie(cookie)
+ r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: value})
+ assertNavigation(t, n, r, "/")
+ }
+ for _, change := range []func(*http.Request){func(r *http.Request) { r.Host = "other.example" }, func(r *http.Request) { r.URL.RawQuery = "return_to=https://evil.example" }, func(r *http.Request) { r.URL.Path = "/wrong" }} {
+ r := navigationRequest(localLinkCompletePath)
+ r.AddCookie(cookie)
+ r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid})
+ change(r)
+ assertNavigation(t, n, r, "/")
+ }
+ // A valid marker expires independently of the browser cookie's MaxAge.
+ n.now = func() time.Time { return now.Add(600 * time.Second) }
+ r := navigationRequest(localLinkCompletePath)
+ r.AddCookie(cookie)
+ r.AddCookie(&http.Cookie{Name: localLinkNavigationCookie, Value: valid})
+ assertNavigation(t, n, r, "/")
+}
+
+type navigationBrokenSealer struct{}
+
+func (navigationBrokenSealer) Seal(string, []byte) ([]byte, error) {
+ return nil, errors.New("private failure")
+}
+func (navigationBrokenSealer) Open(string, []byte) ([]byte, error) {
+ return nil, errors.New("private failure")
+}
+func TestLocalNavigationStorageFailureAndMethod(t *testing.T) {
+ _, n := navigationFixture(t)
+ n.sealer = navigationBrokenSealer{}
+ rec := httptest.NewRecorder()
+ n.Begin(rec, navigationRequest("/account/home"))
+ if rec.Code != 503 || strings.Contains(rec.Body.String(), "private") || rec.Header().Get("Location") != "" {
+ t.Fatal("storage failure published/leaked navigation")
+ }
+ for _, fn := range []http.HandlerFunc{n.Begin, n.Complete} {
+ rec = httptest.NewRecorder()
+ r := navigationRequest(localLinkCompletePath)
+ r.Method = "POST"
+ fn(rec, r)
+ if rec.Code != 405 || rec.Header().Get("Allow") != "GET" || rec.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("wrong method accepted")
+ }
+ }
+}
diff --git a/connect/navigation_test.go b/connect/navigation_test.go
index 0ea147a..5153217 100644
--- a/connect/navigation_test.go
+++ b/connect/navigation_test.go
@@ -100,3 +100,33 @@ func TestNavigationPurposeIsolationAndFixedAction(t *testing.T) {
}
}
}
+
+func TestNavigationLocalLandingPathIsSafeAndDoesNotOverrideLink(t *testing.T) {
+ _, n := navigationFixture(t)
+ config := RoutesConfig{Namespace: n.namespace, Origin: n.origin, SessionCookie: n.sessionCookie, ReadSession: n.readSession, Render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }}
+ for _, target := range []string{"https://other.example/", "//other.example/", "/\\other.example/"} {
+ config.LocalLandingPath = target
+ if newLocalLinkNavigation(n.sealer, config) != nil {
+ t.Fatal("unsafe local landing accepted", target)
+ }
+ }
+ config.LocalLandingPath = "/continue"
+ land := newLocalLinkNavigation(n.sealer, config)
+ if land == nil {
+ t.Fatal("safe local landing refused")
+ }
+ r := navigationRequest(localLinkCompletePath)
+ w := httptest.NewRecorder()
+ land.Complete(w, r)
+ if w.Header().Get("Location") != "/continue" {
+ t.Fatal("local landing ignored")
+ }
+ marker := navigationMarker(t, land)
+ r.AddCookie(marker)
+ r.AddCookie(&http.Cookie{Name: land.sessionCookie, Value: "verified"})
+ w = httptest.NewRecorder()
+ land.Complete(w, r)
+ if w.Header().Get("Location") != "/account/home" {
+ t.Fatal("landing replaced explicit link navigation")
+ }
+}
diff --git a/connect/oauth.go b/connect/oauth.go
new file mode 100644
index 0000000..d306720
--- /dev/null
+++ b/connect/oauth.go
@@ -0,0 +1,91 @@
+package connect
+
+import (
+ "bytes"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// OAuthContinuation ends the local POST before navigating to Keymail. Safari
+// otherwise applies form-action to the entire external redirect chain.
+// Wrap only the trusted local authentication plugin's Begin handler.
+func OAuthContinuation(origin string, next http.HandlerFunc, render func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if next == nil || render == nil {
+ http.Error(w, "Sign-in unavailable.", http.StatusServiceUnavailable)
+ return
+ }
+ result := &oauthResponse{header: make(http.Header)}
+ next(result, r)
+ destination := result.header.Get("Location")
+ external := destination != "" && (!strings.HasPrefix(destination, "/") || strings.HasPrefix(destination, "//"))
+ if result.status == http.StatusSeeOther && external {
+ if !keymailDestination(destination, origin) {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, "Sign-in unavailable.", http.StatusBadGateway)
+ return
+ }
+ for _, cookie := range result.header.Values("Set-Cookie") {
+ w.Header().Add("Set-Cookie", cookie)
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ render(w, r, destination)
+ return
+ }
+ for name, values := range result.header {
+ for _, value := range values {
+ w.Header().Add(name, value)
+ }
+ }
+ if result.status == 0 {
+ result.status = http.StatusOK
+ }
+ w.WriteHeader(result.status)
+ _, _ = w.Write(result.body.Bytes())
+ }
+}
+
+func keymailDestination(destination, origin string) bool {
+ u, err := url.Parse(destination)
+ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || u.Path != "/oauth/authorize" || u.RawPath != "" {
+ return false
+ }
+ q, err := url.ParseQuery(u.RawQuery)
+ if err != nil {
+ return false
+ }
+ for _, key := range []string{"client_id", "redirect_uri", "scope", "code_challenge_method", "state", "code_challenge"} {
+ if len(q[key]) != 1 || q.Get(key) == "" {
+ return false
+ }
+ }
+ origin = strings.TrimSuffix(origin, "/")
+ return q.Get("client_id") == origin && q.Get("redirect_uri") == origin+"/auth/callback" && q.Get("scope") == "identify" && q.Get("code_challenge_method") == "S256"
+}
+
+type oauthResponse struct {
+ header http.Header
+ status int
+ body bytes.Buffer
+}
+
+func (w *oauthResponse) Header() http.Header { return w.header }
+func (w *oauthResponse) WriteHeader(status int) {
+ if w.status == 0 {
+ w.status = status
+ }
+}
+func (w *oauthResponse) Write(p []byte) (int, error) {
+ if w.status == 0 {
+ w.WriteHeader(http.StatusOK)
+ }
+ return w.body.Write(p)
+}
+
+type OAuthData struct{ URL, Title, ActionLabel string }
+
+func OAuthPage(destination string) OAuthData {
+ return OAuthData{URL: destination, Title: "Continue signing in", ActionLabel: "Continue to Keymail"}
+}
diff --git a/connect/oauth_test.go b/connect/oauth_test.go
new file mode 100644
index 0000000..7d88a07
--- /dev/null
+++ b/connect/oauth_test.go
@@ -0,0 +1,48 @@
+package connect
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func oauthTarget() string {
+ return "https://keymail.example/oauth/authorize?" + url.Values{"client_id": {configOrigin}, "redirect_uri": {configOrigin + "/auth/callback"}, "scope": {"identify"}, "code_challenge_method": {"S256"}, "state": {"private-state"}, "code_challenge": {"challenge"}}.Encode()
+}
+func TestOAuthContinuationPreservesPolicyCookiesAndLocalResponses(t *testing.T) {
+ for _, destination := range []string{oauthTarget(), "/signin?sent=1"} {
+ w := httptest.NewRecorder()
+ w.Header().Set("Content-Security-Policy", "form-action 'self'")
+ rendered := ""
+ h := OAuthContinuation(configOrigin, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Set-Cookie", "pending=private; Path=/; Secure; HttpOnly")
+ http.Redirect(w, r, destination, 303)
+ }, func(w http.ResponseWriter, r *http.Request, target string) { rendered = target; w.WriteHeader(200) })
+ h(w, httptest.NewRequest("POST", configOrigin+"/signin", nil))
+ if w.Header().Get("Content-Security-Policy") != "form-action 'self'" || len(w.Result().Cookies()) != 1 {
+ t.Fatal("policy or pending cookie changed")
+ }
+ if strings.HasPrefix(destination, "https:") {
+ if w.Code != 200 || rendered != destination || w.Header().Get("Location") != "" || w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Referrer-Policy") != "no-referrer" {
+ t.Fatal("OAuth POST did not terminate", w.Code, w.Header())
+ }
+ } else if w.Code != 303 || rendered != "" || w.Header().Get("Location") != destination {
+ t.Fatal("local flow changed")
+ }
+ }
+}
+func TestOAuthContinuationRefusesUnboundDestinationsWithoutLeakingState(t *testing.T) {
+ for _, destination := range []string{"//keymail.example/oauth/authorize", strings.Replace(oauthTarget(), "https:", "http:", 1), strings.Replace(oauthTarget(), "scope=identify", "scope=other", 1), oauthTarget() + "&client_id=other", oauthTarget() + "#fragment", "https://user@keymail.example/oauth/authorize", "https://keymail.example/other", strings.Replace(oauthTarget(), "S256", "plain", 1)} {
+ w := httptest.NewRecorder()
+ h := OAuthContinuation(configOrigin, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Set-Cookie", "private-cookie=private")
+ http.Redirect(w, r, destination, 303)
+ }, func(http.ResponseWriter, *http.Request, string) { t.Fatal("invalid redirect rendered") })
+ h(w, httptest.NewRequest("POST", configOrigin+"/signin", nil))
+ if w.Code != 502 || w.Header().Get("Location") != "" || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private") {
+ t.Fatal("unsafe destination accepted or leaked", w.Code)
+ }
+ }
+}
diff --git a/connect/routes.go b/connect/routes.go
index 8c689aa..86baacf 100644
--- a/connect/routes.go
+++ b/connect/routes.go
@@ -14,6 +14,7 @@ import (
const LocalCompletePath = localLinkCompletePath
type RoutesConfig struct {
+ LocalLandingPath string
Namespace, Origin, Issuer, SessionCookie string
FormPolicy string
DEK []byte
@@ -31,7 +32,7 @@ type Routes struct {
}
func NewRoutes(b *Bridge, c RoutesConfig) (*Routes, error) {
- if b == nil || c.ReadSession == nil || c.SignOut == nil || c.LocalSignin == nil || c.Protect == nil || c.Render == nil || c.Origin != b.origin || c.Issuer != b.issuer || c.SessionCookie != b.sessionCookie || c.Namespace != b.namespace || sha256.Sum256(c.DEK) != b.keyHash {
+ if (c.LocalLandingPath != "" && !handoff.SafePath(c.LocalLandingPath)) || b == nil || c.ReadSession == nil || c.SignOut == nil || c.LocalSignin == nil || c.Protect == nil || c.Render == nil || c.Origin != b.origin || c.Issuer != b.issuer || c.SessionCookie != b.sessionCookie || c.Namespace != b.namespace || sha256.Sum256(c.DEK) != b.keyHash {
return nil, errors.New("home connect: invalid routes")
}
s, err := seal.New(c.DEK, c.Namespace)
diff --git a/connect/templates/oauth.html b/connect/templates/oauth.html
new file mode 100644
index 0000000..12aef7a
--- /dev/null
+++ b/connect/templates/oauth.html
@@ -0,0 +1,5 @@
+{{define "home-oauth-head"}}<meta http-equiv="refresh" content="0;url={{.URL}}">{{end}}
+{{define "home-oauth"}}
+<header rst-page-header><div rst-page-header-titles><h1>{{.Title}}</h1></div></header>
+<div rst-box><a rst-btn="primary" href="{{.URL}}">{{.ActionLabel}}</a></div>
+{{end}}