💃 Tango - OAuth Integrations Library

Copy Markdown View Source

Hex.pm Hex Docs License

Tango handles the OAuth dance between third-party services and your Phoenix application.

Tango is an Elixir OAuth integration library for Phoenix applications that provides drop-in OAuth support for third-party integrations. Inspired by Nango (previously Pizzly) and compatible with Nango's provider configuration format, Tango leverages the extensive Nango provider catalog while providing a library-first approach for Phoenix applications.

Key Features

  • Complete OAuth2 flows: Session creation, authorization URL generation, code exchange, token refresh, and revocation
  • Multi-tenant isolation: Tenant-scoped queries for all connections with automatic session lifecycle management
  • Security-first design: AES-GCM encryption for tokens, PKCE implementation, and secure random token generation
  • Comprehensive audit trail: Structured logging for OAuth events, token operations, and system activities

Core Modules

lib/tango/
├── auth.ex                    # Main OAuth flow orchestrator
├── provider.ex                # Provider configuration management
├── connection.ex              # Token lifecycle and refresh
├── vault.ex                   # AES-GCM encryption
└── schemas/                   # Ecto schemas
    ├── provider.ex            # OAuth provider configurations
    ├── connection.ex          # Active OAuth connections
    ├── oauth_session.ex       # Temporary OAuth sessions
    └── audit_log.ex           # Security audit logging

OAuth User Flow

Tango implements a complete OAuth2 Authorization Code Flow:

1. Provider Setup
   ├─ Create provider from Nango catalog or custom config
   ├─ Store OAuth endpoints and client credentials
   └─ Encrypt client secrets with AES-GCM

2. Session Creation
   ├─ Create OAuth session with secure tokens
   ├─ Generate PKCE parameters (64-byte verifier → SHA256 challenge)
   └─ Store with 30-minute expiration and CSRF state

3. Authorization URL Generation
   ├─ Retrieve session and decrypt provider configuration
   ├─ Build authorization URL with PKCE challenge
   └─ Return URL for user redirect

4. Token Exchange
   ├─ Validate CSRF state and prevent cross-tenant attacks
   ├─ Exchange authorization code for access tokens
   ├─ Encrypt and store tokens with AES-GCM
   └─ Create persistent connection with status tracking

5. Connection Management
   ├─ Automatic refresh with 5-minute expiration buffer
   ├─ Exponential backoff with 3-attempt limits
   └─ Batch operations for background refresh jobs

Each step includes comprehensive audit logging, multi-tenant isolation, and security validations.

Installation

Add tango to your dependencies in mix.exs:

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

Install dependencies:

mix deps.get

Configuration

Configure Tango in your application:

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

Migrations

mix tango.setup writes a wrapper migration to priv/repo/migrations/; mix ecto.migrate applies it.

mix tango.setup
mix ecto.migrate

mix tango.setup is for the initial install only — its generated down calls Tango.Migration.down(), which targets version 0 and drops every Tango table. Never rerun it to pick up a new schema version.

Upgrading an existing install

When a Tango release ships a new schema version (see CHANGELOG/release notes), write a fresh migration in your application pinning the version range explicitly, so rolling it back returns to the previous version instead of dropping the schema:

defmodule MyApp.Repo.Migrations.UpgradeTangoV2 do
  use Ecto.Migration

  def up, do: Tango.Migration.up(version: 2)
  def down, do: Tango.Migration.down(version: 1)
end

Not using Ecto migrations? Copy the SQL from priv/repo/sql/versions/<version>/ into your migration tool of choice (replace the $SCHEMA$ placeholder with "public" or your custom prefix), and stamp the version marker EctoEvolver would have written, so Tango.Migration.migrated_version/1 agrees with the physical schema:

COMMENT ON TABLE "$SCHEMA$".tango_providers IS 'Tango version=2';

OAuth Providers

Providers are sourced from the Nango catalog with pre-configured OAuth endpoints and settings.

Mix Tasks

Use mix tasks for provider management with built-in validation and error handling:

# Show provider details from catalog
mix tango.providers.show github

# Create OAuth2 provider with single scope
mix tango.providers.create github --client-id=your_client_id --client-secret=your_secret

# Create OAuth2 provider with multiple scopes (individual --scope flags)
mix tango.providers.create microsoft \
  --client-id="your_azure_client_id" \
  --client-secret="your_azure_client_secret" \
  --scope="Calendars.Read" \
  --scope="Calendars.ReadWrite" \
  --scope="User.Read" \
  --scope="offline_access"

# Create OAuth2 provider with comma-separated scopes
mix tango.providers.create google \
  --client-id="your_google_client_id" \
  --client-secret="your_google_client_secret" \
  --scopes="https://www.googleapis.com/auth/calendar,https://www.googleapis.com/auth/userinfo.email"

# Create API key provider
mix tango.providers.create stripe --api-key=sk_live_xxx

# Sync all providers from catalog to database
mix tango.providers.sync

Programmatic Creation

For dynamic provider creation in your application code:

# OAuth2 provider using catalog configuration
{:ok, nango_config} = Tango.Catalog.get_provider("github")

client_id = "your_github_client_id"

{:ok, provider} = Tango.create_provider(%{
  name: nango_config["display_name"] || "GitHub",
  slug: "github",
  client_secret: "your_github_client_secret",
  config: Map.merge(nango_config, %{
    "client_id" => client_id
  }),
  default_scopes: ["user:email", "repo"],
  active: true,
  metadata: nango_config["metadata"] || %{}
})

# Custom provider without catalog
{:ok, provider} = Tango.create_provider(%{
  name: "Custom API",
  slug: "custom_api",
  client_secret: "your_client_secret",
  config: %{
    "client_id" => "your_client_id",
    "auth_url" => "https://api.example.com/oauth/authorize",
    "token_url" => "https://api.example.com/oauth/token",
    "auth_mode" => "OAUTH2"
  },
  default_scopes: ["read", "write"],
  active: true
})

# API key provider
{:ok, provider} = Tango.create_provider(%{
  name: "Custom API Key Service",
  slug: "custom_service",
  config: %{
    "auth_mode" => "API_KEY",
    "api_config" => %{
      "headers" => %{
        "authorization" => "Bearer ${api_key}"
      }
    }
  },
  api_key: "your_api_key",
  active: true
})

Quick Start

1. Set up OAuth Provider

# Create OAuth2 provider using mix task (recommended)
mix tango.providers.create github --client-id=your_client_id --client-secret=your_secret

# Create API key provider
mix tango.providers.create stripe --api-key=sk_live_xxx
# Or programmatically using catalog configuration
{:ok, nango_config} = Tango.Catalog.get_provider("github")

{:ok, provider} = Tango.create_provider(%{
  name: nango_config["display_name"] || "GitHub",
  slug: "github",
  client_secret: "your_client_secret",
  config: Map.merge(nango_config, %{
    "client_id" => "your_client_id"
  }),
  default_scopes: ["user:email", "repo"],
  active: true,
  metadata: nango_config["metadata"] || %{}
})

2. OAuth Flow Implementation

# 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,
  redirect_uri: "https://yourapp.com/auth/callback"
)

3. Use OAuth Connection

# Get active connection for API calls
{:ok, connection} = Tango.get_connection_for_provider("github", tenant_id)

# Use connection.access_token in your API requests
headers = [{"Authorization", "Bearer #{connection.access_token}"}]

# Mark connection as used (updates last_used_at)
Tango.mark_connection_used(connection)

# Report what the provider said, so revoked credentials can be retired.
# Three consecutive rejections (tune per provider with :max_failures — the
# count is rejections, not elapsed time, so match it to your call cadence)
# expire the connection, and get_connection_for_provider/2 stops serving it.
#
# Report a 401, which means the credentials themselves were rejected. Do NOT
# blanket-report 403: providers use it for insufficient scope, resource
# policy, and quota, and three of those in a row would expire a healthy
# connection — classify a 403 from its provider-specific error body first.
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}
end

Ready-to-Use OAuth API

Tango includes a complete OAuth API router that can be mounted in Phoenix applications with a single line.

Setup

  1. Configure API key in your application:
# config/config.exs  
config :tango,
  encryption_key: System.get_env("TANGO_ENCRYPTION_KEY"),
  api_key: System.get_env("TANGO_API_KEY")
  1. Add the API router to your Phoenix router:
# router.ex
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  scope "/api/oauth" do
    pipe_through :api
    forward "/", Tango.API.Router
  end
end

Available Endpoints

The mounted API provides these endpoints:

  • POST /api/oauth/sessions - Create OAuth session
  • GET /api/oauth/authorize/:session_token - Get authorization URL
  • POST /api/oauth/exchange - Exchange authorization code for connection
  • GET /api/oauth/health - Health check

JavaScript Usage

const API_BASE = '/api/oauth';
const TENANT_ID = 'user-123';
const API_KEY = 'your-secret-api-key';

// Start OAuth flow
async function startOAuth(provider, redirectUri, scopes = []) {
  // Create session
  const session = await fetch(`${API_BASE}/sessions`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Tenant-ID': TENANT_ID,
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify({ provider, redirect_uri: redirectUri, scopes })
  }).then(r => r.json());

  // Get authorization URL
  const authUrl = await fetch(
    `${API_BASE}/authorize/${session.session_token}?redirect_uri=${redirectUri}&scopes=${scopes.join(' ')}`,
    { 
      headers: { 
        'X-Tenant-ID': TENANT_ID,
        'Authorization': `Bearer ${API_KEY}`
      } 
    }
  ).then(r => r.json());

  // Redirect to OAuth provider
  window.location.href = authUrl.authorization_url;
}

// Handle OAuth callback
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const state = params.get('state');
  const code = params.get('code');
  
  if (state && code) {
    const connection = await fetch(`${API_BASE}/exchange`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Tenant-ID': TENANT_ID,
        'Authorization': `Bearer ${API_KEY}`
      },
      body: JSON.stringify({
        state,
        code,
        redirect_uri: window.location.origin + '/callback'
      })
    }).then(r => r.json());

    console.log('OAuth connection established:', connection);
  }
}

Connection Management

Use Tango's programmatic API in your Phoenix application to manage connections:

# List connections for a user
connections = Tango.list_connections(user_id)

# Get connection for API calls
{:ok, connection} = Tango.get_connection_for_provider("github", user_id)
headers = [{"Authorization", "Bearer #{connection.access_token}"}]

# Revoke connection
{:ok, _revoked} = Tango.revoke_connection(connection, user_id)

Testing

Requirements: PostgreSQL running locally with postgres:postgres credentials.

# Run all tests
mix test

# Run with coverage report
mix coveralls

# Generate HTML coverage report
mix coveralls.html

# Run quality checks (format, credo, tests)
mix quality

Planned Features

  • TypeScript client library: Client SDK for web or OAuth flows
  • Phoenix LiveView components: Pre-built UI component for OAuth flows
  • Token Refresh Automation: Background job integration with automatic token refresh scheduling
  • Rate Limiting: Built-in OAuth endpoint protection and request throttling
  • OAuth1 Support: Legacy OAuth support for older providers

Documentation

Documentation is available on HexDocs or generate locally:

mix docs