authproto 0.3.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +31 -0
  4. data/.tm_properties +1 -0
  5. data/.yardopts +4 -0
  6. data/CHANGELOG.md +52 -0
  7. data/MIT-LICENSE.txt +21 -0
  8. data/README.md +304 -0
  9. data/Rakefile +8 -0
  10. data/SECURITY.md +16 -0
  11. data/examples/confidential_client/.gitignore +5 -0
  12. data/examples/confidential_client/Gemfile +14 -0
  13. data/examples/confidential_client/Gemfile.lock +88 -0
  14. data/examples/confidential_client/README.md +226 -0
  15. data/examples/confidential_client/app.rb +290 -0
  16. data/examples/confidential_client/config/client-metadata.example.json +25 -0
  17. data/examples/confidential_client/config.ru +4 -0
  18. data/examples/confidential_client/public/styles.css +70 -0
  19. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  20. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  21. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  22. data/examples/confidential_client/views/authorized.erb +46 -0
  23. data/examples/confidential_client/views/index.erb +44 -0
  24. data/examples/confidential_client/views/layout.erb +11 -0
  25. data/examples/minisky_console/.gitignore +2 -0
  26. data/examples/minisky_console/Gemfile +12 -0
  27. data/examples/minisky_console/Gemfile.lock +131 -0
  28. data/examples/minisky_console/README.md +112 -0
  29. data/examples/minisky_console/app.rb +50 -0
  30. data/examples/minisky_console/bin/console +6 -0
  31. data/examples/minisky_console/config.ru +5 -0
  32. data/examples/minisky_console/localhost.rb +53 -0
  33. data/examples/minisky_console/production.rb +32 -0
  34. data/lib/authproto/client.rb +583 -0
  35. data/lib/authproto/client_metadata.rb +478 -0
  36. data/lib/authproto/configuration.rb +139 -0
  37. data/lib/authproto/dpop/client.rb +161 -0
  38. data/lib/authproto/dpop/key_manager.rb +223 -0
  39. data/lib/authproto/dpop/nonce_manager.rb +145 -0
  40. data/lib/authproto/dpop/proof_generator.rb +124 -0
  41. data/lib/authproto/dpop/stored_nonce.rb +37 -0
  42. data/lib/authproto/encryption.rb +250 -0
  43. data/lib/authproto/errors.rb +75 -0
  44. data/lib/authproto/http_client.rb +320 -0
  45. data/lib/authproto/insecure_http_client.rb +24 -0
  46. data/lib/authproto/localhost_server.rb +274 -0
  47. data/lib/authproto/minisky.rb +273 -0
  48. data/lib/authproto/par/client.rb +240 -0
  49. data/lib/authproto/par/client_assertion.rb +69 -0
  50. data/lib/authproto/par/request.rb +243 -0
  51. data/lib/authproto/par/response.rb +43 -0
  52. data/lib/authproto/par.rb +48 -0
  53. data/lib/authproto/pkce.rb +125 -0
  54. data/lib/authproto/serialization/base.rb +258 -0
  55. data/lib/authproto/serialization/dpop_key.rb +38 -0
  56. data/lib/authproto/serialization/session.rb +88 -0
  57. data/lib/authproto/serialization/stored_nonce.rb +46 -0
  58. data/lib/authproto/serialization/token_set.rb +54 -0
  59. data/lib/authproto/server_metadata/authorization_server.rb +267 -0
  60. data/lib/authproto/server_metadata/origin_url.rb +71 -0
  61. data/lib/authproto/server_metadata/resource_server.rb +98 -0
  62. data/lib/authproto/server_metadata.rb +28 -0
  63. data/lib/authproto/state/session.rb +168 -0
  64. data/lib/authproto/state/session_manager.rb +162 -0
  65. data/lib/authproto/state/token_set.rb +101 -0
  66. data/lib/authproto/state.rb +61 -0
  67. data/lib/authproto/storage/interface.rb +121 -0
  68. data/lib/authproto/storage/key_builder.rb +76 -0
  69. data/lib/authproto/storage/memory.rb +222 -0
  70. data/lib/authproto/storage/redis.rb +156 -0
  71. data/lib/authproto/storage/sqlite.rb +281 -0
  72. data/lib/authproto/token/refresh.rb +414 -0
  73. data/lib/authproto/version.rb +8 -0
  74. data/lib/authproto.rb +18 -0
  75. data/sig/authproto/client.rbs +43 -0
  76. data/sig/authproto/client_metadata.rbs +113 -0
  77. data/sig/authproto/dependencies.rbs +19 -0
  78. data/sig/authproto/dpop/client.rbs +23 -0
  79. data/sig/authproto/dpop/key_manager.rbs +26 -0
  80. data/sig/authproto/dpop/nonce_manager.rbs +23 -0
  81. data/sig/authproto/dpop/proof_generator.rbs +40 -0
  82. data/sig/authproto/dpop/stored_nonce.rbs +10 -0
  83. data/sig/authproto/encryption.rbs +42 -0
  84. data/sig/authproto/http_client.rbs +45 -0
  85. data/sig/authproto/insecure_http_client.rbs +8 -0
  86. data/sig/authproto/localhost_server.rbs +30 -0
  87. data/sig/authproto/minisky_oauth.rbs +22 -0
  88. data/sig/authproto/par/client.rbs +25 -0
  89. data/sig/authproto/par/client_assertion.rbs +10 -0
  90. data/sig/authproto/par/request.rbs +73 -0
  91. data/sig/authproto/par/response.rbs +17 -0
  92. data/sig/authproto/par.rbs +7 -0
  93. data/sig/authproto/pkce.rbs +24 -0
  94. data/sig/authproto/serialization/base.rbs +36 -0
  95. data/sig/authproto/serialization/dpop_key.rbs +14 -0
  96. data/sig/authproto/serialization/session.rbs +19 -0
  97. data/sig/authproto/serialization/stored_nonce.rbs +14 -0
  98. data/sig/authproto/serialization/token_set.rbs +14 -0
  99. data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
  100. data/sig/authproto/server_metadata/origin_url.rbs +21 -0
  101. data/sig/authproto/server_metadata/resource_server.rbs +27 -0
  102. data/sig/authproto/state/session.rbs +28 -0
  103. data/sig/authproto/state/session_manager.rbs +15 -0
  104. data/sig/authproto/state/token_set.rbs +40 -0
  105. data/sig/authproto/state.rbs +6 -0
  106. data/sig/authproto/storage/interface.rbs +20 -0
  107. data/sig/authproto/storage/key_builder.rbs +16 -0
  108. data/sig/authproto/storage/memory.rbs +22 -0
  109. data/sig/authproto/storage/redis.rbs +19 -0
  110. data/sig/authproto/storage/redis_client.rbs +11 -0
  111. data/sig/authproto/storage/sqlite.rbs +26 -0
  112. data/sig/authproto/token/refresh.rbs +47 -0
  113. data/sig/authproto/version.rbs +3 -0
  114. data/sig/authproto.rbs +54 -0
  115. metadata +200 -0
@@ -0,0 +1,226 @@
1
+ # AT Protocol OAuth Confidential Client Example
2
+
3
+ This is an example implementation of a confidential OAuth client for the AT Protocol using the AuthProto gem. It demonstrates how to implement the OAuth flow for a web application, including DPoP token binding and secure session management.
4
+
5
+ <img src="https://github.com/mackuba/authproto/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/mackuba/authproto/blob/main/examples/confidential_client/screenshots/screenshot-2-success.png?raw=true" alt="Sign In Success Screenshot" title="Sign In Success" width="500">
8
+
9
+ ## Overview
10
+
11
+ The example implements a simple web application using Sinatra that:
12
+
13
+ - Allows users to sign in with their AT Protocol handle (@handle) and make a test Bluesky post
14
+ - Implements the complete OAuth authorization flow
15
+ - Uses DPoP-bound tokens for API requests
16
+ - Demonstrates secure session management with encryption
17
+ - Shows how to make authenticated API calls to Bluesky
18
+ - Provides examples of both development and production storage configurations
19
+ - Supports running on localhost
20
+
21
+ ## Requirements
22
+
23
+ - Ruby 3.3+
24
+ - Bundler
25
+ - A domain name for your application that matches your client metadata (required for production)
26
+ - SSL certificate for your domain (required for production)
27
+ - optionally, Redis for production
28
+
29
+ ## Setup
30
+
31
+ 1. Clone the repository and navigate to the example directory:
32
+
33
+ ```bash
34
+ cd examples/confidential_client
35
+ ```
36
+
37
+ 2. Install dependencies:
38
+
39
+ ```bash
40
+ bundle install
41
+ ```
42
+
43
+ 3. Generate EC keys for client authentication:
44
+
45
+ ```bash
46
+ bundle exec ruby scripts/generate_keys.rb > config/keys.json
47
+ ```
48
+
49
+ 4. Generate session & encryption keys:
50
+
51
+ ```bash
52
+ openssl rand -base64 32
53
+ openssl rand -hex 32
54
+
55
+ cat > .env
56
+ ATPROTO_MASTER_KEY=<base64 value from first command>
57
+ SESSION_SECRET=<hex value from second command>
58
+ ```
59
+
60
+ 5. Configure your client metadata:
61
+ - Copy the example metadata file over:
62
+ ```
63
+ cp config/client-metadata.example.json config/oauth-client-metadata.json
64
+ ```
65
+ - Set the correct `client_id` URL where your metadata will be hosted
66
+ - Configure valid `redirect_uris` for your application
67
+ - Add your generated keys from step 3 to the `jwks` field
68
+ - For localhost testing, set `client_id` to `http://localhost/` and `redirect_uris` to `["http://127.0.0.1:9292/callback"]`
69
+
70
+ 6. Set up environment variables:
71
+
72
+ ```bash
73
+ # Your application's domain name
74
+ export PERMITTED_DOMAIN=your.domain.com
75
+ # or 127.0.0.1 for localhost testing
76
+
77
+ # Select a storage backend: memory (default), sqlite, or redis
78
+ export STORAGE_BACKEND=sqlite
79
+
80
+ # Optional SQLite path (defaults to ./db/authproto.sqlite3)
81
+ export SQLITE_PATH=/var/lib/my-app/authproto.sqlite3
82
+
83
+ # Required when STORAGE_BACKEND=redis
84
+ export REDIS_URL=redis://localhost:6379
85
+ ```
86
+
87
+ ## Configuration
88
+
89
+ ### Storage Configuration
90
+
91
+ The example app supports in-memory, SQLite and Redis storage backends. Select
92
+ one by passing `STORAGE_BACKEND=memory`, `STORAGE_BACKEND=sqlite`, or
93
+ `STORAGE_BACKEND=redis`.
94
+
95
+ #### In-memory storage (for development)
96
+
97
+ ```ruby
98
+ # config/development.rb
99
+ AuthProto.configure do |config|
100
+ config.storage = AuthProto::Storage::Memory.new
101
+ config.logger = Logger.new($stdout)
102
+ end
103
+ ```
104
+
105
+ #### SQLite storage
106
+
107
+ SQLite can be used for persistent storage in development or in low traffic production setups:
108
+
109
+ ```bash
110
+ export STORAGE_BACKEND=sqlite
111
+ export SQLITE_PATH=/var/lib/my-app/authproto.sqlite3
112
+ ```
113
+
114
+ #### Redis storage
115
+
116
+ Use Redis in more complex production setups:
117
+
118
+ ```ruby
119
+ # config/production.rb
120
+ require 'redis'
121
+
122
+ AuthProto.configure do |config|
123
+ redis_client = Redis.new(
124
+ url: ENV.fetch('REDIS_URL'),
125
+ ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER }
126
+ )
127
+
128
+ config.storage = AuthProto::Storage::Redis.new(
129
+ redis_client: redis_client
130
+ )
131
+
132
+ config.logger = Logger.new($stdout)
133
+ end
134
+ ```
135
+
136
+ ### Host Authorization
137
+
138
+ This application requires specific domain configuration to function properly:
139
+
140
+ 1. **Domain-Client Matching**: The domain where you run the application must exactly match the `client_id` domain in your client metadata. For example, if your `client_id` is `https://myapp.example.com/oauth-client-metadata.json`, the application must be accessible at `myapp.example.com`.
141
+
142
+ 2. **Internet Accessibility**: The application must be accessible from the internet for AT Protocol OAuth to work. The Authorization Server needs to be able to reach your application's redirect URI during the OAuth flow.
143
+
144
+ 3. **Quick Setup with Tailscale Funnel**: One easy way to expose your local development server to the internet is using Tailscale Funnel:
145
+
146
+ 1. Set up [Tailscale Funnel](https://tailscale.com/kb/1223/funnel)
147
+ 2. Ensure you have HTTPS certificates configured
148
+ 5. Start your Funnel:
149
+ ```bash
150
+ tailscale funnel 9292
151
+ ```
152
+ 4. Ensure your client_id and redirect_uris match your funnel path
153
+ 5. Set the `PERMITTED_DOMAIN` environment variable to your Tailscale domain
154
+ ```bash
155
+ export PERMITTED_DOMAIN=machinename.xyz.ts.net
156
+ ```
157
+ 6. Run the application (see below)
158
+
159
+ Your application will now be accessible via your Tailscale domain with HTTPS enabled.
160
+
161
+ ### Session Security
162
+
163
+ The application uses encrypted sessions to store authorization data. Configure the session secret with:
164
+
165
+ ```bash
166
+ export SESSION_SECRET=your-secure-random-string
167
+ ```
168
+
169
+ If not set, a random secret will be generated on startup (not recommended for production).
170
+
171
+ ## Running the Application
172
+
173
+ ### Development
174
+ ```bash
175
+ RACK_ENV=development bundle exec rackup
176
+ ```
177
+
178
+ ### Production
179
+ ```bash
180
+ RACK_ENV=production bundle exec rackup -E production
181
+ ```
182
+
183
+ This will start the server on `http://localhost:9292`.
184
+
185
+ ## Troubleshooting
186
+
187
+ ### Common Issues
188
+
189
+ 1. "Invalid redirect URI":
190
+ - Ensure your redirect URI matches exactly what's in your client metadata
191
+ - Check that your domain matches the client_id domain
192
+
193
+ 2. "Invalid client metadata":
194
+ - Verify your client metadata is accessible at the URL specified in client_id
195
+ - Check that your JSON is valid and contains all required fields
196
+
197
+ 3. "Authorization failed":
198
+ - Verify your JWKS configuration
199
+ - Check that your DPoP proofs are being generated correctly
200
+ - Ensure your client authentication is working
201
+
202
+ 4. "Storage errors":
203
+ - For SQLite storage, verify the database path is writable
204
+ - For Redis storage, verify Redis connection settings
205
+ - Check Redis SSL configuration if using encrypted connections
206
+ - Ensure proper Redis authentication credentials if required
207
+
208
+ 5. "Session state lost":
209
+ - Verify storage configuration is correct
210
+ - Check that SQLite storage is on persistent local disk
211
+ - Check Redis connection stability if using Redis storage
212
+ - Ensure session TTLs are appropriately configured
213
+
214
+ ## Understanding the Code
215
+
216
+ The example demonstrates several important concepts:
217
+
218
+ 1. **Secure Storage**: The app shows proper configuration of in-memory, SQLite and Redis storage.
219
+
220
+ 2. **Token Management**: All tokens are stored securely with encryption in the configured storage backend.
221
+
222
+ 3. **Session Handling**: The app demonstrates proper session state management with atomic operations and locking.
223
+
224
+ 4. **Error Recovery**: Includes examples of handling storage failures and token refresh scenarios.
225
+
226
+ The code is thoroughly commented to explain these concepts and their implementation details.
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'securerandom'
6
+ require 'time'
7
+ require 'uri'
8
+
9
+ require 'authproto'
10
+ require 'dotenv/load'
11
+ require 'faraday'
12
+ require 'sinatra/base'
13
+ require 'sinatra/reloader'
14
+
15
+ # Main app entry point
16
+ class ExampleApp < Sinatra::Base
17
+ def check_stored_session(session_id)
18
+ return false unless session_id
19
+
20
+ settings.oauth_client.authorized?(session_id)
21
+ rescue AuthProto::SessionError
22
+ false
23
+ end
24
+
25
+ configure :development do
26
+ register Sinatra::Reloader
27
+ end
28
+
29
+ # Initialize the AT Protocol OAuth client
30
+ configure do
31
+ # Configure AuthProto settings
32
+ AuthProto.configure do |config|
33
+ config.http_client = AuthProto::HTTPClient.new(timeout: 10, verify_ssl: true)
34
+ config.default_token_lifetime = 300
35
+ config.dpop_nonce_lifetime = 300
36
+
37
+ case ENV.fetch("STORAGE_BACKEND", "memory")
38
+ when "memory"
39
+ config.storage = AuthProto::Storage::Memory.new
40
+ when "sqlite"
41
+ config.storage = AuthProto::Storage::SQLite.new(
42
+ path: ENV.fetch("SQLITE_PATH", File.join(__dir__, "db", "authproto.sqlite3"))
43
+ )
44
+ when "redis"
45
+ config.storage = AuthProto::Storage::Redis.new(
46
+ redis_client: Redis.new(url: ENV.fetch("REDIS_URL"))
47
+ )
48
+ else
49
+ raise "STORAGE_BACKEND must be memory, sqlite, or redis"
50
+ end
51
+ end
52
+
53
+ # Load client metadata
54
+ metadata_path = File.join(__dir__, "config", "oauth-client-metadata.json")
55
+ metadata = JSON.parse(File.read(metadata_path))
56
+
57
+ client_id = metadata["client_id"]
58
+
59
+ if client_id.chomp("/") == "http://localhost"
60
+ scope = CGI.escape(metadata["scope"])
61
+ redirect_uri = CGI.escape(metadata["redirect_uris"][0])
62
+ client_id += "?scope=#{scope}&redirect_uri=#{redirect_uri}"
63
+ end
64
+
65
+ # Create OAuth client
66
+ set :oauth_client, AuthProto::Client.new(
67
+ client_id: client_id,
68
+ redirect_uri: metadata["redirect_uris"][0],
69
+ metadata: metadata,
70
+ dpop_key: metadata["jwks"]["keys"][0]
71
+ )
72
+ end
73
+
74
+ set :host_authorization, {
75
+ permitted_hosts: ["localhost", ENV.fetch("PERMITTED_DOMAIN", nil)].compact
76
+ }
77
+
78
+ use Rack::Session::Cookie,
79
+ key: "atproto.session",
80
+ expire_after: 86_400, # 1 day in seconds
81
+ secret: ENV.fetch("SESSION_SECRET") { SecureRandom.hex(32) },
82
+ secure: ENV.fetch("RACK_ENV", "development") == "production", # Only send over HTTPS
83
+ httponly: true, # Not accessible via JavaScript
84
+ same_site: :lax # CSRF protection
85
+
86
+ helpers do
87
+ def recover_session
88
+ session_id = session[:oauth_session_id]
89
+ return nil unless session_id
90
+
91
+ begin
92
+ # Check if session is still valid
93
+ return nil unless settings.oauth_client.authorized?(session_id)
94
+
95
+ session_id
96
+ rescue AuthProto::Client::SessionError
97
+ # Clear invalid session
98
+ session.delete(:oauth_session_id)
99
+ nil
100
+ end
101
+ end
102
+ end
103
+
104
+ get "/" do
105
+ # Check for existing session
106
+ redirect "/authorized" if recover_session
107
+
108
+ erb :index
109
+ end
110
+
111
+ get "/oauth-client-metadata.json" do
112
+ content_type :json
113
+
114
+ # Read metadata from config file
115
+ metadata_path = File.join(__dir__, "config", "oauth-client-metadata.json")
116
+ metadata = JSON.parse(File.read(metadata_path))
117
+
118
+ # Strip private key 'd' component from each key in the JWKS
119
+ if metadata["jwks"] && metadata["jwks"]["keys"]
120
+ metadata["jwks"]["keys"] = metadata["jwks"]["keys"].map do |key|
121
+ key.except("d")
122
+ end
123
+ end
124
+
125
+ # Return sanitized metadata
126
+ JSON.generate(metadata)
127
+ end
128
+
129
+ # Start OAuth flow
130
+ post "/auth" do
131
+ handle = params[:handle]
132
+
133
+ begin
134
+ # Start authorization flow
135
+ auth = settings.oauth_client.authorize(
136
+ handle: handle,
137
+ scope: "atproto repo:app.bsky.feed.post"
138
+ )
139
+
140
+ # Store session ID in user's browser session
141
+ session[:oauth_session_id] = auth[:session_id]
142
+
143
+ # Redirect to authorization URL
144
+ redirect auth[:url]
145
+ rescue StandardError => e
146
+ session[:error] = "Authorization failed: #{e.message}"
147
+ redirect "/"
148
+ end
149
+ end
150
+
151
+ # OAuth callback handler
152
+ get "/callback" do
153
+ # Handle the callback
154
+ result = settings.oauth_client.handle_callback(
155
+ code: params[:code],
156
+ state: params[:state],
157
+ iss: params[:iss]
158
+ )
159
+
160
+ # Store tokens
161
+ session[:oauth_session_id] = result[:session_id]
162
+
163
+ redirect "/authorized"
164
+ rescue StandardError => e
165
+ session[:error] = "Callback failed: #{e.message}"
166
+ redirect "/"
167
+ end
168
+
169
+ # Show authorized state and test API call
170
+ get "/authorized" do
171
+ session_id = session[:oauth_session_id]
172
+ return redirect "/" unless check_stored_session(session_id)
173
+
174
+ begin
175
+ # Get current session tokens
176
+ oauth_session = settings.oauth_client.get_tokens(session_id)
177
+
178
+ # Check if token needs refresh
179
+ if oauth_session[:expires_in] < 30
180
+ # Refresh token
181
+ oauth_session = settings.oauth_client.refresh_token(session_id)
182
+ end
183
+
184
+ # Make test API call to com.atproto.identity.resolveHandle
185
+ conn = Faraday.new(url: "https://api.bsky.app") do |f|
186
+ f.request :json
187
+ f.response :json
188
+ end
189
+
190
+ # Get auth headers for request
191
+ headers = settings.oauth_client.auth_headers(
192
+ session_id: session_id,
193
+ method: "GET",
194
+ url: "https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle"
195
+ )
196
+
197
+ # Make authenticated request
198
+ response = conn.get("/xrpc/com.atproto.identity.resolveHandle") do |req|
199
+ headers.each { |k, v| req.headers[k] = v }
200
+ req.params[:handle] = "bsky.app"
201
+ end
202
+
203
+ @api_result = response.body
204
+ @session = oauth_session
205
+ erb :authorized
206
+ rescue StandardError => e
207
+ session[:error] = "API call failed: #{e.message}"
208
+ redirect "/"
209
+ end
210
+ end
211
+
212
+ post "/posts" do
213
+ session_id = session[:oauth_session_id]
214
+ return redirect "/" unless check_stored_session(session_id)
215
+
216
+ text = params[:text].to_s
217
+ if text.strip.empty?
218
+ session[:error] = "Post text cannot be empty"
219
+ return redirect "/authorized"
220
+ end
221
+
222
+ if text.length > 300 || text.bytesize > 3_000
223
+ session[:error] = "Post text must be at most 300 characters and 3,000 bytes"
224
+ return redirect "/authorized"
225
+ end
226
+
227
+ oauth_session = settings.oauth_client.session_manager.get_session(session_id)
228
+
229
+ pds_url = ENV['PDS_URL'] || DIDKit::DID.new(oauth_session.did).document.pds_endpoint
230
+ raise "No HTTPS PDS location found" unless pds_url && URI(pds_url).is_a?(URI::HTTPS)
231
+
232
+ endpoint = URI.join(
233
+ "#{pds_url.chomp("/")}/",
234
+ "xrpc/com.atproto.repo.createRecord"
235
+ ).to_s
236
+
237
+ headers = settings.oauth_client.auth_headers(
238
+ session_id: session_id,
239
+ method: "POST",
240
+ url: endpoint
241
+ )
242
+
243
+ conn = Faraday.new do |f|
244
+ f.request :json
245
+ f.response :json
246
+ end
247
+ response = conn.post(endpoint) do |req|
248
+ headers.each { |key, value| req.headers[key] = value }
249
+ req.body = {
250
+ repo: oauth_session.did,
251
+ collection: "app.bsky.feed.post",
252
+ record: {
253
+ "$type": "app.bsky.feed.post",
254
+ text: text,
255
+ langs: ["en"],
256
+ createdAt: Time.now.utc.iso8601(3)
257
+ }
258
+ }
259
+ end
260
+
261
+ unless response.success?
262
+ error_body = response.body.is_a?(String) ? response.body : JSON.generate(response.body)
263
+ raise "PDS rejected post (#{response.status}): #{error_body}"
264
+ end
265
+
266
+ session[:notice] = "Post published successfully"
267
+ redirect "/authorized"
268
+ rescue StandardError => e
269
+ session[:error] = "Publishing post failed: #{e.message}"
270
+ redirect "/authorized"
271
+ end
272
+
273
+ get "/signout" do
274
+ if session[:oauth_session_id]
275
+ # Clean up stored session
276
+ settings.oauth_client.remove_session(session[:oauth_session_id])
277
+ end
278
+
279
+ session.clear
280
+ session[:notice] = "Successfully signed out"
281
+ redirect "/"
282
+ end
283
+
284
+ # Helper method to check if user is authenticated
285
+ def authenticated?
286
+ return false unless session[:tokens]
287
+
288
+ settings.oauth_client.authorized?(session[:tokens][:session_id])
289
+ end
290
+ end
@@ -0,0 +1,25 @@
1
+ {
2
+ "client_id": "https://YOUR_DOMAIN_HERE/oauth-client-metadata.json",
3
+ "client_name": "AT Protocol OAuth Ruby Example",
4
+ "redirect_uris": ["https://YOUR_DOMAIN_HERE/callback"],
5
+ "grant_types": ["authorization_code", "refresh_token"],
6
+ "response_types": ["code"],
7
+ "scope": "atproto repo:app.bsky.feed.post",
8
+ "token_endpoint_auth_method": "private_key_jwt",
9
+ "token_endpoint_auth_signing_alg": "ES256",
10
+ "application_type": "web",
11
+ "dpop_bound_access_tokens": true,
12
+ "jwks": {
13
+ "keys": [
14
+ {
15
+ "use": "sig",
16
+ "kid": "key-1",
17
+ "x": "...",
18
+ "crv": "P-256",
19
+ "d": "...",
20
+ "kty": "EC",
21
+ "y": "..."
22
+ }
23
+ ]
24
+ }
25
+ }
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'app'
4
+ run ExampleApp
@@ -0,0 +1,70 @@
1
+ .container {
2
+ max-width: 800px;
3
+ margin: 0 auto;
4
+ padding: 20px;
5
+ }
6
+
7
+ .header {
8
+ max-width: 400px;
9
+ margin: 0 auto;
10
+ }
11
+
12
+ .error {
13
+ background-color: #ffebee;
14
+ color: #c62828;
15
+ padding: 10px;
16
+ margin-bottom: 20px;
17
+ border-radius: 4px;
18
+ }
19
+
20
+ .auth-form {
21
+ max-width: 400px;
22
+ margin: 40px auto;
23
+ }
24
+
25
+ .form-group {
26
+ margin-bottom: 15px;
27
+ }
28
+
29
+ .form-group label {
30
+ display: block;
31
+ margin-bottom: 5px;
32
+ }
33
+
34
+ .form-group input {
35
+ width: 100%;
36
+ padding: 8px;
37
+ border: 1px solid #ddd;
38
+ border-radius: 4px;
39
+ }
40
+
41
+ button {
42
+ background: #2196f3;
43
+ color: white;
44
+ padding: 10px 20px;
45
+ border: none;
46
+ border-radius: 4px;
47
+ cursor: pointer;
48
+ }
49
+
50
+ button:hover {
51
+ background: #1976d2;
52
+ }
53
+
54
+ .token-info,
55
+ .api-test {
56
+ margin-top: 20px;
57
+ padding: 15px;
58
+ background: #f5f5f5;
59
+ border-radius: 4px;
60
+ }
61
+
62
+ pre {
63
+ overflow-x: auto;
64
+ white-space: pre-wrap;
65
+ word-wrap: break-word;
66
+ }
67
+
68
+ code {
69
+ font-size: 14px;
70
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jose'
4
+
5
+ # Generate a new EC key using P-256 curve
6
+ keypair = JOSE::JWK.generate_key([:ec, "P-256"])
7
+
8
+ # Get the key as a hash (need to convert from JOSE::Map)
9
+ jwk = keypair.to_map.to_h
10
+
11
+ # Pretty print the JWK to stdout
12
+ require 'json'
13
+ puts JSON.pretty_generate({
14
+ keys: [jwk]
15
+ })
@@ -0,0 +1,46 @@
1
+ <div class="flex flex-col items-center md:flex md:flex-row md:justify-stretch md:items-center min-h-screen min-w-screen bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
2
+ <div class="px-6 pt-4 md:max-w-lg md:grid md:content-center md:justify-items-end md:self-stretch md:w-1/2 md:max-w-fix md:p-4 md:text-right md:dark:border-r md:dark:border-gray-700 md:bg-gray-100 md:dark:bg-gray-800">
3
+ <h1 class="text-xl md:text-2xl lg:text-5xl md:mt-4 mb-4 font-semibold text-brand">Successfully Authenticated!</h1>
4
+ </div>
5
+ <div class="w-full px-6 md:max-w-3xl md:px-12">
6
+ <div class="space-y-8">
7
+ <% if session[:error] %>
8
+ <div class="mb-8 bg-red-50 border-red-700 text-red-700 p-4 rounded-md">
9
+ <%= session[:error] %>
10
+ <% session[:error] = nil %>
11
+ </div>
12
+ <% end %>
13
+
14
+ <% if session[:notice] %>
15
+ <div class="mb-8 bg-green-50 border-green-700 text-green-700 p-4 rounded-md">
16
+ <%= session[:notice] %>
17
+ <% session[:notice] = nil %>
18
+ </div>
19
+ <% end %>
20
+
21
+ <form method="post" action="/posts" class="flex flex-col py-4 space-y-4">
22
+ <fieldset>
23
+ <label for="post-text" class="mb-1 block text-gray-600 dark:text-gray-400 text-sm font-medium">New Bluesky post</label>
24
+ <textarea id="post-text" name="text" rows="4" maxlength="300" required placeholder="What's up?" class="w-full rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 dark:bg-gray-800 p-3 outline-none border-solid border-2 border-transparent hover:border-gray-400 focus:border-brand dark:hover:border-gray-500"></textarea>
25
+ </fieldset>
26
+ <div>
27
+ <button type="submit" class="py-2 px-6 rounded-lg cursor-pointer touch-manipulation tracking-wide bg-blue-400 text-white">Post to Bluesky</button>
28
+ </div>
29
+ </form>
30
+
31
+ <div class="token-info">
32
+ <p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Token Information</p>
33
+ <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>
34
+ </div>
35
+
36
+ <div class="api-test">
37
+ <p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Test API Call Result</p>
38
+ <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(@api_result) %></code></pre>
39
+ </div>
40
+
41
+ <div class="flex">
42
+ <a href="/signout" aria-label="Signout" class="py-2 px-6 rounded-lg truncate cursor-pointer touch-manipulation tracking-wide overflow-hidden bg-blue-400 text-white">Sign out</a>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>