Staged authorization supports two server-selected lifecycles. The default
completion_mode: :connection verifies identity and finalizes a stored credential
connection. Optional completion_mode: :consume verifies identity and atomically
consumes a safe proof without creating or replacing a connection. The callback
page posts a completion handle and intent reference only; no credential or raw
provider payload reaches the browser.
1. Server creates an authorization session
└─ binds provider, scopes, redirect URI, completion origin, and intent reference
2. Popup consent; provider redirects to Tango /callback
└─ code exchange stores an encrypted candidate (10-minute lifetime)
3. Callback posts { completion_handle, intent_reference } to the completion origin
4. Browser forwards the handle to an authenticated app endpoint
5. App verifies identity, then completes in its Ecto.Multi
└─ connection or consumed proof and app records commit together or not at allServer: verify and finalize a connection
Verification and finalization are server-only. Browser input cannot choose the verifier module or its options.
A verifier receives the candidate's ephemeral access token, the granted scopes,
any token-response metadata the provider is configured to retain, and options
that only your server code supplies. The credential is always
%{access_token: binary, granted_scopes: [String.t()], provider_data: map}.
provider_data holds the scalar fields listed in the provider's
token_response_metadata (for example Salesforce's instance_url or Slack's
team.id) and is %{} when none are configured or present. Hosts must allowlist
only non-secret identity attributes: a key without the word token is not
automatically safe. It exists only
while the candidate is staged: verify_connection_identity/3 receives
provider_data: %{}. A verifier returns a stable account
subject and a small allowlist of identity claims. Never return tokens, headers,
or raw provider payloads from it.
defmodule MyApp.ExampleVerifier do
@behaviour Tango.AuthorizationVerifier
@impl true
def verify(_provider, %{access_token: access_token, granted_scopes: _scopes, provider_data: _data}, opts) do
case MyApp.ExampleClient.fetch_account(access_token) do
{:ok, %{"id" => id, "display_name" => display_name}} ->
{:ok,
%{
subject: to_string(id),
claims: %{"display_name" => display_name},
continuity: continuity(opts)
}}
_ ->
{:error, :invalid_identity}
end
end
# :initial for a first connection, {:connection, id} when upgrading one
defp continuity(opts) do
case Keyword.get(opts, :connection_id) do
nil -> :initial
connection_id -> {:connection, connection_id}
end
end
endTango validates the returned claims: JSON-compatible, at most 4 KiB encoded,
and no token-like keys at any depth. When the session was started to upgrade an
existing connection, the returned subject must equal the expected subject and
the proof must be {:connection, expected_connection_id}.
Complete the authorization in a request handled by your authenticated application. The tenant comes from your session, never from the browser:
def complete(handle, intent_reference, tenant_id, expected_connection_id \\ nil) do
verifier = {MyApp.ExampleVerifier, connection_id: expected_connection_id}
# HTTP verification runs before the database transaction opens.
with {:ok, identity} <-
Tango.verify_authorization(handle, tenant_id, verifier,
intent_reference: intent_reference,
provider: "example"
) do
Ecto.Multi.new()
|> Tango.multi_finalize_authorization(:authorization, handle, tenant_id,
required_scopes: ["accounts.read", "records.write"],
require_refresh_token: true,
intent_reference: intent_reference,
provider: "example",
expected_connection_id: expected_connection_id
)
|> Ecto.Multi.insert(:link, fn %{authorization: %{connection: connection}} ->
MyApp.AccountLink.changeset(%MyApp.AccountLink{}, %{
tenant_id: tenant_id,
tango_connection_id: connection.id,
subject: identity.subject
})
end)
|> MyApp.Repo.transaction()
|> case do
{:ok, %{authorization: %{connection: connection}, link: link}} -> {:ok, connection, link}
{:error, _step, reason, _changes} -> {:error, reason}
end
end
endRequirements and behavior:
- Same Repo. The Multi must run on the Repo configured as
config :tango, repo: .... A different Repo returns{:error, :repo_mismatch}. required_scopesare the API scopes your consumer needs. Do not list protocol hints that providers omit from granted scopes (for exampleoffline_access). A shortfall returns{:error, :missing_scopes}.require_refresh_token: truereturns{:error, :refresh_token_required}when the provider issued none and there is no existing refresh token to retain.- The finalized operation returns
%{connection: connection, authorization: summary}.summaryholds the provider slug, subject, claims, granted scopes, and intent reference. - A handle works once. Replays return
{:error, :completion_consumed}, expired candidates:expired_completion, and unknown or cross-tenant handles:invalid_completion. - Other errors are atoms:
:invalid_session,:verification_required,:account_mismatch,:stale_connection,:client_changed,:intent_mismatch, and:token_expired. OAuth failures are sanitized{:oauth_error, category}tuples. No exception or raw provider body is returned.
To start a flow without the LiveView component, create the session on your server and open the authorization URL in a popup:
{:ok, session} =
Tango.create_authorization_session("example", tenant_id,
scopes: ["accounts.read", "records.write"],
redirect_uri: "https://app.example.com/api/oauth/callback",
completion_origin: "https://app.example.com",
intent_reference: intent_reference,
# Optional: bind a reauthorization to an existing connection
expected_connection_id: existing_connection_id,
expected_subject: existing_subject,
auth_params: %{"prompt" => "consent"}
)
{:ok, url} = Tango.Auth.authorization_url(session)The session's scopes and authorization parameters are authoritative. The
redirect_uri must be registered with the provider and routed to Tango's
/callback endpoint (see Ready-to-Use OAuth API).
Server: verify and consume a proof
Create the session from authenticated server code, selecting consume mode before consent. The provider slug below identifies a synthetic example provider:
{:ok, session} =
Tango.create_authorization_session("example", tenant_id,
completion_mode: :consume,
scopes: ["records.read"],
redirect_uri: "https://app.example.com/api/oauth/callback",
completion_origin: "https://app.example.com",
intent_reference: intent,
expected_subject: known_subject
)
{:ok, url} = Tango.Auth.authorization_url(session)expected_subject may be omitted for account discovery or be a nonblank string
bound independently of a connection. Consume mode rejects any non-nil
expected_connection_id with :invalid_completion_options. Invalid modes return
:invalid_completion_mode. Tango.create_session/3 supports direct credential
connections and rejects consume mode. The callback stages an ordinary encrypted
token response, still requiring an access token. Browser data, authorization URL
parameters, and token-response metadata cannot select or override the mode.
The account-link pattern uses the authenticated tenant and a server-selected
verifier. AccountVerifier, AccountLinks, and MyApp.Repo are host-owned example
modules, not Tango APIs. The verifier uses the same subject/claims/continuity
contract shown above, with continuity: :initial for consume mode:
with {:ok, _identity} <-
Tango.verify_authorization(handle, tenant_id, {AccountVerifier, []},
provider: provider_slug,
intent_reference: intent
) do
Ecto.Multi.new()
|> Tango.Authorization.multi_consume_authorization(:proof, handle, tenant_id,
provider: provider_slug,
intent_reference: intent,
required_scopes: ["records.read"]
)
|> Ecto.Multi.run(:association, fn repo, %{proof: proof} ->
AccountLinks.insert_verified(repo, tenant_id, proof.subject)
end)
|> MyApp.Repo.transaction()
endAccountLinks.insert_verified/3 must lock and revalidate the host's pending
operation and the authenticated actor's permission in this same transaction.
Capture the authenticated actor and intent in host code as needed. Likewise, an
organization onboarding write must check cancellation, operation freshness, actor
permissions, and any reconnect/version binding under the appropriate host locks.
If host validation or a later write fails, the transaction rolls back consumption
and credential erasure, allowing completion to be retried without another code
exchange. Provider HTTP verification runs before opening the transaction.
Organization administrator example
This second verifier checks active administrator membership for one
server-selected organization, returning only its ID and role. All module names
in this example are host-owned. OrganizationClient.fetch_membership/3 is a
host-defined adapter that performs a bounded, read-only authenticated provider
request, normalizes the response to the synthetic shape below, and returns a
sanitized error on failure. Its options, including the client module and
organization ID, must come from trusted server code:
defmodule OrganizationAdminVerifier do
@behaviour Tango.AuthorizationVerifier
@impl true
def verify(_provider, %{access_token: access_token}, opts) do
organization_id = Keyword.fetch!(opts, :organization_id)
client = Keyword.fetch!(opts, :client)
with {:ok,
%{
"account_id" => subject,
"organization_id" => ^organization_id,
"state" => "active",
"role" => "admin"
}} <- client.fetch_membership(access_token, organization_id, opts),
true <- is_binary(subject) and byte_size(subject) > 0 do
{:ok,
%{
subject: subject,
claims: %{"organization_id" => organization_id, "role" => "admin"},
continuity: :initial
}}
else
_ -> {:error, :invalid_identity}
end
end
end
verifier =
{OrganizationAdminVerifier,
client: OrganizationClient, organization_id: organization_id}
with {:ok, _identity} <-
Tango.verify_authorization(handle, tenant_id, verifier,
provider: provider_slug,
intent_reference: intent
) do
Ecto.Multi.new()
|> Tango.Authorization.multi_consume_authorization(:proof, handle, tenant_id,
provider: provider_slug,
intent_reference: intent,
required_scopes: ["records.read"]
)
|> Ecto.Multi.run(:onboarding, fn repo, %{proof: proof} ->
OrganizationOnboarding.insert_verified(repo, tenant_id, proof)
end)
|> MyApp.Repo.transaction()
endOrganizationOnboarding.insert_verified/3 performs the host revalidation described
above. The executable synthetic example in authorization_consume_test.exs uses
the same verifier logic with Bypass, and rejects inactive administrators and
active non-administrators. No provider-specific behavior is built into Tango.
Consume result and errors
The named Multi step returns exactly five keys:
%{
provider: provider_slug,
subject: verified_subject,
claims: safe_claims,
granted_scopes: granted_scopes,
intent_reference: intent
}There is no connection, credential, raw provider data, or completion handle in
this proof. The preceding Tango.verify_authorization/4 result remains a
verification summary with these five identity fields plus
expected_connection_id and has_refresh_token; consume mode does not change
that summary's shape. Use the committed proof for host writes.
multi_consume_authorization/5 returns an Ecto.Multi, not a standalone proof.
With the example's step name, Repo.transaction/1 returns
{:ok, results} on success (results.proof is the proof) or
{:error, :proof, reason, changes_so_far} on consume failure. A later host step
failure uses that step's name. Run the transaction on the configured Tango Repo.
Only provider, intent_reference, and optional required_scopes are accepted.
Provider and intent are required nonempty strings; scopes default to [] and
must be a list of valid nonblank scope strings. Connection/refresh options,
completion_mode, expected_subject, and all other options are rejected even
when their values are nil or false.
Consume errors are:
:repo_mismatch: transaction Repo differs from the configured Tango Repo.:invalid_completion_options: malformed options or unsupported keys.:invalid_scopes: malformed scope list.:invalid_completion: malformed, unknown, or cross-tenant handle; missing required provider/intent; wrong provider; or inactive provider.:completion_mode_mismatch: wrong completion API or altered session/candidate mode or bindings. It does not consume the candidate.:intent_mismatch: intent does not match the staged candidate.:client_changed: the provider's OAuth client changed.:completion_consumed: a handle was already consumed. After cleanup removes its row, replay can return:invalid_completioninstead.:expired_completion: the candidate expired.:verification_required: no verified subject is stored.:token_expired: the staged token expired.:account_mismatch: the verified subject differs from the bound subject.:missing_scopes: granted scopes do not cover the required scopes.
Concurrent completion permits one committed consume; another attempt returns
:completion_consumed while the row remains. Existing unrelated credential
connections are neither replaced nor blockers. Connection finalization rejects
consume candidates and consumption rejects connection candidates.
Verification is point-in-time and proves only the verifier's actual checks. It does not certify fresh password entry, MFA, future membership, or continuing access. Consume mode retains no credentials for refresh or background import. Nango catalog compatibility covers configuration and OAuth transport; retention is a host-selected Tango lifecycle. There is no documented equivalent Nango consume API.
Browser: consume the completion handle
The callback page posts exactly one of these messages to the session's completion origin:
{ type: "oauth_complete", completion_handle: "...", intent_reference: "..." }
{ type: "oauth_error", error: "access_denied", intent_reference: "..." }With Tango.Live.OAuthComponent and the TangoOAuth hook, validation and
forwarding are built in. Your LiveView receives only the handle:
<.live_component
module={Tango.Live.OAuthComponent}
id="example-oauth"
provider="example"
tenant_id={@tenant_id}
scopes={["accounts.read", "records.write"]}
callback_url="https://app.example.com/api/oauth/callback"
completion_origin="https://app.example.com"
on_connect="oauth_connected"
on_error="oauth_error"
/>@completion_keys [:provider, :tenant_id, :completion_handle, :intent_reference]
def handle_info({"oauth_connected", %{completion_handle: handle, intent_reference: intent} = message}, socket) do
# Reject any other key, such as a credential, before doing any work.
# tenant_id comes from the socket, never from the message.
with true <- Enum.all?(Map.keys(message), &(&1 in @completion_keys)),
{:ok, _connection, _link} <-
MyApp.Integrations.complete(handle, intent, socket.assigns.tenant_id) do
{:noreply, assign(socket, :connected, true)}
else
_error -> {:noreply, put_flash(socket, :error, "Could not verify the connection")}
end
endReject any other shape. A message that includes access_token (or any
credential) is a 0.1.x payload and must connect nothing.
Without LiveView, accept the message only from the popup you opened and from the exact origin of your Tango callback page, then post the handle to your own authenticated endpoint:
window.addEventListener("message", async (event) => {
if (event.source !== popup) return;
if (event.origin !== CALLBACK_ORIGIN) return;
const data = event.data;
if (!data || data.intent_reference !== pendingIntent) return;
if (data.type === "oauth_complete" && typeof data.completion_handle === "string") {
await fetch("/integrations/complete", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify({
completion_handle: data.completion_handle,
intent_reference: data.intent_reference
})
});
}
});Token retention policy
- Tango stores access and refresh tokens only in encrypted columns (AES-GCM). A candidate in
tango_authorizationslives at most ten minutes and its handle is stored only as a SHA-256 hash. - Candidate access and refresh token columns are set to
NULLandprovider_datato%{}in the same transaction as finalization or consumption, and again by cleanup before the row is deleted. Safe identity fields remain until cleanup. This erases live application records, not backups/WAL, and does not guarantee memory zeroization. Local erasure is distinct from provider-side revocation; consume mode does not revoke the provider authorization. - The verifier sees the candidate's access token for the duration of one call. Do not store it, log it, or include it in claims.
- Ecto SQL logs and telemetry metadata for Tango's token writes carry an opaque placeholder, not the token. Audit rows never contain tokens, codes, provider bodies, raw state, or the completion handle.
- The
connectionreturned bymulti_finalize_authorization/5holds usable plaintext tokens. Persist onlyconnection.idand the identity summary in your own tables. Do not copy tokens into your records, job arguments, logs, error reports, or browser responses. - Read credentials when you call the provider with
Tango.get_active_connection(connection_id, tenant_id, auto_refresh: true). A missing or inactive generation is{:error, :not_found}. A refresh that cannot fall back to an active, unexpired token is{:error, {:refresh_failed, reason}}, wherereasonis aTango.RefreshErrorterm. PutTango.RefreshError.to_storage/1in logs and JSON. Do not treat a storedlast_refresh_failurestring as proof ofinvalid_grant.Tango.refresh_connection/1returns the bare{:error, reason}. - Recheck an existing connection's identity with
Tango.verify_connection_identity(connection_id, tenant_id, {MyApp.ExampleVerifier, []}).
Cleanup
Tango starts no scheduler. Run both cleanup functions from your own job runner (Oban, Quantum, a timer process, and so on), authorizations first, so candidate credentials are wiped before their sessions are deleted:
def perform(_job) do
{:ok, _candidates} = Tango.cleanup_expired_authorizations()
{:ok, _sessions} = Tango.cleanup_expired_sessions()
:ok
endBoth return {:ok, deleted_count}. Every few minutes keeps the candidate table small.
Exact origin setup
List every page origin that may host the connect button. Tango compares the
configured string exactly, so a session whose completion_origin is not listed
fails with {:error, :invalid_completion_origin}:
# config/runtime.exs
config :tango,
allowed_completion_origins: ["https://app.example.com"]- An origin is
scheme://host[:port]: no path, query, fragment, user info, or wildcard. httpsis required.httpis accepted only forlocalhostand loopback hosts, which keeps local development working.- The callback page posts to this origin only. Without an allowed origin it posts nothing.
completion_originis the origin of the page that hosts the button, which may differ from the origin serving Tango's/callback. The hook checksevent.originagainst the callback origin andevent.sourceagainst the popup it opened.
Migrating a 0.1.x callback to 0.2.0
- Update the dependency to
{:tango, "~> 0.2.0"}, runmix deps.update tango, and apply the V03 migration. - Configure
allowed_completion_originsand passcompletion_origintoTango.Live.OAuthComponent(now required). Register theTangoOAuthhook from the updatedassets/tango_oauth_hook.js. - Change the
on_connecthandler. 0.1.x delivered a token and connection details to the browser and parent. 0.2.0 delivers%{provider, tenant_id, completion_handle, intent_reference}. Call your server-side verify and finalize flow, using the tenant from your own session. - Replace any custom JavaScript that read
connectionor token fields from theoauth_completemessage. Readcompletion_handleandintent_reference, checkevent.originandevent.source, and send the handle to an authenticated endpoint. - Remove code that copied tokens from the browser or logged callback payloads. Credentials no longer arrive there.
- Expect these behavior changes:
- The popup
/callbackstages only. It completes sessions created withTango.create_authorization_session/3, not direct-flow sessions fromTango.create_session/3. POST /exchangeignoresinclude_token, never returns credentials, and rejects staged sessions. Staged sessions cannot be completed byTango.exchange_code/4either.Tango.exchange_code/4returns{:oauth_error, category}instead of a raw%OAuth2.Response{}on provider errors.- Callback audit rows store a hash of the session state and an error category, not the raw error, description, or state.
- The popup
- Schedule
Tango.cleanup_expired_authorizations/0alongsideTango.cleanup_expired_sessions/0.
Direct server-side flows that use create_session/3, authorize_url/2, and
exchange_code/4 without a browser popup keep working.