Browser popup flows use authorization completion. The direct server-side flow below suits integrations where your server handles the redirect itself.
Direct OAuth flow
# Start OAuth session
{:ok, session} = Tango.create_session("github", tenant_id,
redirect_uri: "https://yourapp.com/auth/callback",
scopes: ["user:email", "repo"]
)
# Generate authorization URL
{:ok, auth_url} = Tango.authorize_url(session.session_token,
redirect_uri: "https://yourapp.com/auth/callback"
)
# Redirect user to auth_url
# Handle callback — exchange code for tokens
{:ok, connection} = Tango.exchange_code(state, authorization_code, tenant_id,
redirect_uri: "https://yourapp.com/auth/callback"
)Using a connection
{:ok, connection} = Tango.get_connection_for_provider("github", tenant_id)
headers = [{"Authorization", "Bearer #{connection.access_token}"}]
Tango.mark_connection_used(connection)
# On-demand refresh returns {:error, {:refresh_failed, reason}} when the
# current token cannot be served. `reason` is a Tango.RefreshError term.
# Credential failure and an exhausted client or request budget do not return
# the old access token. Timeouts, 429, and 5xx may still return it while the
# row stays active and unexpired. Tango.refresh_connection/1 always returns
# {:error, reason} for a failed refresh. Sweep backoff does not apply here.
{:ok, connection} =
Tango.get_connection_for_provider("github", tenant_id, auto_refresh: true)
# Log or store a failure with Tango.RefreshError.to_storage/1.
# Do not match old last_refresh_failure strings.
{:ok, _revoked} = Tango.revoke_connection(connection, tenant_id)
{:ok, info} = Tango.get_connection_info(connection.id, tenant_id)Reporting provider auth outcomes
Report a 401 when the credentials themselves were rejected. Do not
blanket-report 403: providers use it for insufficient scope, resource policy,
and quota. Three consecutive rejections (tune with :max_failures) expire the
connection.
case call_the_provider(connection) do
{:ok, result} ->
Tango.record_auth_success(connection)
{:ok, result}
{:error, {:http_error, 401, _body}} ->
Tango.record_auth_failure(connection, "http_401", max_failures: 3)
{:error, :unauthorized}
endToken exchange response checks
The code exchange rejects responses that are not a token response:
- A 3xx redirect and a 2xx JSON object with an
errorkey are treated as provider errors and categorized like other OAuth errors (for example{:oauth_error, :access_denied}). - A 2xx HTML page, JSON scalar or undecodable body fails with
:invalid_token_response. - A 2xx empty body fails with
:empty_access_token.
Tango.exchange_code/4 returns these as bare atoms ({:error, :invalid_token_response}). The staged popup flow wraps them as {:error, {:oauth_error, :invalid_token_response}} and posts the code string to the
callback. :invalid_grant_metadata (retained token-response metadata over
4 KiB) occurs only on the staged flow, since exchange_code/4 does not retain
metadata. Audit records store :invalid_token_response and
:invalid_grant_metadata as error codes.
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.
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.