diff --git a/lib/kodo/integrations.ex b/lib/kodo/integrations.ex index f52226c..8c6ece6 100644 --- a/lib/kodo/integrations.ex +++ b/lib/kodo/integrations.ex @@ -27,6 +27,7 @@ defmodule Kodo.Integrations do @device_authorization_token_max_bytes 8_192 @device_authorization_cleanup_age_seconds 24 * 60 * 60 @device_authorization_claim_lease_ms 30_000 + @refresh_claim_lease_ms 30_000 @device_authorization_terminal_errors ~w( provider_unavailable provider_rejected @@ -393,6 +394,114 @@ defmodule Kodo.Integrations do ) end + @doc "Claims one generation of an OAuth integration for a bounded refresh operation." + def claim_refresh(%Scope{user: user}, id, generation, claim_owner_id) + when is_integer(generation) and generation >= 0 do + with {:ok, id} <- cast_uuid(id), + {:ok, claim_owner_id} <- cast_uuid(claim_owner_id) do + lease_expires_at = + DateTime.add(database_now(), @refresh_claim_lease_ms, :millisecond) + + query = + from integration in Integration, + where: + integration.id == ^id and integration.user_id == ^user.id and + integration.provider == "openai_codex" and + integration.authentication_type == "oauth" and + integration.connection_status == "connected" and + integration.credential_generation == ^generation and + (is_nil(integration.refresh_claim_owner_id) or + integration.refresh_lease_expires_at <= fragment("clock_timestamp()")) + + updates = [ + set: [ + refresh_claim_owner_id: claim_owner_id, + refresh_claim_generation: generation, + refresh_lease_expires_at: lease_expires_at, + updated_at: now() + ], + inc: [refresh_claim_epoch: 1] + ] + + case Repo.update_all(query, updates) do + {1, nil} -> {:ok, Repo.get_by!(Integration, id: id, user_id: user.id)} + {0, nil} -> refresh_claim_conflict(user.id, id, generation) + end + end + end + + def claim_refresh(%Scope{}, _id, _generation, _claim_owner_id), + do: {:error, :stale_credential_generation} + + @doc "Releases only the exact refresh claim represented by the supplied snapshot." + def release_refresh_claim(%Scope{user: user}, %Integration{} = claim) do + query = exact_refresh_claim_query(user.id, claim) + + case Repo.update_all(query, + set: [ + refresh_claim_owner_id: nil, + refresh_claim_generation: nil, + refresh_lease_expires_at: nil, + updated_at: now() + ] + ) do + {1, nil} -> :ok + {0, nil} -> {:error, :stale_refresh_claim} + end + end + + def require_refresh_reauthorization(%Scope{user: user}, %Integration{} = claim, error_code) + when error_code in ~w(refresh_invalid_grant refresh_account_identity_mismatch) do + Repo.transaction(fn -> + changes = [ + connection_status: "reauthorization_required", + active: false, + validation_status: "unverified", + validated_at: nil, + validation_error_code: error_code, + refresh_claim_owner_id: nil, + refresh_claim_generation: nil, + refresh_lease_expires_at: nil, + updated_at: now() + ] + + case Repo.update_all(exact_refresh_claim_query(user.id, claim), set: changes) do + {1, nil} -> + integration = Repo.get_by!(Integration, id: claim.id, user_id: user.id) + audit!(user.id, integration, "refresh_reauthorization_required") + integration + + {0, nil} -> + Repo.rollback(:stale_refresh_claim) + end + end) + |> notify_integration_change() + end + + def require_refresh_reauthorization(%Scope{}, %Integration{}, _error_code), + do: {:error, :unsafe_refresh_error} + + def fail_refresh(%Scope{user: user}, %Integration{} = claim) do + Repo.transaction(fn -> + case Repo.update_all(exact_refresh_claim_query(user.id, claim), + set: [ + refresh_claim_owner_id: nil, + refresh_claim_generation: nil, + refresh_lease_expires_at: nil, + updated_at: now() + ] + ) do + {1, nil} -> + integration = Repo.get_by!(Integration, id: claim.id, user_id: user.id) + audit!(user.id, integration, "refresh_failed") + integration + + {0, nil} -> + Repo.rollback(:stale_refresh_claim) + end + end) + end + def validation_succeeded(%Scope{} = scope, id, generation) do update_fenced(scope, id, generation, ["connected"], "validation_succeeded", %{ validation_status: "valid", @@ -455,7 +564,10 @@ defmodule Kodo.Integrations do expires_at: nil, validated_at: nil, refreshed_at: nil, - validation_error_code: nil + validation_error_code: nil, + refresh_claim_owner_id: nil, + refresh_claim_generation: nil, + refresh_lease_expires_at: nil } ) |> notify_integration_change() @@ -914,7 +1026,13 @@ defmodule Kodo.Integrations do end defp device_authorization_integration_changes(%Integration{}, generation, timestamp) do - [credential_generation: generation, updated_at: timestamp] + [ + credential_generation: generation, + refresh_claim_owner_id: nil, + refresh_claim_generation: nil, + refresh_lease_expires_at: nil, + updated_at: timestamp + ] end defp supersede_active_device_authorization(integration_id, timestamp) do @@ -1086,7 +1204,10 @@ defmodule Kodo.Integrations do expires_at: opts[:expires_at], validated_at: nil, refreshed_at: opts[:refreshed_at], - validation_error_code: nil + validation_error_code: nil, + refresh_claim_owner_id: nil, + refresh_claim_generation: nil, + refresh_lease_expires_at: nil }) update_fenced(scope, id, generation, allowed_connections, audit_event_type, changes) @@ -1106,6 +1227,29 @@ defmodule Kodo.Integrations do defp require_connected(%Integration{connection_status: "connected"}), do: :ok defp require_connected(%Integration{}), do: {:error, :integration_not_connected} + defp refresh_claim_conflict(user_id, id, generation) do + case Repo.get_by(Integration, id: id, user_id: user_id) do + %Integration{credential_generation: ^generation, refresh_claim_owner_id: owner} + when not is_nil(owner) -> + {:error, :refresh_in_progress} + + %Integration{} -> + {:error, :stale_credential_generation} + + nil -> + {:error, :integration_not_found} + end + end + + defp exact_refresh_claim_query(user_id, %Integration{} = claim) do + from integration in Integration, + where: + integration.id == ^claim.id and integration.user_id == ^user_id and + integration.credential_generation == ^claim.refresh_claim_generation and + integration.refresh_claim_owner_id == ^claim.refresh_claim_owner_id and + integration.refresh_claim_epoch == ^claim.refresh_claim_epoch + end + defp lock_provider_integrations(user_id, integration_id) do provider = Integration diff --git a/lib/kodo/integrations/audit_event.ex b/lib/kodo/integrations/audit_event.ex index e78ba87..b1e3fdd 100644 --- a/lib/kodo/integrations/audit_event.ex +++ b/lib/kodo/integrations/audit_event.ex @@ -19,6 +19,8 @@ defmodule Kodo.Integrations.AuditEvent do oauth_succeeded refresh_succeeded refresh_invalid_grant + refresh_reauthorization_required + refresh_failed ) @primary_key {:id, :binary_id, autogenerate: true} diff --git a/lib/kodo/integrations/device_authorization_req_adapter.ex b/lib/kodo/integrations/credential_req_adapter.ex similarity index 93% rename from lib/kodo/integrations/device_authorization_req_adapter.ex rename to lib/kodo/integrations/credential_req_adapter.ex index 5756a83..406416a 100644 --- a/lib/kodo/integrations/device_authorization_req_adapter.ex +++ b/lib/kodo/integrations/credential_req_adapter.ex @@ -1,6 +1,6 @@ -defmodule Kodo.Integrations.DeviceAuthorizationReqAdapter do +defmodule Kodo.Integrations.CredentialReqAdapter do @moduledoc """ - Req adapter for credential-bearing device authorization requests. + Req adapter for credential-bearing OAuth requests. It deliberately uses Mint directly: Finch telemetry includes request and response values, which are secrets throughout this protocol. @@ -10,7 +10,7 @@ defmodule Kodo.Integrations.DeviceAuthorizationReqAdapter do @max_response_bytes 64 * 1024 def run(%Req.Request{} = request) do - timeout = request.options[:device_authorization_timeout] || @default_timeout + timeout = request.options[:credential_request_timeout] || @default_timeout started_at = System.monotonic_time(:millisecond) task = Task.async(fn -> safe_perform(request, started_at, timeout) end) @@ -30,7 +30,7 @@ defmodule Kodo.Integrations.DeviceAuthorizationReqAdapter do end defp perform( - %Req.Request{options: %{device_authorization_transport: transport}} = request, + %Req.Request{options: %{credential_request_transport: transport}} = request, started_at, timeout ) diff --git a/lib/kodo/integrations/integration.ex b/lib/kodo/integrations/integration.ex index 2b0f0bc..2e80063 100644 --- a/lib/kodo/integrations/integration.ex +++ b/lib/kodo/integrations/integration.ex @@ -34,6 +34,10 @@ defmodule Kodo.Integrations.Integration do field :validated_at, :utc_datetime_usec field :refreshed_at, :utc_datetime_usec field :validation_error_code, :string + field :refresh_claim_owner_id, Ecto.UUID + field :refresh_claim_epoch, :integer, default: 0 + field :refresh_claim_generation, :integer + field :refresh_lease_expires_at, :utc_datetime_usec belongs_to :user, Kodo.Accounts.User, type: :id @@ -72,6 +76,12 @@ defmodule Kodo.Integrations.Integration do |> check_constraint(:credential_generation, name: :provider_integrations_credential_generation_valid ) + |> check_constraint(:refresh_claim_epoch, + name: :provider_integrations_refresh_claim_epoch_valid + ) + |> check_constraint(:refresh_claim_owner_id, + name: :provider_integrations_refresh_claim_valid + ) |> check_constraint(:connection_status, name: :provider_integrations_state_valid) |> check_constraint(:active, name: :provider_integrations_active_connected) |> unique_constraint(:active, name: :provider_integrations_one_active_index) diff --git a/lib/kodo/integrations/oauth_access_validation.ex b/lib/kodo/integrations/oauth_access_validation.ex new file mode 100644 index 0000000..3091d52 --- /dev/null +++ b/lib/kodo/integrations/oauth_access_validation.ex @@ -0,0 +1,67 @@ +defmodule Kodo.Integrations.OAuthAccessValidation do + @moduledoc "Validates ChatGPT subscription access through the bounded refresh contract." + + alias Kodo.Accounts.Scope + alias Kodo.Integrations + alias Kodo.Integrations.Integration + alias Kodo.Integrations.OAuthRefresh + + def start(%Scope{} = scope, %{id: id, credential_generation: generation}) do + Task.Supervisor.async_nolink(Kodo.ControlPlaneTaskSupervisor, fn -> + validate(scope, id, generation) + end) + end + + @doc false + def validate(%Scope{} = scope, id, generation, opts \\ []) do + with {:ok, integration} <- Integrations.get_integration(scope, id), + :ok <- admit(integration, generation) do + case OAuthRefresh.ensure_fresh(scope, integration, Keyword.put(opts, :force, true)) do + {:ok, refreshed} -> + scope + |> Integrations.validation_succeeded(refreshed.id, refreshed.credential_generation) + |> broadcast_result(refreshed) + + {:error, :provider_unavailable} = error -> + result = + Integrations.validation_unavailable( + scope, + integration.id, + integration.credential_generation, + "provider_unavailable" + ) + + _result = broadcast_result(result, integration) + error + + {:error, _reason} = error -> + error + end + end + end + + defp admit( + %Integration{ + provider: "openai_codex", + authentication_type: "oauth", + connection_status: "connected", + credential_generation: generation + }, + generation + ), + do: :ok + + defp admit(%Integration{}, _generation), do: {:error, :stale_credential_generation} + + defp broadcast_result({:ok, validated} = result, integration) do + Phoenix.PubSub.broadcast( + Kodo.PubSub, + "integration:#{integration.user_id}", + {:integration_validation_finished, validated.id, validated.credential_generation} + ) + + result + end + + defp broadcast_result(error, _integration), do: error +end diff --git a/lib/kodo/integrations/oauth_refresh.ex b/lib/kodo/integrations/oauth_refresh.ex new file mode 100644 index 0000000..76705d4 --- /dev/null +++ b/lib/kodo/integrations/oauth_refresh.ex @@ -0,0 +1,183 @@ +defmodule Kodo.Integrations.OAuthRefresh do + @moduledoc "Runs generation-fenced, just-in-time OAuth refreshes as finite tasks." + + alias Kodo.Accounts.Scope + alias Kodo.Cluster.InstanceManager + alias Kodo.Integrations + alias Kodo.Integrations.CredentialEncryption + alias Kodo.Integrations.Integration + alias Kodo.Integrations.RefreshTokens + + @refresh_buffer_seconds 5 * 60 + @wait_timeout_ms 12_000 + @wait_interval_ms 250 + + def ensure_fresh(%Scope{} = scope, %Integration{} = integration, opts \\ []) do + now = Keyword.get(opts, :now, DateTime.utc_now()) + buffer = Keyword.get(opts, :refresh_buffer_seconds, @refresh_buffer_seconds) + + if Keyword.get(opts, :force, false) or refresh_due?(integration, now, buffer) do + refresh(scope, integration, opts) + else + {:ok, integration} + end + end + + defp refresh(scope, integration, opts) do + owner_id = Keyword.get_lazy(opts, :claim_owner_id, &InstanceManager.current_boot_id/0) + + deadline = + System.monotonic_time(:millisecond) + Keyword.get(opts, :wait_timeout, @wait_timeout_ms) + + if is_binary(owner_id) do + claim_or_wait(scope, integration, owner_id, deadline, opts) + else + {:error, :provider_unavailable} + end + end + + defp claim_or_wait(scope, integration, owner_id, deadline, opts) do + case Integrations.claim_refresh( + scope, + integration.id, + integration.credential_generation, + owner_id + ) do + {:ok, claim} -> + run_claim(scope, claim, opts) + + {:error, :refresh_in_progress} -> + wait_for_claim(scope, integration, owner_id, deadline, opts) + + {:error, :stale_credential_generation} -> + current_result(scope, integration) + + {:error, _reason} -> + {:error, :provider_unavailable} + end + end + + defp wait_for_claim(scope, integration, owner_id, deadline, opts) do + if System.monotonic_time(:millisecond) < deadline do + receive do + after + @wait_interval_ms -> claim_or_wait(scope, integration, owner_id, deadline, opts) + end + else + current_result(scope, integration) + end + end + + defp run_claim(scope, claim, opts) do + supervisor = Keyword.get(opts, :supervisor, Kodo.ControlPlaneTaskSupervisor) + + task = + Task.Supervisor.async_nolink(supervisor, fn -> + perform(scope, claim, opts) + end) + + case Task.yield(task, Keyword.get(opts, :wait_timeout, @wait_timeout_ms)) do + {:ok, result} -> + result + + {:exit, _reason} -> + {:error, :provider_unavailable} + + nil -> + _result = Task.shutdown(task, :brutal_kill) + {:error, :provider_unavailable} + end + end + + defp perform(scope, claim, opts) do + client = Keyword.get(opts, :client, configured_client()) + client_options = Keyword.get(opts, :client_options, []) + + with {:ok, current} <- CredentialEncryption.decrypt(claim), + {:ok, refresh_token} <- fetch_refresh_token(current), + {:ok, response} <- safe_refresh(client, refresh_token, client_options), + {:ok, normalized} <- RefreshTokens.normalize(response, current), + {:ok, integration} <- + Integrations.refresh_succeeded( + scope, + claim.id, + claim.refresh_claim_generation, + normalized.credentials, + expires_at: normalized.expires_at, + refreshed_at: DateTime.utc_now() + ) do + {:ok, integration} + else + {:error, :invalid_grant} -> + require_reauthorization(scope, claim, "refresh_invalid_grant") + + {:error, :refresh_account_identity_mismatch} -> + require_reauthorization(scope, claim, "refresh_account_identity_mismatch") + + {:error, :stale_credential_generation} -> + current_result(scope, claim) + + {:error, _reason} -> + _result = Integrations.fail_refresh(scope, claim) + {:error, :provider_unavailable} + end + end + + defp require_reauthorization(scope, claim, error_code) do + case Integrations.require_refresh_reauthorization(scope, claim, error_code) do + {:ok, _integration} -> {:error, :integration_reauthorization_required} + {:error, :stale_refresh_claim} -> current_result(scope, claim) + {:error, _reason} -> {:error, :provider_unavailable} + end + end + + defp current_result(scope, integration) do + case Integrations.get_integration(scope, integration.id) do + {:ok, %{connection_status: "connected"} = current} + when current.credential_generation > integration.credential_generation -> + {:ok, current} + + {:ok, %{connection_status: "reauthorization_required"}} -> + {:error, :integration_reauthorization_required} + + _other -> + {:error, :provider_unavailable} + end + end + + defp refresh_due?( + %Integration{ + provider: "openai_codex", + authentication_type: "oauth", + expires_at: expires_at + }, + %DateTime{} = now, + buffer + ) + when is_integer(buffer) and buffer >= 0 do + not match?(%DateTime{}, expires_at) or + DateTime.compare(expires_at, DateTime.add(now, buffer, :second)) != :gt + end + + defp refresh_due?(_integration, _now, _buffer), do: false + + defp fetch_refresh_token(%{"refresh_token" => token}) + when is_binary(token) and byte_size(token) > 0, + do: {:ok, token} + + defp fetch_refresh_token(_current), do: {:error, :oauth_refresh_response_invalid} + + defp safe_refresh(client, refresh_token, options) do + try do + client.refresh(refresh_token, options) + rescue + _exception -> {:error, :provider_unavailable} + catch + _kind, _reason -> {:error, :provider_unavailable} + end + end + + defp configured_client do + Application.get_env(:kodo, :oauth_refresh_client, Kodo.Integrations.ReqRefreshClient) + end +end diff --git a/lib/kodo/integrations/refresh_client.ex b/lib/kodo/integrations/refresh_client.ex new file mode 100644 index 0000000..2507453 --- /dev/null +++ b/lib/kodo/integrations/refresh_client.ex @@ -0,0 +1,5 @@ +defmodule Kodo.Integrations.RefreshClient do + @moduledoc false + + @callback refresh(String.t(), keyword()) :: {:ok, map()} | {:error, atom()} +end diff --git a/lib/kodo/integrations/refresh_tokens.ex b/lib/kodo/integrations/refresh_tokens.ex new file mode 100644 index 0000000..74df26e --- /dev/null +++ b/lib/kodo/integrations/refresh_tokens.ex @@ -0,0 +1,91 @@ +defmodule Kodo.Integrations.RefreshTokens do + @moduledoc false + + @account_claim "https://api.openai.com/auth" + @max_token_bytes 8_192 + + def normalize(response, current, now \\ DateTime.utc_now()) + + def normalize(response, current, %DateTime{} = now) + when is_map(response) and is_map(current) do + with {:ok, access_token} <- token(response, "access_token"), + {:ok, expires_at} <- access_expiry(access_token, now), + {:ok, refresh_token} <- optional_token(response, current, "refresh_token"), + {:ok, id_token} <- optional_token(response, current, "id_token"), + {:ok, current_account_id} <- token(current, "account_id"), + {:ok, account_id} <- account_id(response, id_token, current_account_id), + true <- account_id == current_account_id do + {:ok, + %{ + credentials: %{ + "access_token" => access_token, + "refresh_token" => refresh_token, + "id_token" => id_token, + "account_id" => account_id + }, + expires_at: expires_at + }} + else + false -> {:error, :refresh_account_identity_mismatch} + {:error, _reason} = error -> error + end + end + + def normalize(_response, _current, _now), do: {:error, :oauth_refresh_response_invalid} + + defp account_id(response, id_token, current_account_id) do + if Map.has_key?(response, "id_token") do + with {:ok, claims} <- jwt_claims(id_token), + %{"chatgpt_account_id" => account_id} <- claims[@account_claim], + :ok <- valid_token(account_id) do + {:ok, account_id} + else + _invalid -> {:error, :oauth_refresh_response_invalid} + end + else + {:ok, current_account_id} + end + end + + defp access_expiry(access_token, now) do + with {:ok, %{"exp" => unix}} when is_integer(unix) <- jwt_claims(access_token), + {:ok, expires_at} <- DateTime.from_unix(unix), + true <- DateTime.after?(expires_at, now) do + {:ok, %{expires_at | microsecond: {0, 6}}} + else + _invalid -> {:error, :oauth_refresh_response_invalid} + end + end + + defp optional_token(response, current, field) do + case Map.fetch(response, field) do + {:ok, value} -> checked_token(value) + :error -> token(current, field) + end + end + + defp token(payload, field), do: payload |> Map.get(field) |> checked_token() + + defp checked_token(value) do + case valid_token(value) do + :ok -> {:ok, value} + :error -> {:error, :oauth_refresh_response_invalid} + end + end + + defp valid_token(value) + when is_binary(value) and byte_size(value) > 0 and byte_size(value) <= @max_token_bytes, + do: :ok + + defp valid_token(_value), do: :error + + defp jwt_claims(token) do + with [_header, payload, _signature] <- String.split(token, "."), + {:ok, json} <- Base.url_decode64(payload, padding: false), + {:ok, claims} when is_map(claims) <- Jason.decode(json) do + {:ok, claims} + else + _invalid -> {:error, :oauth_refresh_response_invalid} + end + end +end diff --git a/lib/kodo/integrations/req_device_authorization_client.ex b/lib/kodo/integrations/req_device_authorization_client.ex index 5b3ecd2..bdf8b61 100644 --- a/lib/kodo/integrations/req_device_authorization_client.ex +++ b/lib/kodo/integrations/req_device_authorization_client.ex @@ -134,25 +134,25 @@ defmodule Kodo.Integrations.ReqDeviceAuthorizationClient do req_options = Keyword.take(req_options, [ :plug, - :device_authorization_transport, - :device_authorization_timeout + :credential_request_transport, + :credential_request_timeout ]) options = [ url: url, - adapter: Kodo.Integrations.DeviceAuthorizationReqAdapter, + adapter: Kodo.Integrations.CredentialReqAdapter, redirect: false, retry: false, decode_body: false, - device_authorization_timeout: @timeout + credential_request_timeout: @timeout ] ++ request_options ++ req_options request = Req.new() |> Req.Request.register_options([ - :device_authorization_timeout, - :device_authorization_transport + :credential_request_timeout, + :credential_request_transport ]) case Req.post(request, options) do diff --git a/lib/kodo/integrations/req_refresh_client.ex b/lib/kodo/integrations/req_refresh_client.ex new file mode 100644 index 0000000..5e01825 --- /dev/null +++ b/lib/kodo/integrations/req_refresh_client.ex @@ -0,0 +1,85 @@ +defmodule Kodo.Integrations.ReqRefreshClient do + @moduledoc "Performs the pinned OpenAI Codex refresh-token contract." + + @behaviour Kodo.Integrations.RefreshClient + + @client_id "app_EMoamEEZ73f0CkXaXp7hrann" + @token_url "https://auth.openai.com/oauth/token" + @timeout 10_000 + @max_response_bytes 8_192 + + @impl true + def refresh(refresh_token, req_options \\ []) + + def refresh(refresh_token, req_options) + when is_binary(refresh_token) and byte_size(refresh_token) > 0 and + byte_size(refresh_token) <= @max_response_bytes and is_list(req_options) do + options = + [ + url: @token_url, + json: %{ + client_id: @client_id, + grant_type: "refresh_token", + refresh_token: refresh_token + }, + adapter: Kodo.Integrations.CredentialReqAdapter, + redirect: false, + retry: false, + decode_body: false, + credential_request_timeout: @timeout + ] ++ + Keyword.take(req_options, [ + :plug, + :credential_request_transport, + :credential_request_timeout + ]) + + request = + Req.new() + |> Req.Request.register_options([ + :credential_request_timeout, + :credential_request_transport + ]) + + case Req.post(request, options) do + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> + decode_success(body) + + {:ok, %Req.Response{status: 400, body: body}} -> + if invalid_grant?(body), + do: {:error, :invalid_grant}, + else: {:error, :provider_unavailable} + + {:ok, %Req.Response{status: status}} when status in 300..399 -> + {:error, :redirect} + + {:ok, %Req.Response{}} -> + {:error, :provider_unavailable} + + {:error, _reason} -> + {:error, :provider_unavailable} + end + end + + def refresh(_refresh_token, _req_options), do: {:error, :oauth_refresh_response_invalid} + + defp decode_success(body) do + with {:ok, decoded} when is_map(decoded) <- decode_json(body), + access_token when is_binary(access_token) and byte_size(access_token) > 0 <- + decoded["access_token"] do + {:ok, decoded} + else + _invalid -> {:error, :oauth_refresh_response_invalid} + end + end + + defp invalid_grant?(body) do + case decode_json(body) do + {:ok, %{"error" => "invalid_grant"}} -> true + _other -> false + end + end + + defp decode_json(body) when is_binary(body), do: Jason.decode(body) + defp decode_json(_body), do: {:error, :invalid_body} +end diff --git a/lib/kodo/llm.ex b/lib/kodo/llm.ex index c54f1de..2029a02 100644 --- a/lib/kodo/llm.ex +++ b/lib/kodo/llm.ex @@ -124,7 +124,8 @@ defmodule Kodo.LLM do :integration_disconnected, :integration_reauthorization_required, :integration_invalid, - :stale_credential_generation + :stale_credential_generation, + :provider_unavailable ] -> {:error, ProviderError.from_integration(reason, model)} diff --git a/lib/kodo/llm/credential_resolver.ex b/lib/kodo/llm/credential_resolver.ex index 0659332..c5e400b 100644 --- a/lib/kodo/llm/credential_resolver.ex +++ b/lib/kodo/llm/credential_resolver.ex @@ -12,6 +12,7 @@ defmodule Kodo.LLM.CredentialResolver do alias Kodo.Integrations alias Kodo.Integrations.CredentialEncryption alias Kodo.Integrations.Integration + alias Kodo.Integrations.OAuthRefresh alias Kodo.LLM.Credential alias Kodo.LLM.IntegrationRef @@ -47,6 +48,7 @@ defmodule Kodo.LLM.CredentialResolver do :ok <- require_authentication_type(integration, reference), :ok <- require_billing_path(integration, reference), :ok <- require_usable(integration), + {:ok, integration} <- OAuthRefresh.ensure_fresh(scope, integration), {:ok, payload} <- CredentialEncryption.decrypt(integration) do build_credential(integration, reference, payload) end diff --git a/lib/kodo/llm/provider_error.ex b/lib/kodo/llm/provider_error.ex index 249f1bc..5c24671 100644 --- a/lib/kodo/llm/provider_error.ex +++ b/lib/kodo/llm/provider_error.ex @@ -28,7 +28,8 @@ defmodule Kodo.LLM.ProviderError do :integration_disconnected, :integration_reauthorization_required, :integration_invalid, - :stale_credential_generation + :stale_credential_generation, + :provider_unavailable ] do provider = Atom.to_string(model.provider) @@ -42,8 +43,7 @@ defmodule Kodo.LLM.ProviderError do end def guidance(%__MODULE__{kind: :integration_required, provider: "openai_codex"}), - do: - "ChatGPT connections are not available yet. Choose models from connected API-key providers for the required roles, then start a new session." + do: "Connect or activate a ChatGPT Subscription account, then retry the turn." def guidance(%__MODULE__{kind: :integration_required, provider: provider}), do: "Connect or activate an account for #{provider_name(provider)}, then retry the turn." @@ -91,11 +91,15 @@ defmodule Kodo.LLM.ProviderError do def provider_help_url(%__MODULE__{provider: "openrouter"}), do: "https://openrouter.ai/credits" + def provider_help_url(%__MODULE__{provider: "openai_codex"}), + do: "https://chatgpt.com/#settings/Subscription" + def provider_help_url(%__MODULE__{}), do: nil defp integration_kind(:integration_invalid), do: :authentication_rejected defp integration_kind(:integration_reauthorization_required), do: :authentication_rejected defp integration_kind(:stale_credential_generation), do: :integration_changed + defp integration_kind(:provider_unavailable), do: :provider_unavailable defp integration_kind(_reason), do: :integration_required defp billing_path("openai_codex"), do: :subscription diff --git a/lib/kodo_web/live/integrations_live.ex b/lib/kodo_web/live/integrations_live.ex index 1487bb0..cc515f5 100644 --- a/lib/kodo_web/live/integrations_live.ex +++ b/lib/kodo_web/live/integrations_live.ex @@ -4,6 +4,7 @@ defmodule KodoWeb.IntegrationsLive do alias Kodo.Integrations alias Kodo.Integrations.APIKeyValidation alias Kodo.Integrations.DeviceAuthorization + alias Kodo.Integrations.OAuthAccessValidation @provider_configs [ %{ @@ -454,7 +455,11 @@ defmodule KodoWeb.IntegrationsLive do end defp start_validation(socket, integration) do - task = APIKeyValidation.start(socket.assigns.current_scope, integration) + task = + case integration.authentication_type do + "oauth" -> OAuthAccessValidation.start(socket.assigns.current_scope, integration) + "api_key" -> APIKeyValidation.start(socket.assigns.current_scope, integration) + end update(socket, :validation_tasks, fn tasks -> Map.put(tasks, task.ref, {integration.id, integration.credential_generation}) @@ -812,7 +817,7 @@ defmodule KodoWeb.IntegrationsLive do

Waiting for OpenAI authorization

-
+

Open this verification page

<.link @@ -897,10 +902,7 @@ defmodule KodoWeb.IntegrationsLive do Activate