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,583 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'didkit/did'
|
|
4
|
+
require 'jose'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
require_relative 'client_metadata'
|
|
9
|
+
require_relative 'configuration'
|
|
10
|
+
require_relative 'dpop/client'
|
|
11
|
+
require_relative 'dpop/key_manager'
|
|
12
|
+
require_relative 'errors'
|
|
13
|
+
require_relative 'par'
|
|
14
|
+
require_relative 'par/client'
|
|
15
|
+
require_relative 'par/client_assertion'
|
|
16
|
+
require_relative 'par/request'
|
|
17
|
+
require_relative 'server_metadata/authorization_server'
|
|
18
|
+
require_relative 'server_metadata/resource_server'
|
|
19
|
+
require_relative 'state/session_manager'
|
|
20
|
+
require_relative 'state/token_set'
|
|
21
|
+
require_relative 'storage/key_builder'
|
|
22
|
+
require_relative 'token/refresh'
|
|
23
|
+
|
|
24
|
+
module AuthProto
|
|
25
|
+
|
|
26
|
+
#
|
|
27
|
+
# Main client class for AT Protocol OAuth implementation. Handles the complete
|
|
28
|
+
# OAuth flow including authorization, token management, and identity verification.
|
|
29
|
+
#
|
|
30
|
+
|
|
31
|
+
class Client
|
|
32
|
+
|
|
33
|
+
# Error raised when authorization callback fails
|
|
34
|
+
class CallbackError < Error; end
|
|
35
|
+
|
|
36
|
+
# Error raised when token operations fail
|
|
37
|
+
class TokenError < Error; end
|
|
38
|
+
|
|
39
|
+
#
|
|
40
|
+
# Error raised when session operations fail
|
|
41
|
+
#
|
|
42
|
+
|
|
43
|
+
class SessionError < Error
|
|
44
|
+
|
|
45
|
+
# Identifier of the session associated with the failure, if known.
|
|
46
|
+
# @return [String, nil]
|
|
47
|
+
attr_reader :session_id
|
|
48
|
+
|
|
49
|
+
# Creates a session error with an optional session identifier.
|
|
50
|
+
#
|
|
51
|
+
# @param message [String] explanation of the failure
|
|
52
|
+
# @param session_id [String, nil] associated session ID
|
|
53
|
+
#
|
|
54
|
+
def initialize(message, session_id: nil)
|
|
55
|
+
@session_id = session_id
|
|
56
|
+
super(message)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# OAuth client ID URL.
|
|
61
|
+
# @return [String]
|
|
62
|
+
attr_reader :client_id
|
|
63
|
+
|
|
64
|
+
# Callback URI registered in the client metadata.
|
|
65
|
+
# @return [String]
|
|
66
|
+
attr_reader :redirect_uri
|
|
67
|
+
|
|
68
|
+
# Validated client metadata.
|
|
69
|
+
# @return [ClientMetadata]
|
|
70
|
+
attr_reader :client_metadata
|
|
71
|
+
|
|
72
|
+
# Manager for stored OAuth sessions.
|
|
73
|
+
# @return [State::SessionManager]
|
|
74
|
+
attr_reader :session_manager
|
|
75
|
+
|
|
76
|
+
# DPoP key, proof and nonce manager.
|
|
77
|
+
# @return [DPoP::Client]
|
|
78
|
+
attr_reader :dpop_client
|
|
79
|
+
|
|
80
|
+
# Creates an OAuth client with its own DPoP key and session manager.
|
|
81
|
+
#
|
|
82
|
+
# Configure {AuthProto.configure} with an HTTP transport before fetching metadata
|
|
83
|
+
# or starting authorization. Reuse `dpop_key` when restoring tokens bound to that key.
|
|
84
|
+
#
|
|
85
|
+
# @param client_id [String] OAuth client ID URL
|
|
86
|
+
# @param redirect_uri [String] callback URI listed in `redirect_uris`
|
|
87
|
+
# @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch `client_id`
|
|
88
|
+
# @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
|
|
89
|
+
#
|
|
90
|
+
# @raise [InvalidClientMetadata] if client metadata is invalid or unavailable
|
|
91
|
+
# @raise [Error] if the redirect URI is not registered
|
|
92
|
+
# @raise [DPoP::KeyManager::KeyError] if the DPoP key cannot be imported or generated
|
|
93
|
+
# @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if fetching client metadata fails
|
|
94
|
+
#
|
|
95
|
+
def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil)
|
|
96
|
+
@client_id = client_id
|
|
97
|
+
@redirect_uri = redirect_uri
|
|
98
|
+
|
|
99
|
+
# Initialize core dependencies
|
|
100
|
+
@client_metadata = load_client_metadata(metadata)
|
|
101
|
+
validate_redirect_uri
|
|
102
|
+
|
|
103
|
+
@session_manager = State::SessionManager.new
|
|
104
|
+
@dpop_client = initialize_dpop(dpop_key)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Starts authorization, stores a pending session, and submits a PAR request.
|
|
108
|
+
#
|
|
109
|
+
# Supply exactly one of `handle` or `pds_url`. Open the returned `:url` in a browser
|
|
110
|
+
# and pass the eventual callback parameters to {#handle_callback}.
|
|
111
|
+
#
|
|
112
|
+
# @param handle [String, nil] account handle (optionally prefixed with `@`) or DID
|
|
113
|
+
# @param pds_url [String, nil] PDS URL when no handle is supplied
|
|
114
|
+
# @param scope [String] space-separated scopes, including `atproto`
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash{Symbol => String}] `:url` is the browser URL; `:session_id` identifies the pending session
|
|
117
|
+
# @raise [Error] if identity arguments or scope are invalid, the handle cannot be verified, or no HTTPS PDS is found
|
|
118
|
+
# @raise [DIDKit::DIDError, DIDKit::FormatError, DIDKit::APIError] if DID validation or document fetching fails
|
|
119
|
+
# @raise [InvalidAuthorizationServer] if server discovery or metadata validation fails
|
|
120
|
+
# @raise [PAR::Error] if the pushed authorization request fails
|
|
121
|
+
# @raise [PAR::ClientAssertion::Error] if confidential-client signing fails
|
|
122
|
+
# @raise [DPoP::Client::Error] if generating the PAR proof fails
|
|
123
|
+
# @raise [HTTPClient::SSRFError] if server discovery rejects a destination
|
|
124
|
+
# @raise [Storage::StorageError] if session storage or locking fails
|
|
125
|
+
# @raise [Encryption::EncryptionError] if session serialization cannot encrypt secrets
|
|
126
|
+
#
|
|
127
|
+
def authorize(handle: nil, pds_url: nil, scope: "atproto")
|
|
128
|
+
validate_auth_params(handle, pds_url, scope)
|
|
129
|
+
|
|
130
|
+
# Create new session
|
|
131
|
+
session = session_manager.create_session(
|
|
132
|
+
client_id: client_id,
|
|
133
|
+
scope: scope
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Store session with storage backend
|
|
137
|
+
session_manager.update_session(session)
|
|
138
|
+
|
|
139
|
+
# Resolve identity and authorization server if handle provided
|
|
140
|
+
if handle
|
|
141
|
+
auth_info = resolve_from_handle(handle, session)
|
|
142
|
+
elsif pds_url
|
|
143
|
+
auth_info = resolve_from_pds(pds_url, session)
|
|
144
|
+
else
|
|
145
|
+
raise Error, "Either handle or pds_url must be provided"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Generate authorization URL
|
|
149
|
+
auth_url = generate_authorization_url(
|
|
150
|
+
auth_info[:server],
|
|
151
|
+
session,
|
|
152
|
+
login_hint: handle
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
{
|
|
156
|
+
url: auth_url,
|
|
157
|
+
session_id: session.session_id
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Validates a callback, exchanges its code, and stores the authenticated session.
|
|
162
|
+
#
|
|
163
|
+
# The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
|
|
164
|
+
# `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
|
|
165
|
+
# and `:scope` (String).
|
|
166
|
+
# It also contains `:session_id` (String) and `:did` (String).
|
|
167
|
+
#
|
|
168
|
+
# @param code [String] authorization code from the callback
|
|
169
|
+
# @param state [String] state token from the callback
|
|
170
|
+
# @param iss [String] issuer URL from the callback
|
|
171
|
+
#
|
|
172
|
+
# @return [Hash{Symbol => String, Integer, nil}] tokens and authenticated session details
|
|
173
|
+
# @raise [CallbackError] if state is unknown or the issuer does not match the session
|
|
174
|
+
# @raise [TokenError] if token exchange or response validation fails
|
|
175
|
+
# @raise [DPoP::Client::Error, PAR::ClientAssertion::Error] if proof/assertion generation fails
|
|
176
|
+
# @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if token transport fails
|
|
177
|
+
# @raise [State::SessionError] if token subject conflicts with the session DID
|
|
178
|
+
# @raise [ArgumentError] if returned token fields fail token-set validation
|
|
179
|
+
# @raise [Storage::StorageError] if session storage or locking fails
|
|
180
|
+
# @raise [Encryption::EncryptionError] if tokens cannot be encrypted for storage
|
|
181
|
+
#
|
|
182
|
+
def handle_callback(code:, state:, iss:)
|
|
183
|
+
# Find and validate session
|
|
184
|
+
session = session_manager.get_session_by_state(state)
|
|
185
|
+
raise CallbackError, "Invalid state parameter" unless session
|
|
186
|
+
|
|
187
|
+
# Verify issuer matches session
|
|
188
|
+
raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
|
|
189
|
+
|
|
190
|
+
AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
|
|
191
|
+
# Exchange code for tokens
|
|
192
|
+
token_response = exchange_code(
|
|
193
|
+
code: code,
|
|
194
|
+
session: session
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Validate token response
|
|
198
|
+
validate_token_response(token_response, session)
|
|
199
|
+
|
|
200
|
+
# Create token set and store in session
|
|
201
|
+
token_set = State::TokenSet.new(
|
|
202
|
+
access_token: token_response["access_token"],
|
|
203
|
+
token_type: token_response["token_type"],
|
|
204
|
+
expires_in: token_response["expires_in"],
|
|
205
|
+
refresh_token: token_response["refresh_token"],
|
|
206
|
+
scope: token_response["scope"],
|
|
207
|
+
sub: token_response["sub"]
|
|
208
|
+
)
|
|
209
|
+
session.tokens = token_set
|
|
210
|
+
|
|
211
|
+
# Update stored session
|
|
212
|
+
session_manager.update_session(session)
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
access_token: token_set.access_token,
|
|
216
|
+
token_type: token_set.token_type,
|
|
217
|
+
expires_in: (token_set.expires_at - Time.now).to_i,
|
|
218
|
+
refresh_token: token_set.refresh_token,
|
|
219
|
+
scope: token_set.scope,
|
|
220
|
+
session_id: session.session_id,
|
|
221
|
+
did: session.did
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Reads tokens for a session with a currently valid access token.
|
|
227
|
+
#
|
|
228
|
+
# The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
|
|
229
|
+
# `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
|
|
230
|
+
# and `:scope` (String).
|
|
231
|
+
# This method does not refresh expired tokens.
|
|
232
|
+
#
|
|
233
|
+
# @param session_id [String] session identifier
|
|
234
|
+
# @return [Hash{Symbol => String, Integer, nil}, nil] token details, or `nil` for missing/unauthorized sessions
|
|
235
|
+
#
|
|
236
|
+
def get_tokens(session_id)
|
|
237
|
+
session = session_manager.get_session(session_id)
|
|
238
|
+
return nil unless session&.authorized?
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
access_token: session.tokens.access_token,
|
|
242
|
+
token_type: session.tokens.token_type,
|
|
243
|
+
expires_in: (session.tokens.expires_at - Time.now).to_i,
|
|
244
|
+
refresh_token: session.tokens.refresh_token,
|
|
245
|
+
scope: session.tokens.scope
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Refreshes a renewable session and persists the replacement tokens.
|
|
250
|
+
#
|
|
251
|
+
# The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
|
|
252
|
+
# `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
|
|
253
|
+
# and `:scope` (String).
|
|
254
|
+
# The result also contains `:session_id` (String). An expired access token can
|
|
255
|
+
# still be refreshed when the stored session has a refresh token.
|
|
256
|
+
#
|
|
257
|
+
# @param session_id [String] session to refresh
|
|
258
|
+
# @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session ID
|
|
259
|
+
# @raise [TokenError] if the session is missing or has no refresh token
|
|
260
|
+
# @raise [Token::RefreshError] if token refresh fails or exhausts retries
|
|
261
|
+
# @raise [State::SessionError] if replacement tokens have a different subject
|
|
262
|
+
# @raise [Storage::StorageError] if session storage or locking fails
|
|
263
|
+
# @raise [Encryption::EncryptionError] if replacement tokens cannot be stored
|
|
264
|
+
#
|
|
265
|
+
def refresh_token(session_id)
|
|
266
|
+
session = session_manager.get_session(session_id)
|
|
267
|
+
raise TokenError, "Invalid session" unless session
|
|
268
|
+
raise TokenError, "Session not authorized" unless session.renewable?
|
|
269
|
+
|
|
270
|
+
AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
|
|
271
|
+
refresher = Token::Refresh.new(
|
|
272
|
+
session: session,
|
|
273
|
+
dpop_client: @dpop_client,
|
|
274
|
+
auth_server: session.auth_server,
|
|
275
|
+
client_metadata: client_metadata
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
new_tokens = refresher.perform
|
|
279
|
+
session.tokens = new_tokens
|
|
280
|
+
|
|
281
|
+
# Update stored session
|
|
282
|
+
session_manager.update_session(session)
|
|
283
|
+
|
|
284
|
+
{
|
|
285
|
+
access_token: new_tokens.access_token,
|
|
286
|
+
token_type: new_tokens.token_type,
|
|
287
|
+
expires_in: (new_tokens.expires_at - Time.now).to_i,
|
|
288
|
+
refresh_token: new_tokens.refresh_token,
|
|
289
|
+
scope: new_tokens.scope,
|
|
290
|
+
session_id: session.session_id
|
|
291
|
+
}
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Checks if a session has valid tokens
|
|
296
|
+
#
|
|
297
|
+
# @param session_id [String] ID of session to check
|
|
298
|
+
# @return [Boolean] true if session exists and has valid tokens
|
|
299
|
+
#
|
|
300
|
+
def authorized?(session_id)
|
|
301
|
+
session = session_manager.get_session(session_id)
|
|
302
|
+
session&.authorized? || false
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Builds authorization headers and a fresh DPoP proof for one HTTP request.
|
|
306
|
+
#
|
|
307
|
+
# The caller sends the request. This method does not refresh tokens, send HTTP,
|
|
308
|
+
# or retry nonce challenges. Feed response headers to {DPoP::Client#process_response}
|
|
309
|
+
# and call {#refresh_token} explicitly when needed. Generate new headers for each retry.
|
|
310
|
+
#
|
|
311
|
+
# @param session_id [String] authorized session ID
|
|
312
|
+
# @param method [String] HTTP method for the proof
|
|
313
|
+
# @param url [String] absolute request URL without a query string or fragment
|
|
314
|
+
#
|
|
315
|
+
# @return [Hash{String => String}] `Authorization: DPoP ...` and `DPoP` proof headers
|
|
316
|
+
# @raise [TokenError] if the session is missing or its access token is expired
|
|
317
|
+
# @raise [DPoP::Client::Error] if proof generation or nonce lookup fails
|
|
318
|
+
#
|
|
319
|
+
def auth_headers(session_id:, method:, url:)
|
|
320
|
+
session = session_manager.get_session(session_id)
|
|
321
|
+
raise TokenError, "Invalid session" unless session
|
|
322
|
+
raise TokenError, "Session not authorized" unless session.authorized?
|
|
323
|
+
|
|
324
|
+
# Generate DPoP proof
|
|
325
|
+
proof = dpop_client.generate_proof(
|
|
326
|
+
http_method: method,
|
|
327
|
+
http_uri: url,
|
|
328
|
+
access_token: session.tokens.access_token
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
{
|
|
332
|
+
"Authorization" => "DPoP #{session.tokens.access_token}",
|
|
333
|
+
"DPoP" => proof
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Removes a stored session and its state-token lookup.
|
|
338
|
+
#
|
|
339
|
+
# @param session_id [String] session identifier
|
|
340
|
+
# @return [void]
|
|
341
|
+
# @raise [Storage::StorageError] if deletion or locking fails
|
|
342
|
+
#
|
|
343
|
+
def remove_session(session_id)
|
|
344
|
+
key = Storage::KeyBuilder.session_key(session_id)
|
|
345
|
+
AuthProto.storage.delete(key)
|
|
346
|
+
session_manager.remove_session(session_id)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Invokes the session manager's expiry cleanup hook.
|
|
350
|
+
#
|
|
351
|
+
# Currently a no-op; expiry is checked during retrieval and by storage TTLs.
|
|
352
|
+
#
|
|
353
|
+
# @return [void]
|
|
354
|
+
#
|
|
355
|
+
def cleanup_expired_sessions
|
|
356
|
+
session_manager.cleanup_expired
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
private
|
|
360
|
+
|
|
361
|
+
def load_client_metadata(metadata)
|
|
362
|
+
if metadata
|
|
363
|
+
ClientMetadata.new(metadata)
|
|
364
|
+
else
|
|
365
|
+
ClientMetadata.from_url(@client_id)
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def validate_redirect_uri
|
|
370
|
+
valid = @client_metadata.redirect_uris.include?(@redirect_uri)
|
|
371
|
+
raise Error, "redirect_uri not found in client metadata" unless valid
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def initialize_dpop(key)
|
|
375
|
+
key_manager = if key
|
|
376
|
+
DPoP::KeyManager.from_jwk(key)
|
|
377
|
+
else
|
|
378
|
+
DPoP::KeyManager.new
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
DPoP::Client.new(key_manager: key_manager)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def validate_auth_params(handle, pds_url, scope)
|
|
385
|
+
raise Error, "scope must include 'atproto'" unless scope.split.include?("atproto")
|
|
386
|
+
raise Error, "handle or pds_url must be provided" if handle.nil? && pds_url.nil?
|
|
387
|
+
raise Error, "cannot provide both handle and pds_url" if handle && pds_url
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def resolve_from_handle(handle, session)
|
|
391
|
+
did = DIDKit::DID.resolve_handle(handle)
|
|
392
|
+
raise Error, "Could not resolve or verify identity #{handle}" unless did
|
|
393
|
+
|
|
394
|
+
pds_url = did.document.pds_endpoint
|
|
395
|
+
raise Error, "No HTTPS PDS location found for #{did}" unless pds_url && URI(pds_url).is_a?(URI::HTTPS)
|
|
396
|
+
|
|
397
|
+
session.did = did.to_s
|
|
398
|
+
|
|
399
|
+
# Get authorization server from PDS
|
|
400
|
+
server = resolve_auth_server(pds_url)
|
|
401
|
+
session.authorization_server = server
|
|
402
|
+
|
|
403
|
+
# Update stored session
|
|
404
|
+
session_manager.update_session(session)
|
|
405
|
+
|
|
406
|
+
{ server: server, pds: pds_url }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def resolve_from_pds(pds_url, session)
|
|
410
|
+
# Get authorization server from PDS
|
|
411
|
+
server = resolve_auth_server(pds_url)
|
|
412
|
+
session.authorization_server = server
|
|
413
|
+
|
|
414
|
+
# Update stored session
|
|
415
|
+
session_manager.update_session(session)
|
|
416
|
+
|
|
417
|
+
{ server: server, pds: pds_url }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def resolve_auth_server(pds_url)
|
|
421
|
+
# Get resource server metadata
|
|
422
|
+
resource_server = ServerMetadata::ResourceServer.from_url(pds_url)
|
|
423
|
+
auth_server_url = resource_server.authorization_servers.first
|
|
424
|
+
|
|
425
|
+
# Get and validate authorization server metadata
|
|
426
|
+
ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def generate_authorization_url(auth_server, session, login_hint: nil)
|
|
430
|
+
# Create PAR client
|
|
431
|
+
par_client = PAR::Client.new(
|
|
432
|
+
endpoint: auth_server.pushed_authorization_request_endpoint,
|
|
433
|
+
dpop_client: @dpop_client
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Build PAR request
|
|
437
|
+
par_request = PAR::Request.build do |config|
|
|
438
|
+
config.client_id = client_id
|
|
439
|
+
config.redirect_uri = redirect_uri
|
|
440
|
+
config.state = session.state_token
|
|
441
|
+
config.scope = session.scope
|
|
442
|
+
config.login_hint = login_hint if login_hint
|
|
443
|
+
|
|
444
|
+
# Add PKCE parameters
|
|
445
|
+
config.code_challenge = session.pkce_challenge
|
|
446
|
+
config.code_challenge_method = "S256"
|
|
447
|
+
|
|
448
|
+
# Public clients use PKCE and DPoP without client authentication.
|
|
449
|
+
if client_metadata.confidential?
|
|
450
|
+
signing_key = JOSE::JWK.from_map(client_metadata.jwks.fetch("keys").first)
|
|
451
|
+
client_assertion = PAR::ClientAssertion.new(client_id: client_id, signing_key: signing_key)
|
|
452
|
+
config.client_assertion = client_assertion.generate_jwt(audience: auth_server.issuer)
|
|
453
|
+
config.client_assertion_type = PAR::CLIENT_ASSERTION_TYPE
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Add DPoP proof
|
|
457
|
+
proof = @dpop_client.generate_proof(
|
|
458
|
+
http_method: "POST",
|
|
459
|
+
http_uri: auth_server.pushed_authorization_request_endpoint
|
|
460
|
+
)
|
|
461
|
+
config.dpop_proof = proof
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Submit PAR request
|
|
465
|
+
response = par_client.submit(par_request)
|
|
466
|
+
|
|
467
|
+
# Build final authorization URL
|
|
468
|
+
par_client.authorization_url(
|
|
469
|
+
authorize_endpoint: auth_server.authorization_endpoint,
|
|
470
|
+
request_uri: response.request_uri,
|
|
471
|
+
client_id: client_id
|
|
472
|
+
)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def exchange_code(code:, session:)
|
|
476
|
+
# Initial token request without nonce
|
|
477
|
+
response = make_token_request(code, session)
|
|
478
|
+
|
|
479
|
+
# Handle DPoP nonce requirement
|
|
480
|
+
if requires_dpop_nonce?(response)
|
|
481
|
+
# Extract and store nonce from error response
|
|
482
|
+
extract_dpop_nonce(response)
|
|
483
|
+
dpop_client.process_response(response[:headers], session.auth_server.issuer)
|
|
484
|
+
|
|
485
|
+
# Retry request with nonce
|
|
486
|
+
response = make_token_request(code, session)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
raise TokenError, "Token request failed: #{response[:status]}" unless response[:status] == 200
|
|
490
|
+
|
|
491
|
+
begin
|
|
492
|
+
JSON.parse(response[:body])
|
|
493
|
+
rescue JSON::ParserError => e
|
|
494
|
+
raise TokenError, "Invalid token response: #{e.message}"
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def make_token_request(code, session)
|
|
499
|
+
# Generate proof
|
|
500
|
+
proof = dpop_client.generate_proof(
|
|
501
|
+
http_method: "POST",
|
|
502
|
+
http_uri: session.auth_server.token_endpoint
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
body = {
|
|
506
|
+
grant_type: "authorization_code",
|
|
507
|
+
code: code,
|
|
508
|
+
redirect_uri: redirect_uri,
|
|
509
|
+
client_id: client_id,
|
|
510
|
+
code_verifier: session.pkce_verifier
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
# Add client authentication
|
|
514
|
+
if client_metadata.confidential?
|
|
515
|
+
signing_key = JOSE::JWK.from_map(client_metadata.jwks["keys"].first)
|
|
516
|
+
client_assertion = PAR::ClientAssertion.new(
|
|
517
|
+
client_id: client_id,
|
|
518
|
+
signing_key: signing_key
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
body.merge!(
|
|
522
|
+
client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
|
|
523
|
+
client_assertion: client_assertion.generate_jwt(
|
|
524
|
+
audience: session.auth_server.issuer
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
AuthProto.configuration.http_client.post(
|
|
530
|
+
session.auth_server.token_endpoint,
|
|
531
|
+
body: body,
|
|
532
|
+
headers: {
|
|
533
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
534
|
+
"DPoP" => proof
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def requires_dpop_nonce?(response)
|
|
540
|
+
return false unless response[:status] == 400
|
|
541
|
+
|
|
542
|
+
error_data = JSON.parse(response[:body])
|
|
543
|
+
error_data["error"] == "use_dpop_nonce"
|
|
544
|
+
rescue JSON::ParserError
|
|
545
|
+
false
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def extract_dpop_nonce(response)
|
|
549
|
+
headers = response[:headers]
|
|
550
|
+
nonce = headers["DPoP-Nonce"] ||
|
|
551
|
+
headers["dpop-nonce"] ||
|
|
552
|
+
headers["Dpop-Nonce"]
|
|
553
|
+
|
|
554
|
+
raise TokenError, "No DPoP nonce provided in error response" unless nonce
|
|
555
|
+
|
|
556
|
+
nonce
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def validate_token_response(response, session)
|
|
560
|
+
# Required fields
|
|
561
|
+
%w[access_token token_type expires_in scope sub].each do |field|
|
|
562
|
+
raise TokenError, "Missing #{field} in token response" unless response[field]
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Token type must be DPoP
|
|
566
|
+
raise TokenError, "Invalid token_type: #{response["token_type"]}" unless response["token_type"] == "DPoP"
|
|
567
|
+
|
|
568
|
+
# Scope must include atproto
|
|
569
|
+
raise TokenError, "Missing atproto scope in token response" unless response["scope"].split.include?("atproto")
|
|
570
|
+
|
|
571
|
+
# If we have a pre-resolved DID, verify it matches
|
|
572
|
+
raise TokenError, "Subject mismatch in token response" if session.did && session.did != response["sub"]
|
|
573
|
+
|
|
574
|
+
# Process DPoP-Nonce from response headers if present
|
|
575
|
+
return unless response[:headers] && response[:headers]["DPoP-Nonce"]
|
|
576
|
+
|
|
577
|
+
dpop_client.process_response(
|
|
578
|
+
response[:headers],
|
|
579
|
+
session.auth_server.issuer
|
|
580
|
+
)
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|