- Ruby 100%
- Shell <1%
AuthProto #
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
# Set client assertion validity (seconds), used for each newly generated JWT.
config.client_assertion_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.
client_assertion_lifetime must also be a positive integer and defaults to 300
seconds. It is read for each assertion, including by existing clients and signers.
An explicit ClientAssertion#generate_jwt(lifetime: ...) overrides the configured
default. This controls assertion validity, not access-token or session lifetime.
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
DPoP Session Keys #
Each new OAuth session generates its own DPoP key before PAR. The session serializer
stores its private JWK with the session and encrypts the private d component.
Restoring the session restores that same key for code exchange, refresh and API
requests. Configure persistent storage and a stable ATPROTO_MASTER_KEY to retain
sessions across application restarts.
Client and the Minisky integration no longer accept dpop_key:. Restore sessions
by their session ID; no separate key export or constructor argument is needed.
Client#dpop_client remains shared and accepts session: when generating proofs.
Its ProofGenerator accepts key_set: per call. Nonces are stored separately with
TTLs, indexed by session ID and server origin. URLs on the same origin share a
nonce within that session; other sessions and origins have independent nonces.
DPoP::Client#with_nonce_handling(session, url) { ... } runs a request block,
processes its response, and runs it once more if the server sends a nonce challenge.
The block must generate a fresh proof and send the request on each invocation.
The helper returns the final response; other errors and exceptions are left to
its caller. PAR, code exchange, and refresh use this helper.
DPoP::Client#process_response accepts the whole HTTP response hash and requires
server_url: and session_id: to update the requesting
session's nonce. When sending requests without with_nonce_handling, call it
for every DPoP response, including successes, failures, and retry responses; missing or empty DPoP-Nonce headers raise an error. Nonces must use RFC 9449's
NQCHAR syntax; malformed values and duplicate nonce headers are rejected.
has_nonce_error? recognizes HTTP 400 JSON nonce errors and HTTP 401 DPoP
authentication challenges. It does not update the nonce or send a retry.
Direct nonce manager calls to get, update, and clear also
require server_url: and session_id: keywords. Old per-server cache entries are
ignored and expire normally; a missing nonce can be obtained through a server challenge.
Serialized sessions from older versions that lack a private DPoP key cannot be restored: start authorization again. Loading never generates a replacement key for an existing session. Client-authentication signing keys remain separate from these per-session DPoP keys.
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"].
Each key in an inline jwks must have a distinct, case-sensitive kid.
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
and its RFC 7638 SHA-256 thumbprint as signing_key_thumbprint before PAR. Both
values persist through callbacks and refreshes without including the private
client-authentication key. The session manager returns nil if the key ID is
unsupported or its thumbprint no longer matches, leaving stored data intact.
Client supplies a key-ID-to-thumbprint mapping derived from its configured
private signing key, rather than the public keys advertised in metadata.
Sessions with a recorded key ID but no thumbprint must authorize again.
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.