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