Skip to content

About

Ruby implementation of the AT Protocol OAuth specification

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

 
 

Repository files navigation

AuthProto

Gem Version Documentation

A Ruby implementation of the AT Protocol OAuth specification. This library provides support for both client and server-side implementations, with built-in security features including DPoP, PAR, and dynamic client registration.

Warning

I have inherited this gem from someone else, and haven't fully reviewed all code yet. Consider this an alpha version.

Features

  • Complete AT Protocol OAuth 2.0 implementation
  • Secure by default with mandatory DPoP and PKCE
  • Support for confidential (backend) and public clients
  • Thread-safe session and token management
  • Comprehensive identity resolution and verification
  • Automatic token refresh and session management
  • Robust error handling and recovery mechanisms
  • Configurable storage backends with built-in SQLite and Redis support
  • Encrypted storage of sensitive data

Installation

Add this line to your application's Gemfile:

gem 'authproto'

And then execute:

bundle install

Or install it yourself as:

gem install authproto

Requirements

  • Ruby 3.3 or higher
  • OpenSSL support
  • For confidential clients: HTTPS-capable domain for client metadata hosting
  • Optional: SQLite or Redis storage adapter gems for production use

Basic Usage

Configuration

require 'authproto'

AuthProto.configure do |config|
  # Configure HTTP client settings
  config.http_client = AuthProto::HTTPClient.new(
    timeout: 10,
    verify_ssl: true
  )

  # Set the default nonce cache lifetime (seconds), used by newly created clients.
  config.dpop_nonce_lifetime = 300  # 5 minutes

  # Configure storage backend (default is in-memory)
  config.storage = AuthProto::Storage::Memory.new
end

# Storage adapter gems are optional dependencies. Add one of these:
gem 'sqlite3', '~> 2.9'
gem 'redis', '~> 5.4'

Set dpop_nonce_lifetime to a positive integer before creating clients or nonce managers. Its default is 300 seconds. An explicit NonceManager.new(ttl: ...) or DPoP::Client.new(nonce_ttl: ...) overrides it. Existing managers retain the lifetime chosen at initialization.

Local HTTP debugging

For debugging a flow that needs both local HTTP and remote HTTPS (handle/PLC resolution, metadata discovery and OAuth requests), explicitly select:

AuthProto.configure do |config|
  config.http_client = AuthProto::InsecureHTTPClient.new(timeout: 10)
end

InsecureHTTPClient accepts HTTP and HTTPS, including loopback and private-network destinations, and permits redirects between them. It disables the regular client's scheme/IP restrictions for development; HTTPS certificate verification stays enabled by default. Timeouts, response size limits and request headers are inherited.

The regular HTTPClient accepts HTTPS and rejects loopback/private addresses. Use it for remote OAuth servers, including when your application's callback is local: the browser, rather than this HTTP client, follows the callback redirect.

Client metadata fetches call get(url, follow_redirects: false) and require HTTP 200 with an application/json response. Custom HTTP clients must support this keyword and return the initial response without following redirects when it is false.

Client metadata must explicitly set token_endpoint_auth_method to none for public clients or private_key_jwt for confidential clients. Omitting it defaults to client_secret_basic in OAuth metadata, which this library does not support.

For localhost development, Metadata::ClientMetadata.from_url generates virtual metadata without making an HTTP request. It defaults to native public authentication, the atproto scope, and redirects http://127.0.0.1/ and http://[::1]/. The client ID query can specify one scope parameter and multiple redirect_uri parameters, encoded using URI.encode_www_form. These values are decoded and validated; the original client ID is preserved exactly.

Storage Backends

The library supports multiple storage backends for managing OAuth state:

Pending authorization sessions and their state-token lookups expire after 15 minutes by default. Configure the lifetime in seconds with a positive integer:

AuthProto.configure do |config|
  config.pending_session_ttl = 900
end

Updating a pending session preserves its original deadline. Successful authorization removes the pending deadline and state-token lookup. Authenticated sessions without refresh tokens expire with their access token; sessions with refresh tokens have no automatic storage expiry.

In-Memory Storage (Default)

# Default configuration - good for development
AuthProto.configure do |config|
  config.storage = AuthProto::Storage::Memory.new
end

SQLite Storage

SQLite can be used for persistent storage in development or in production setups with low-to-moderate write concurrency:

AuthProto.configure do |config|
  config.storage = AuthProto::Storage::SQLite.new(
    path: '/var/lib/my_app/authproto.sqlite3'
  )
end

Add the adapter gem to your Gemfile:

gem 'sqlite3', '~> 2.9'

Redis Storage

Use Redis for production deployments with multiple hosts or write-heavy scenarios:

require 'redis'

AuthProto.configure do |config|
  redis_client = Redis.new(
    url: ENV['REDIS_URL'],
    ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER }
  )
  
  config.storage = AuthProto::Storage::Redis.new(
    redis_client: redis_client
  )
end

Add the adapter gem to your Gemfile:

gem 'redis', '~> 5.4'

Custom Storage Implementation

# Implement your own storage backend
class CustomStorage < AuthProto::Storage::Interface
  def set(key, value, ttl: nil)
    # Implementation
  end

  def get(key)
    # Implementation
  end

  def delete(key)
    # Implementation
  end

  def exists?(key)
    # Implementation
  end

  def with_lock(key, ttl: 30)
    # Implementation
  end
end

Confidential Client Example

Here's a basic example of using the library in a confidential client application:

Supply the private EC P-256 client-authentication JWK through signing_key:, separately from metadata. It must have a nonempty kid and permit ES256 signing. Metadata's jwks contains only public keys; the client checks that the matching public key and kid are advertised and active. A public key may declare use: "sig" or key_ops: ["verify"]. Other advertised public keys may serve other purposes, such as encryption; the matching authentication key must permit signature verification.

With jwks_uri, the client still uses your separate private signing key. It does not fetch that URL or check the advertised key locally; the authorization server fetches it to verify assertions.

New confidential-client sessions record the signing key's kid as signing_key_id before PAR. Session serialization preserves this ID through callbacks and token refreshes, without including the private client-authentication key. The session manager returns nil when loading a session with an unsupported signing key ID, leaving its stored data intact. Client supplies IDs of its configured private signing keys, rather than the public keys advertised in metadata. Keep each kid associated with the same key material. Clients still accept one signing_key:; automatic key selection and rotation are not implemented.

Public-client sessions have no signing key ID. Older serialized sessions without this field remain readable and use the configured signing key without an ID check; their original key ID cannot be inferred from the stored session.

# Initialize client with metadata
client = AuthProto::Client.new(
  client_id: "https://app.example.com/oauth-client-metadata.json",
  redirect_uri: "https://app.example.com/callback",
  signing_key: JSON.parse(File.read("config/keys.json")).fetch("keys").first,
  metadata: {
    # Your client metadata...
  }
)

# Start authorization flow
auth = client.authorize(
  handle: "user.bsky.social",
  scope: "atproto"
)

# Retain the expected ID in this browser's session before redirecting to auth[:url].
session[:pending_oauth_session_id] = auth.fetch(:session_id)

# Handle callback
tokens = client.handle_callback(
  code: params[:code],
  state: params[:state],
  iss: params[:iss],
  expected_session_id: session[:pending_oauth_session_id]
)
session.delete(:pending_oauth_session_id)

# Make authenticated requests
headers = client.auth_headers(
  session_id: tokens[:session_id],
  method: "GET",
  url: "https://api.example.com/resource"
)

For a complete working example of a confidential client implementation, check out the example application in examples/confidential_client/. This Sinatra-based web application demonstrates:

  • Complete OAuth flow implementation
  • Session management
  • DPoP token binding
  • Making authenticated API requests
  • Proper error handling
  • Key generation and management

See examples/confidential_client/README.md for setup instructions and implementation details.

Public Client Example

client = AuthProto::Client.new(
  client_id: "https://app.example.com/oauth-client-metadata.json",
  redirect_uri: "https://app.example.com/callback"
)

# Browser will open authorization URL
auth = client.authorize(
  handle: "user.bsky.social",
  scope: "atproto"
)

# Retain this in the initiating browser session, or locally for a native app.
session[:pending_oauth_session_id] = auth.fetch(:session_id)

# After callback, exchange code for tokens
tokens = client.handle_callback(
  code: params[:code],
  state: params[:state],
  iss: params[:iss],
  expected_session_id: session[:pending_oauth_session_id]
)
session.delete(:pending_oauth_session_id)

Binding Callbacks to the Initiating Login

handle_callback requires expected_session_id: for both AuthProto::Client and Minisky::OAuthClient. Retain the :session_id returned by authorize in the initiating browser's session, then supply that retained ID on callback. Missing, nil, empty, or mismatched IDs are rejected before any token exchange.

Never take the expected ID from callback parameters or derive it from the returned state: that would not establish which browser started the login. A shared web client's session_id is not a substitute for per-browser session storage. Remove the pending association after successful completion. For concurrent login attempts, retain each expected ID separately and verify membership in that browser's pending attempts.

Native and console apps can retain the ID in the local context for that login attempt, as the localhost example does. The console example's manual callback helper uses the ID remembered by its local Minisky client. When upgrading existing callers, add this required keyword using the retained authorization result.

Features In Detail

Identity Resolution

The library uses DIDKit for AT Protocol identity resolution:

  • Handle to DID resolution (DNS-based or HTTP fallback)
  • DID document fetching and validation
  • PDS (Personal Data Server) location verification
  • Bidirectional handle verification
  • Authorization server binding verification

Token & Session Management

Comprehensive token lifecycle management:

  • Secure token storage with encryption
  • Automatic token refresh
  • DPoP proof generation and binding
  • Session state tracking
  • Cleanup of expired sessions

Security Features

Built-in security best practices:

  • Mandatory PKCE for all flows
  • DPoP token binding
  • Constant-time token comparisons
  • Thread-safe state management
  • Protection against SSRF attacks
  • Secure encrypted token storage
  • Atomic storage operations with locking

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rspec (or bundle exec rake spec) to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/mackuba/authproto.

License

The gem is available as open source under the terms of the MIT License.

About

Ruby implementation of the AT Protocol OAuth specification

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages