-
Notifications
You must be signed in to change notification settings - Fork 10
fix(drivers): keep OAuth credentials across restarts #1108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
48013fd
8c14a8a
b16f803
ab21200
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "ftw": patch | ||
| --- | ||
|
|
||
| Restore and save rotated OAuth tokens before drivers start so myUplink can stay connected across Core restarts and updates. | ||
|
|
||
| Apply signed OAuth rules to managed drivers, including official beta installs. Allow token exchange only at the declared path, block redirects, and let each driver save only its declared secret keys with bounded keys and values. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "github.com/srcfl/ftw/go/internal/drivers" | ||
| "github.com/srcfl/ftw/go/internal/state" | ||
| "github.com/srcfl/ftw/go/internal/telemetry" | ||
| ) | ||
|
|
||
| func newDriverRegistry(tel *telemetry.Store, st *state.Store) *drivers.Registry { | ||
| reg := drivers.NewRegistry(tel) | ||
| // Install both callbacks before Add can initialize or poll any driver. | ||
| // Rotations keep their own KV rows so they do not apply the whole config | ||
| // or restart the driver that just refreshed its credential. | ||
| driverSecretKey := func(driverName, key string) string { | ||
| return "driver_secret:" + driverName + ":" + key | ||
| } | ||
| reg.SecretPersister = func(driverName, key, value string) error { | ||
| return st.SaveConfig(driverSecretKey(driverName, key), value) | ||
| } | ||
| reg.SecretOverride = func(driverName, key string) (string, bool) { | ||
| return st.LoadConfig(driverSecretKey(driverName, key)) | ||
| } | ||
| return reg | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/srcfl/ftw/go/internal/config" | ||
| "github.com/srcfl/ftw/go/internal/drivers" | ||
| "github.com/srcfl/ftw/go/internal/state" | ||
| "github.com/srcfl/ftw/go/internal/telemetry" | ||
| ) | ||
|
|
||
| func TestDriverRegistryRotatedSecretSurvivesStartup(t *testing.T) { | ||
| for _, scenario := range []struct { | ||
| name, phase string | ||
| managed bool | ||
| }{{"init", "init", false}, {"first_poll", "first_poll", false}, {"managed_init", "init", true}, {"managed_first_poll", "first_poll", true}} { | ||
| t.Run(scenario.name, func(t *testing.T) { | ||
| dir := t.TempDir() | ||
| path := filepath.Join(dir, "oauth.lua") | ||
| // Synthetic tokens only. A remains in the config document after the | ||
| // driver rotates to B, just as it does after real OAuth consent. | ||
| source := fmt.Sprintf(` | ||
| local token | ||
| local rotated = false | ||
| local function rotate() | ||
| if token == "synthetic-A" and not rotated then | ||
| local ok = host.persist_secret("refresh_token", "synthetic-B") | ||
| host.emit_metric("persist_ok", ok and 1 or 0) | ||
| rotated = true | ||
| end | ||
| end | ||
| function driver_init(config) | ||
| token = config.refresh_token | ||
| host.emit_metric("started_with_B", token == "synthetic-B" and 1 or 0) | ||
| host.set_poll_interval(10) | ||
| if %q == "init" then rotate() end | ||
| end | ||
| function driver_poll() rotate() return 60000 end | ||
| function driver_command() end | ||
| function driver_default_mode() end | ||
| `, scenario.phase) | ||
| if err := os.WriteFile(path, []byte(source), 0600); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| cfg := config.Driver{Name: "oauth-test", Lua: path, Config: map[string]any{"refresh_token": "synthetic-A"}} | ||
| dbPath := filepath.Join(dir, "state.db") | ||
| start := func() (*state.Store, *telemetry.Store, func()) { | ||
| t.Helper() | ||
| st, err := state.Open(dbPath) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| tel := telemetry.NewStore() | ||
| reg := newDriverRegistry(tel, st) | ||
| if scenario.managed { | ||
| reg.RuntimePolicyResolver = func(config.Driver) (*drivers.RuntimePolicy, error) { | ||
| return &drivers.RuntimePolicy{ | ||
| PackageID: "com.sourceful.driver.myuplink", Version: "1.2.2", | ||
| ArtifactSHA256: fmt.Sprintf("%064x", 1), RuntimeABI: "gopher-lua-source-v1", | ||
| HostAPIProfile: "sourceful.host/ftw-core/v1", ReadOnly: true, | ||
| Permissions: map[string]bool{"http.get": true, "http.post": true}, AuthPostPath: "/oauth/token", | ||
| ConfigSecrets: []string{"refresh_token"}, | ||
| }, nil | ||
| } | ||
| } | ||
| stop := func() { reg.ShutdownAll(); st.Close() } | ||
| if err := reg.Add(context.Background(), cfg); err != nil { | ||
| stop() | ||
| t.Fatal(err) | ||
| } | ||
| return st, tel, stop | ||
| } | ||
| metric := func(tel *telemetry.Store, key string) float64 { | ||
| t.Helper() | ||
| deadline := time.Now().Add(2 * time.Second) | ||
| for time.Now().Before(deadline) { | ||
| if value, _, ok := tel.LatestMetric(cfg.Name, key); ok { | ||
| return value | ||
| } | ||
| time.Sleep(time.Millisecond) | ||
| } | ||
| t.Fatalf("driver did not emit %s", key) | ||
| return 0 | ||
| } | ||
| st, tel, stop := start() | ||
| func() { | ||
| defer stop() | ||
| if got := metric(tel, "started_with_B"); got != 0 { | ||
| t.Errorf("first start did not use config token A") | ||
| } | ||
| if got := metric(tel, "persist_ok"); got != 1 { | ||
| t.Errorf("secret persistence during %s failed", scenario.phase) | ||
| } | ||
| if got, ok := st.LoadConfig("driver_secret:oauth-test:refresh_token"); !ok || got != "synthetic-B" { | ||
| t.Errorf("rotated token B was not stored") | ||
| } | ||
| }() | ||
| _, tel, stop = start() | ||
| defer stop() | ||
| if got := metric(tel, "started_with_B"); got != 1 { | ||
| t.Error("restart used stale config token A instead of persisted token B") | ||
| } | ||
| if cfg.Config["refresh_token"] != "synthetic-A" { | ||
| t.Error("rotation changed the source config") | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -590,6 +590,11 @@ func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, erro | |
| break | ||
| } | ||
| } | ||
| // InstallChannel uses this pinned trust source without adding it to the | ||
| // stable config list. Bind only its exact recorded repository identity. | ||
| if repo == nil && m.betaRepo.ID != "" && installed.RepoID == m.betaRepo.ID { | ||
| repo = &m.betaRepo | ||
|
Comment on lines
+595
to
+596
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
| if repo == nil { | ||
| if cfg.Control != nil && cfg.Control.Enabled { | ||
| return nil, errors.New("control opt-in requires a configured Device Support trust root") | ||
|
|
@@ -761,7 +766,8 @@ func (m *Manager) directManifestRuntimePolicy( | |
| // Only a read-only driver can have one, and only the path the signed | ||
| // manifest names. An unsigned or absent value leaves it empty, which | ||
| // is the same as having no exemption at all. | ||
| AuthPostPath: matched.Metadata.AuthPostPath, | ||
| AuthPostPath: matched.Metadata.AuthPostPath, | ||
| ConfigSecrets: append([]string(nil), matched.Metadata.ConfigSecrets...), | ||
| }, nil | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a managed OAuth driver rotates token A to B and the operator later renames the YAML driver, this key changes even though the device does not. Because the source config intentionally remains at A, the next initialization misses B and presents the now-invalidated token, disconnecting the driver; reusing an old name can conversely apply that credential to the wrong device. Store and migrate the override under a stable hardware/account identity rather than
cfg.Name.AGENTS.md reference: AGENTS.md:L38-L38
Useful? React with 👍 / 👎.