💃 Tango

Hex.pm Hex Docs License

OAuth integrations for Phoenix — sessions, encrypted tokens, and multi-tenant connections.

Tango is an Elixir library for third-party OAuth in Phoenix apps. It is compatible with Nango's provider catalog and stores tokens encrypted in Postgres via your host Ecto repo.

Why Tango?

Further reading

Prerequisites

Installation

Add tango to your dependencies in mix.exs:

def deps do
  [
    {:tango, "~> 0.3.0"}
  ]
end

Then fetch dependencies:

mix deps.get

Quick Start

1. Configure

# config/config.exs
config :tango,
  repo: MyApp.Repo,
  encryption_key: System.get_env("TANGO_ENCRYPTION_KEY"),
  api_key: System.get_env("TANGO_API_KEY")

Generate an encryption key:

openssl rand -base64 32

2. Install the schema

mix tango.setup writes a host-owned wrapper migration; mix ecto.migrate applies it. Use setup for the initial install only — upgrades need a separate pinned migration (see SETUP.md).

mix tango.setup
mix ecto.migrate

3. Create a provider

mix tango.providers.create github \
  --client-id=your_client_id \
  --client-secret=your_client_secret

4. Run a direct OAuth flow

tenant_id = "user-123"
redirect_uri = "https://yourapp.com/auth/callback"

{:ok, session} =
  Tango.create_session("github", tenant_id,
    redirect_uri: redirect_uri,
    scopes: ["user:email", "repo"]
  )

{:ok, auth_url} =
  Tango.authorize_url(session.session_token, redirect_uri: redirect_uri)

# Redirect the user to auth_url. After the provider returns state + code:

{:ok, connection} =
  Tango.exchange_code(state, code, tenant_id, redirect_uri: redirect_uri)

{:ok, connection} = Tango.get_connection_for_provider("github", tenant_id)
headers = [{"Authorization", "Bearer #{connection.access_token}"}]

For browser popups (credential-free completion handle), see AUTHORIZATION.md. More connection helpers: USAGE.md. Mount the HTTP API: API.md.

Upgrading to 0.3.0

0.3.0 changes refresh error values and when an on-demand refresh may still return a usable access token. The database schema stays V03. Installations already on 0.2.0 do not run a new migration. Older installations still apply the existing V03 migration in SETUP.md before using 0.3.0. There is no migration away from V03.

record_auth_failure/3 and record_auth_success/2 are unchanged. Resource API strings such as "http_401" are not refresh errors.

Refresh errors

Tango.refresh_connection/1 returns {:error, reason}. These on-demand entries return {:error, {:refresh_failed, reason}} unless the active, still-valid token fallback below applies:

Tango does not define refresh_if_needed/1. Tango.refresh_expiring_connections/0 still returns {:ok, success_count}. Missing and inactive rows stay {:error, :not_found}, outside the reason union.

reason is a Tango.RefreshError term. HTTP status wins over the body: 429 is {:rate_limited, seconds | nil}, and 408 or 5xx is {:http_error, status}, even when the body says invalid_grant. Other 4xx responses use an allowlisted OAuth code ({:oauth_error, :invalid_grant | :invalid_client | :unauthorized_client | :invalid_request | :invalid_scope | :unsupported_grant_type | :temporarily_unavailable | :server_error}) or {:http_error, status} when the code is absent or unknown. Transport failures are {:transport_error, :timeout | :network_error}. Local failures include :no_refresh_token, :refresh_not_allowed, :provider_not_found, :invalid_config, :persistence_failed, and :transaction_failed.

Database failures that Tango returns are only those two atoms. A changeset never leaves a refresh path. Raised database exceptions are not rescued and are not classified as transport errors.

Serialize with Tango.RefreshError.to_storage/1 at audit, log, telemetry, and JSON boundaries. Examples: "oauth_error:invalid_grant", "rate_limited:30", "rate_limited", "http_error:503", "transport_error:timeout", "persistence_failed". Unknown terms become "unknown_refresh_error". Do not match stored last_refresh_failure strings, including values written before 0.3.0. Those strings stay informational. They are not migrated and they do not affect retry.

Retry, fallback, and scheduling

Class Effect
:credential (invalid_grant) One attempt, then status: :expired and refresh_exhausted: true immediately
:client, :request Increment attempts; expire and exhaust at 3
:transient Keep attempts, status, and exhaustion; set next_refresh_at
:persistence_failed, :transaction_failed No attempt write and no token fallback

Named local eligibility failures such as :no_refresh_token and :refresh_not_allowed do not write a new attempt. Success clears next_refresh_at. A failure never revives an expired or revoked row and never clears exhaustion.

Delay is the numeric Retry-After seconds, otherwise 300, and never more than 3600. next_refresh_at delays sweeps only. Explicit and on-demand refresh ignore it, so a request can still reach the provider during backoff.

Fallback to the current access token is allowed only while the row stays :active and the token is unexpired (expires_at in the future, or nil). Credential expiry and the third client or request failure return the error even when the access token still has time left. Later active getters then return {:error, :not_found}. Client and request exhaustion means the retry budget ended. It does not by itself prove the user revoked consent.

Rows that are already expired or refresh-exhausted stay that way after upgrade. Reconnect them through the ordinary authorization flow. A future next_refresh_at now delays sweeps. A 0.2.0 worker ignores that column. Avoid running mixed refresh-worker versions when backoff must hold immediately.

Reads, locks, revoke, and tests

Testing

Requires local PostgreSQL with postgres:postgres credentials. Consumer tests can insert providers and connections with Tango.Testing.

mix test
mix coveralls
mix quality

License

See LICENSE.