Current section
Files
Jump to
Current section
Files
lib/codex/oauth.ex
defmodule Codex.OAuth do
@moduledoc """
Native OAuth login and session management for Codex.
`Codex.OAuth` adds an SDK-managed ChatGPT login path alongside the existing
CLI passthrough and API-key flows:
- `storage: :file` or `:auto` writes an upstream-compatible `auth.json`
under the effective `CODEX_HOME`
- `storage: :memory` keeps tokens in memory for host-managed and app-server
external auth flows
Flow selection is environment-aware:
- local desktop prefers browser auth-code + PKCE + loopback callback
- WSL starts with browser auth, then falls back to device code if the
callback does not arrive quickly
- SSH/headless/container environments prefer device code
- non-interactive environments never start a login automatically
All OAuth HTTP traffic reuses `Codex.Net.CA`, so `CODEX_CA_CERTIFICATE` and
`SSL_CERT_FILE` apply consistently to login and refresh requests.
"""
alias Codex.Auth.Store
alias Codex.Config.Defaults
alias Codex.Net.CA
alias Codex.OAuth.Browser
alias Codex.OAuth.Context
alias Codex.OAuth.Environment
alias Codex.OAuth.Flows.BrowserCode
alias Codex.OAuth.Flows.DeviceCode
alias Codex.OAuth.LoginResult
alias Codex.OAuth.LoopbackServer
alias Codex.OAuth.Session
alias Codex.OAuth.Status
alias Codex.OAuth.TokenStore.File, as: FileTokenStore
alias Codex.OAuth.TokenStore.Memory, as: MemoryTokenStore
@typedoc "OAuth flow selection."
@type flow :: :auto | :browser | :browser_code | :device | :device_code
@typedoc "OAuth storage strategy."
@type storage :: :auto | :file | :memory
@spec login(keyword()) :: {:ok, LoginResult.t()} | {:error, term()}
@doc """
Ensures a usable OAuth session exists and returns its current status.
With `storage: :file` or `:auto`, this writes upstream-compatible auth state
under the effective `CODEX_HOME`. With `storage: :memory`, tokens stay in
memory only.
"""
def login(opts \\ []) when is_list(opts) do
with {:ok, session, warnings} <- login_session(opts) do
{:ok, to_login_result(session, warnings)}
end
end
@spec begin_login(keyword()) ::
{:ok, Session.PendingLogin.t() | Session.PendingDeviceLogin.t()} | {:error, term()}
@doc """
Starts an OAuth login without opening a browser or waiting for completion.
Host applications can use this together with `open_in_browser/2` and
`await_login/2` to control the login UX themselves.
"""
def begin_login(opts \\ []) when is_list(opts) do
with {:ok, context, storage, _warnings} <- resolve_context_storage(opts),
{:ok, flow} <- resolve_flow(context, opts),
:ok <- ensure_interactive(flow, context.environment) do
begin_flow(context, flow, storage, opts)
end
end
@spec await_login(Session.PendingLogin.t() | Session.PendingDeviceLogin.t(), keyword()) ::
{:ok, LoginResult.t()} | {:error, term()}
@doc """
Waits for a pending login started by `begin_login/1` to complete.
"""
def await_login(pending, opts \\ [])
def await_login(%Session.PendingLogin{} = pending, opts) do
with {:ok, session} <- BrowserCode.await(pending, opts) do
{:ok, to_login_result(session, pending.warnings || [])}
end
end
def await_login(%Session.PendingDeviceLogin{} = pending, opts) do
with {:ok, session} <- DeviceCode.await(pending, opts) do
{:ok, to_login_result(session, pending.warnings || [])}
end
end
@spec open_in_browser(Session.PendingLogin.t(), keyword()) :: :ok | {:error, term()}
@doc """
Opens a pending browser-based login in the user's external browser.
"""
def open_in_browser(pending, opts \\ [])
def open_in_browser(%Session.PendingLogin{} = pending, opts) do
Browser.open(
pending.authorize_url,
pending.context.environment,
browser_opener:
Keyword.get(opts, :browser_opener) || pending.context.browser_opener ||
Keyword.get(opts, :opener)
)
end
def open_in_browser(_pending, _opts), do: {:error, :browser_flow_required}
@spec authorize_url(keyword()) :: {:ok, String.t()} | {:error, term()}
@doc """
Returns the authorize URL for a browser-based login attempt.
"""
def authorize_url(opts \\ []) when is_list(opts) do
with {:ok, %Session.PendingLogin{} = pending} <-
begin_login(Keyword.put(opts, :flow, :browser_code)) do
{:ok, pending.authorize_url}
end
end
@spec status(keyword()) :: {:ok, Status.t()} | {:error, term()}
@doc """
Reads the current OAuth auth state for the effective `CODEX_HOME`.
"""
def status(opts \\ []) when is_list(opts) do
with {:ok, context, _storage, warnings} <- resolve_context_storage(opts) do
case load_session(context, opts) do
{:ok, nil} ->
{:ok, %Status{authenticated?: false, warnings: warnings}}
{:ok, %Session{} = session} ->
{:ok, to_status(session, warnings)}
{:error, _} = error ->
error
end
end
end
@spec refresh(keyword()) :: {:ok, Status.t()} | {:error, term()}
@doc """
Refreshes the current OAuth session with the provider token endpoint.
"""
def refresh(opts \\ []) when is_list(opts) do
with {:ok, context, _storage, warnings} <- resolve_context_storage(opts),
{:ok, %Session{} = session} <- load_session(context, opts),
{:ok, refreshed} <- refresh_session(session, opts) do
{:ok, to_status(refreshed, warnings)}
else
{:ok, nil} -> {:error, :not_authenticated}
{:error, _} = error -> error
end
end
@spec logout(keyword()) :: :ok | {:error, term()}
@doc """
Removes persisted OAuth auth state and stops any in-memory token store.
"""
def logout(opts \\ []) when is_list(opts) do
with {:ok, context, _storage, _warnings} <- resolve_context_storage(opts) do
maybe_stop_token_store(Keyword.get(opts, :token_store))
case FileTokenStore.delete(context) do
:ok -> :ok
{:error, _} = error -> error
end
end
end
@doc false
@spec login_session(keyword()) :: {:ok, Session.t(), [String.t()]} | {:error, term()}
def login_session(opts \\ []) when is_list(opts) do
case resolve_context_storage(opts) do
{:ok, context, storage, warnings} ->
maybe_login_with_stored_session(context, storage, warnings, opts)
{:error, _} = error ->
error
end
end
@doc false
@spec load_session(Context.t(), keyword()) :: {:ok, Session.t() | nil} | {:error, term()}
def load_session(%Context{} = context, opts \\ []) when is_list(opts) do
case Keyword.get(opts, :token_store) do
pid when is_pid(pid) ->
{:ok, MemoryTokenStore.fetch(pid)}
_ ->
FileTokenStore.load(context)
end
end
@doc false
@spec refresh_session(Session.t(), keyword()) :: {:ok, Session.t()} | {:error, term()}
def refresh_session(%Session{auth_record: %Store.Record{tokens: nil}}, _opts) do
{:error, :missing_tokens}
end
def refresh_session(%Session{} = session, opts) do
tokens = session.auth_record.tokens
refresh_token = tokens.refresh_token || session.auth_record.tokens.refresh_token
persist? = Keyword.get(opts, :persist?, true)
case refresh_token do
token when is_binary(token) and token != "" ->
session
|> refresh_request_options(token)
|> request_refresh(session, persist?)
_ ->
{:error, :missing_refresh_token}
end
end
defp maybe_login_with_stored_session(context, storage, warnings, opts) do
if Keyword.get(opts, :ignore_stored_session?, false) do
run_login_flow(context, storage, warnings, opts)
else
load_or_login_session(context, storage, warnings, opts)
end
end
defp load_or_login_session(context, storage, warnings, opts) do
case load_session(context, opts) do
{:ok, %Session{} = session} ->
{:ok, session, warnings}
{:ok, nil} ->
run_login_flow(context, storage, warnings, opts)
{:error, _} = error ->
error
end
end
defp refresh_request_options(session, refresh_token) do
[
headers: [{"content-type", "application/x-www-form-urlencoded"}],
form: [
grant_type: "refresh_token",
client_id: session.context.client_id,
refresh_token: refresh_token
],
receive_timeout: Defaults.oauth_http_timeout_ms()
]
|> CA.merge_req_options(session.context.child_process_env)
end
defp request_refresh(request_opts, session, persist?) do
case Req.post(session.context.provider.token_url, request_opts) do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
refresh_success(session, body, persist?)
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:refresh_failed, status, body}}
{:error, _} = error ->
error
end
end
defp refresh_success(session, body, persist?) do
with {:ok, refreshed_record} <- refresh_record(session.auth_record, body) do
persist_refreshed_session(session, refreshed_record, persist?)
end
end
defp run_login_flow(context, storage, warnings, opts) do
with {:ok, flow} <- resolve_flow(context, opts),
:ok <- ensure_interactive(flow, context.environment) do
case flow do
:browser_code ->
login_with_browser(context, storage, warnings, opts)
:device_code ->
login_with_device_code(context, storage, warnings, opts)
:none ->
{:error, :interactive_login_unavailable}
end
end
end
defp login_with_browser(context, storage, warnings, opts) do
case BrowserCode.begin(context, Keyword.put(opts, :storage, storage)) do
{:ok, pending} ->
open_and_await_browser_login(pending, context, storage, warnings, opts)
{:error, _reason} = error ->
maybe_fallback_browser_error(error, context, storage, warnings, opts)
end
end
defp login_with_device_code(context, storage, warnings, opts) do
with {:ok, pending} <- DeviceCode.begin(context, Keyword.put(opts, :storage, storage)),
:ok <- maybe_present_device_login(pending, context),
{:ok, session} <- DeviceCode.await(pending, opts) do
{:ok, session, warnings}
end
end
defp maybe_present_device_login(%Session.PendingDeviceLogin{} = pending, %Context{
presenter: presenter
}) do
message = %{
verification_url: pending.verification_url,
user_code: pending.user_code,
expires_at: pending.expires_at
}
case presenter do
fun when is_function(fun, 1) ->
_ = fun.(message)
:ok
_ ->
IO.puts("Open #{pending.verification_url} and enter code #{pending.user_code}")
:ok
end
end
defp cancel_browser_pending(%Session.PendingLogin{} = pending) do
LoopbackServer.cancel(pending.loopback_server)
end
defp resolve_context_storage(opts) do
with {:ok, context} <- Context.resolve(opts) do
requested_storage = Keyword.get(opts, :storage, :auto)
{storage, warnings} = normalize_storage(requested_storage, context)
{:ok, context, storage, warnings}
end
end
defp normalize_storage(:memory, _context), do: {:memory, []}
defp normalize_storage(storage, context) when storage in [:auto, :file] do
if context.credentials_store_mode in [:keyring, :auto] and not Store.keyring_supported?() do
{:file,
[
"codex_sdk does not support keyring-backed OAuth storage yet; falling back to file storage under CODEX_HOME."
]}
else
{:file, []}
end
end
defp normalize_storage(_other, context), do: normalize_storage(:auto, context)
defp resolve_flow(%Context{} = context, opts) do
case Keyword.get(opts, :flow, :auto) do
:auto -> {:ok, context.environment.preferred_flow}
:browser -> {:ok, :browser_code}
:browser_code -> {:ok, :browser_code}
:device -> {:ok, :device_code}
:device_code -> {:ok, :device_code}
other -> {:error, {:unsupported_oauth_flow, other}}
end
end
defp ensure_interactive(:none, _environment), do: {:error, :interactive_login_unavailable}
defp ensure_interactive(_flow, %Environment{interactive?: true}), do: :ok
defp ensure_interactive(_flow, _environment), do: {:error, :interactive_login_unavailable}
defp begin_flow(context, :browser_code, storage, opts) do
with {:ok, pending} <- BrowserCode.begin(context, Keyword.put(opts, :storage, storage)) do
{:ok, put_pending_warnings(pending, storage_warning(context, storage))}
end
end
defp begin_flow(context, :device_code, storage, opts) do
with {:ok, pending} <- DeviceCode.begin(context, Keyword.put(opts, :storage, storage)) do
{:ok, put_pending_warnings(pending, storage_warning(context, storage))}
end
end
defp begin_flow(_context, :none, _storage, _opts), do: {:error, :interactive_login_unavailable}
defp storage_warning(context, :file) do
if context.credentials_store_mode in [:keyring, :auto] and not Store.keyring_supported?() do
[
"codex_sdk does not support keyring-backed OAuth storage yet; falling back to file storage under CODEX_HOME."
]
else
[]
end
end
defp storage_warning(_context, _storage), do: []
defp put_pending_warnings(%module{} = pending, warnings)
when module in [Session.PendingLogin, Session.PendingDeviceLogin] do
%{pending | warnings: warnings}
end
defp refresh_record(%Store.Record{} = current_record, body) do
case normalize_refresh_response(body, current_record) do
{:ok, record} -> {:ok, record}
{:error, _} = error -> error
end
end
defp normalize_refresh_response(%{} = body, %Store.Record{} = current_record) do
access_token = Map.get(body, "access_token") || current_record.tokens.access_token
refresh_token = Map.get(body, "refresh_token") || current_record.tokens.refresh_token
id_token = Map.get(body, "id_token") || current_record.tokens.id_token
{:ok,
Store.build_record(
auth_mode: current_record.auth_mode,
openai_api_key: access_token,
access_token: access_token,
refresh_token: refresh_token,
id_token: id_token,
account_id: Map.get(body, "account_id"),
last_refresh: DateTime.utc_now()
)}
end
defp normalize_refresh_response(body, %Store.Record{} = current_record) when is_binary(body) do
case Jason.decode(body) do
{:ok, %{} = decoded} -> normalize_refresh_response(decoded, current_record)
{:error, reason} -> {:error, reason}
end
end
defp normalize_refresh_response(_body, _current_record), do: {:error, :invalid_refresh_response}
defp persist_refreshed_session(
%Session{} = session,
%Store.Record{} = refreshed_record,
persist?
) do
refreshed_session = %Session{session | auth_record: refreshed_record}
cond do
persist? and session.persisted? ->
with :ok <- Store.write(refreshed_record, codex_home: session.context.codex_home) do
{:ok, refreshed_session}
end
persist? and is_pid(session.token_store) ->
:ok = MemoryTokenStore.put(session.token_store, refreshed_session)
{:ok, refreshed_session}
true ->
{:ok, refreshed_session}
end
end
defp maybe_stop_token_store(pid) when is_pid(pid) do
Agent.stop(pid)
:ok
catch
:exit, _ -> :ok
end
defp maybe_stop_token_store(_), do: :ok
defp open_and_await_browser_login(pending, context, storage, warnings, opts) do
case open_in_browser(pending, opts) do
:ok ->
await_browser_login(pending, context, storage, warnings, opts)
{:error, _reason} = error ->
maybe_fallback_browser_error(error, context, storage, warnings, opts)
end
end
defp await_browser_login(pending, context, storage, warnings, opts) do
case browser_login_result(pending, context, opts) do
{:ok, session} ->
{:ok, session, warnings}
{:error, :timeout} ->
maybe_fallback_browser_timeout(pending, context, storage, warnings, opts)
{:error, _} = error ->
error
end
end
defp browser_login_result(pending, context, opts) do
if browser_wsl_fallback?(context) do
BrowserCode.await(
pending,
timeout: Defaults.oauth_wsl_device_fallback_grace_ms()
)
else
BrowserCode.await(pending, opts)
end
end
defp maybe_fallback_browser_error(error, context, storage, warnings, opts) do
if browser_wsl_fallback?(context) do
login_with_device_code(
context,
storage,
warnings ++ ["Browser open failed under WSL; falling back to device code login."],
opts
)
else
error
end
end
defp maybe_fallback_browser_timeout(pending, context, storage, warnings, opts) do
if browser_wsl_fallback?(context) do
cancel_browser_pending(pending)
login_with_device_code(
context,
storage,
warnings ++
["WSL browser callback did not arrive; falling back to device code login."],
opts
)
else
{:error, :timeout}
end
end
defp browser_wsl_fallback?(context) do
context.environment.wsl? and context.environment.fallback_flow == :device_code
end
defp to_login_result(%Session{} = session, warnings) do
tokens = session.auth_record.tokens
%LoginResult{
provider: session.provider,
flow_used: session.flow,
storage_used: session.storage,
auth_mode: session.auth_record.auth_mode,
account_id: tokens && tokens.account_id,
plan_type: tokens && tokens.plan_type,
expires_at: tokens && tokens.expires_at,
persisted?: session.persisted?,
warnings: warnings
}
end
defp to_status(%Session{} = session, warnings) do
tokens = session.auth_record.tokens
%Status{
authenticated?: true,
provider: session.provider,
storage_used: session.storage,
auth_mode: session.auth_record.auth_mode,
account_id: tokens && tokens.account_id,
plan_type: tokens && tokens.plan_type,
expires_at: tokens && tokens.expires_at,
persisted?: session.persisted?,
warnings: warnings
}
end
end