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?
- OAuth2 authorization code flow with PKCE, CSRF state, and tenant isolation
- Encrypted tokens (AES-GCM) plus structured audit logging
- Credential-free browser callback (0.2.0) — completion handle only; verify and finalize on your server
- Optional LiveView component and mountable OAuth API router
- Nango-compatible providers from catalog or custom config (the catalog's OAuth2 authorization-code entries; see PROVIDERS.md)
Further reading
- SETUP.md — config options, upgrades, non-Ecto installs
- PROVIDERS.md — catalog mix tasks and programmatic providers
- USAGE.md — direct server-side OAuth and connection usage
- AUTHORIZATION.md — browser popup completion (0.2.0)
- API.md — mountable HTTP router and JavaScript examples
Prerequisites
- Elixir 1.15+
- PostgreSQL
- An Ecto SQL repo
Installation
Add tango to your dependencies in mix.exs:
def deps do
[
{:tango, "~> 0.3.0"}
]
endThen 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.Connection.refresh_if_needed/1Tango.get_active_connection/3withauto_refresh: trueTango.get_connection_for_provider/3withauto_refresh: true
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
next_refresh_atdelays 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
Tango.get_connection_info/2andTango.list_connection_infos/2return%Tango.ConnectionInfo{}with no tokens. Listing defaults tostatus: :activeand accepts:expired,:revoked,:any, or a list. Both include inactive providers.Tango.get_provider/1remains active-only.Tango.lock_connection_generations/3locks one tenant's provider slugs inside the caller's Tango transaction. Theoptsargument defaults tomissing: :error. Inactive providers are locked.missing: :skipignores unknown slugs. Order is provider id, then tenant binary, for the whole outer transaction. Take the full set once before nested Tango calls.Tango.revoke_connection/2accepts a struct or an integer id. The struct clause still returns{:error, :not_authorized}on a tenant mismatch. The id clause returns{:error, :not_found}for a missing, wrong-tenant, or already-revoked row. Active and expired rows can be revoked.revoke_provider_connections/1still looks up an active provider.Tango.Testing.insert_provider!/1andTango.Testing.insert_connection!/3insert fixtures. Both attribute arguments default to%{}. They are for test suites. Authorization candidates, row aging, and cleanup stay in the host test suite.
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.