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:
- Understand the app authorization flow
- Generate authorization and API request JWTs
-
Use the
/app-creatortool for development - 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:
- Apps create an authorization JWT with their public key
- Users authorize apps via the web interface
- 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
-
App ID: Unique identifier (e.g.,
com.example.podcast-player) - App Name: Display name users will see
- App URL: Your app’s homepage
- App Image: Icon URL (displayed if 10%+ users authorized)
- Scopes: Select permissions your app needs
- 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
-
Keep Private Keys Secret
- Never commit private keys to version control
- Store securely on your servers
- Rotate keys periodically
-
Request Minimal Scopes
- Only request scopes your app actually needs
- Users can see all requested permissions
- Excessive permissions may discourage authorization
-
Use Short-Lived Tokens
- API request JWTs should expire quickly (1 hour)
- Generate new JWTs as needed
- Don’t reuse expired tokens
-
Validate on Server
- API validates JWT signature using your public key
- Checks scope permissions for each endpoint
- Verifies token hasn’t been revoked
-
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
issorsubclaims
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
- App Authorization Details - Complete OAuth flow
- Subscriptions API - Subscribe to podcasts
- Play Status API - Track listening progress
Additional Resources
-
Token Generator: Visit
/app-creatorfor a GUI tool - Scope Reference: See CLAUDE.md for complete scope list
- API Endpoints: Check controller files for all available endpoints