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.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +31 -0
- data/.tm_properties +1 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +52 -0
- data/MIT-LICENSE.txt +21 -0
- data/README.md +304 -0
- data/Rakefile +8 -0
- data/SECURITY.md +16 -0
- data/examples/confidential_client/.gitignore +5 -0
- data/examples/confidential_client/Gemfile +14 -0
- data/examples/confidential_client/Gemfile.lock +88 -0
- data/examples/confidential_client/README.md +226 -0
- data/examples/confidential_client/app.rb +290 -0
- data/examples/confidential_client/config/client-metadata.example.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
- data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +46 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/examples/minisky_console/.gitignore +2 -0
- data/examples/minisky_console/Gemfile +12 -0
- data/examples/minisky_console/Gemfile.lock +131 -0
- data/examples/minisky_console/README.md +112 -0
- data/examples/minisky_console/app.rb +50 -0
- data/examples/minisky_console/bin/console +6 -0
- data/examples/minisky_console/config.ru +5 -0
- data/examples/minisky_console/localhost.rb +53 -0
- data/examples/minisky_console/production.rb +32 -0
- data/lib/authproto/client.rb +583 -0
- data/lib/authproto/client_metadata.rb +478 -0
- data/lib/authproto/configuration.rb +139 -0
- data/lib/authproto/dpop/client.rb +161 -0
- data/lib/authproto/dpop/key_manager.rb +223 -0
- data/lib/authproto/dpop/nonce_manager.rb +145 -0
- data/lib/authproto/dpop/proof_generator.rb +124 -0
- data/lib/authproto/dpop/stored_nonce.rb +37 -0
- data/lib/authproto/encryption.rb +250 -0
- data/lib/authproto/errors.rb +75 -0
- data/lib/authproto/http_client.rb +320 -0
- data/lib/authproto/insecure_http_client.rb +24 -0
- data/lib/authproto/localhost_server.rb +274 -0
- data/lib/authproto/minisky.rb +273 -0
- data/lib/authproto/par/client.rb +240 -0
- data/lib/authproto/par/client_assertion.rb +69 -0
- data/lib/authproto/par/request.rb +243 -0
- data/lib/authproto/par/response.rb +43 -0
- data/lib/authproto/par.rb +48 -0
- data/lib/authproto/pkce.rb +125 -0
- data/lib/authproto/serialization/base.rb +258 -0
- data/lib/authproto/serialization/dpop_key.rb +38 -0
- data/lib/authproto/serialization/session.rb +88 -0
- data/lib/authproto/serialization/stored_nonce.rb +46 -0
- data/lib/authproto/serialization/token_set.rb +54 -0
- data/lib/authproto/server_metadata/authorization_server.rb +267 -0
- data/lib/authproto/server_metadata/origin_url.rb +71 -0
- data/lib/authproto/server_metadata/resource_server.rb +98 -0
- data/lib/authproto/server_metadata.rb +28 -0
- data/lib/authproto/state/session.rb +168 -0
- data/lib/authproto/state/session_manager.rb +162 -0
- data/lib/authproto/state/token_set.rb +101 -0
- data/lib/authproto/state.rb +61 -0
- data/lib/authproto/storage/interface.rb +121 -0
- data/lib/authproto/storage/key_builder.rb +76 -0
- data/lib/authproto/storage/memory.rb +222 -0
- data/lib/authproto/storage/redis.rb +156 -0
- data/lib/authproto/storage/sqlite.rb +281 -0
- data/lib/authproto/token/refresh.rb +414 -0
- data/lib/authproto/version.rb +8 -0
- data/lib/authproto.rb +18 -0
- data/sig/authproto/client.rbs +43 -0
- data/sig/authproto/client_metadata.rbs +113 -0
- data/sig/authproto/dependencies.rbs +19 -0
- data/sig/authproto/dpop/client.rbs +23 -0
- data/sig/authproto/dpop/key_manager.rbs +26 -0
- data/sig/authproto/dpop/nonce_manager.rbs +23 -0
- data/sig/authproto/dpop/proof_generator.rbs +40 -0
- data/sig/authproto/dpop/stored_nonce.rbs +10 -0
- data/sig/authproto/encryption.rbs +42 -0
- data/sig/authproto/http_client.rbs +45 -0
- data/sig/authproto/insecure_http_client.rbs +8 -0
- data/sig/authproto/localhost_server.rbs +30 -0
- data/sig/authproto/minisky_oauth.rbs +22 -0
- data/sig/authproto/par/client.rbs +25 -0
- data/sig/authproto/par/client_assertion.rbs +10 -0
- data/sig/authproto/par/request.rbs +73 -0
- data/sig/authproto/par/response.rbs +17 -0
- data/sig/authproto/par.rbs +7 -0
- data/sig/authproto/pkce.rbs +24 -0
- data/sig/authproto/serialization/base.rbs +36 -0
- data/sig/authproto/serialization/dpop_key.rbs +14 -0
- data/sig/authproto/serialization/session.rbs +19 -0
- data/sig/authproto/serialization/stored_nonce.rbs +14 -0
- data/sig/authproto/serialization/token_set.rbs +14 -0
- data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
- data/sig/authproto/server_metadata/origin_url.rbs +21 -0
- data/sig/authproto/server_metadata/resource_server.rbs +27 -0
- data/sig/authproto/state/session.rbs +28 -0
- data/sig/authproto/state/session_manager.rbs +15 -0
- data/sig/authproto/state/token_set.rbs +40 -0
- data/sig/authproto/state.rbs +6 -0
- data/sig/authproto/storage/interface.rbs +20 -0
- data/sig/authproto/storage/key_builder.rbs +16 -0
- data/sig/authproto/storage/memory.rbs +22 -0
- data/sig/authproto/storage/redis.rbs +19 -0
- data/sig/authproto/storage/redis_client.rbs +11 -0
- data/sig/authproto/storage/sqlite.rbs +26 -0
- data/sig/authproto/token/refresh.rbs +47 -0
- data/sig/authproto/version.rbs +3 -0
- data/sig/authproto.rbs +54 -0
- 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,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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -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>
|