Browser popup callback for Tango 0.2.0. The callback page posts a completion handle and intent reference only — not access tokens, refresh tokens, or provider payloads.
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 finalizes in its Ecto.Multi
└─ connection and app records commit together or not at allServer: verify and finalize
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, and options that only your server code supplies. It 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}, 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).
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 token columns are set to
NULLwhen the candidate is finalized and again by cleanup before the row is deleted. - 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 legacy 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.