atproto_auth 0.0.1

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 (67) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +16 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +179 -0
  6. data/Rakefile +16 -0
  7. data/examples/confidential_client/Gemfile +12 -0
  8. data/examples/confidential_client/Gemfile.lock +84 -0
  9. data/examples/confidential_client/README.md +110 -0
  10. data/examples/confidential_client/app.rb +136 -0
  11. data/examples/confidential_client/config/client-metadata.json +25 -0
  12. data/examples/confidential_client/config.ru +4 -0
  13. data/examples/confidential_client/public/client-metadata.json +24 -0
  14. data/examples/confidential_client/public/styles.css +70 -0
  15. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  16. data/examples/confidential_client/views/authorized.erb +29 -0
  17. data/examples/confidential_client/views/index.erb +44 -0
  18. data/examples/confidential_client/views/layout.erb +11 -0
  19. data/lib/atproto_auth/client.rb +410 -0
  20. data/lib/atproto_auth/client_metadata.rb +264 -0
  21. data/lib/atproto_auth/configuration.rb +17 -0
  22. data/lib/atproto_auth/dpop/client.rb +122 -0
  23. data/lib/atproto_auth/dpop/key_manager.rb +235 -0
  24. data/lib/atproto_auth/dpop/nonce_manager.rb +138 -0
  25. data/lib/atproto_auth/dpop/proof_generator.rb +112 -0
  26. data/lib/atproto_auth/errors.rb +47 -0
  27. data/lib/atproto_auth/http_client.rb +227 -0
  28. data/lib/atproto_auth/identity/document.rb +104 -0
  29. data/lib/atproto_auth/identity/resolver.rb +221 -0
  30. data/lib/atproto_auth/identity.rb +24 -0
  31. data/lib/atproto_auth/par/client.rb +203 -0
  32. data/lib/atproto_auth/par/client_assertion.rb +50 -0
  33. data/lib/atproto_auth/par/request.rb +140 -0
  34. data/lib/atproto_auth/par/response.rb +23 -0
  35. data/lib/atproto_auth/par.rb +40 -0
  36. data/lib/atproto_auth/pkce.rb +105 -0
  37. data/lib/atproto_auth/server_metadata/authorization_server.rb +175 -0
  38. data/lib/atproto_auth/server_metadata/origin_url.rb +51 -0
  39. data/lib/atproto_auth/server_metadata/resource_server.rb +71 -0
  40. data/lib/atproto_auth/server_metadata.rb +24 -0
  41. data/lib/atproto_auth/state/session.rb +117 -0
  42. data/lib/atproto_auth/state/session_manager.rb +75 -0
  43. data/lib/atproto_auth/state/token_set.rb +68 -0
  44. data/lib/atproto_auth/state.rb +54 -0
  45. data/lib/atproto_auth/version.rb +5 -0
  46. data/lib/atproto_auth.rb +56 -0
  47. data/sig/atproto_auth/client_metadata.rbs +95 -0
  48. data/sig/atproto_auth/dpop/client.rbs +38 -0
  49. data/sig/atproto_auth/dpop/key_manager.rbs +33 -0
  50. data/sig/atproto_auth/dpop/nonce_manager.rbs +48 -0
  51. data/sig/atproto_auth/dpop/proof_generator.rbs +42 -0
  52. data/sig/atproto_auth/http_client.rbs +58 -0
  53. data/sig/atproto_auth/identity/document.rbs +31 -0
  54. data/sig/atproto_auth/identity/resolver.rbs +41 -0
  55. data/sig/atproto_auth/par/client.rbs +31 -0
  56. data/sig/atproto_auth/par/request.rbs +73 -0
  57. data/sig/atproto_auth/par/response.rbs +17 -0
  58. data/sig/atproto_auth/pkce.rbs +24 -0
  59. data/sig/atproto_auth/server_metadata/authorization_server.rbs +69 -0
  60. data/sig/atproto_auth/server_metadata/origin_url.rbs +21 -0
  61. data/sig/atproto_auth/server_metadata/resource_server.rbs +27 -0
  62. data/sig/atproto_auth/state/session.rbs +50 -0
  63. data/sig/atproto_auth/state/session_manager.rbs +26 -0
  64. data/sig/atproto_auth/state/token_set.rbs +40 -0
  65. data/sig/atproto_auth/version.rbs +3 -0
  66. data/sig/atproto_auth.rbs +39 -0
  67. metadata +142 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 32471644f76751fd94dd3e7ede93e8b88c43494a23afa6b5d48196163d2d427c
4
+ data.tar.gz: 3a92957c782c800c23be621be8ecf85da19c4f3b2145705d79d3b8a9ac898d73
5
+ SHA512:
6
+ metadata.gz: b19ee50bae30ffba5a45deea718a7587c2db0c02a0f9ead117d2750e87943473ba8758c72fe5f8246d1499cd67bd2996d38f6144396130fd55252fb0e3acf5f8
7
+ data.tar.gz: a0f12a5e81da053323fd2712f21626bb6e719b45282a26039c480d37bce652998d0180b96c47751c4245792cbdaed8a8f02798790042b6eb50ea9e14feb158b6
data/.rubocop.yml ADDED
@@ -0,0 +1,16 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ SuggestExtensions: false
4
+ TargetRubyVersion: 3.0
5
+
6
+ Metrics/ClassLength:
7
+ Max: 500
8
+
9
+ Metrics/MethodLength:
10
+ Max: 50
11
+
12
+ Style/StringLiterals:
13
+ EnforcedStyle: double_quotes
14
+
15
+ Style/StringLiteralsInInterpolation:
16
+ EnforcedStyle: double_quotes
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-12-02
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Josh Huckabee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # AtprotoAuth
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/atproto_auth.svg)](https://badge.fury.io/rb/atproto_auth)
4
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
+ [![Documentation](https://img.shields.io/badge/docs-rdoc-blue.svg)](https://www.rubydoc.info/gems/atproto_auth)
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.
8
+
9
+ ## Features
10
+
11
+ - Complete AT Protocol OAuth 2.0 implementation
12
+ - Secure by default with mandatory DPoP and PKCE
13
+ - Support for confidential (backend) and public clients
14
+ - Thread-safe session and token management
15
+ - Comprehensive identity resolution and verification
16
+ - Automatic token refresh and session management
17
+ - Robust error handling and recovery mechanisms
18
+
19
+ ## Installation
20
+
21
+ Add this line to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem 'atproto_auth'
25
+ ```
26
+
27
+ And then execute:
28
+
29
+ ```sh
30
+ bundle install
31
+ ```
32
+
33
+ Or install it yourself as:
34
+
35
+ ```sh
36
+ gem install atproto_auth
37
+ ```
38
+
39
+ ## Requirements
40
+
41
+ - Ruby 3.0 or higher
42
+ - OpenSSL support
43
+ - For confidential clients: HTTPS-capable domain for client metadata hosting
44
+
45
+ ## Basic Usage
46
+
47
+ ### Configuration
48
+
49
+ ```ruby
50
+ require 'atproto_auth'
51
+
52
+ AtprotoAuth.configure do |config|
53
+ # Configure HTTP client settings
54
+ config.http_client = AtprotoAuth::HttpClient.new(
55
+ timeout: 10,
56
+ verify_ssl: true
57
+ )
58
+
59
+ # Set token lifetimes
60
+ config.default_token_lifetime = 300 # 5 minutes
61
+ config.dpop_nonce_lifetime = 300 # 5 minutes
62
+ end
63
+ ```
64
+
65
+ ### Confidential Client Example
66
+
67
+ Here's a basic example of using the library in a confidential client application:
68
+
69
+ ```ruby
70
+ # Initialize client with metadata
71
+ client = AtprotoAuth::Client.new(
72
+ client_id: "https://app.example.com/client-metadata.json",
73
+ redirect_uri: "https://app.example.com/callback",
74
+ metadata: {
75
+ # Your client metadata...
76
+ }
77
+ )
78
+
79
+ # Start authorization flow
80
+ auth = client.authorize(
81
+ handle: "user.bsky.social",
82
+ scope: "atproto"
83
+ )
84
+
85
+ # Use auth[:url] to redirect user
86
+
87
+ # Handle callback
88
+ tokens = client.handle_callback(
89
+ code: params[:code],
90
+ state: params[:state],
91
+ iss: params[:iss]
92
+ )
93
+
94
+ # Make authenticated requests
95
+ headers = client.auth_headers(
96
+ session_id: tokens[:session_id],
97
+ method: "GET",
98
+ url: "https://api.example.com/resource"
99
+ )
100
+ ```
101
+
102
+ 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:
103
+ - Complete OAuth flow implementation
104
+ - Session management
105
+ - DPoP token binding
106
+ - Making authenticated API requests
107
+ - Proper error handling
108
+ - Key generation and management
109
+
110
+ See `examples/confidential_client/README.md` for setup instructions and implementation details.
111
+
112
+ ### Public Client Example
113
+
114
+ ```ruby
115
+ client = AtprotoAuth::Client.new(
116
+ client_id: "https://app.example.com/client-metadata.json",
117
+ redirect_uri: "https://app.example.com/callback"
118
+ )
119
+
120
+ # Browser will open authorization URL
121
+ auth = client.authorize(
122
+ handle: "user.bsky.social",
123
+ scope: "atproto"
124
+ )
125
+
126
+ # After callback, exchange code for tokens
127
+ tokens = client.handle_callback(
128
+ code: params[:code],
129
+ state: params[:state],
130
+ iss: params[:iss]
131
+ )
132
+ ```
133
+
134
+ ## Features In Detail
135
+
136
+ ### Identity Resolution
137
+
138
+ The library handles the complete AT Protocol identity resolution flow:
139
+
140
+ - Handle to DID resolution (DNS-based or HTTP fallback)
141
+ - DID document fetching and validation
142
+ - PDS (Personal Data Server) location verification
143
+ - Bidirectional handle verification
144
+ - Authorization server binding verification
145
+
146
+ ### Token & Session Management
147
+
148
+ Comprehensive token lifecycle management:
149
+
150
+ - Secure token storage with encryption
151
+ - Automatic token refresh
152
+ - DPoP proof generation and binding
153
+ - Session state tracking
154
+ - Cleanup of expired sessions
155
+
156
+ ### Security Features
157
+
158
+ Built-in security best practices:
159
+
160
+ - Mandatory PKCE for all flows
161
+ - DPoP token binding
162
+ - Constant-time token comparisons
163
+ - Thread-safe state management
164
+ - Protection against SSRF attacks
165
+ - Secure token storage
166
+
167
+ ## Development
168
+
169
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
170
+
171
+ 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](https://rubygems.org).
172
+
173
+ ## Contributing
174
+
175
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jhuckabee/atproto_auth.
176
+
177
+ ## License
178
+
179
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ end
11
+
12
+ require "rubocop/rake_task"
13
+
14
+ RuboCop::RakeTask.new
15
+
16
+ task default: %i[test rubocop]
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "atproto_auth", path: "../.."
6
+ gem "dotenv"
7
+ gem "faraday"
8
+ gem "json"
9
+ gem "puma"
10
+ gem "rackup"
11
+ gem "sinatra"
12
+ gem "sinatra-contrib"
@@ -0,0 +1,84 @@
1
+ PATH
2
+ remote: ../..
3
+ specs:
4
+ atproto_auth (0.1.0)
5
+ jose (~> 1.2)
6
+ jwt (~> 2.9)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ base64 (0.2.0)
12
+ concurrent-ruby (1.3.4)
13
+ dotenv (3.1.4)
14
+ faraday (2.12.1)
15
+ faraday-net_http (>= 2.0, < 3.5)
16
+ json
17
+ logger
18
+ faraday-net_http (3.4.0)
19
+ net-http (>= 0.5.0)
20
+ immutable-ruby (0.2.0)
21
+ concurrent-ruby (~> 1.1)
22
+ sorted_set (~> 1.0)
23
+ jose (1.2.0)
24
+ base64
25
+ immutable-ruby
26
+ json (2.9.0)
27
+ jwt (2.9.3)
28
+ base64
29
+ logger (1.6.2)
30
+ multi_json (1.15.0)
31
+ mustermann (3.0.3)
32
+ ruby2_keywords (~> 0.0.1)
33
+ net-http (0.6.0)
34
+ uri
35
+ nio4r (2.7.4)
36
+ puma (6.5.0)
37
+ nio4r (~> 2.0)
38
+ rack (3.1.8)
39
+ rack-protection (4.1.1)
40
+ base64 (>= 0.1.0)
41
+ logger (>= 1.6.0)
42
+ rack (>= 3.0.0, < 4)
43
+ rack-session (2.0.0)
44
+ rack (>= 3.0.0)
45
+ rackup (2.2.1)
46
+ rack (>= 3)
47
+ rbtree (0.4.6)
48
+ ruby2_keywords (0.0.5)
49
+ set (1.1.1)
50
+ sinatra (4.1.1)
51
+ logger (>= 1.6.0)
52
+ mustermann (~> 3.0)
53
+ rack (>= 3.0.0, < 4)
54
+ rack-protection (= 4.1.1)
55
+ rack-session (>= 2.0.0, < 3)
56
+ tilt (~> 2.0)
57
+ sinatra-contrib (4.1.1)
58
+ multi_json (>= 0.0.2)
59
+ mustermann (~> 3.0)
60
+ rack-protection (= 4.1.1)
61
+ sinatra (= 4.1.1)
62
+ tilt (~> 2.0)
63
+ sorted_set (1.0.3)
64
+ rbtree
65
+ set (~> 1.0)
66
+ tilt (2.4.0)
67
+ uri (1.0.2)
68
+
69
+ PLATFORMS
70
+ arm64-darwin-24
71
+ ruby
72
+
73
+ DEPENDENCIES
74
+ atproto_auth!
75
+ dotenv
76
+ faraday
77
+ json
78
+ puma
79
+ rackup
80
+ sinatra
81
+ sinatra-contrib
82
+
83
+ BUNDLED WITH
84
+ 2.5.23
@@ -0,0 +1,110 @@
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 AtprotoAuth gem. It demonstrates how to implement the OAuth flow for a web application, including DPoP token binding and secure session management.
4
+
5
+ ## Overview
6
+
7
+ The example implements a simple web application using Sinatra that:
8
+ - Allows users to sign in with their AT Protocol handle (@handle)
9
+ - Implements the complete OAuth authorization flow
10
+ - Uses DPoP-bound tokens for API requests
11
+ - Demonstrates secure session management
12
+ - Shows how to make authenticated API calls to Bluesky
13
+
14
+ ## Requirements
15
+
16
+ - Ruby 3.0+
17
+ - Bundler
18
+ - A domain name for your application that matches your client metadata
19
+ - SSL certificate for your domain (required for production)
20
+
21
+ ## Setup
22
+
23
+ 1. Clone the repository and navigate to the example directory:
24
+ ```bash
25
+ cd examples/confidential_client
26
+ ```
27
+
28
+ 2. Install dependencies:
29
+ ```bash
30
+ bundle install
31
+ ```
32
+
33
+ 3. Generate EC keys for client authentication:
34
+ ```bash
35
+ bundle exec ruby scripts/generate_keys.rb > config/keys.json
36
+ ```
37
+
38
+ 4. Configure your client metadata in `config/client-metadata.json`. Make sure to:
39
+ - Set the correct `client_id` URL where your metadata will be hosted
40
+ - Configure valid `redirect_uris` for your application
41
+ - Add your generated keys from step 3 to the `jwks` field
42
+
43
+ 5. Set up environment variables:
44
+ ```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
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ ### Host Authorization
52
+
53
+ This application requires specific domain configuration to function properly:
54
+
55
+ 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/client-metadata.json`, the application must be accessible at `myapp.example.com`.
56
+
57
+ 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.
58
+
59
+ 3. **Quick Setup with Tailscale Funnel**: One easy way to expose your local development server to the internet is using Tailscale Funnel:
60
+
61
+ 1. Set up [Tailscale Funnel](https://tailscale.com/kb/1223/funnel)
62
+ 2. Ensure you have HTTPS certificates configured
63
+ 5. Start your Funnel:
64
+ ```bash
65
+ tailscale funnel 9292
66
+ ```
67
+ 4. Ensure your client_id and redirect_uris match your funnel path
68
+ 5. Set the `PERMITTED_DOMAIN` environment variable to your Tailscale domain
69
+ ```bash
70
+ export PERMITTED_DOMAIN=machinename.xyz.ts.net
71
+ ```
72
+ 6. Run the application (see below).
73
+
74
+ Your application will now be accessible via your Tailscale domain with HTTPS enabled.
75
+
76
+ ### Session Security
77
+
78
+ The application uses encrypted sessions to store authorization data. Configure the session secret with:
79
+
80
+ ```ruby
81
+ export SESSION_SECRET=your-secure-random-string
82
+ ```
83
+
84
+ If not set, a random secret will be generated on startup.
85
+
86
+ ## Running the Application
87
+
88
+ ```bash
89
+ bundle exec rackup
90
+ ```
91
+
92
+ This will start the server on `http://localhost:9292`.
93
+
94
+
95
+ ## Troubleshooting
96
+
97
+ ### Common Issues
98
+
99
+ 1. "Invalid redirect URI":
100
+ - Ensure your redirect URI matches exactly what's in your client metadata
101
+ - Check that your domain matches the client_id domain
102
+
103
+ 2. "Invalid client metadata":
104
+ - Verify your client metadata is accessible at the URL specified in client_id
105
+ - Check that your JSON is valid and contains all required fields
106
+
107
+ 3. "Authorization failed":
108
+ - Verify your JWKS configuration
109
+ - Check that your DPoP proofs are being generated correctly
110
+ - Ensure your client authentication is working
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sinatra/base"
4
+ require "sinatra/reloader"
5
+ require "atproto_auth"
6
+ require "faraday"
7
+ require "json"
8
+ require "dotenv/load"
9
+
10
+ # Main app entry point
11
+ class ExampleApp < Sinatra::Base
12
+ configure :development do
13
+ register Sinatra::Reloader
14
+ end
15
+
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
+ # Initialize the AT Protocol OAuth client
24
+ configure do
25
+ # Configure AtprotoAuth settings
26
+ AtprotoAuth.configure do |config|
27
+ config.http_client = AtprotoAuth::HttpClient.new(
28
+ timeout: 10,
29
+ verify_ssl: true
30
+ )
31
+ config.default_token_lifetime = 300
32
+ config.dpop_nonce_lifetime = 300
33
+ end
34
+
35
+ # Load client metadata
36
+ metadata_path = File.join(__dir__, "config", "client-metadata.json")
37
+ metadata = JSON.parse(File.read(metadata_path))
38
+
39
+ # Create OAuth client
40
+ set :oauth_client, AtprotoAuth::Client.new(
41
+ client_id: metadata["client_id"],
42
+ redirect_uri: metadata["redirect_uris"][0],
43
+ metadata: metadata,
44
+ dpop_key: metadata["jwks"]["keys"][0]
45
+ )
46
+ end
47
+
48
+ get "/" do
49
+ erb :index
50
+ end
51
+
52
+ # Start OAuth flow
53
+ post "/auth" do
54
+ handle = params[:handle]
55
+
56
+ begin
57
+ # Start authorization flow
58
+ auth = settings.oauth_client.authorize(
59
+ handle: handle,
60
+ scope: "atproto"
61
+ )
62
+
63
+ # Store session ID for callback
64
+ session[:oauth_session_id] = auth[:session_id]
65
+
66
+ # Redirect to authorization URL
67
+ redirect auth[:url]
68
+ rescue StandardError => e
69
+ session[:error] = "Authorization failed: #{e.message}"
70
+ redirect "/"
71
+ end
72
+ end
73
+
74
+ # OAuth callback handler
75
+ get "/callback" do
76
+ # Handle the callback
77
+ result = settings.oauth_client.handle_callback(
78
+ code: params[:code],
79
+ state: params[:state],
80
+ iss: params[:iss]
81
+ )
82
+
83
+ # Store tokens in session
84
+ session[:tokens] = result
85
+
86
+ redirect "/authorized"
87
+ rescue StandardError => e
88
+ session[:error] = "Callback failed: #{e.message}"
89
+ redirect "/"
90
+ end
91
+
92
+ # Show authorized state and test API call
93
+ get "/authorized" do
94
+ return redirect "/" unless session[:tokens]
95
+
96
+ begin
97
+ # Make test API call to com.atproto.identity.resolveHandle
98
+ conn = Faraday.new(url: "https://api.bsky.app") do |f|
99
+ f.request :json
100
+ f.response :json
101
+ end
102
+
103
+ # Get auth headers for request
104
+ headers = settings.oauth_client.auth_headers(
105
+ session_id: session[:tokens][:session_id],
106
+ method: "GET",
107
+ url: "https://api.bsky.app/xrpc/com.atproto.identity.resolveHandle"
108
+ )
109
+
110
+ # Make authenticated request
111
+ response = conn.get("/xrpc/com.atproto.identity.resolveHandle") do |req|
112
+ headers.each { |k, v| req.headers[k] = v }
113
+ req.params[:handle] = "bsky.app"
114
+ end
115
+
116
+ @api_result = response.body
117
+ erb :authorized
118
+ rescue StandardError => e
119
+ session[:error] = "API call failed: #{e.message}"
120
+ redirect "/"
121
+ end
122
+ end
123
+
124
+ get "/signout" do
125
+ session.clear
126
+ session[:notice] = "Successfully signed out"
127
+ redirect "/"
128
+ end
129
+
130
+ # Helper method to check if user is authenticated
131
+ def authenticated?
132
+ return false unless session[:tokens]
133
+
134
+ settings.oauth_client.authorized?(session[:tokens][:session_id])
135
+ end
136
+ end
@@ -0,0 +1,25 @@
1
+ {
2
+ "client_id": "https://mac.tail7f768.ts.net/client-metadata.json",
3
+ "client_name": "AT Protocol OAuth Ruby Example",
4
+ "redirect_uris": ["https://mac.tail7f768.ts.net/callback"],
5
+ "grant_types": ["authorization_code", "refresh_token"],
6
+ "response_types": ["code"],
7
+ "scope": "atproto",
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": "SzXlDk9rSyrZ3b0fVKOWFYY-AFZtld2zElycsmDZ3Xk",
18
+ "crv": "P-256",
19
+ "d": "OLJJKo9T9W7taz8gFd5YdsBw8cOpv3p5zPPtv2XaKcM",
20
+ "kty": "EC",
21
+ "y": "4hIBLl-BLD1Ypk-mvPxT2OR52ezMs4XI1MGBdhlLLm4"
22
+ }
23
+ ]
24
+ }
25
+ }
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "app"
4
+ run ExampleApp
@@ -0,0 +1,24 @@
1
+ {
2
+ "client_id": "https://mac.tail7f768.ts.net/client-metadata.json",
3
+ "client_name": "AT Protocol OAuth Ruby Example",
4
+ "redirect_uris": ["https://mac.tail7f768.ts.net/callback"],
5
+ "grant_types": ["authorization_code", "refresh_token"],
6
+ "response_types": ["code"],
7
+ "scope": "atproto",
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": "SzXlDk9rSyrZ3b0fVKOWFYY-AFZtld2zElycsmDZ3Xk",
18
+ "crv": "P-256",
19
+ "kty": "EC",
20
+ "y": "4hIBLl-BLD1Ypk-mvPxT2OR52ezMs4XI1MGBdhlLLm4"
21
+ }
22
+ ]
23
+ }
24
+ }