Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: switch garden auth to private_key_jwt (no refresh tokens)

Replace client_credentials + refresh_token flow with client_credentials +
private_key_jwt. Gardens generate a 4096-bit RSA keypair and send the public
key during registration. Token acquisition uses RS512-signed JWT assertions.
No refresh tokens — gardens reauthenticate with fresh assertions when access
tokens expire. Fully RFC 6749 §4.4.3 compliant.

sow-105

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

+233 -157
+88
apps/garden/lib/garden/auth.ex
··· 1 + defmodule Garden.Auth do 2 + require Logger 3 + 4 + @jws_alg "RS512" 5 + @key_size 4096 6 + @assertion_ttl_seconds 30 7 + 8 + def generate_keypair do 9 + jwk = JOSE.JWK.generate_key({:rsa, @key_size}) 10 + {_, private_pem} = JOSE.JWK.to_pem(jwk) 11 + {_, public_pem} = jwk |> JOSE.JWK.to_public() |> JOSE.JWK.to_pem() 12 + {private_pem, public_pem} 13 + end 14 + 15 + def public_key_from_private(private_key_pem) do 16 + {_, public_pem} = 17 + private_key_pem 18 + |> JOSE.JWK.from_pem() 19 + |> JOSE.JWK.to_public() 20 + |> JOSE.JWK.to_pem() 21 + 22 + public_pem 23 + end 24 + 25 + def ensure_keypair(%{private_key_pem: pem} = storage) when is_binary(pem) do 26 + {public_key_from_private(pem), storage} 27 + end 28 + 29 + def ensure_keypair(%{} = storage) do 30 + {private_pem, public_pem} = generate_keypair() 31 + storage = Map.put(storage, :private_key_pem, private_pem) 32 + Garden.Storage.write(storage) 33 + Logger.info(msg: "Generated new RSA keypair for garden authentication") 34 + {public_pem, storage} 35 + end 36 + 37 + def build_assertion(client_id, private_key_pem) do 38 + now = System.system_time(:second) 39 + 40 + claims = %{ 41 + "iss" => client_id, 42 + "sub" => client_id, 43 + "aud" => "sower", 44 + "iat" => now, 45 + "exp" => now + @assertion_ttl_seconds 46 + } 47 + 48 + jwk = JOSE.JWK.from_pem(private_key_pem) 49 + jws = %{"alg" => @jws_alg} 50 + {_, token} = JOSE.JWS.compact(JOSE.JWT.sign(jwk, jws, claims)) 51 + token 52 + end 53 + 54 + def request_token(client_id, private_key_pem) do 55 + endpoint = Garden.Config.get().endpoint 56 + assertion = build_assertion(client_id, private_key_pem) 57 + 58 + case Req.post("#{endpoint}/api/oauth/token", 59 + json: %{ 60 + grant_type: "client_credentials", 61 + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 62 + client_assertion: assertion, 63 + scope: "garden:agent" 64 + } 65 + ) do 66 + {:ok, %{status: 200, body: body}} -> 67 + {:ok, 68 + %{ 69 + access_token: body["access_token"], 70 + expires_in: body["expires_in"], 71 + token_type: body["token_type"] 72 + }} 73 + 74 + {:ok, %{status: status, body: body}} -> 75 + Logger.warning( 76 + msg: "Token request failed", 77 + status: to_string(status), 78 + error: inspect(body) 79 + ) 80 + 81 + {:error, :token_request_failed} 82 + 83 + {:error, error} -> 84 + Logger.warning(msg: "Token request error", error: inspect(error)) 85 + {:error, :token_request_failed} 86 + end 87 + end 88 + end
+85 -66
apps/garden/lib/garden/socket.ex
··· 129 129 defp resolve_connect_token do 130 130 storage = Storage.read() 131 131 132 - case storage.oauth_credentials do 133 - %{access_token: token, token_issued_at: issued_at, expires_in: expires_in} 134 - when is_binary(token) -> 132 + case storage do 133 + %{ 134 + oauth_credentials: %{ 135 + access_token: token, 136 + token_issued_at: issued_at, 137 + expires_in: expires_in 138 + }, 139 + private_key_pem: private_key_pem 140 + } 141 + when is_binary(token) and is_binary(private_key_pem) -> 135 142 if token_expired?(issued_at, expires_in) do 136 - try_http_refresh(storage) || registration_token() 143 + try_reauthenticate(storage) || registration_token() 137 144 else 138 145 Logger.debug(msg: "Using stored Boruta access token") 139 146 "boruta:#{token}" 140 147 end 141 148 149 + %{ 150 + oauth_credentials: %{client_id: client_id}, 151 + private_key_pem: private_key_pem 152 + } 153 + when is_binary(client_id) and is_binary(private_key_pem) -> 154 + try_reauthenticate(storage) || registration_token() 155 + 142 156 _ -> 143 157 registration_token() 144 158 end ··· 151 165 152 166 defp token_expired?(_, _), do: true 153 167 154 - defp try_http_refresh(%{oauth_credentials: %{refresh_token: refresh_token}} = storage) 155 - when is_binary(refresh_token) do 156 - endpoint = Application.fetch_env!(:garden, :config).endpoint 168 + defp try_reauthenticate(%{ 169 + oauth_credentials: %{client_id: client_id}, 170 + private_key_pem: private_key_pem 171 + }) 172 + when is_binary(client_id) and is_binary(private_key_pem) do 173 + case Garden.Auth.request_token(client_id, private_key_pem) do 174 + {:ok, token_response} -> 175 + storage = Storage.read() 157 176 158 - case Req.post("#{endpoint}/api/oauth/token", 159 - json: %{grant_type: "refresh_token", refresh_token: refresh_token} 160 - ) do 161 - {:ok, %{status: 200, body: body}} -> 162 177 updated_creds = 163 - storage.oauth_credentials 178 + (storage.oauth_credentials || %{}) 164 179 |> Map.merge(%{ 165 - access_token: body["access_token"], 166 - refresh_token: body["refresh_token"], 167 - expires_in: body["expires_in"], 180 + access_token: token_response.access_token, 181 + expires_in: token_response.expires_in, 168 182 token_issued_at: System.system_time(:second) 169 183 }) 170 184 171 185 storage |> Map.put(:oauth_credentials, updated_creds) |> Storage.write() 172 - Logger.info(msg: "Refreshed OAuth token via HTTP") 173 - "boruta:#{body["access_token"]}" 186 + Logger.info(msg: "Reauthenticated via JWT assertion") 187 + "boruta:#{token_response.access_token}" 174 188 175 - {:ok, %{status: status}} -> 176 - Logger.warning(msg: "HTTP token refresh failed", status: status) 177 - nil 178 - 179 - {:error, error} -> 180 - Logger.warning(msg: "HTTP token refresh error", error: inspect(error)) 189 + {:error, _} -> 181 190 nil 182 191 end 183 192 end 184 193 185 - defp try_http_refresh(_), do: nil 194 + defp try_reauthenticate(_), do: nil 186 195 187 196 defp registration_token do 188 197 Logger.debug(msg: "Using registration token") 189 198 Base.encode64(Application.fetch_env!(:garden, :config).access_token) 190 199 end 191 200 192 - defp schedule_token_refresh(socket, %{expires_in: expires_in}) when is_integer(expires_in) do 193 - # Refresh at 80% of TTL 194 - refresh_ms = trunc(expires_in * 0.8 * 1000) 195 - timer_ref = Process.send_after(self(), :refresh_token, refresh_ms) 201 + defp schedule_reauthentication(socket, %{expires_in: expires_in}) 202 + when is_integer(expires_in) do 203 + # Reauthenticate at 80% of TTL 204 + reauth_ms = trunc(expires_in * 0.8 * 1000) 205 + timer_ref = Process.send_after(self(), :reauthenticate, reauth_ms) 196 206 197 - Logger.debug(msg: "Scheduled token refresh", refresh_in_seconds: div(refresh_ms, 1000)) 198 - assign(socket, :refresh_timer, timer_ref) 207 + Logger.debug(msg: "Scheduled reauthentication", reauth_in_seconds: div(reauth_ms, 1000)) 208 + assign(socket, :reauth_timer, timer_ref) 199 209 end 200 210 201 - defp schedule_token_refresh(socket, _), do: socket 211 + defp schedule_reauthentication(socket, _), do: socket 202 212 203 - defp maybe_schedule_existing_refresh(socket, %{expires_in: _} = creds) do 204 - schedule_token_refresh(socket, creds) 213 + defp maybe_schedule_existing_reauth(socket, %{expires_in: _} = creds) do 214 + schedule_reauthentication(socket, creds) 205 215 end 206 216 207 - defp maybe_schedule_existing_refresh(socket, _), do: socket 217 + defp maybe_schedule_existing_reauth(socket, _), do: socket 208 218 209 219 @impl Slipstream 210 220 def handle_join(@lobby_topic, %{"conn_sid" => conn_sid}, socket) do 211 221 Logger.info(msg: "Joined channel topic", topic: @lobby_topic, conn_sid: conn_sid) 222 + 223 + storage = Garden.Storage.read() 224 + {public_key_pem, storage} = Garden.Auth.ensure_keypair(storage) 212 225 213 226 {:ok, hello_ref} = 214 227 push_message( 215 228 socket, 216 229 SowerClient.GardenHello.cast!(%{ 217 230 name: Garden.Config.get().name, 218 - local_sid: Garden.Storage.read().local_sid, 219 - garden_sid: Garden.Storage.read().garden_sid 231 + local_sid: storage.local_sid, 232 + garden_sid: storage.garden_sid, 233 + public_key: public_key_pem 220 234 }) 221 235 ) 222 236 ··· 327 341 328 342 {storage, socket} = 329 343 case reply do 330 - %{"oauth_credentials" => creds} when is_map(creds) -> 331 - oauth_creds = %{ 332 - client_id: creds["client_id"], 333 - client_secret: creds["client_secret"], 334 - access_token: creds["access_token"], 335 - refresh_token: creds["refresh_token"], 336 - expires_in: creds["expires_in"], 337 - token_type: creds["token_type"] || "bearer", 338 - token_issued_at: System.system_time(:second) 339 - } 344 + %{"oauth_credentials" => %{"client_id" => client_id}} -> 345 + Logger.info(msg: "Received client registration", client_id: client_id) 340 346 341 - Logger.info(msg: "Received OAuth credentials from registration") 347 + oauth_creds = %{client_id: client_id} 348 + 349 + storage = Map.put(storage, :oauth_credentials, oauth_creds) 350 + 351 + case Garden.Auth.request_token(client_id, storage.private_key_pem) do 352 + {:ok, token_response} -> 353 + updated_creds = 354 + Map.merge(oauth_creds, %{ 355 + access_token: token_response.access_token, 356 + expires_in: token_response.expires_in, 357 + token_issued_at: System.system_time(:second) 358 + }) 359 + 360 + {Map.put(storage, :oauth_credentials, updated_creds), 361 + schedule_reauthentication(socket, updated_creds)} 342 362 343 - {Map.put(storage, :oauth_credentials, oauth_creds), 344 - schedule_token_refresh(socket, oauth_creds)} 363 + {:error, _} -> 364 + Logger.warning(msg: "Initial token request failed after registration") 365 + {storage, socket} 366 + end 345 367 346 368 _ -> 347 - {storage, maybe_schedule_existing_refresh(socket, storage.oauth_credentials)} 369 + {storage, maybe_schedule_existing_reauth(socket, storage.oauth_credentials)} 348 370 end 349 371 350 372 if persist? or Map.has_key?(reply, "oauth_credentials") do ··· 436 458 end 437 459 end 438 460 439 - def handle_info(:refresh_token, socket) do 461 + def handle_info(:reauthenticate, socket) do 440 462 storage = Storage.read() 441 463 442 - case storage.oauth_credentials do 443 - %{refresh_token: rt} when is_binary(rt) -> 444 - topic = private_channel(socket) 445 - {:ok, ref} = push(socket, topic, "token:refresh", %{refresh_token: rt}) 446 - 447 - case await_reply(ref) do 448 - {:ok, new_tokens} -> 464 + case storage do 465 + %{oauth_credentials: %{client_id: client_id}, private_key_pem: private_key_pem} 466 + when is_binary(client_id) and is_binary(private_key_pem) -> 467 + case Garden.Auth.request_token(client_id, private_key_pem) do 468 + {:ok, token_response} -> 449 469 updated_creds = 450 470 storage.oauth_credentials 451 471 |> Map.merge(%{ 452 - access_token: new_tokens["access_token"], 453 - refresh_token: new_tokens["refresh_token"], 454 - expires_in: new_tokens["expires_in"], 472 + access_token: token_response.access_token, 473 + expires_in: token_response.expires_in, 455 474 token_issued_at: System.system_time(:second) 456 475 }) 457 476 458 477 storage |> Map.put(:oauth_credentials, updated_creds) |> Storage.write() 459 - Logger.info(msg: "Token refreshed via channel") 460 - {:noreply, schedule_token_refresh(socket, updated_creds)} 478 + Logger.info(msg: "Reauthenticated via JWT assertion") 479 + {:noreply, schedule_reauthentication(socket, updated_creds)} 461 480 462 - {:error, error} -> 463 - Logger.warning(msg: "Channel token refresh failed, retrying in 60s", error: error) 464 - Process.send_after(self(), :refresh_token, 60_000) 481 + {:error, _} -> 482 + Logger.warning(msg: "Reauthentication failed, retrying in 60s") 483 + Process.send_after(self(), :reauthenticate, 60_000) 465 484 {:noreply, socket} 466 485 end 467 486
+1
apps/garden/lib/garden/storage.ex
··· 11 11 field :garden_sid, String.t() 12 12 field :subscriptions, list(SowerClient.Orchestration.Subscription) 13 13 field :oauth_credentials, map() 14 + field :private_key_pem, String.t() 14 15 end 15 16 16 17 @cooldown_seconds 60
+1
apps/garden/mix.exs
··· 30 30 {:quantum, "~> 3.0"}, 31 31 {:cuid2_ex, "~> 0.2"}, 32 32 {:jason, "~> 1.0"}, 33 + {:jose, "~> 1.11"}, 33 34 {:nix, in_umbrella: true}, 34 35 {:slipstream, "~> 1.0"}, 35 36 # load typedstruct before typed_struct_ecto_changeset
+9 -16
apps/sower/lib/sower/garden_auth.ex
··· 9 9 alias Boruta.Oauth.TokenResponse 10 10 11 11 @access_token_ttl 900 12 - @refresh_token_ttl 2_592_000 13 12 14 13 typedstruct module: Context do 15 14 field :org_id, String.t(), enforce: true ··· 17 16 field :scope, String.t(), enforce: true 18 17 end 19 18 20 - def create_client(garden_name) do 19 + def create_client(garden_name, public_key_pem) do 21 20 Boruta.Ecto.Admin.create_client(%{ 22 21 name: "garden:#{garden_name}", 23 22 redirect_uris: ["https://localhost"], 24 - supported_grant_types: ["client_credentials", "refresh_token"], 23 + supported_grant_types: ["client_credentials"], 25 24 access_token_ttl: @access_token_ttl, 26 - refresh_token_ttl: @refresh_token_ttl, 27 25 authorize_scope: true, 28 26 authorized_scopes: [%{name: "garden:agent"}], 29 - confidential: true 27 + confidential: true, 28 + jwt_public_key: public_key_pem, 29 + token_endpoint_auth_methods: ["private_key_jwt"], 30 + token_endpoint_jwt_auth_alg: "RS512" 30 31 }) 31 32 end 32 33 ··· 35 36 Boruta.Ecto.Admin.delete_client(client) 36 37 end 37 38 38 - def issue(client_id, client_secret) do 39 + def issue(client_assertion) do 39 40 token_request(%{ 40 41 "grant_type" => "client_credentials", 41 - "client_id" => client_id, 42 - "client_secret" => client_secret, 42 + "client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 43 + "client_assertion" => client_assertion, 43 44 "scope" => "garden:agent" 44 - }) 45 - end 46 - 47 - def refresh(refresh_token_value) do 48 - token_request(%{ 49 - "grant_type" => "refresh_token", 50 - "refresh_token" => refresh_token_value 51 45 }) 52 46 end 53 47 ··· 60 54 {:ok, 61 55 %{ 62 56 access_token: response.access_token, 63 - refresh_token: response.refresh_token, 64 57 expires_in: response.expires_in, 65 58 token_type: response.token_type 66 59 }}
+18 -12
apps/sower/lib/sower/orchestration/garden.ex
··· 87 87 end 88 88 89 89 def get_garden( 90 - %SowerClient.GardenHello{garden_sid: nil, name: name, local_sid: local_sid}, 90 + %SowerClient.GardenHello{ 91 + garden_sid: nil, 92 + name: name, 93 + local_sid: local_sid, 94 + public_key: public_key 95 + }, 91 96 socket 92 97 ) do 93 98 case get_garden_local_sid(local_sid) do ··· 99 104 ) 100 105 101 106 if socket.assigns.access_token |> can() |> create?(__MODULE__) do 102 - register_new_garden(%{name: name, local_sid: local_sid}) 107 + register_new_garden(%{name: name, local_sid: local_sid, public_key: public_key}) 103 108 else 104 109 {:error, :unauthorized} 105 110 end ··· 117 122 end 118 123 119 124 def get_garden( 120 - %SowerClient.GardenHello{garden_sid: garden_sid, name: name, local_sid: local_sid}, 125 + %SowerClient.GardenHello{ 126 + garden_sid: garden_sid, 127 + name: name, 128 + local_sid: local_sid, 129 + public_key: public_key 130 + }, 121 131 socket 122 132 ) do 123 133 case get_garden_sid(garden_sid) do ··· 130 140 ) 131 141 132 142 if socket.assigns.access_token |> can() |> create?(__MODULE__) do 133 - register_new_garden(%{name: name, local_sid: local_sid}) 143 + register_new_garden(%{name: name, local_sid: local_sid, public_key: public_key}) 134 144 else 135 145 {:error, :unauthorized} 136 146 end ··· 204 214 205 215 def get_garden_local_sid!(local_sid), do: Repo.get_by!(__MODULE__, local_sid: local_sid) 206 216 207 - def register_new_garden(attrs) do 217 + def register_new_garden(%{public_key: public_key} = attrs) do 208 218 with {:ok, garden} <- create_garden(attrs), 209 - {:ok, client} <- Sower.GardenAuth.create_client(garden.sid), 210 - {:ok, garden} <- update_garden(garden, %{boruta_client_id: client.id}), 211 - {:ok, token_response} <- Sower.GardenAuth.issue(client.id, client.secret) do 212 - oauth_credentials = 213 - Map.merge(token_response, %{client_id: client.id, client_secret: client.secret}) 214 - 215 - {:ok, garden, oauth_credentials} 219 + {:ok, client} <- Sower.GardenAuth.create_client(garden.sid, public_key), 220 + {:ok, garden} <- update_garden(garden, %{boruta_client_id: client.id}) do 221 + {:ok, garden, %{client_id: client.id}} 216 222 else 217 223 {:error, reason} -> 218 224 Logger.error(msg: "Failed to register new garden with OAuth", error: inspect(reason))
+9 -5
apps/sower/lib/sower_web/controllers/oauth/token_controller.ex
··· 3 3 4 4 require Logger 5 5 6 - def create(conn, %{"grant_type" => "refresh_token", "refresh_token" => refresh_token}) do 7 - case Sower.GardenAuth.refresh(refresh_token) do 6 + def create(conn, %{ 7 + "grant_type" => "client_credentials", 8 + "client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 9 + "client_assertion" => client_assertion 10 + }) do 11 + case Sower.GardenAuth.issue(client_assertion) do 8 12 {:ok, token_response} -> 9 13 json(conn, token_response) 10 14 ··· 12 16 conn 13 17 |> put_status(:bad_request) 14 18 |> json(%{ 15 - error: "invalid_grant", 16 - error_description: "Refresh token is invalid or expired" 19 + error: "invalid_client", 20 + error_description: "Client assertion is invalid" 17 21 }) 18 22 end 19 23 end ··· 23 27 |> put_status(:bad_request) 24 28 |> json(%{ 25 29 error: "unsupported_grant_type", 26 - error_description: "Only refresh_token grant is supported" 30 + error_description: "Only client_credentials grant with JWT client assertion is supported" 27 31 }) 28 32 end 29 33 end
-10
apps/sower/lib/sower_web/garden_channel.ex
··· 96 96 {:reply, :ok, socket} 97 97 end 98 98 99 - def handle_in("token:refresh", %{"refresh_token" => refresh_token}, socket) do 100 - case Sower.GardenAuth.refresh(refresh_token) do 101 - {:ok, token_response} -> 102 - {:reply, {:ok, token_response}, socket} 103 - 104 - {:error, _error} -> 105 - {:reply, {:error, %{reason: "token_refresh_failed"}}, socket} 106 - end 107 - end 108 - 109 99 # Accept both "garden:hello" and "agent:hello" 110 100 def handle_in("garden:hello", payload, socket), do: do_handle_hello(payload, socket) 111 101 def handle_in("agent:hello", payload, socket), do: do_handle_hello(payload, socket)
+14 -6
apps/sower/test/sower_web/channels/garden_channel_handle_in_test.exs
··· 30 30 31 31 local_sid = SowerClient.Sid.generate("lc_grdn") 32 32 33 + {_private_pem, public_pem} = 34 + JOSE.JWK.generate_key({:rsa, 2048}) 35 + |> then(fn jwk -> 36 + {_, priv} = JOSE.JWK.to_pem(jwk) 37 + {_, pub} = jwk |> JOSE.JWK.to_public() |> JOSE.JWK.to_pem() 38 + {priv, pub} 39 + end) 40 + 33 41 ref = 34 42 push(socket, "garden:hello", %{ 35 43 "local_sid" => local_sid, 36 - "name" => "new-garden" 44 + "name" => "new-garden", 45 + "public_key" => public_pem 37 46 }) 38 47 39 - # Registration now includes Boruta client creation (RSA keygen) + token issuance 48 + # Registration includes Boruta client creation with public key 40 49 assert_reply ref, :ok, reply, 5_000 41 50 assert is_binary(reply.sid) 42 51 assert is_map(reply.oauth_credentials) 43 - assert is_binary(reply.oauth_credentials.access_token) 44 - assert is_binary(reply.oauth_credentials.refresh_token) 45 52 assert is_binary(reply.oauth_credentials.client_id) 46 - assert is_binary(reply.oauth_credentials.client_secret) 47 - assert is_integer(reply.oauth_credentials.expires_in) 53 + refute Map.has_key?(reply.oauth_credentials, :client_secret) 54 + refute Map.has_key?(reply.oauth_credentials, :refresh_token) 55 + refute Map.has_key?(reply.oauth_credentials, :access_token) 48 56 end 49 57 50 58 test "accepts legacy agent:hello event" do
+2 -22
apps/sower_client/lib/sower_client/auth/oauth_credentials.ex
··· 5 5 6 6 OpenApiSpex.schema(%{ 7 7 title: "OAuthCredentials", 8 - description: "OAuth credentials issued to a garden after registration", 8 + description: "OAuth client registration returned to a garden after registration", 9 9 type: :object, 10 10 properties: %{ 11 11 client_id: %Schema{ 12 12 type: :string, 13 13 description: "Boruta OAuth client ID" 14 - }, 15 - client_secret: %Schema{ 16 - type: :string, 17 - description: "Boruta OAuth client secret" 18 - }, 19 - access_token: %Schema{ 20 - type: :string, 21 - description: "OAuth access token" 22 - }, 23 - refresh_token: %Schema{ 24 - type: :string, 25 - description: "OAuth refresh token for obtaining new access tokens" 26 - }, 27 - expires_in: %Schema{ 28 - type: :integer, 29 - description: "Access token TTL in seconds" 30 - }, 31 - token_type: %Schema{ 32 - type: :string, 33 - description: "Token type, typically bearer" 34 14 } 35 15 }, 36 - required: [:client_id, :client_secret, :access_token, :refresh_token, :expires_in] 16 + required: [:client_id] 37 17 }) 38 18 end
+5
apps/sower_client/lib/sower_client/garden_hello.ex
··· 19 19 name: %Schema{ 20 20 type: :string, 21 21 description: "Name of garden" 22 + }, 23 + public_key: %Schema{ 24 + type: :string, 25 + description: "PEM-encoded RSA public key for private_key_jwt authentication", 26 + nullable: true 22 27 } 23 28 }, 24 29 required: [:local_sid, :name]
+1 -20
apps/sower_client/test/fixtures/contract_baseline.json
··· 24 24 }, 25 25 "OAuthCredentials": { 26 26 "properties": { 27 - "access_token": { 28 - "type": "string" 29 - }, 30 27 "client_id": { 31 28 "type": "string" 32 - }, 33 - "client_secret": { 34 - "type": "string" 35 - }, 36 - "expires_in": { 37 - "type": "integer" 38 - }, 39 - "refresh_token": { 40 - "type": "string" 41 - }, 42 - "token_type": { 43 - "type": "string" 44 29 } 45 30 }, 46 31 "required": [ 47 - "access_token", 48 - "client_id", 49 - "client_secret", 50 - "expires_in", 51 - "refresh_token" 32 + "client_id" 52 33 ] 53 34 }, 54 35 "PresignedUploadReply": {