atproto_auth 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -2
  3. data/CHANGELOG.md +23 -2
  4. data/PROJECT_STRUCTURE.txt +10129 -0
  5. data/README.md +88 -2
  6. data/examples/confidential_client/.gitignore +2 -0
  7. data/examples/confidential_client/Gemfile.lock +6 -0
  8. data/examples/confidential_client/README.md +86 -9
  9. data/examples/confidential_client/app.rb +83 -12
  10. data/examples/confidential_client/{public/client-metadata.json → config/client-metadata.example.json} +5 -4
  11. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  12. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  13. data/examples/confidential_client/scripts/generate_keys.rb +0 -0
  14. data/examples/confidential_client/views/authorized.erb +1 -1
  15. data/lib/atproto_auth/client.rb +98 -38
  16. data/lib/atproto_auth/client_metadata.rb +2 -2
  17. data/lib/atproto_auth/configuration.rb +35 -1
  18. data/lib/atproto_auth/dpop/key_manager.rb +1 -1
  19. data/lib/atproto_auth/dpop/nonce_manager.rb +30 -47
  20. data/lib/atproto_auth/encryption.rb +156 -0
  21. data/lib/atproto_auth/http_client.rb +2 -2
  22. data/lib/atproto_auth/identity/document.rb +1 -1
  23. data/lib/atproto_auth/identity/resolver.rb +1 -1
  24. data/lib/atproto_auth/serialization/base.rb +189 -0
  25. data/lib/atproto_auth/serialization/dpop_key.rb +29 -0
  26. data/lib/atproto_auth/serialization/session.rb +77 -0
  27. data/lib/atproto_auth/serialization/stored_nonce.rb +37 -0
  28. data/lib/atproto_auth/serialization/token_set.rb +43 -0
  29. data/lib/atproto_auth/server_metadata/authorization_server.rb +20 -1
  30. data/lib/atproto_auth/state/session_manager.rb +67 -20
  31. data/lib/atproto_auth/storage/interface.rb +112 -0
  32. data/lib/atproto_auth/storage/key_builder.rb +39 -0
  33. data/lib/atproto_auth/storage/memory.rb +191 -0
  34. data/lib/atproto_auth/storage/redis.rb +119 -0
  35. data/lib/atproto_auth/token/refresh.rb +249 -0
  36. data/lib/atproto_auth/version.rb +1 -1
  37. data/lib/atproto_auth.rb +29 -1
  38. metadata +32 -4
  39. data/examples/confidential_client/config/client-metadata.json +0 -25
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
5
  [![Documentation](https://img.shields.io/badge/docs-rdoc-blue.svg)](https://www.rubydoc.info/gems/atproto_auth)
6
6
 
7
- A Ruby implementation of the AT Protocol OAuth specification. This library provides comprehensive support for both client and server-side implementations, with built-in security features including DPoP (Demonstrating Proof of Possession), PAR (Pushed Authorization Requests), and dynamic client registration.
7
+ A Ruby implementation of the [AT Protocol OAuth specification](https://docs.bsky.app/docs/advanced-guides/oauth-client). This library provides comprehensive support for both client and server-side implementations, with built-in security features including DPoP (Demonstrating Proof of Possession), PAR (Pushed Authorization Requests), and dynamic client registration.
8
8
 
9
9
  ## Features
10
10
 
@@ -15,6 +15,8 @@ A Ruby implementation of the AT Protocol OAuth specification. This library provi
15
15
  - Comprehensive identity resolution and verification
16
16
  - Automatic token refresh and session management
17
17
  - Robust error handling and recovery mechanisms
18
+ - Configurable storage backends with built-in Redis support
19
+ - Encrypted storage of sensitive data
18
20
 
19
21
  ## Installation
20
22
 
@@ -41,6 +43,7 @@ gem install atproto_auth
41
43
  - Ruby 3.0 or higher
42
44
  - OpenSSL support
43
45
  - For confidential clients: HTTPS-capable domain for client metadata hosting
46
+ - Optional: Redis 5.0+ for production storage backend
44
47
 
45
48
  ## Basic Usage
46
49
 
@@ -59,6 +62,88 @@ AtprotoAuth.configure do |config|
59
62
  # Set token lifetimes
60
63
  config.default_token_lifetime = 300 # 5 minutes
61
64
  config.dpop_nonce_lifetime = 300 # 5 minutes
65
+
66
+ # Configure storage backend (default is in-memory)
67
+ config.storage = AtprotoAuth::Storage::Memory.new
68
+ end
69
+
70
+ # For production environments, use Redis storage:
71
+ AtprotoAuth.configure do |config|
72
+ # Configure Redis storage
73
+ config.storage = AtprotoAuth::Storage::Redis.new(
74
+ redis_client: Redis.new(url: ENV['REDIS_URL'])
75
+ )
76
+ end
77
+ ```
78
+
79
+ ### Storage Backends
80
+
81
+ The library supports multiple storage backends for managing OAuth state:
82
+
83
+ #### In-Memory Storage (Default)
84
+ ```ruby
85
+ # Default configuration - good for development
86
+ AtprotoAuth.configure do |config|
87
+ config.storage = AtprotoAuth::Storage::Memory.new
88
+ end
89
+ ```
90
+
91
+ #### Redis Storage (Recommended for Production)
92
+ ```ruby
93
+ # Redis configuration - recommended for production
94
+ require 'redis'
95
+
96
+ AtprotoAuth.configure do |config|
97
+ redis_client = Redis.new(
98
+ url: ENV['REDIS_URL'],
99
+ ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER }
100
+ )
101
+
102
+ config.storage = AtprotoAuth::Storage::Redis.new(
103
+ redis_client: redis_client
104
+ )
105
+ end
106
+ ```
107
+
108
+ #### Custom Storage Implementation
109
+ ```ruby
110
+ # Implement your own storage backend
111
+ class CustomStorage < AtprotoAuth::Storage::Interface
112
+ def set(key, value, ttl: nil)
113
+ # Implementation
114
+ end
115
+
116
+ def get(key)
117
+ # Implementation
118
+ end
119
+
120
+ def delete(key)
121
+ # Implementation
122
+ end
123
+
124
+ def exists?(key)
125
+ # Implementation
126
+ end
127
+
128
+ def multi_get(keys)
129
+ # Implementation
130
+ end
131
+
132
+ def multi_set(hash, ttl: nil)
133
+ # Implementation
134
+ end
135
+
136
+ def acquire_lock(key, ttl:)
137
+ # Implementation
138
+ end
139
+
140
+ def release_lock(key)
141
+ # Implementation
142
+ end
143
+
144
+ def with_lock(key, ttl: 30)
145
+ # Implementation
146
+ end
62
147
  end
63
148
  ```
64
149
 
@@ -162,7 +247,8 @@ Built-in security best practices:
162
247
  - Constant-time token comparisons
163
248
  - Thread-safe state management
164
249
  - Protection against SSRF attacks
165
- - Secure token storage
250
+ - Secure encrypted token storage
251
+ - Atomic storage operations with locking
166
252
 
167
253
  ## Development
168
254
 
@@ -0,0 +1,2 @@
1
+ /config/client-metadata.json
2
+ /public/client-metadata.json
@@ -4,12 +4,14 @@ PATH
4
4
  atproto_auth (0.1.0)
5
5
  jose (~> 1.2)
6
6
  jwt (~> 2.9)
7
+ redis (~> 5.3)
7
8
 
8
9
  GEM
9
10
  remote: https://rubygems.org/
10
11
  specs:
11
12
  base64 (0.2.0)
12
13
  concurrent-ruby (1.3.4)
14
+ connection_pool (2.4.1)
13
15
  dotenv (3.1.4)
14
16
  faraday (2.12.1)
15
17
  faraday-net_http (>= 2.0, < 3.5)
@@ -45,6 +47,10 @@ GEM
45
47
  rackup (2.2.1)
46
48
  rack (>= 3)
47
49
  rbtree (0.4.6)
50
+ redis (5.3.0)
51
+ redis-client (>= 0.22.0)
52
+ redis-client (0.22.2)
53
+ connection_pool
48
54
  ruby2_keywords (0.0.5)
49
55
  set (1.1.1)
50
56
  sinatra (4.1.1)
@@ -2,14 +2,19 @@
2
2
 
3
3
  This is an example implementation of a confidential OAuth client for the AT Protocol using the AtprotoAuth gem. It demonstrates how to implement the OAuth flow for a web application, including DPoP token binding and secure session management.
4
4
 
5
+ <img src="https://github.com/jhuckabee/atproto_auth/blob/main/examples/confidential_client/screenshots/screenshot-1-sign-in.png?raw=true" alt="Sign In Form Screenshot" title="Sign In Form" width="500">
6
+
7
+ <img src="https://github.com/jhuckabee/atproto_auth/blob/main/examples/confidential_client/screenshots/screenshot-2-success.png?raw=true" alt="Sign In Success Screenshot" title="Sign In Success" width="500">
8
+
5
9
  ## Overview
6
10
 
7
11
  The example implements a simple web application using Sinatra that:
8
12
  - Allows users to sign in with their AT Protocol handle (@handle)
9
13
  - Implements the complete OAuth authorization flow
10
14
  - Uses DPoP-bound tokens for API requests
11
- - Demonstrates secure session management
15
+ - Demonstrates secure session management with encryption
12
16
  - Shows how to make authenticated API calls to Bluesky
17
+ - Provides examples of both development and production storage configurations
13
18
 
14
19
  ## Requirements
15
20
 
@@ -17,6 +22,7 @@ The example implements a simple web application using Sinatra that:
17
22
  - Bundler
18
23
  - A domain name for your application that matches your client metadata
19
24
  - SSL certificate for your domain (required for production)
25
+ - Redis (optional, recommended for production)
20
26
 
21
27
  ## Setup
22
28
 
@@ -35,19 +41,61 @@ bundle install
35
41
  bundle exec ruby scripts/generate_keys.rb > config/keys.json
36
42
  ```
37
43
 
38
- 4. Configure your client metadata in `config/client-metadata.json`. Make sure to:
44
+ 4. Configure your client metadata:
45
+ - Copy the example metadata file over:
46
+ ```
47
+ cp config/client-metadata.example.json config/client-metadata.json
48
+ ```
39
49
  - Set the correct `client_id` URL where your metadata will be hosted
40
50
  - Configure valid `redirect_uris` for your application
41
51
  - Add your generated keys from step 3 to the `jwks` field
42
52
 
43
53
  5. Set up environment variables:
44
54
  ```bash
45
- export SESSION_SECRET=your-secure-session-secret # Required for session encryption
46
- export PERMITTED_DOMAIN=your.domain.com # Your application's domain name
55
+ # Required for session encryption
56
+ export SESSION_SECRET=your-secure-session-secret
57
+
58
+ # Your application's domain name
59
+ export PERMITTED_DOMAIN=your.domain.com
60
+
61
+ # Optional: Redis URL for production storage
62
+ export REDIS_URL=redis://localhost:6379
47
63
  ```
48
64
 
49
65
  ## Configuration
50
66
 
67
+ ### Storage Configuration
68
+
69
+ The example app supports both in-memory and Redis storage backends:
70
+
71
+ #### Development (In-Memory Storage)
72
+ ```ruby
73
+ # config/development.rb
74
+ AtprotoAuth.configure do |config|
75
+ config.storage = AtprotoAuth::Storage::Memory.new
76
+ config.logger = Logger.new($stdout)
77
+ end
78
+ ```
79
+
80
+ #### Production (Redis Storage)
81
+ ```ruby
82
+ # config/production.rb
83
+ require 'redis'
84
+
85
+ AtprotoAuth.configure do |config|
86
+ redis_client = Redis.new(
87
+ url: ENV.fetch('REDIS_URL'),
88
+ ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER }
89
+ )
90
+
91
+ config.storage = AtprotoAuth::Storage::Redis.new(
92
+ redis_client: redis_client
93
+ )
94
+
95
+ config.logger = Logger.new($stdout)
96
+ end
97
+ ```
98
+
51
99
  ### Host Authorization
52
100
 
53
101
  This application requires specific domain configuration to function properly:
@@ -69,7 +117,7 @@ This application requires specific domain configuration to function properly:
69
117
  ```bash
70
118
  export PERMITTED_DOMAIN=machinename.xyz.ts.net
71
119
  ```
72
- 6. Run the application (see below).
120
+ 6. Run the application (see below)
73
121
 
74
122
  Your application will now be accessible via your Tailscale domain with HTTPS enabled.
75
123
 
@@ -77,20 +125,25 @@ Your application will now be accessible via your Tailscale domain with HTTPS ena
77
125
 
78
126
  The application uses encrypted sessions to store authorization data. Configure the session secret with:
79
127
 
80
- ```ruby
128
+ ```bash
81
129
  export SESSION_SECRET=your-secure-random-string
82
130
  ```
83
131
 
84
- If not set, a random secret will be generated on startup.
132
+ If not set, a random secret will be generated on startup (not recommended for production).
85
133
 
86
134
  ## Running the Application
87
135
 
136
+ ### Development
88
137
  ```bash
89
- bundle exec rackup
138
+ RACK_ENV=development bundle exec rackup
90
139
  ```
91
140
 
92
- This will start the server on `http://localhost:9292`.
141
+ ### Production
142
+ ```bash
143
+ RACK_ENV=production bundle exec rackup -E production
144
+ ```
93
145
 
146
+ This will start the server on `http://localhost:9292`.
94
147
 
95
148
  ## Troubleshooting
96
149
 
@@ -108,3 +161,27 @@ This will start the server on `http://localhost:9292`.
108
161
  - Verify your JWKS configuration
109
162
  - Check that your DPoP proofs are being generated correctly
110
163
  - Ensure your client authentication is working
164
+
165
+ 4. "Storage errors":
166
+ - For Redis storage, verify Redis connection settings
167
+ - Check Redis SSL configuration if using encrypted connections
168
+ - Ensure proper Redis authentication credentials if required
169
+
170
+ 5. "Session state lost":
171
+ - Verify storage configuration is correct
172
+ - Check Redis connection stability if using Redis storage
173
+ - Ensure session TTLs are appropriately configured
174
+
175
+ ## Understanding the Code
176
+
177
+ The example demonstrates several important concepts:
178
+
179
+ 1. **Secure Storage**: The app shows proper configuration of both development (in-memory) and production (Redis) storage backends.
180
+
181
+ 2. **Token Management**: All tokens are stored securely with encryption in the configured storage backend.
182
+
183
+ 3. **Session Handling**: The app demonstrates proper session state management with atomic operations and locking.
184
+
185
+ 4. **Error Recovery**: Includes examples of handling storage failures and token refresh scenarios.
186
+
187
+ The code is thoroughly commented to explain these concepts and their implementation details.
@@ -9,17 +9,18 @@ require "dotenv/load"
9
9
 
10
10
  # Main app entry point
11
11
  class ExampleApp < Sinatra::Base
12
+ def check_stored_session(session_id)
13
+ return false unless session_id
14
+
15
+ settings.oauth_client.authorized?(session_id)
16
+ rescue AtprotoAuth::SessionError
17
+ false
18
+ end
19
+
12
20
  configure :development do
13
21
  register Sinatra::Reloader
14
22
  end
15
23
 
16
- set :host_authorization, {
17
- permitted_hosts: ["localhost", ENV.fetch("PERMITTED_DOMAIN", nil)].compact
18
- }
19
-
20
- enable :sessions
21
- set :session_secret, ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) }
22
-
23
24
  # Initialize the AT Protocol OAuth client
24
25
  configure do
25
26
  # Configure AtprotoAuth settings
@@ -30,6 +31,9 @@ class ExampleApp < Sinatra::Base
30
31
  )
31
32
  config.default_token_lifetime = 300
32
33
  config.dpop_nonce_lifetime = 300
34
+
35
+ # Optionally, use Redis storage instead of in-memory
36
+ # config.storage = AtprotoAuth::Storage::Redis.new
33
37
  end
34
38
 
35
39
  # Load client metadata
@@ -45,10 +49,61 @@ class ExampleApp < Sinatra::Base
45
49
  )
46
50
  end
47
51
 
52
+ set :host_authorization, {
53
+ permitted_hosts: ["localhost", ENV.fetch("PERMITTED_DOMAIN", nil)].compact
54
+ }
55
+
56
+ use Rack::Session::Cookie,
57
+ key: "atproto.session",
58
+ expire_after: 86_400, # 1 day in seconds
59
+ secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) },
60
+ secure: true, # Only send over HTTPS
61
+ httponly: true, # Not accessible via JavaScript
62
+ same_site: :lax # CSRF protection
63
+
64
+ helpers do
65
+ def recover_session
66
+ session_id = session[:oauth_session_id]
67
+ return nil unless session_id
68
+
69
+ begin
70
+ # Check if session is still valid
71
+ return nil unless settings.oauth_client.authorized?(session_id)
72
+
73
+ session_id
74
+ rescue AtprotoAuth::Client::SessionError
75
+ # Clear invalid session
76
+ session.delete(:oauth_session_id)
77
+ nil
78
+ end
79
+ end
80
+ end
81
+
48
82
  get "/" do
83
+ # Check for existing session
84
+ redirect "/authorized" if recover_session
85
+
49
86
  erb :index
50
87
  end
51
88
 
89
+ get "/client-metadata.json" do
90
+ content_type :json
91
+
92
+ # Read metadata from config file
93
+ metadata_path = File.join(__dir__, "config", "client-metadata.json")
94
+ metadata = JSON.parse(File.read(metadata_path))
95
+
96
+ # Strip private key 'd' component from each key in the JWKS
97
+ if metadata["jwks"] && metadata["jwks"]["keys"]
98
+ metadata["jwks"]["keys"] = metadata["jwks"]["keys"].map do |key|
99
+ key.except("d")
100
+ end
101
+ end
102
+
103
+ # Return sanitized metadata
104
+ JSON.generate(metadata)
105
+ end
106
+
52
107
  # Start OAuth flow
53
108
  post "/auth" do
54
109
  handle = params[:handle]
@@ -60,7 +115,7 @@ class ExampleApp < Sinatra::Base
60
115
  scope: "atproto"
61
116
  )
62
117
 
63
- # Store session ID for callback
118
+ # Store session ID in user's browser session
64
119
  session[:oauth_session_id] = auth[:session_id]
65
120
 
66
121
  # Redirect to authorization URL
@@ -80,8 +135,8 @@ class ExampleApp < Sinatra::Base
80
135
  iss: params[:iss]
81
136
  )
82
137
 
83
- # Store tokens in session
84
- session[:tokens] = result
138
+ # Store tokens
139
+ session[:oauth_session_id] = result[:session_id]
85
140
 
86
141
  redirect "/authorized"
87
142
  rescue StandardError => e
@@ -91,9 +146,19 @@ class ExampleApp < Sinatra::Base
91
146
 
92
147
  # Show authorized state and test API call
93
148
  get "/authorized" do
94
- return redirect "/" unless session[:tokens]
149
+ session_id = session[:oauth_session_id]
150
+ return redirect "/" unless check_stored_session(session_id)
95
151
 
96
152
  begin
153
+ # Get current session tokens
154
+ oauth_session = settings.oauth_client.get_tokens(session_id)
155
+
156
+ # Check if token needs refresh
157
+ if oauth_session[:expires_in] < 30
158
+ # Refresh token
159
+ oauth_session = settings.oauth_client.refresh_token(session_id)
160
+ end
161
+
97
162
  # Make test API call to com.atproto.identity.resolveHandle
98
163
  conn = Faraday.new(url: "https://api.bsky.app") do |f|
99
164
  f.request :json
@@ -102,7 +167,7 @@ class ExampleApp < Sinatra::Base
102
167
 
103
168
  # Get auth headers for request
104
169
  headers = settings.oauth_client.auth_headers(
105
- session_id: session[:tokens][:session_id],
170
+ session_id: session_id,
106
171
  method: "GET",
107
172
  url: "https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle"
108
173
  )
@@ -114,6 +179,7 @@ class ExampleApp < Sinatra::Base
114
179
  end
115
180
 
116
181
  @api_result = response.body
182
+ @session = oauth_session
117
183
  erb :authorized
118
184
  rescue StandardError => e
119
185
  session[:error] = "API call failed: #{e.message}"
@@ -122,6 +188,11 @@ class ExampleApp < Sinatra::Base
122
188
  end
123
189
 
124
190
  get "/signout" do
191
+ if session[:oauth_session_id]
192
+ # Clean up stored session
193
+ settings.oauth_client.remove_session(session[:oauth_session_id])
194
+ end
195
+
125
196
  session.clear
126
197
  session[:notice] = "Successfully signed out"
127
198
  redirect "/"
@@ -1,7 +1,7 @@
1
1
  {
2
- "client_id": "https://mac.tail7f768.ts.net/client-metadata.json",
2
+ "client_id": "https://YOUR_DOMAIN_HERE/client-metadata.json",
3
3
  "client_name": "AT Protocol OAuth Ruby Example",
4
- "redirect_uris": ["https://mac.tail7f768.ts.net/callback"],
4
+ "redirect_uris": ["https://YOUR_DOMAIN_HERE/callback"],
5
5
  "grant_types": ["authorization_code", "refresh_token"],
6
6
  "response_types": ["code"],
7
7
  "scope": "atproto",
@@ -14,10 +14,11 @@
14
14
  {
15
15
  "use": "sig",
16
16
  "kid": "key-1",
17
- "x": "SzXlDk9rSyrZ3b0fVKOWFYY-AFZtld2zElycsmDZ3Xk",
17
+ "x": "...",
18
18
  "crv": "P-256",
19
+ "d": "...",
19
20
  "kty": "EC",
20
- "y": "4hIBLl-BLD1Ypk-mvPxT2OR52ezMs4XI1MGBdhlLLm4"
21
+ "y": "..."
21
22
  }
22
23
  ]
23
24
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  <div class="token-info">
15
15
  <p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Token Information</p>
16
- <pre class="rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 dark:bg-slate-800 border border-gray-400 p-2 overflow-auto"><code><%= JSON.pretty_generate(session[:tokens]) %></code></pre>
16
+ <pre class="rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 dark:bg-slate-800 border border-gray-400 p-2 overflow-auto"><code><%= JSON.pretty_generate(@session) %></code></pre>
17
17
  </div>
18
18
 
19
19
  <div class="api-test">