Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: hard-fail on unrecognized OAuth client, add rekey flow

Gardens with stored credentials that fail reauthentication no longer
silently re-register as new gardens. Instead they attempt a rekey via
the new POST /api/v1/gardens/:sid/rekey endpoint, which creates a new
OAuth client for the existing garden identity.

Server-side changes:
- Add rekey_garden/2 to Sower.Orchestration.Garden
- Add rekey action to GardenController with garden:register permission
- Rescue ArgumentError in GardenSocket when Boruta token references a
deleted OAuth client

Client-side changes:
- Add SowerClient.Registration.rekey/3
- Add try_http_rekey path in resolve_connect_token when garden has a
stored garden_sid but no usable credentials
- Fresh registration only happens for truly new gardens (no garden_sid)

sow-157

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+238 -14
+58
apps/garden/lib/garden/socket.ex
··· 210 210 when is_binary(client_id) and is_binary(private_key_pem) -> 211 211 try_reauthenticate(storage) 212 212 213 + %{garden_sid: garden_sid} when is_binary(garden_sid) -> 214 + Logger.warning( 215 + msg: "Garden is registered but has no usable credentials, attempting rekey", 216 + garden_sid: garden_sid 217 + ) 218 + 219 + try_http_rekey(storage) 220 + 213 221 _ -> 214 222 try_http_registration(storage) 215 223 end ··· 249 257 end 250 258 251 259 defp try_reauthenticate(_), do: {:error, :no_credentials} 260 + 261 + defp try_http_rekey(storage) do 262 + config = Garden.Config.get() 263 + {public_key_pem, storage} = Garden.Auth.ensure_keypair(storage) 264 + 265 + req = 266 + Req.new( 267 + base_url: "#{config.endpoint}/api/v1", 268 + auth: {:bearer, config.access_token}, 269 + retry: false 270 + ) 271 + 272 + case SowerClient.Registration.rekey(req, storage.garden_sid, public_key_pem) do 273 + {:ok, %{client_id: client_id}} -> 274 + Logger.info( 275 + msg: "Re-keyed via HTTP", 276 + garden_sid: storage.garden_sid, 277 + client_id: client_id 278 + ) 279 + 280 + oauth_creds = %{client_id: client_id} 281 + storage = Map.put(storage, :oauth_credentials, oauth_creds) 282 + 283 + case Garden.Auth.request_token(client_id, storage.private_key_pem) do 284 + {:ok, token_response} -> 285 + updated_creds = 286 + Map.merge(oauth_creds, %{ 287 + access_token: token_response.access_token, 288 + expires_in: token_response.expires_in, 289 + token_issued_at: System.system_time(:second) 290 + }) 291 + 292 + storage |> Map.put(:oauth_credentials, updated_creds) |> Storage.write() 293 + {:ok, "boruta:#{token_response.access_token}"} 294 + 295 + {:error, reason} -> 296 + Storage.write(storage) 297 + {:error, {:token_request_failed, reason}} 298 + end 299 + 300 + {:error, reason} -> 301 + Logger.error( 302 + msg: "Rekey failed, garden may need operator intervention", 303 + garden_sid: storage.garden_sid, 304 + reason: inspect(reason) 305 + ) 306 + 307 + {:error, {:rekey_failed, reason}} 308 + end 309 + end 252 310 253 311 defp try_http_registration(storage) do 254 312 config = Garden.Config.get()
+40 -11
apps/sower/lib/sower/orchestration/garden.ex
··· 261 261 |> Repo.update() 262 262 end 263 263 264 - def delete_garden(%__MODULE__{} = garden) do 265 - if garden.oauth_client_id do 266 - try do 267 - Sower.GardenAuth.delete_client(garden.oauth_client_id) 268 - rescue 269 - Ecto.NoResultsError -> 270 - Logger.warning( 271 - msg: "Boruta client not found during garden deletion", 272 - oauth_client_id: garden.oauth_client_id 273 - ) 274 - end 264 + def rekey_garden(%__MODULE__{} = garden, public_key_pem) do 265 + with :ok <- delete_existing_client(garden), 266 + {:ok, client} <- Sower.GardenAuth.create_client(garden.sid, public_key_pem), 267 + {:ok, garden} <- update_garden(garden, %{oauth_client_id: client.id}) do 268 + Logger.info( 269 + msg: "Garden re-keyed", 270 + garden_sid: garden.sid, 271 + new_client_id: client.id 272 + ) 273 + 274 + {:ok, garden, %{client_id: client.id}} 275 + else 276 + {:error, reason} -> 277 + Logger.error( 278 + msg: "Failed to re-key garden", 279 + garden_sid: garden.sid, 280 + error: inspect(reason) 281 + ) 282 + 283 + {:error, reason} 284 + end 285 + end 286 + 287 + defp delete_existing_client(%__MODULE__{oauth_client_id: nil}), do: :ok 288 + 289 + defp delete_existing_client(%__MODULE__{oauth_client_id: client_id}) do 290 + try do 291 + Sower.GardenAuth.delete_client(client_id) 292 + :ok 293 + rescue 294 + Ecto.NoResultsError -> 295 + Logger.warning( 296 + msg: "Old Boruta client not found during re-key", 297 + oauth_client_id: client_id 298 + ) 299 + 300 + :ok 275 301 end 302 + end 276 303 304 + def delete_garden(%__MODULE__{} = garden) do 305 + delete_existing_client(garden) 277 306 Repo.delete(garden) 278 307 end 279 308
+73
apps/sower/lib/sower_web/controllers/api/garden_controller.ex
··· 66 66 conn |> put_status(:unauthorized) |> render(:error, error: "unauthorized") 67 67 end 68 68 end 69 + 70 + operation(:rekey, 71 + operation_id: "RekeyGarden", 72 + summary: "Re-key a garden's OAuth client", 73 + parameters: [ 74 + sid: [ 75 + in: :path, 76 + type: :string, 77 + description: "Garden SID", 78 + required: true 79 + ] 80 + ], 81 + request_body: {"Garden rekey params", "application/json", SowerClient.GardenRekey}, 82 + responses: %{ 83 + ok: 84 + {"Garden rekey response", "application/json", 85 + %Schema{ 86 + type: :object, 87 + properties: %{ 88 + sid: %Schema{type: :string, description: "Garden SID"}, 89 + oauth_credentials: SowerClient.Auth.OAuthCredentials 90 + }, 91 + required: [:sid, :oauth_credentials] 92 + }}, 93 + unauthorized: 94 + {"Unauthorized", "application/json", 95 + %Schema{type: :object, properties: %{error: %Schema{type: :string}}}}, 96 + not_found: 97 + {"Not found", "application/json", 98 + %Schema{type: :object, properties: %{error: %Schema{type: :string}}}}, 99 + unprocessable_entity: 100 + {"Rekey error", "application/json", 101 + %Schema{type: :object, properties: %{error: %Schema{type: :string}}}} 102 + } 103 + ) 104 + 105 + def rekey( 106 + %Plug.Conn{ 107 + body_params: %SowerClient.GardenRekey{ 108 + public_key: public_key 109 + } 110 + } = conn, 111 + %{sid: sid} 112 + ) do 113 + access_token = conn.assigns.access_token 114 + 115 + if can(access_token) 116 + |> create?(%Sower.Orchestration.Garden{org_id: access_token.org_id}) do 117 + case Sower.Orchestration.Garden.get_garden_sid(sid) do 118 + nil -> 119 + conn 120 + |> put_status(:not_found) 121 + |> render(:error, error: "garden not found") 122 + 123 + garden -> 124 + case Sower.Orchestration.Garden.rekey_garden(garden, public_key) do 125 + {:ok, garden, %{client_id: client_id}} -> 126 + conn 127 + |> put_status(:ok) 128 + |> render(:register, garden: garden, client_id: client_id) 129 + 130 + {:error, reason} -> 131 + Logger.error(msg: "Garden rekey failed", error: inspect(reason)) 132 + 133 + conn 134 + |> put_status(:unprocessable_entity) 135 + |> render(:error, error: "rekey failed") 136 + end 137 + end 138 + else 139 + conn |> put_status(:unauthorized) |> render(:error, error: "unauthorized") 140 + end 141 + end 69 142 end
+15 -3
apps/sower/lib/sower_web/garden_socket.ex
··· 47 47 defp extract_token_from_params(_params), do: :error 48 48 49 49 defp authenticate_token("boruta:" <> boruta_token) do 50 - case Boruta.Oauth.Authorization.AccessToken.authorize(value: boruta_token) do 50 + case authorize_boruta_token(boruta_token) do 51 51 {:ok, oauth_token} -> 52 52 case Sower.Orchestration.Garden.get_by_oauth_client_id(oauth_token.client.id) do 53 53 nil -> ··· 67 67 }} 68 68 end 69 69 70 - {:error, _} -> 71 - {:error, :invalid_boruta_token} 70 + {:error, reason} -> 71 + {:error, reason} 72 72 end 73 73 end 74 74 ··· 92 92 {:error, error} -> 93 93 {:error, error} 94 94 end 95 + end 96 + 97 + defp authorize_boruta_token(token) do 98 + Boruta.Oauth.Authorization.AccessToken.authorize(value: token) 99 + rescue 100 + ArgumentError -> 101 + Logger.error( 102 + msg: "Boruta token references a deleted OAuth client", 103 + token_prefix: String.slice(token, 0, 8) 104 + ) 105 + 106 + {:error, :invalid_boruta_token} 95 107 end 96 108 end
+1
apps/sower/lib/sower_web/router.ex
··· 113 113 get "/auth/verify", AuthController, :verify 114 114 115 115 post "/gardens/register", GardenController, :register 116 + post "/gardens/:sid/rekey", GardenController, :rekey 116 117 117 118 get "/nix/caches", Nix.CacheController, :list 118 119 get "/seeds", SeedController, :list
+1
apps/sower_client/lib/sower_client.ex
··· 26 26 SowerClient.GardenHello, 27 27 SowerClient.GardenRegistration, 28 28 SowerClient.AgentHello, 29 + SowerClient.GardenRekey, 29 30 SowerClient.Auth.OAuthCredentials, 30 31 SowerClient.Auth.TokenInfo, 31 32 SowerClient.Orchestration.GardenSeedGeneration,
+16
apps/sower_client/lib/sower_client/garden_rekey.ex
··· 1 + defmodule SowerClient.GardenRekey do 2 + use SowerClient.Schema 3 + 4 + OpenApiSpex.schema(%{ 5 + title: "GardenRekey", 6 + description: "Request to re-key a garden's OAuth client", 7 + type: :object, 8 + properties: %{ 9 + public_key: %Schema{ 10 + type: :string, 11 + description: "PEM-encoded RSA public key for private_key_jwt authentication" 12 + } 13 + }, 14 + required: [:public_key] 15 + }) 16 + end
+34
apps/sower_client/lib/sower_client/registration.ex
··· 32 32 err 33 33 end 34 34 end 35 + 36 + def rekey(%Req.Request{} = req, garden_sid, public_key_pem) do 37 + case Req.post(req, 38 + url: "/gardens/#{garden_sid}/rekey", 39 + json: %{ 40 + public_key: public_key_pem 41 + } 42 + ) do 43 + {:ok, %{status: 200, body: body}} -> 44 + {:ok, 45 + %{ 46 + garden_sid: body["sid"], 47 + client_id: body["oauth_credentials"]["client_id"] 48 + }} 49 + 50 + {:ok, %{status: 401}} -> 51 + {:error, :unauthorized} 52 + 53 + {:ok, %{status: 404}} -> 54 + {:error, :garden_not_found} 55 + 56 + {:ok, %{body: %{"error" => error}}} -> 57 + {:error, error} 58 + 59 + {:ok, response} -> 60 + {:error, response} 61 + 62 + {:error, %Req.TransportError{reason: reason}} -> 63 + {:error, {:connection_error, reason}} 64 + 65 + {:error, _} = err -> 66 + err 67 + end 68 + end 35 69 end