Powered by AppSignal & Oban Pro

App Authorization (OAuth-style Flow)

docs/api/app_authorization.livemd

App Authorization (OAuth-style Flow)

Mix.install([
  {:req, "~> 0.4"},
  {:jason, "~> 1.4"}
])

Introduction

Balados Sync provides an OAuth-style authorization flow for third-party applications. This allows:

  • App developers to request access to user data
  • Users to authorize apps without sharing credentials
  • Token-based access with revocation support
  • Device-specific authorization tracking

The Authorization Flow

IO.puts("""
OAuth-style Authorization Flow:

┌─────────────┐
│ 1. App Dev  │ Creates authorization request
│   /app-creator │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 2. Token    │ Receives authorization token
│   Generated │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 3. User     │ Visits /authorize?token=...
│   Authorizes│
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 4. Token    │ Token becomes valid JWT
│   Activated │
└──────┬──────┘
       │
       ▼
┌─────────────┐
│ 5. App      │ Makes API calls with JWT
│   Uses API  │
└─────────────┘
""")

Setup

# API Configuration
base_url = "http://localhost:4000"
api_base = "#{base_url}/api/v1"

IO.puts("✓ Configuration ready")
IO.puts("App Creator: #{base_url}/app-creator")

Step 1: Create Authorization Request

Visit the app-creator page in your browser to generate an authorization token.

app_creator_url = "#{base_url}/app-creator"

IO.puts("🌐 Open this URL in your browser:")
IO.puts(app_creator_url)
IO.puts("\nYou'll see a form with:")
IO.puts("  • User ID (who is authorizing)")
IO.puts("  • Device ID (unique device identifier)")
IO.puts("  • Device Name (human-readable name)")
IO.puts("  • App Name (your application name)")
IO.puts("  • App Description (what your app does)")
IO.puts("\nFill out the form and submit.")

Step 2: Receive Authorization Token

After submitting the form, you’ll receive an authorization token. Paste it here:

# Paste the token you received from the app-creator form
authorization_token = "paste_your_authorization_token_here"

IO.puts("Authorization Token:")
IO.puts(authorization_token)
IO.puts("\n⚠️  This token is not yet activated!")
IO.puts("The user must authorize it first.")

Step 3: Generate Authorization URL

Share this URL with the user who needs to authorize your app:

authorize_url = "#{base_url}/authorize?token=#{authorization_token}"

IO.puts("🔗 Authorization URL:")
IO.puts(authorize_url)
IO.puts("\nSend this URL to the user:")
IO.puts("  • User opens URL in browser")
IO.puts("  • Reviews app details")
IO.puts("  • Clicks 'Authorize'")
IO.puts("  • Token becomes activated")

Step 4: User Authorization (Manual Step)

IO.puts("""
👤 User Authorization Steps:

1. User opens: #{authorize_url}

2. User sees authorization page:
   ┌──────────────────────────────┐
   │ Authorize Application        │
   │                              │
   │ App: [Your App Name]         │
   │ Device: [Device Name]        │
   │ Description: [...]           │
   │                              │
   │ [Authorize] [Deny]           │
   └──────────────────────────────┘

3. User clicks "Authorize"

4. Token is activated ✓

5. App can now use token for API calls
""")

IO.puts("\n⏳ Waiting for user to authorize...")
IO.puts("Press Enter after user has authorized the app")
_ = IO.gets("")
IO.puts("✓ Proceeding...")

Step 5: Use the Activated Token

After user authorization, the token becomes a valid JWT:

# The authorization token is now a valid JWT
jwt_token = authorization_token

# Create authenticated client
client = Req.new(
  base_url: api_base,
  headers: [{"authorization", "Bearer #{jwt_token}"}]
)

# Test the token
test_response = Req.get(client, "/subscriptions")

case test_response do
  {:ok, %{status: 200, body: subscriptions}} ->
    IO.puts("✓ Token is valid and activated!")
    IO.puts("  User has #{length(subscriptions)} subscriptions")

  {:ok, %{status: 401}} ->
    IO.puts("✗ Token not yet activated")
    IO.puts("  User needs to visit the authorization URL")

  {:error, reason} ->
    IO.puts("✗ Request failed: #{inspect(reason)}")
end

List Authorized Apps

Users can see all apps they’ve authorized:

# GET /api/v1/apps
apps_response = Req.get!(client, "/apps")

IO.inspect(apps_response.status, label: "Status")
IO.inspect(apps_response.body, label: "Authorized Apps")

apps = apps_response.body

IO.puts("\n📱 Authorized Applications (#{length(apps)}):")
Enum.each(apps, fn app ->
  IO.puts("\n  #{app["app_name"]}")
  IO.puts("    Device: #{app["device_name"]} (#{app["device_id"]})")
  IO.puts("    Description: #{app["app_description"]}")
  IO.puts("    Token ID: #{app["jti"]}")
  IO.puts("    Authorized: #{app["authorized_at"]}")
end)

Revoke App Authorization

Users can revoke access for any authorized app:

# Find an app to revoke
apps = Req.get!(client, "/apps").body

if length(apps) > 0 do
  # Get the first app's token ID (jti)
  app_to_revoke = List.first(apps)
  jti = app_to_revoke["jti"]

  IO.puts("Revoking authorization for: #{app_to_revoke["app_name"]}")

  # DELETE /api/v1/apps/:jti
  revoke_response = Req.delete!(client, "/apps/#{jti}")

  IO.inspect(revoke_response.status, label: "Status")

  case revoke_response.status do
    204 -> IO.puts("✓ App authorization revoked")
    200 -> IO.puts("✓ App authorization revoked")
    404 -> IO.puts("✗ App not found")
    _ -> IO.puts("✗ Failed to revoke authorization")
  end
else
  IO.puts("ℹ No authorized apps to revoke")
end

Complete Example Flow

Here’s a complete example simulating the entire flow:

defmodule AuthorizationDemo do
  def demonstrate(base_url) do
    IO.puts("🚀 Complete Authorization Flow Demo\n")

    # Step 1: App Creator
    IO.puts("Step 1: App Developer uses /app-creator")
    IO.puts("  URL: #{base_url}/app-creator")
    IO.puts("  Fills form with app details")
    IO.puts("  ✓ Receives authorization token\n")

    # Step 2: Generate auth URL
    token = "example_auth_token_abc123"
    auth_url = "#{base_url}/authorize?token=#{token}"

    IO.puts("Step 2: Generate Authorization URL")
    IO.puts("  URL: #{auth_url}")
    IO.puts("  ✓ Share with user\n")

    # Step 3: User authorizes
    IO.puts("Step 3: User Authorizes")
    IO.puts("  User opens: #{auth_url}")
    IO.puts("  Reviews app details")
    IO.puts("  Clicks 'Authorize'")
    IO.puts("  ✓ Token activated\n")

    # Step 4: App uses token
    IO.puts("Step 4: App Makes API Calls")
    IO.puts("  Uses token as JWT Bearer token")
    IO.puts("  Makes requests to /api/v1/*")
    IO.puts("  ✓ Access granted\n")

    # Step 5: View/revoke
    IO.puts("Step 5: Manage Authorization")
    IO.puts("  GET /api/v1/apps - List authorized apps")
    IO.puts("  DELETE /api/v1/apps/:jti - Revoke access")
    IO.puts("  ✓ User maintains control\n")
  end
end

AuthorizationDemo.demonstrate(base_url)

Security Considerations

IO.puts("""
🔒 Security Best Practices:

1. Token Storage
   • Store tokens securely (encrypted storage)
   • Never commit tokens to version control
   • Use environment variables or secure vaults

2. Token Transmission
   • Always use HTTPS in production
   • Never send tokens in URLs (query params)
   • Use Authorization header

3. Token Lifecycle
   • Implement token refresh if needed
   • Handle 401 errors gracefully
   • Allow users to revoke access easily

4. Scope and Permissions
   • Request only necessary permissions
   • Clearly explain what data you'll access
   • Respect user privacy settings

5. User Experience
   • Show clear app descriptions
   • Explain why authorization is needed
   • Provide easy revocation mechanism
""")

Building an App Authorization UI

For app developers building a client application:

defmodule AppAuthUI do
  @moduledoc """
  Example code for implementing app authorization in your client
  """

  def request_authorization(app_name, app_description, user_id, device_id, device_name) do
    """
    1. Direct user to app-creator page with pre-filled data:
       #{base_url}/app-creator?
         user_id=#{user_id}&
         device_id=#{device_id}&
         device_name=#{URI.encode(device_name)}&
         app_name=#{URI.encode(app_name)}&
         app_description=#{URI.encode(app_description)}

    2. User submits form and receives token

    3. Show authorization URL to user:
       #{base_url}/authorize?token=

    4. Poll or wait for user to authorize

    5. Start using token for API calls
    """
  end

  def check_authorization(client) do
    case Req.get(client, "/apps") do
      {:ok, %{status: 200}} -> {:ok, :authorized}
      {:ok, %{status: 401}} -> {:error, :not_authorized}
      {:error, reason} -> {:error, reason}
    end
  end

  def handle_revocation(client) do
    """
    Handle 401 responses during API calls:

    1. Detect 401 Unauthorized response
    2. Clear stored token
    3. Show user that authorization was revoked
    4. Prompt to re-authorize if needed
    """
  end
end

IO.puts("Example app authorization code loaded")
IO.puts("See AppAuthUI module for implementation examples")

Testing Authorization Flow

defmodule AuthFlowTester do
  def test_unauthorized_access(base_url) do
    # Try to access API without authorization
    response = Req.get("#{base_url}/api/v1/subscriptions",
      headers: [{"authorization", "Bearer invalid_token"}]
    )

    case response do
      {:ok, %{status: 401}} ->
        IO.puts("✓ Unauthorized access correctly rejected")
        :ok

      {:ok, %{status: status}} ->
        IO.puts("✗ Unexpected status: #{status}")
        :error

      {:error, reason} ->
        IO.puts("✗ Request failed: #{inspect(reason)}")
        :error
    end
  end

  def test_authorized_access(client) do
    response = Req.get(client, "/subscriptions")

    case response do
      {:ok, %{status: 200}} ->
        IO.puts("✓ Authorized access successful")
        :ok

      {:ok, %{status: 401}} ->
        IO.puts("✗ Token not authorized")
        :error

      {:error, reason} ->
        IO.puts("✗ Request failed: #{inspect(reason)}")
        :error
    end
  end
end

IO.puts("Testing unauthorized access:")
AuthFlowTester.test_unauthorized_access(base_url)

IO.puts("\nTesting authorized access (requires valid token):")
# Uncomment to test with valid token:
# AuthFlowTester.test_authorized_access(client)

Monitoring Authorized Apps

defmodule AppMonitor do
  def list_apps_summary(client) do
    case Req.get(client, "/apps") do
      {:ok, %{status: 200, body: apps}} ->
        IO.puts("📱 Authorized Apps Summary:\n")

        grouped = Enum.group_by(apps, fn app -> app["device_name"] end)

        Enum.each(grouped, fn {device, device_apps} ->
          IO.puts("  #{device}")
          Enum.each(device_apps, fn app ->
            IO.puts("    • #{app["app_name"]}")
          end)
          IO.puts("")
        end)

        IO.puts("Total: #{length(apps)} authorized apps")

      {:ok, %{status: 401}} ->
        IO.puts("✗ Not authorized")

      {:error, reason} ->
        IO.puts("✗ Error: #{inspect(reason)}")
    end
  end

  def revoke_all_for_device(client, device_id) do
    {:ok, %{body: apps}} = Req.get(client, "/apps")

    device_apps = Enum.filter(apps, fn app ->
      app["device_id"] == device_id
    end)

    IO.puts("Revoking #{length(device_apps)} apps for device: #{device_id}")

    Enum.each(device_apps, fn app ->
      Req.delete(client, "/apps/#{app["jti"]}")
      IO.puts("  ✓ Revoked: #{app["app_name"]}")
    end)
  end
end

# Example usage (requires valid token):
# AppMonitor.list_apps_summary(client)

Next Steps

Common Issues

Token not activated

  • User hasn’t visited /authorize URL yet
  • User clicked “Deny” instead of “Authorize”
  • Token was already used and expired

401 Unauthorized after working

  • User revoked app authorization
  • Token expired
  • Need to re-authorize

Cannot revoke app

  • Wrong JTI (token ID)
  • Already revoked
  • Using wrong authentication token

App creator form fails

  • All fields are required
  • User ID must be valid
  • Device ID must be unique for user