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.
- checksums.yaml +7 -0
- data/.rubocop.yml +16 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/Rakefile +16 -0
- data/examples/confidential_client/Gemfile +12 -0
- data/examples/confidential_client/Gemfile.lock +84 -0
- data/examples/confidential_client/README.md +110 -0
- data/examples/confidential_client/app.rb +136 -0
- data/examples/confidential_client/config/client-metadata.json +25 -0
- data/examples/confidential_client/config.ru +4 -0
- data/examples/confidential_client/public/client-metadata.json +24 -0
- data/examples/confidential_client/public/styles.css +70 -0
- data/examples/confidential_client/scripts/generate_keys.rb +15 -0
- data/examples/confidential_client/views/authorized.erb +29 -0
- data/examples/confidential_client/views/index.erb +44 -0
- data/examples/confidential_client/views/layout.erb +11 -0
- data/lib/atproto_auth/client.rb +410 -0
- data/lib/atproto_auth/client_metadata.rb +264 -0
- data/lib/atproto_auth/configuration.rb +17 -0
- data/lib/atproto_auth/dpop/client.rb +122 -0
- data/lib/atproto_auth/dpop/key_manager.rb +235 -0
- data/lib/atproto_auth/dpop/nonce_manager.rb +138 -0
- data/lib/atproto_auth/dpop/proof_generator.rb +112 -0
- data/lib/atproto_auth/errors.rb +47 -0
- data/lib/atproto_auth/http_client.rb +227 -0
- data/lib/atproto_auth/identity/document.rb +104 -0
- data/lib/atproto_auth/identity/resolver.rb +221 -0
- data/lib/atproto_auth/identity.rb +24 -0
- data/lib/atproto_auth/par/client.rb +203 -0
- data/lib/atproto_auth/par/client_assertion.rb +50 -0
- data/lib/atproto_auth/par/request.rb +140 -0
- data/lib/atproto_auth/par/response.rb +23 -0
- data/lib/atproto_auth/par.rb +40 -0
- data/lib/atproto_auth/pkce.rb +105 -0
- data/lib/atproto_auth/server_metadata/authorization_server.rb +175 -0
- data/lib/atproto_auth/server_metadata/origin_url.rb +51 -0
- data/lib/atproto_auth/server_metadata/resource_server.rb +71 -0
- data/lib/atproto_auth/server_metadata.rb +24 -0
- data/lib/atproto_auth/state/session.rb +117 -0
- data/lib/atproto_auth/state/session_manager.rb +75 -0
- data/lib/atproto_auth/state/token_set.rb +68 -0
- data/lib/atproto_auth/state.rb +54 -0
- data/lib/atproto_auth/version.rb +5 -0
- data/lib/atproto_auth.rb +56 -0
- data/sig/atproto_auth/client_metadata.rbs +95 -0
- data/sig/atproto_auth/dpop/client.rbs +38 -0
- data/sig/atproto_auth/dpop/key_manager.rbs +33 -0
- data/sig/atproto_auth/dpop/nonce_manager.rbs +48 -0
- data/sig/atproto_auth/dpop/proof_generator.rbs +42 -0
- data/sig/atproto_auth/http_client.rbs +58 -0
- data/sig/atproto_auth/identity/document.rbs +31 -0
- data/sig/atproto_auth/identity/resolver.rbs +41 -0
- data/sig/atproto_auth/par/client.rbs +31 -0
- data/sig/atproto_auth/par/request.rbs +73 -0
- data/sig/atproto_auth/par/response.rbs +17 -0
- data/sig/atproto_auth/pkce.rbs +24 -0
- data/sig/atproto_auth/server_metadata/authorization_server.rbs +69 -0
- data/sig/atproto_auth/server_metadata/origin_url.rbs +21 -0
- data/sig/atproto_auth/server_metadata/resource_server.rbs +27 -0
- data/sig/atproto_auth/state/session.rbs +50 -0
- data/sig/atproto_auth/state/session_manager.rbs +26 -0
- data/sig/atproto_auth/state/token_set.rbs +40 -0
- data/sig/atproto_auth/version.rbs +3 -0
- data/sig/atproto_auth.rbs +39 -0
- metadata +142 -0
@@ -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,29 @@
|
|
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
|
+
<div class="token-info">
|
15
|
+
<p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Token Information</p>
|
16
|
+
<pre class="rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 dark:bg-slate-800 border border-gray-400 p-2 overflow-auto"><code><%= JSON.pretty_generate(session[:tokens]) %></code></pre>
|
17
|
+
</div>
|
18
|
+
|
19
|
+
<div class="api-test">
|
20
|
+
<p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Test API Call Result</p>
|
21
|
+
<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>
|
22
|
+
</div>
|
23
|
+
|
24
|
+
<div class="flex">
|
25
|
+
<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>
|
26
|
+
</div>
|
27
|
+
</div>
|
28
|
+
</div>
|
29
|
+
</div>
|
@@ -0,0 +1,44 @@
|
|
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">Sign in</h1>
|
4
|
+
<p class="hidden md:block max-w-xs text-gray-500 dark:text-gray-500">Enter your AT Protocol handle</p>
|
5
|
+
</div>
|
6
|
+
<div class="w-full px-6 md:max-w-3xl md:px-12">
|
7
|
+
<form method="post" action="/auth" class="flex flex-col py-4 space-y-4">
|
8
|
+
<div class="space-y-4">
|
9
|
+
<% if session[:error] %>
|
10
|
+
<div class="mb-8 bg-red-50 border-red-700 text-red-700 p-4 rounded-md">
|
11
|
+
<%= session[:error] %>
|
12
|
+
<% session[:error] = nil %>
|
13
|
+
</div>
|
14
|
+
<% end %>
|
15
|
+
<% if session[:notice] %>
|
16
|
+
<div class="mb-8 bg-yellow-50 border-yellow-700 text-yellow-700 p-4 rounded-md">
|
17
|
+
<%= session[:notice] %>
|
18
|
+
<% session[:notice] = nil %>
|
19
|
+
</div>
|
20
|
+
<% end %>
|
21
|
+
<fieldset>
|
22
|
+
<p class="mb-1 text-gray-600 dark:text-gray-400 text-sm font-medium">Handle</p>
|
23
|
+
<div class="flex flex-col space-y-4">
|
24
|
+
<div class="pl-1 pr-2 min-h-12 flex items-center justify-stretch rounded-lg text-gray-700 dark:text-gray-100 bg-gray-100 has-[:focus]:bg-gray-200 dark:bg-gray-800 dark:has-[:focus]:bg-gray-700 outline-none border-solid border-2 border-transparent hover:border-gray-400 has-[:focus]:border-brand hover:has-[:focus]:border-brand dark:hover:border-gray-500">
|
25
|
+
<div class="self-start shrink-0 grow-0 w-8 h-12 flex items-center justify-center text-gray-500">
|
26
|
+
<div>
|
27
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-5">
|
28
|
+
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z"></path>
|
29
|
+
</svg>
|
30
|
+
</div>
|
31
|
+
</div>
|
32
|
+
<div class="relative">
|
33
|
+
<input class="w-full bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder-gray-500" name="handle" type="text" placeholder="handle.bsky.social" aria-label="Handle" autocapitalize="none" autocorrect="off" autocomplete="username" spellcheck="false" dir="auto" enterkeyhint="next" required title="valid handle">
|
34
|
+
</div>
|
35
|
+
</div>
|
36
|
+
</div>
|
37
|
+
</fieldset>
|
38
|
+
</div>
|
39
|
+
<div class="pt-4">
|
40
|
+
<button role="Button" type="submit" aria-label="Next" class="py-2 px-6 rounded-lg truncate cursor-pointer touch-manipulation tracking-wide overflow-hidden bg-blue-400 text-white">Next</button>
|
41
|
+
</div>
|
42
|
+
</form>
|
43
|
+
</div>
|
44
|
+
</div>
|
@@ -0,0 +1,11 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>AT Protocol OAuth Example</title>
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
7
|
+
</head>
|
8
|
+
<body>
|
9
|
+
<%= yield %>
|
10
|
+
</body>
|
11
|
+
</html>
|
@@ -0,0 +1,410 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# rubocop:disable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
4
|
+
|
5
|
+
module AtprotoAuth
|
6
|
+
# Main client class for AT Protocol OAuth implementation. Handles the complete
|
7
|
+
# OAuth flow including authorization, token management, and identity verification.
|
8
|
+
class Client
|
9
|
+
# Error raised when authorization callback fails
|
10
|
+
class CallbackError < Error; end
|
11
|
+
|
12
|
+
# Error raised when token operations fail
|
13
|
+
class TokenError < Error; end
|
14
|
+
|
15
|
+
# @return [String] OAuth client ID
|
16
|
+
attr_reader :client_id
|
17
|
+
# @return [String] OAuth redirect URI
|
18
|
+
attr_reader :redirect_uri
|
19
|
+
# @return [ClientMetadata] Validated client metadata
|
20
|
+
attr_reader :client_metadata
|
21
|
+
# @return [SessionManager] Session state manager
|
22
|
+
attr_reader :session_manager
|
23
|
+
# @return [Identity::Resolver] Identity resolver
|
24
|
+
attr_reader :identity_resolver
|
25
|
+
# @return [DPoP::Client] DPoP client
|
26
|
+
attr_reader :dpop_client
|
27
|
+
|
28
|
+
# Creates a new AT Protocol OAuth client
|
29
|
+
# @param client_id [String] OAuth client ID URL
|
30
|
+
# @param redirect_uri [String] OAuth redirect URI
|
31
|
+
# @param metadata [Hash, nil] Optional pre-loaded client metadata
|
32
|
+
# @param dpop_key [Hash, nil] Optional existing DPoP key in JWK format
|
33
|
+
# @raise [Error] if configuration is invalid
|
34
|
+
def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil)
|
35
|
+
@client_id = client_id
|
36
|
+
@redirect_uri = redirect_uri
|
37
|
+
|
38
|
+
# Initialize core dependencies
|
39
|
+
@client_metadata = load_client_metadata(metadata)
|
40
|
+
validate_redirect_uri!
|
41
|
+
|
42
|
+
@session_manager = State::SessionManager.new
|
43
|
+
@identity_resolver = Identity::Resolver.new
|
44
|
+
@dpop_client = initialize_dpop(dpop_key)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Begins an authorization flow and generates authorization URL
|
48
|
+
# @param handle [String, nil] Optional user handle
|
49
|
+
# @param pds_url [String, nil] Optional PDS URL
|
50
|
+
# @param scope [String] OAuth scope (must include "atproto")
|
51
|
+
# @return [Hash] Authorization details including :url and :session_id
|
52
|
+
# @raise [Error] if parameters are invalid or resolution fails
|
53
|
+
def authorize(handle: nil, pds_url: nil, scope: "atproto")
|
54
|
+
validate_auth_params!(handle, pds_url, scope)
|
55
|
+
|
56
|
+
# Create new session
|
57
|
+
session = session_manager.create_session(
|
58
|
+
client_id: client_id,
|
59
|
+
scope: scope
|
60
|
+
)
|
61
|
+
|
62
|
+
# Resolve identity and authorization server if handle provided
|
63
|
+
if handle
|
64
|
+
auth_info = resolve_from_handle(handle, session)
|
65
|
+
elsif pds_url
|
66
|
+
auth_info = resolve_from_pds(pds_url, session)
|
67
|
+
else
|
68
|
+
raise Error, "Either handle or pds_url must be provided"
|
69
|
+
end
|
70
|
+
|
71
|
+
# Generate authorization URL
|
72
|
+
auth_url = generate_authorization_url(
|
73
|
+
auth_info[:server],
|
74
|
+
session,
|
75
|
+
login_hint: handle
|
76
|
+
)
|
77
|
+
|
78
|
+
{
|
79
|
+
url: auth_url,
|
80
|
+
session_id: session.session_id
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
# Handles the authorization callback and completes token exchange
|
85
|
+
# @param code [String] Authorization code from callback
|
86
|
+
# @param state [String] State parameter from callback
|
87
|
+
# @param iss [String] Issuer from callback (required by AT Protocol OAuth)
|
88
|
+
# @return [Hash] Token response including :access_token and :session_id
|
89
|
+
# @raise [CallbackError] if callback validation fails
|
90
|
+
# @raise [TokenError] if token exchange fails
|
91
|
+
def handle_callback(code:, state:, iss:)
|
92
|
+
# Find and validate session
|
93
|
+
session = session_manager.get_session_by_state(state)
|
94
|
+
raise CallbackError, "Invalid state parameter" unless session
|
95
|
+
|
96
|
+
# Verify issuer matches session
|
97
|
+
raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
|
98
|
+
|
99
|
+
# Exchange code for tokens
|
100
|
+
token_response = exchange_code(
|
101
|
+
code: code,
|
102
|
+
session: session
|
103
|
+
)
|
104
|
+
|
105
|
+
# Validate token response
|
106
|
+
validate_token_response!(token_response, session)
|
107
|
+
|
108
|
+
# Create token set and store in session
|
109
|
+
token_set = State::TokenSet.new(
|
110
|
+
access_token: token_response["access_token"],
|
111
|
+
token_type: token_response["token_type"],
|
112
|
+
expires_in: token_response["expires_in"],
|
113
|
+
refresh_token: token_response["refresh_token"],
|
114
|
+
scope: token_response["scope"],
|
115
|
+
sub: token_response["sub"]
|
116
|
+
)
|
117
|
+
session.tokens = token_set
|
118
|
+
|
119
|
+
{
|
120
|
+
access_token: token_set.access_token,
|
121
|
+
token_type: token_set.token_type,
|
122
|
+
expires_in: (token_set.expires_at - Time.now).to_i,
|
123
|
+
refresh_token: token_set.refresh_token,
|
124
|
+
scope: token_set.scope,
|
125
|
+
session_id: session.session_id
|
126
|
+
}
|
127
|
+
end
|
128
|
+
|
129
|
+
# Gets active tokens for a session
|
130
|
+
# @param session_id [String] ID of session to get tokens for
|
131
|
+
# @return [Hash, nil] Current token information if session exists and is authorized
|
132
|
+
def get_tokens(session_id)
|
133
|
+
session = session_manager.get_session(session_id)
|
134
|
+
return nil unless session&.authorized?
|
135
|
+
|
136
|
+
{
|
137
|
+
access_token: session.tokens.access_token,
|
138
|
+
token_type: session.tokens.token_type,
|
139
|
+
expires_in: (session.tokens.expires_at - Time.now).to_i,
|
140
|
+
refresh_token: session.tokens.refresh_token,
|
141
|
+
scope: session.tokens.scope
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
# Checks if a session has valid tokens
|
146
|
+
# @param session_id [String] ID of session to check
|
147
|
+
# @return [Boolean] true if session exists and has valid tokens
|
148
|
+
def authorized?(session_id)
|
149
|
+
session = session_manager.get_session(session_id)
|
150
|
+
session&.authorized? || false
|
151
|
+
end
|
152
|
+
|
153
|
+
# Generates headers for an authenticated request
|
154
|
+
# @param session_id [String] ID of session to use
|
155
|
+
# @param method [String] HTTP method for the request
|
156
|
+
# @param url [String] Full URL for the request
|
157
|
+
# @return [Hash] Headers to add to request
|
158
|
+
# @raise [TokenError] if session is invalid or unauthorized
|
159
|
+
def auth_headers(session_id:, method:, url:)
|
160
|
+
session = session_manager.get_session(session_id)
|
161
|
+
raise TokenError, "Invalid session" unless session
|
162
|
+
raise TokenError, "Session not authorized" unless session.authorized?
|
163
|
+
|
164
|
+
# Generate DPoP proof
|
165
|
+
proof = dpop_client.generate_proof(
|
166
|
+
http_method: method,
|
167
|
+
http_uri: url,
|
168
|
+
access_token: session.tokens.access_token
|
169
|
+
)
|
170
|
+
|
171
|
+
{
|
172
|
+
"Authorization" => "DPoP #{session.tokens.access_token}",
|
173
|
+
"DPoP" => proof
|
174
|
+
}
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def load_client_metadata(metadata)
|
180
|
+
if metadata
|
181
|
+
ClientMetadata.new(metadata)
|
182
|
+
else
|
183
|
+
ClientMetadata.from_url(@client_id)
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def validate_redirect_uri!
|
188
|
+
valid = @client_metadata.redirect_uris.include?(@redirect_uri)
|
189
|
+
raise Error, "redirect_uri not found in client metadata" unless valid
|
190
|
+
end
|
191
|
+
|
192
|
+
def initialize_dpop(key)
|
193
|
+
key_manager = if key
|
194
|
+
DPoP::KeyManager.from_jwk(key)
|
195
|
+
else
|
196
|
+
DPoP::KeyManager.new
|
197
|
+
end
|
198
|
+
|
199
|
+
DPoP::Client.new(key_manager: key_manager)
|
200
|
+
end
|
201
|
+
|
202
|
+
def validate_auth_params!(handle, pds_url, scope)
|
203
|
+
raise Error, "scope must include 'atproto'" unless scope.split.include?("atproto")
|
204
|
+
raise Error, "handle or pds_url must be provided" if handle.nil? && pds_url.nil?
|
205
|
+
raise Error, "cannot provide both handle and pds_url" if handle && pds_url
|
206
|
+
end
|
207
|
+
|
208
|
+
def resolve_from_handle(handle, session)
|
209
|
+
# Resolve handle to DID document
|
210
|
+
resolution = @identity_resolver.resolve_handle(handle)
|
211
|
+
session.did = resolution[:did]
|
212
|
+
|
213
|
+
# Get authorization server from PDS
|
214
|
+
server = resolve_auth_server(resolution[:pds])
|
215
|
+
session.authorization_server = server
|
216
|
+
|
217
|
+
{ server: server, pds: resolution[:pds] }
|
218
|
+
end
|
219
|
+
|
220
|
+
def resolve_from_pds(pds_url, session)
|
221
|
+
# Get authorization server from PDS
|
222
|
+
server = resolve_auth_server(pds_url)
|
223
|
+
session.authorization_server = server
|
224
|
+
|
225
|
+
{ server: server, pds: pds_url }
|
226
|
+
end
|
227
|
+
|
228
|
+
def resolve_auth_server(pds_url)
|
229
|
+
# Get resource server metadata
|
230
|
+
resource_server = ServerMetadata::ResourceServer.from_url(pds_url)
|
231
|
+
auth_server_url = resource_server.authorization_servers.first
|
232
|
+
|
233
|
+
# Get and validate authorization server metadata
|
234
|
+
ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
|
235
|
+
end
|
236
|
+
|
237
|
+
def generate_authorization_url(auth_server, session, login_hint: nil)
|
238
|
+
# Create PAR client
|
239
|
+
par_client = PAR::Client.new(
|
240
|
+
endpoint: auth_server.pushed_authorization_request_endpoint,
|
241
|
+
dpop_client: @dpop_client
|
242
|
+
)
|
243
|
+
|
244
|
+
signing_key = if client_metadata.jwks && !client_metadata.jwks["keys"].empty?
|
245
|
+
key_data = client_metadata.jwks["keys"].first
|
246
|
+
JOSE::JWK.from_map(key_data)
|
247
|
+
else
|
248
|
+
JOSE::JWK.generate_key([:ec, "P-256"])
|
249
|
+
end
|
250
|
+
|
251
|
+
client_assertion = PAR::ClientAssertion.new(
|
252
|
+
client_id: client_id,
|
253
|
+
signing_key: signing_key
|
254
|
+
)
|
255
|
+
|
256
|
+
# Build PAR request
|
257
|
+
par_request = PAR::Request.build do |config|
|
258
|
+
config.client_id = client_id
|
259
|
+
config.redirect_uri = redirect_uri
|
260
|
+
config.state = session.state_token
|
261
|
+
config.scope = session.scope
|
262
|
+
config.login_hint = login_hint if login_hint
|
263
|
+
|
264
|
+
# Add PKCE parameters
|
265
|
+
config.code_challenge = session.pkce_challenge
|
266
|
+
config.code_challenge_method = "S256"
|
267
|
+
|
268
|
+
# Add client assertion
|
269
|
+
config.client_assertion = client_assertion.generate_jwt(
|
270
|
+
audience: auth_server.issuer
|
271
|
+
)
|
272
|
+
config.client_assertion_type = PAR::CLIENT_ASSERTION_TYPE
|
273
|
+
|
274
|
+
# Add DPoP proof
|
275
|
+
proof = @dpop_client.generate_proof(
|
276
|
+
http_method: "POST",
|
277
|
+
http_uri: auth_server.pushed_authorization_request_endpoint
|
278
|
+
)
|
279
|
+
config.dpop_proof = proof
|
280
|
+
end
|
281
|
+
|
282
|
+
# Submit PAR request
|
283
|
+
response = par_client.submit(par_request)
|
284
|
+
|
285
|
+
# Build final authorization URL
|
286
|
+
par_client.authorization_url(
|
287
|
+
authorize_endpoint: auth_server.authorization_endpoint,
|
288
|
+
request_uri: response.request_uri,
|
289
|
+
client_id: client_id
|
290
|
+
)
|
291
|
+
end
|
292
|
+
|
293
|
+
def exchange_code(code:, session:)
|
294
|
+
# Initial token request without nonce
|
295
|
+
response = make_token_request(code, session)
|
296
|
+
|
297
|
+
# Handle DPoP nonce requirement
|
298
|
+
if requires_dpop_nonce?(response)
|
299
|
+
puts "*" * 88
|
300
|
+
puts "requires_dpop_nonce"
|
301
|
+
# Extract and store nonce from error response
|
302
|
+
extract_dpop_nonce(response)
|
303
|
+
dpop_client.process_response(response[:headers], session.auth_server.issuer)
|
304
|
+
|
305
|
+
# Retry request with nonce
|
306
|
+
response = make_token_request(code, session)
|
307
|
+
end
|
308
|
+
|
309
|
+
raise TokenError, "Token request failed: #{response[:status]}" unless response[:status] == 200
|
310
|
+
|
311
|
+
begin
|
312
|
+
JSON.parse(response[:body])
|
313
|
+
rescue JSON::ParserError => e
|
314
|
+
raise TokenError, "Invalid token response: #{e.message}"
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def make_token_request(code, session)
|
319
|
+
# Generate proof
|
320
|
+
proof = dpop_client.generate_proof(
|
321
|
+
http_method: "POST",
|
322
|
+
http_uri: session.auth_server.token_endpoint
|
323
|
+
)
|
324
|
+
|
325
|
+
# Log the DPoP proof details
|
326
|
+
AtprotoAuth.configuration.logger.debug "Token Request DPoP Proof:"
|
327
|
+
AtprotoAuth.configuration.logger.debug "- Key: #{dpop_client.public_key}"
|
328
|
+
AtprotoAuth.configuration.logger.debug "- Proof: #{proof}"
|
329
|
+
|
330
|
+
body = {
|
331
|
+
grant_type: "authorization_code",
|
332
|
+
code: code,
|
333
|
+
redirect_uri: redirect_uri,
|
334
|
+
client_id: client_id,
|
335
|
+
code_verifier: session.pkce_verifier
|
336
|
+
}
|
337
|
+
|
338
|
+
# Add client authentication
|
339
|
+
if client_metadata.confidential?
|
340
|
+
signing_key = JOSE::JWK.from_map(client_metadata.jwks["keys"].first)
|
341
|
+
client_assertion = PAR::ClientAssertion.new(
|
342
|
+
client_id: client_id,
|
343
|
+
signing_key: signing_key
|
344
|
+
)
|
345
|
+
|
346
|
+
body.merge!(
|
347
|
+
client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
|
348
|
+
client_assertion: client_assertion.generate_jwt(
|
349
|
+
audience: session.auth_server.issuer
|
350
|
+
)
|
351
|
+
)
|
352
|
+
end
|
353
|
+
|
354
|
+
AtprotoAuth.configuration.http_client.post(
|
355
|
+
session.auth_server.token_endpoint,
|
356
|
+
body: body,
|
357
|
+
headers: {
|
358
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
359
|
+
"DPoP" => proof
|
360
|
+
}
|
361
|
+
)
|
362
|
+
end
|
363
|
+
|
364
|
+
def requires_dpop_nonce?(response)
|
365
|
+
return false unless response[:status] == 400
|
366
|
+
|
367
|
+
error_data = JSON.parse(response[:body])
|
368
|
+
error_data["error"] == "use_dpop_nonce"
|
369
|
+
rescue JSON::ParserError
|
370
|
+
false
|
371
|
+
end
|
372
|
+
|
373
|
+
def extract_dpop_nonce(response)
|
374
|
+
headers = response[:headers]
|
375
|
+
nonce = headers["DPoP-Nonce"] ||
|
376
|
+
headers["dpop-nonce"] ||
|
377
|
+
headers["Dpop-Nonce"]
|
378
|
+
|
379
|
+
raise TokenError, "No DPoP nonce provided in error response" unless nonce
|
380
|
+
|
381
|
+
nonce
|
382
|
+
end
|
383
|
+
|
384
|
+
def validate_token_response!(response, session)
|
385
|
+
# Required fields
|
386
|
+
%w[access_token token_type expires_in scope sub].each do |field|
|
387
|
+
raise TokenError, "Missing #{field} in token response" unless response[field]
|
388
|
+
end
|
389
|
+
|
390
|
+
# Token type must be DPoP
|
391
|
+
raise TokenError, "Invalid token_type: #{response["token_type"]}" unless response["token_type"] == "DPoP"
|
392
|
+
|
393
|
+
# Scope must include atproto
|
394
|
+
raise TokenError, "Missing atproto scope in token response" unless response["scope"].split.include?("atproto")
|
395
|
+
|
396
|
+
# If we have a pre-resolved DID, verify it matches
|
397
|
+
raise TokenError, "Subject mismatch in token response" if session.did && session.did != response["sub"]
|
398
|
+
|
399
|
+
# Process DPoP-Nonce from response headers if present
|
400
|
+
return unless response[:headers] && response[:headers]["DPoP-Nonce"]
|
401
|
+
|
402
|
+
dpop_client.process_response(
|
403
|
+
response[:headers],
|
404
|
+
session.auth_server.issuer
|
405
|
+
)
|
406
|
+
end
|
407
|
+
end
|
408
|
+
end
|
409
|
+
|
410
|
+
# rubocop:enable Metrics/PerceivedComplexity, Metrics/AbcSize, Metrics/CyclomaticComplexity
|