Authentication
Copy MarkdownProtoRune supports password-based authentication with app passwords and OAuth 2.0 for applications.
App Passwords
App passwords are the recommended way to authenticate bots and applications with Bluesky. Never use your main account password.
Creating an App Password
- Log in to Bluesky
- Go to Settings → Privacy and Security → App Passwords
- Create a new app password
- Save the generated password (it will only be shown once)
Basic Authentication
The login/3 function creates a session with your credentials:
{:ok, session} = ProtoRune.login(
"your-handle.bsky.social",
"your-app-password"
)Login Parameters
identifier: Your handle (e.g., "alice.bsky.social") or emailpassword: Your app password (not your main account password)opts: Optional keyword list:service: Service URL (default: "https://bsky.social")
Custom Service URL
To connect to a different PDS (Personal Data Server):
{:ok, session} = ProtoRune.login(
"alice.bsky.social",
"app-password",
service: "https://custom-pds.example.com"
)Session Management
Session Structure
A session contains:
%{
access_jwt: "eyJ...", # Short-lived access token
refresh_jwt: "eyJ...", # Long-lived refresh token
did: "did:plc:abc123", # Your DID
handle: "alice.bsky.social", # Your handle
service_url: "https://...", # Your PDS endpoint
did_doc: %{...} # Your DID document
}Token Refresh
Access tokens expire after a period of time. Use refresh_session/1 to get a fresh access token:
case ProtoRune.refresh_session(session) do
{:ok, fresh_session} ->
# Use fresh_session for subsequent requests
ProtoRune.Bsky.post(fresh_session, "Posted with refreshed session")
{:error, :missing_refresh_jwt} ->
# Session doesn't have a refresh token
# Need to login again
{:error, reason} ->
# Refresh failed, may need to re-authenticate
IO.puts("Refresh failed: #{inspect(reason)}")
endSession Information
Get current session details:
{:ok, info} = ProtoRune.get_session(session)This returns information about your current session without refreshing tokens.
Storing Sessions
For persistent applications, you may want to store session tokens:
defmodule MyApp.SessionStore do
def save_session(session) do
# Store refresh_jwt securely
# Never store in version control
# Consider encryption for sensitive storage
File.write!("session.json", JSON.encode!(%{
refresh_jwt: session.refresh_jwt,
access_jwt: session.access_jwt,
did: session.did
}))
end
def load_session do
case File.read("session.json") do
{:ok, content} ->
data = JSON.decode!(content)
{:ok, Map.new(data, fn {k, v} -> {String.to_atom(k), v} end)}
{:error, _} ->
{:error, :no_saved_session}
end
end
endThen restore on application start:
case MyApp.SessionStore.load_session() do
{:ok, stored_session} ->
# Verify session is still valid
case ProtoRune.get_session(stored_session) do
{:ok, _info} ->
stored_session
{:error, _} ->
# Session expired, refresh or re-login
ProtoRune.refresh_session(stored_session)
end
{:error, :no_saved_session} ->
# Need to login
ProtoRune.login(identifier, password)
endSecurity Best Practices
Credential Management
- Never hardcode credentials in source code
- Use environment variables for development
- Use secure secret management in production
# Good: Environment variables
identifier = System.get_env("BSKY_IDENTIFIER")
password = System.get_env("BSKY_APP_PASSWORD")
# Bad: Hardcoded (never do this)
identifier = "alice.bsky.social"
password = "abcd-1234-efgh-5678"Token Storage
- Encrypt tokens when storing to disk
- Set restrictive file permissions (0600)
- Never commit tokens to version control
- Add session files to .gitignore
# .gitignore
session.json
*.sessionToken Rotation
Implement automatic token refresh in long-running applications:
defmodule MyApp.SessionManager do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(opts) do
# Initial login
{:ok, session} = ProtoRune.login(
opts[:identifier],
opts[:password]
)
# Schedule refresh every 4 hours
schedule_refresh()
{:ok, %{session: session}}
end
def handle_info(:refresh, state) do
case ProtoRune.refresh_session(state.session) do
{:ok, fresh_session} ->
schedule_refresh()
{:noreply, %{state | session: fresh_session}}
{:error, _reason} ->
# Refresh failed, could re-login or stop
{:stop, :refresh_failed, state}
end
end
defp schedule_refresh do
# Refresh every 4 hours (14400 seconds)
Process.send_after(self(), :refresh, 14_400_000)
end
endError Handling
Common authentication errors:
case ProtoRune.login(identifier, password) do
{:ok, session} ->
session
{:error, %{error: "AuthenticationRequired"}} ->
# Invalid credentials
IO.puts("Invalid username or password")
{:error, %{error: "InvalidToken"}} ->
# Token expired or invalid
IO.puts("Token is invalid, please re-authenticate")
{:error, reason} ->
# Network or other errors
IO.puts("Login error: #{inspect(reason)}")
endTesting with Authentication
For testing, consider using test accounts or mocking:
defmodule MyApp.Test do
use ExUnit.Case
setup do
# Option 1: Use test account
{:ok, session} = ProtoRune.login(
System.get_env("TEST_IDENTIFIER"),
System.get_env("TEST_PASSWORD")
)
# Option 2: Mock session (for unit tests)
mock_session = %{
access_jwt: "test-access-token",
refresh_jwt: "test-refresh-token",
did: "did:plc:test123",
handle: "test.bsky.social"
}
{:ok, session: session}
end
test "can post with authenticated session", %{session: session} do
{:ok, post} = ProtoRune.Bsky.post(session, "Test post")
assert post.uri
end
endOAuth
OAuth is the recommended flow for applications acting on behalf of users, since tokens are granted without sharing a password. ProtoRune implements the AT Protocol OAuth profile for public clients: authorization code flow with PAR, PKCE and DPoP, using only :crypto (no JWT dependency).
Requirements
Your application must serve a client metadata document at its client_id URL declaring the redirect URIs and scope. See the AT Protocol OAuth spec for the document format.
Flow
alias ProtoRune.Atproto.OAuth
alias ProtoRune.Atproto.OAuth.Client
# 1. Configure the client (a DPoP key pair is generated for you)
{:ok, client} =
Client.new(
client_id: "https://myapp.example.com/oauth/client-metadata.json",
redirect_uri: "https://myapp.example.com/oauth/callback"
)
# 2. Send the user to their authorization server
{:ok, url, pending} = OAuth.authorization_url(client, "alice.bsky.social")
# Redirect the user to `url` and persist `pending` (e.g. in the web session)
# 3. On the callback, exchange the code for tokens
{:ok, session} = OAuth.exchange_code(client, pending, conn.query_params)
# 4. Refresh when the access token expires
{:ok, fresh_session} = OAuth.refresh(client, session)The pending map and the returned session contain the DPoP private key: persist them securely and never expose them to the browser.
To keep the DPoP key across restarts, store client.dpop_key (a 32-byte binary) and pass it back with dpop_key: when rebuilding the client.
Current limitations
- OAuth access tokens are DPoP-bound, so they cannot be passed to the XRPC functions yet (those expect app-password
Bearersessions). Use the OAuth session with the token endpoints for now; XRPC integration is planned. - Only public clients are supported (no
private_key_jwtconfidential clients).