diff --git a/.changeset/lua-http-request-cookie-jar.md b/.changeset/lua-http-request-cookie-jar.md new file mode 100644 index 000000000..e6bd8c5e3 --- /dev/null +++ b/.changeset/lua-http-request-cookie-jar.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Lua drivers can now sign in through a web login. The new `host.http_request` returns the status, headers and redirect target. The host keeps the session cookies for the driver's allowed hosts, in memory only. A read-only driver may declare several sign-in paths with `auth_post_paths`. This lets the VW Group driver renew its portal session itself, instead of the owner pasting a new cookie every hour. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index e061887d2..bd85a08d9 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -41,7 +41,7 @@ current host API and the source of truth for it. Today it registers: | Decoding | `decode_string`, `decode_i16`, `decode_i32_be`, `decode_i32_le`, `decode_u32_be`, `decode_u32_le` | | Modbus | `modbus_read`, `write`, `write_registers` (canonical); `modbus_write`, `modbus_write_multi` (legacy aliases) | | MQTT | `mqtt_pub`, `mqtt_sub`, `mqtt_publish`, `mqtt_subscribe`, `mqtt_messages` | -| HTTP | `http_get`, `http_post`, `http_patch` | +| HTTP | `http_get`, `http_post`, `http_patch`, `http_request`, `http_cookies_clear` | | WebSocket | `ws_open`, `ws_send`, `ws_messages`, `ws_is_open`, `ws_close` | | Raw TCP | `tcp_open`, `tcp_recv`, `tcp_close`, `tcp_is_open` | | Serial | `serial_read` | @@ -60,6 +60,22 @@ not enough, it also needs `capabilities.http.allow_write`. It refuses to follow redirects, because Go re-issues a redirected `PATCH` as a body-less GET and a device write that never landed would otherwise report success. +`http_request{method, url, headers, body}` is for a driver that has to sign in +through a web login before it can read. It returns `{status, headers, +location, body}` for every status instead of turning 4xx into an error, and it +never follows a redirect: the driver reads `location` and makes the next call +itself, so the host checks every hop against `allowed_hosts`. It accepts GET +and POST over https only, and only with a non-empty `allowed_hosts`. POST has +the same gate as `http_post`. The host keeps the driver's session cookies in +an in-memory jar that stores and sends them only for allowed hosts. Lua never +sees them: `headers` leaves out `Set-Cookie`. `http_cookies_clear()` empties +the jar before a fresh sign-in. Header names are lowercase, so a driver +without a wall clock can read the server time from `headers.date`. + +A read-only driver may POST only to the sign-in paths its `DRIVER` block +declares: `auth_post_path` for one path, or `auth_post_paths` when the login +posts more than one form. The host matches each exactly. + A driver with an opt-in write path names it in its `DRIVER` block — `write_capabilities = { "solar_pv" }` for a driver that feeds a heat pump's own solar-surplus input. The Settings UI offers a switch for a path it @@ -122,7 +138,7 @@ may omit the default hook because Core cannot dispatch commands to them. `driver_fingerprint(target)` is an optional passive setup probe. It must never reconfigure the device. The host denies mutating verbs (`modbus_write`, -`mqtt_pub`, `http_post`, `http_patch`) for that VM, including bundled drivers +`mqtt_pub`, `http_post`, `http_patch`, and POST through `http_request`) for that VM, including bundled drivers that may otherwise write. Call `host.set_make` and `host.set_sn` as soon as stable identity is known. diff --git a/go/internal/driverrepo/runtime_policy.go b/go/internal/driverrepo/runtime_policy.go index 3bc2de8b3..877b1ef0e 100644 --- a/go/internal/driverrepo/runtime_policy.go +++ b/go/internal/driverrepo/runtime_policy.go @@ -269,6 +269,7 @@ func (m *Manager) directManifestRuntimePolicy( // manifest names. An unsigned or absent value leaves it empty, which // is the same as having no exemption at all. AuthPostPath: matched.Metadata.AuthPostPath, + AuthPostPaths: append([]string(nil), matched.Metadata.AuthPostPaths...), ConfigSecrets: append([]string(nil), matched.Metadata.ConfigSecrets...), }, nil } diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 2d38ea649..c1277767f 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -48,6 +48,8 @@ type CatalogEntry struct { // that reads a vendor cloud has to POST for a token before it can read, // and that POST is not actuation. Only meaningful with ReadOnly. AuthPostPath string `json:"auth_post_path,omitempty"` + // AuthPostPaths lists further sign-in paths for a multi-step login. + AuthPostPaths []string `json:"auth_post_paths,omitempty"` // ReadOnly means the driver never accepts dispatch commands. The catalog // UI uses it to avoid presenting battery capacity as a control opt-in. ReadOnly bool `json:"read_only,omitempty"` @@ -227,6 +229,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) { e.WriteCapabilities = pickList(block, "write_capabilities") e.Replaces = pickList(block, "replaces") e.AuthPostPath = pickString(block, "auth_post_path") + e.AuthPostPaths = pickList(block, "auth_post_paths") e.Controls = pickControls(block) return e, nil } diff --git a/go/internal/drivers/catalog_test.go b/go/internal/drivers/catalog_test.go index 4f1dd47d4..3135a1da2 100644 --- a/go/internal/drivers/catalog_test.go +++ b/go/internal/drivers/catalog_test.go @@ -163,6 +163,23 @@ func TestLoadCatalogReadsAuthPostPath(t *testing.T) { } } +func TestLoadCatalogReadsAuthPostPaths(t *testing.T) { + dir := t.TempDir() + src := "DRIVER = {\n id = \"vag\",\n name = \"VAG\",\n read_only = true,\n" + + " auth_post_paths = { \"/a/login/identifier\", \"/a/login/authenticate\" },\n}\n" + if err := os.WriteFile(filepath.Join(dir, "vag.lua"), []byte(src), 0644); err != nil { + t.Fatal(err) + } + entries, err := LoadCatalog(dir) + if err != nil || len(entries) != 1 { + t.Fatalf("LoadCatalog: %v %v", entries, err) + } + got := entries[0].AuthPostPaths + if len(got) != 2 || got[0] != "/a/login/identifier" || got[1] != "/a/login/authenticate" { + t.Fatalf("AuthPostPaths = %q", got) + } +} + func TestCatalogMyUplinkDeclaresAuthPostPath(t *testing.T) { entries, err := LoadCatalog("../../../drivers") if err != nil { diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index 480a0c42a..778bd68bb 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -240,15 +240,20 @@ func (h *HostEnv) allowAuthPost(rawURL string) bool { if h.RuntimePolicy == nil || !h.RuntimePolicy.ReadOnly { return false } - declared := h.RuntimePolicy.AuthPostPath - if declared == "" || !h.RuntimePolicy.allows("http.post") { + declared := h.RuntimePolicy.authPaths() + if len(declared) == 0 || !h.RuntimePolicy.allows("http.post") { return false } parsed, err := net_url.Parse(rawURL) if err != nil { return false } - return parsed.Path == declared + for _, path := range declared { + if parsed.Path == path { + return true + } + } + return false } func (h *HostEnv) allowWrite(permission string) error { diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 01ebc7eb9..c39769291 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -1073,6 +1073,8 @@ func registerHost(L *lua.LState, env *HostEnv) { // host.http_post(url, body, headers?) → (body, nil) or (nil, error_string) // host.http_patch(url, body, headers?) → (body, nil) or (nil, error_string); // the mutating verb, gated by capabilities.http.allow_write (default off) + // host.http_request{method, url, headers?, body?} → response table; see + // lua_http_request.go // headers is an optional Lua table {["Content-Type"]="application/json", ...} rawTLSPin := strings.TrimSpace(env.HTTPTLSPinSHA256) tlsPin := normalizeHexFingerprint(rawTLSPin) @@ -1403,6 +1405,8 @@ func registerHost(L *lua.LState, env *HostEnv) { return 1 })) + registerHTTPRequest(L, host, env, httpClient, hostAllowed) + // ---- WebSocket capability ---- // host.ws_open(url, headers?) → (true, nil) or (nil, error_string) // host.ws_send(text) → (true, nil) or (nil, error_string) diff --git a/go/internal/drivers/lua_http_request.go b/go/internal/drivers/lua_http_request.go new file mode 100644 index 000000000..387de78a9 --- /dev/null +++ b/go/internal/drivers/lua_http_request.go @@ -0,0 +1,179 @@ +package drivers + +import ( + "fmt" + "io" + net_http "net/http" + "net/http/cookiejar" + net_url "net/url" + "sort" + "strings" + + lua "github.com/yuin/gopher-lua" + "golang.org/x/net/publicsuffix" +) + +// host.http_request{method, url, headers?, body?} returns +// {status, headers, location, body} or (nil, error_string). +// +// It is for a driver that has to sign in through a web login before it can +// read: the status and Location come back to Lua instead of being followed or +// turned into an error, and session cookies live in a jar the host keeps for +// this driver. The jar is in memory only, so a driver restart starts a clean +// session. Lua never sees the cookies: Set-Cookie is left out of headers. +// +// Rules on top of the http_get/http_post ones: +// - allowed_hosts must be non-empty, and the URL must be https; +// - only GET and POST, with POST under the same write or sign-in gate as +// http_post; +// - redirects are never followed, so every hop is a separate call the +// host checks against allowed_hosts; +// - the jar stores and sends cookies only for allowed hosts. +// +// host.http_cookies_clear() empties the jar before a fresh sign-in. +func registerHTTPRequest(L *lua.LState, host *lua.LTable, env *HostEnv, base *net_http.Client, hostAllowed func(string) (bool, string)) { + newJar := func() *allowedHostJar { + inner, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + return &allowedHostJar{inner: inner, allowed: hostAllowed} + } + jar := newJar() + client := &net_http.Client{ + Timeout: base.Timeout, + Transport: base.Transport, + Jar: jar, + CheckRedirect: func(*net_http.Request, []*net_http.Request) error { + return net_http.ErrUseLastResponse + }, + } + + host.RawSetString("http_cookies_clear", L.NewFunction(func(L *lua.LState) int { + jar = newJar() + client.Jar = jar + return 0 + })) + + host.RawSetString("http_request", L.NewFunction(func(L *lua.LState) int { + fail := func(msg string) int { + L.Push(lua.LNil) + L.Push(lua.LString(msg)) + return 2 + } + opts := L.CheckTable(1) + method := strings.ToUpper(lua.LVAsString(opts.RawGetString("method"))) + if method == "" { + method = "GET" + } + rawURL := lua.LVAsString(opts.RawGetString("url")) + if !env.HTTP { + return fail("http: capability not granted") + } + if len(env.HTTPAllowedHosts) == 0 { + return fail("http_request: requires a non-empty allowed_hosts") + } + switch method { + case "GET": + if !env.permissionAllowed("http.get") { + return fail("http.get: permission not granted by signed package") + } + case "POST": + if !env.allowAuthPost(rawURL) { + if err := env.allowWrite("http.post"); err != nil { + return fail(err.Error()) + } + } + default: + return fail(fmt.Sprintf("http_request: method %q not supported (GET or POST)", method)) + } + u, err := net_url.Parse(rawURL) + if err != nil || !strings.EqualFold(u.Scheme, "https") { + return fail("http_request: requires an https URL") + } + if ok, reason := hostAllowed(rawURL); !ok { + return fail("http: " + reason) + } + + var body io.Reader + if v := opts.RawGetString("body"); v != lua.LNil { + body = strings.NewReader(lua.LVAsString(v)) + } + var req *net_http.Request + if method == "GET" { + req, err = net_http.NewRequestWithContext(luaCallContext(L), method, rawURL, body) + } else { + // Same ordering rule as http_post: do not cancel a request the + // server may already have acted on. + req, err = net_http.NewRequest(method, rawURL, body) + } + if err != nil { + return fail(err.Error()) + } + if headers, ok := opts.RawGetString("headers").(*lua.LTable); ok { + headers.ForEach(func(k, v lua.LValue) { + if ks, ok := k.(lua.LString); ok { + req.Header.Set(string(ks), v.String()) + } + }) + } + resp, err := client.Do(req) + if err != nil { + return fail(err.Error()) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fail(err.Error()) + } + + out := L.NewTable() + out.RawSetString("status", lua.LNumber(resp.StatusCode)) + hdrs := L.NewTable() + names := make([]string, 0, len(resp.Header)) + for name := range resp.Header { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + lower := strings.ToLower(name) + if lower == "set-cookie" { + continue + } + hdrs.RawSetString(lower, lua.LString(strings.Join(resp.Header[name], ", "))) + } + out.RawSetString("headers", hdrs) + if loc, err := resp.Location(); err == nil { + out.RawSetString("location", lua.LString(loc.String())) + } + out.RawSetString("body", lua.LString(string(data))) + L.Push(out) + return 1 + })) +} + +// allowedHostJar keeps a driver's session cookies to the hosts it may reach +// over https. A cookie a server sets for any other host is dropped, and none +// is ever sent elsewhere. +type allowedHostJar struct { + inner *cookiejar.Jar + allowed func(string) (bool, string) +} + +func (j *allowedHostJar) ok(u *net_url.URL) bool { + if u == nil || !strings.EqualFold(u.Scheme, "https") { + return false + } + ok, _ := j.allowed(u.String()) + return ok +} + +func (j *allowedHostJar) SetCookies(u *net_url.URL, cookies []*net_http.Cookie) { + if j.ok(u) { + j.inner.SetCookies(u, cookies) + } +} + +func (j *allowedHostJar) Cookies(u *net_url.URL) []*net_http.Cookie { + if !j.ok(u) { + return nil + } + return j.inner.Cookies(u) +} diff --git a/go/internal/drivers/lua_http_request_test.go b/go/internal/drivers/lua_http_request_test.go new file mode 100644 index 000000000..7c9c263a0 --- /dev/null +++ b/go/internal/drivers/lua_http_request_test.go @@ -0,0 +1,217 @@ +package drivers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// loginServer mimics a web sign-in: POST /login sets a session cookie and +// redirects, GET /data answers only with that cookie. +func loginServer(t *testing.T) (*httptest.Server, string, *atomic.Int32) { + t.Helper() + var posts atomic.Int32 + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + posts.Add(1) + http.SetCookie(w, &http.Cookie{Name: "session", Value: "synthetic", Path: "/", Secure: true}) + w.Header().Set("Location", "/data") + w.WriteHeader(http.StatusFound) + case "/data": + if c, err := r.Cookie("session"); err != nil || c.Value != "synthetic" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("no session")) + return + } + _, _ = w.Write([]byte("reading")) + case "/away": + w.Header().Set("Location", "https://not-allowed.invalid/steal") + w.WriteHeader(http.StatusFound) + case "/device": + posts.Add(1) + _, _ = w.Write([]byte("written")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + sum := sha256.Sum256(srv.Certificate().Raw) + return srv, hex.EncodeToString(sum[:]), &posts +} + +func runRequestDriver(t *testing.T, env *HostEnv, policy *RuntimePolicy, script string, config map[string]any) error { + t.Helper() + path := filepath.Join(t.TempDir(), "request.lua") + if err := os.WriteFile(path, []byte(script), 0600); err != nil { + t.Fatal(err) + } + var d *LuaDriver + var err error + if policy != nil { + d, err = NewLuaDriverWithPolicy(path, env, policy) + } else { + d, err = NewLuaDriver(path, env) + } + if err != nil { + t.Fatal(err) + } + defer d.Cleanup() + return d.Init(context.Background(), config) +} + +const signInScript = `function driver_init(config) + local r, err = host.http_request{url = config.base .. "/data"} + assert(r, err) + assert(r.status == 401, "status " .. tostring(r.status)) + assert(r.body == "no session", "a 4xx body comes back, not an error") + + r, err = host.http_request{method = "POST", url = config.base .. "/login", + headers = {["Content-Type"] = "application/x-www-form-urlencoded"}, body = "email=x"} + assert(r, err) + assert(r.status == 302, "redirect is returned, not followed: " .. tostring(r.status)) + assert(r.location == config.base .. "/data", "location resolved: " .. tostring(r.location)) + assert(r.headers["set-cookie"] == nil, "cookies stay in the host jar") + assert(r.headers["date"] ~= nil, "server time is readable") + + r, err = host.http_request{url = r.location} + assert(r, err) + assert(r.status == 200 and r.body == "reading", "jar sends the session: " .. tostring(r.status)) + + local plain = host.http_get(config.base .. "/data") + assert(plain == nil, "http_get does not use the jar") + + host.http_cookies_clear() + r = host.http_request{url = config.base .. "/data"} + assert(r.status == 401, "cleared jar") + + r, err = host.http_request{url = config.base .. "/away"} + assert(r and r.status == 302, "redirect to another host is returned, not followed") + r, err = host.http_request{url = r.location} + assert(r == nil and err:find("not in allowed_hosts"), "next hop is checked: " .. tostring(err)) +end` + +func TestHTTPRequestSignInWithHostCookieJar(t *testing.T) { + srv, pin, _ := loginServer(t) + env := NewHostEnv("request", telemetry.NewStore()).WithHTTP().WithHTTPTLSPin(pin). + WithHTTPAllowedHosts([]string{strings.TrimPrefix(srv.URL, "https://")}) + if err := runRequestDriver(t, env, nil, signInScript, map[string]any{"base": srv.URL}); err != nil { + t.Fatal(err) + } +} + +func TestHTTPRequestRefusals(t *testing.T) { + srv, pin, _ := loginServer(t) + host := strings.TrimPrefix(srv.URL, "https://") + script := `function driver_init(config) + local r, err = host.http_request{method = config.method, url = config.url} + assert(r == nil, "request should be refused") + assert(err and err:find(config.want, 1, true), "reason: " .. tostring(err)) +end` + for _, tc := range []struct { + name, method, url, want string + allowed []string + }{ + {"empty allowlist", "GET", srv.URL + "/data", "non-empty allowed_hosts", nil}, + {"plain http", "GET", "http://" + host + "/data", "https URL", []string{host}}, + {"other host", "GET", "https://not-allowed.invalid/data", "not in allowed_hosts", []string{host}}, + {"method", "PUT", srv.URL + "/data", "not supported", []string{host}}, + } { + t.Run(tc.name, func(t *testing.T) { + env := NewHostEnv("request", telemetry.NewStore()).WithHTTP().WithHTTPTLSPin(pin) + if tc.allowed != nil { + env.WithHTTPAllowedHosts(tc.allowed) + } + if err := runRequestDriver(t, env, nil, script, map[string]any{"method": tc.method, "url": tc.url, "want": tc.want}); err != nil { + t.Fatal(err) + } + }) + } +} + +// A read-only driver may POST through http_request only to the sign-in paths +// its signed metadata declares, exactly as with http_post. +func TestHTTPRequestReadOnlyPostOnlyToDeclaredPaths(t *testing.T) { + srv, pin, posts := loginServer(t) + host := strings.TrimPrefix(srv.URL, "https://") + policy := readOnlyAuthPostPolicy("") + policy.AuthPostPaths = []string{"/identifier", "/login"} + script := `function driver_init(config) + local r, err = host.http_request{method = "POST", url = config.base .. "/login", body = "x"} + assert(r and r.status == 302, "declared path: " .. tostring(err)) + r, err = host.http_request{method = "POST", url = config.base .. "/device", body = "x"} + assert(r == nil and err:find("cannot write"), "undeclared path: " .. tostring(err)) +end` + env := NewHostEnv("request", telemetry.NewStore()).WithHTTP().WithHTTPTLSPin(pin). + WithHTTPAllowedHosts([]string{host}) + if err := runRequestDriver(t, env, policy, script, map[string]any{"base": srv.URL}); err != nil { + t.Fatal(err) + } + if got := posts.Load(); got != 1 { + t.Fatalf("POSTs reaching the server = %d, want 1", got) + } +} + +func TestHTTPRequestAssertionFailureFailsInit(t *testing.T) { + srv, pin, _ := loginServer(t) + env := NewHostEnv("request", telemetry.NewStore()).WithHTTP().WithHTTPTLSPin(pin). + WithHTTPAllowedHosts([]string{strings.TrimPrefix(srv.URL, "https://")}) + script := `function driver_init(config) + local r = host.http_request{url = config.base .. "/data"} + assert(r.status == 200, "expected to fail") +end` + if err := runRequestDriver(t, env, nil, script, map[string]any{"base": srv.URL}); err == nil { + t.Fatal("a failing assertion must surface, or the other tests prove nothing") + } +} + +func TestAllowedHostJarDropsOtherHosts(t *testing.T) { + allowed := func(raw string) (bool, string) { + return strings.Contains(raw, "//good.example"), "" + } + inner, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + jar := &allowedHostJar{inner: inner, allowed: allowed} + cookie := []*http.Cookie{{Name: "s", Value: "v", Path: "/"}} + good := mustURL(t, "https://good.example/x") + evil := mustURL(t, "https://evil.example/x") + clear := mustURL(t, "http://good.example/x") + jar.SetCookies(evil, cookie) + jar.SetCookies(clear, cookie) + if len(jar.inner.Cookies(evil)) != 0 || len(jar.inner.Cookies(good)) != 0 { + t.Fatal("cookie stored for a host outside allowed_hosts or over http") + } + jar.SetCookies(good, cookie) + if len(jar.Cookies(good)) != 1 { + t.Fatal("allowed https host lost its cookie") + } + if len(jar.Cookies(clear)) != 0 { + t.Fatal("cookie sent over plain http") + } +} + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return u +} diff --git a/go/internal/drivers/lua_persist_test.go b/go/internal/drivers/lua_persist_test.go index 8137336bf..4fc36e186 100644 --- a/go/internal/drivers/lua_persist_test.go +++ b/go/internal/drivers/lua_persist_test.go @@ -25,6 +25,7 @@ func TestManagedPersistSecretScope(t *testing.T) { {name: "listed_path", key: "../refresh_token", change: func(p *RuntimePolicy) { p.ConfigSecrets = []string{"../refresh_token"} }}, {name: "listed_long_key", key: strings.Repeat("a", 65), change: func(p *RuntimePolicy) { p.ConfigSecrets = []string{strings.Repeat("a", 65)} }}, {name: "no_auth_path", key: "refresh_token", change: func(p *RuntimePolicy) { p.AuthPostPath = "" }}, + {name: "auth_path_list", key: "refresh_token", allowed: true, change: func(p *RuntimePolicy) { p.AuthPostPath, p.AuthPostPaths = "", []string{"/login/identifier", "/login/authenticate"} }}, {name: "no_http_get", key: "refresh_token", change: func(p *RuntimePolicy) { p.Permissions = nil }}, {name: "not_read_only", key: "refresh_token", change: func(p *RuntimePolicy) { p.ReadOnly = false }}, {name: "broad_http_write", key: "refresh_token", change: func(p *RuntimePolicy) { p.Permissions["http.patch"] = true }}, diff --git a/go/internal/drivers/runtime_policy.go b/go/internal/drivers/runtime_policy.go index 49c7f7e8d..913efe254 100644 --- a/go/internal/drivers/runtime_policy.go +++ b/go/internal/drivers/runtime_policy.go @@ -29,16 +29,36 @@ type RuntimePolicy struct { // init or poll, which allowWrite refuses. Empty for every driver that // does not declare one, which is all of them by default. AuthPostPath string + // AuthPostPaths adds further exact sign-in paths, for a login that posts + // more than one form (an OIDC identifier step, then a password step). + AuthPostPaths []string // ConfigSecrets comes from verified signed metadata. A read-only OAuth // driver may persist only these keys in its own secret namespace. ConfigSecrets []string } +// authPaths returns every declared sign-in path. +func (p *RuntimePolicy) authPaths() []string { + if p == nil { + return nil + } + var paths []string + if p.AuthPostPath != "" { + paths = append(paths, p.AuthPostPath) + } + for _, path := range p.AuthPostPaths { + if path != "" { + paths = append(paths, path) + } + } + return paths +} + func (p *RuntimePolicy) allowsSecretPersistence(key string) bool { if p == nil { return true } - if !p.IsReadOnly() || p.AuthPostPath == "" || !p.Permissions["http.get"] { + if !p.IsReadOnly() || len(p.authPaths()) == 0 || !p.Permissions["http.get"] { return false } for _, allowed := range p.ConfigSecrets { @@ -68,7 +88,7 @@ func (p *RuntimePolicy) validate() error { switch permission { case "http.get", "modbus.read", "mqtt.subscribe", "serial.read": case "http.post": - if p.AuthPostPath == "" || !p.Permissions["http.get"] { + if len(p.authPaths()) == 0 || !p.Permissions["http.get"] { return errors.New("read-only HTTP POST requires a declared auth path and http.get") } default: