Powered by AppSignal & Oban Pro

Authentication with Balados Sync API

docs/api/authentication.livemd

Authentication with Balados Sync API

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

Introduction

The Balados Sync API uses JWT (JSON Web Tokens) with RS256 signing for third-party app authentication. This Livebook demonstrates how to:

  1. Understand the app authorization flow
  2. Generate authorization and API request JWTs
  3. Use the /app-creator tool for development
  4. Make authenticated API requests with scopes

Configuration

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

Understanding the Authorization System

Balados Sync uses an OAuth-style flow where:

  1. Apps create an authorization JWT with their public key
  2. Users authorize apps via the web interface
  3. Apps make API requests using JWTs signed with their private key

App Identification

Apps are identified by their app_id (from the JWT iss field) and associated with a public/private key pair.

JWT Structures

Authorization Request JWT

This JWT is used to request authorization from users:

# Authorization JWT Structure
authorization_jwt = %{
  "iss" => "com.example.my-podcast-app",  # App ID (required)
  "app" => %{
    "name" => "My Podcast App",            # App name (required)
    "url" => "https://example.com",        # Homepage (optional)
    "image" => "https://example.com/icon.png",  # Icon URL (optional)
    "public_key" => "-----BEGIN PUBLIC KEY-----..."  # Public key (required)
  },
  "scopes" => ["user.subscriptions.read", "user.plays.write"],  # Requested permissions
  "iat" => :os.system_time(:second),
  "exp" => :os.system_time(:second) + 86400  # 24 hours
}

IO.inspect(authorization_jwt, label: "Authorization JWT")

API Request JWT

After authorization, apps use this JWT structure for API calls:

# API Request JWT Structure
api_request_jwt = %{
  "iss" => "com.example.my-podcast-app",  # App ID (must match authorization)
  "sub" => "user_123",                    # User ID
  "iat" => :os.system_time(:second),
  "exp" => :os.system_time(:second) + 3600  # 1 hour
}

IO.inspect(api_request_jwt, label: "API Request JWT")

Scope System

The API uses hierarchical scopes with wildcard support:

Available Scopes

scopes = %{
  # Wildcards
  "*" => "Full access to all data and operations",
  "*.read" => "Read access to all data",
  "*.write" => "Write access to all data",

  # User Profile
  "user" => "Full access to user profile",
  "user.read" => "Read user profile information",
  "user.write" => "Update user profile information",

  # Subscriptions
  "user.subscriptions" => "Full access to subscriptions",
  "user.subscriptions.read" => "List podcast subscriptions",
  "user.subscriptions.write" => "Add and remove podcast subscriptions",

  # Play Status
  "user.plays" => "Full access to play status and positions",
  "user.plays.read" => "Read playback positions and play status",
  "user.plays.write" => "Update playback positions and mark episodes as played",

  # Playlists
  "user.playlists" => "Full access to playlists",
  "user.playlists.read" => "List playlists and their contents",
  "user.playlists.write" => "Create, update, and delete playlists",

  # Privacy
  "user.privacy" => "Full access to privacy settings",
  "user.privacy.read" => "View privacy settings",
  "user.privacy.write" => "Update privacy settings",

  # Sync
  "user.sync" => "Full synchronization access (all user data)"
}

IO.inspect(scopes, label: "Available Scopes")

Wildcard Patterns

# Examples of scope matching
examples = [
  {"*", ["Matches ANY scope"]},
  {"*.read", ["user.subscriptions.read", "user.plays.read", "user.privacy.read"]},
  {"user.*", ["user.subscriptions", "user.plays", "user.playlists", "user.privacy"]},
  {"user.*.read", ["user.subscriptions.read", "user.plays.read", "user.playlists.read"]}
]

IO.puts("Wildcard Pattern Examples:\n")
Enum.each(examples, fn {pattern, matches} ->
  IO.puts("Pattern: #{pattern}")
  IO.puts("  Matches: #{Enum.join(matches, ", ")}\n")
end)

Using the App Creator Tool (Recommended)

The /app-creator page provides a user-friendly interface for generating authorization tokens.

Step 1: Visit the App Creator

app_creator_url = "#{base_url}/app-creator"
IO.puts("Visit: #{app_creator_url}")
IO.puts("\nThe form will help you:")
IO.puts("1. Generate an RSA key pair")
IO.puts("2. Select required scopes")
IO.puts("3. Create an authorization JWT")

Step 2: Fill in App Details

  1. App ID: Unique identifier (e.g., com.example.podcast-player)
  2. App Name: Display name users will see
  3. App URL: Your app’s homepage
  4. App Image: Icon URL (displayed if 10%+ users authorized)
  5. Scopes: Select permissions your app needs
  6. Keys: Generate or paste RSA key pair

Step 3: Get Authorization Token

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

IO.puts("Authorization Token: #{String.slice(authorization_token, 0..50)}...")

Step 4: User Authorization

# Users visit this URL to authorize your app
authorize_url = "#{base_url}/authorize?token=#{authorization_token}"

IO.puts("Send users to this URL:")
IO.puts(authorize_url)
IO.puts("\nAfter authorization, you can make API requests using JWTs signed with your private key.")

Making Authenticated API Requests

After authorization, create API request JWTs signed with your private key:

defmodule APIClient do
  def make_request_jwt(app_id, user_id, private_key_pem) do
    # Create the JWT payload
    claims = %{
      "iss" => app_id,
      "sub" => user_id,
      "iat" => :os.system_time(:second),
      "exp" => :os.system_time(:second) + 3600
    }

    # Sign with RS256 using the private key
    signer = Joken.Signer.create("RS256", %{"pem" => private_key_pem})

    case Joken.encode_and_sign(claims, signer) do
      {:ok, token, _claims} -> {:ok, token}
      {:error, reason} -> {:error, reason}
    end
  end
end

# Example usage (you need your private key):
# app_id = "com.example.my-app"
# user_id = "user_123"
# private_key = "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# {:ok, api_token} = APIClient.make_request_jwt(app_id, user_id, private_key)

Making API Calls

# Use the generated API token
# api_token = "your_api_request_jwt_here"

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

# Make requests
# subscriptions = Req.get!(client, "/subscriptions")
# IO.inspect(subscriptions.body, label: "Subscriptions")

Scope Enforcement

API endpoints check for required scopes:

# Endpoint scope requirements
endpoint_scopes = %{
  "GET /api/v1/subscriptions" => ["user.subscriptions.read"],
  "POST /api/v1/subscriptions" => ["user.subscriptions.write"],
  "DELETE /api/v1/subscriptions/:feed" => ["user.subscriptions.write"],

  "GET /api/v1/plays" => ["user.plays.read"],
  "POST /api/v1/plays" => ["user.plays.write"],
  "PUT /api/v1/plays/:item/position" => ["user.plays.write"],

  "GET /api/v1/privacy" => ["user.privacy.read"],
  "PUT /api/v1/privacy" => ["user.privacy.write"],

  "POST /api/v1/sync" => ["user.sync", "user"],  # Either scope works
}

IO.inspect(endpoint_scopes, label: "Endpoint Scope Requirements")

Insufficient Permissions

If your app lacks required scopes, you’ll receive:

# Response for insufficient permissions
insufficient_response = %{
  "status" => 403,
  "body" => %{"error" => "Insufficient permissions"}
}

IO.inspect(insufficient_response, label: "Insufficient Scope Response")

Testing Authentication

Test Scope Validation

# To test different scopes, you need to re-authorize with different permissions
IO.puts("""
To test scope validation:

1. Create an authorization JWT with limited scopes
   (e.g., only ["user.subscriptions.read"])

2. Authorize the app via /authorize

3. Try to access endpoints requiring different scopes
   - GET /subscriptions should work (has .read)
   - POST /subscriptions should fail 403 (needs .write)

4. Re-authorize with additional scopes to update permissions
""")

Managing Authorized Apps

List Authorized Apps (API)

# GET /api/v1/apps
# Returns all apps you've authorized

# Example response:
apps_response = %{
  "apps" => [
    %{
      "id" => "uuid",
      "app_id" => "com.example.podcast-player",
      "app_name" => "Podcast Player Pro",
      "scopes" => ["user.subscriptions.read", "user.plays.write"],
      "last_used_at" => "2024-01-15T10:30:00Z"
    }
  ]
}

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

Revoke App Authorization (API)

# DELETE /api/v1/apps/:app_id
# Revokes authorization for a specific app

# The app can no longer make requests on your behalf
revoke_response = %{
  "status" => "success",
  "message" => "App authorization revoked"
}

IO.inspect(revoke_response, label: "Revoke Response")

Manage Apps (Web Interface)

# Users can also manage apps via web interface
manage_apps_url = "#{base_url}/apps"

IO.puts("Manage authorized apps at:")
IO.puts(manage_apps_url)

Security Best Practices

  1. Keep Private Keys Secret

    • Never commit private keys to version control
    • Store securely on your servers
    • Rotate keys periodically
  2. Request Minimal Scopes

    • Only request scopes your app actually needs
    • Users can see all requested permissions
    • Excessive permissions may discourage authorization
  3. Use Short-Lived Tokens

    • API request JWTs should expire quickly (1 hour)
    • Generate new JWTs as needed
    • Don’t reuse expired tokens
  4. Validate on Server

    • API validates JWT signature using your public key
    • Checks scope permissions for each endpoint
    • Verifies token hasn’t been revoked
  5. Use HTTPS in Production

    • Never send tokens over unencrypted connections
    • Configure proper TLS certificates

Troubleshooting

401 Unauthorized

Possible causes:

  • Token signature invalid (wrong private key)
  • Token expired
  • App not authorized by user
  • Missing iss or sub claims

403 Forbidden

Possible causes:

  • Insufficient scopes for the endpoint
  • Need to re-authorize with additional scopes

App Authorization Rejected

Possible causes:

  • Invalid public key in authorization JWT
  • Missing required fields (app_id, app.name)
  • Invalid scope names

Next Steps

Additional Resources

  • Token Generator: Visit /app-creator for a GUI tool
  • Scope Reference: See CLAUDE.md for complete scope list
  • API Endpoints: Check controller files for all available endpoints