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,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "base64"
|
5
|
+
require "openssl"
|
6
|
+
|
7
|
+
module AtprotoAuth
|
8
|
+
# Implementation of Proof Key for Code Exchange (PKCE) for OAuth 2.0 / AT Protocol
|
9
|
+
# as specified in RFC 7636.
|
10
|
+
#
|
11
|
+
# This module provides functionality to:
|
12
|
+
# - Generate cryptographically secure code verifiers
|
13
|
+
# - Create SHA-256 code challenges from verifiers
|
14
|
+
# - Verify challenge/verifier pairs
|
15
|
+
#
|
16
|
+
# Only the S256 challenge method is supported, as required by AT Protocol OAuth.
|
17
|
+
module PKCE
|
18
|
+
# Error raised for PKCE-related failures
|
19
|
+
class Error < AtprotoAuth::Error; end
|
20
|
+
|
21
|
+
# Minimum and maximum lengths for code verifier as per RFC 7636
|
22
|
+
MIN_VERIFIER_LENGTH = 43
|
23
|
+
MAX_VERIFIER_LENGTH = 128
|
24
|
+
|
25
|
+
# PKCE code verifier charset as per RFC 7636 Section 4.1
|
26
|
+
ALLOWED_VERIFIER_CHARS = /^[A-Za-z0-9\-\._~]+$/
|
27
|
+
|
28
|
+
class << self
|
29
|
+
# Generates a cryptographically secure random code verifier
|
30
|
+
# @param length [Integer] Length of verifier to generate
|
31
|
+
# @return [String] The generated code verifier
|
32
|
+
# @raise [Error] if length is invalid
|
33
|
+
def generate_verifier(length = MAX_VERIFIER_LENGTH)
|
34
|
+
validate_verifier_length!(length)
|
35
|
+
|
36
|
+
# Generate random bytes and encode as URL-safe base64
|
37
|
+
random_bytes = SecureRandom.random_bytes(length * 3 / 4)
|
38
|
+
Base64.urlsafe_encode64(random_bytes, padding: false)[0...length]
|
39
|
+
rescue StandardError => e
|
40
|
+
raise Error, "Failed to generate verifier: #{e.message}"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Creates a code challenge from a verifier using SHA-256
|
44
|
+
# @param verifier [String] The code verifier to create challenge from
|
45
|
+
# @return [String] Base64URL-encoded SHA-256 hash of the verifier
|
46
|
+
# @raise [Error] if verifier is invalid or hashing fails
|
47
|
+
def generate_challenge(verifier)
|
48
|
+
validate_verifier!(verifier)
|
49
|
+
|
50
|
+
# Hash with SHA-256 and encode as URL-safe base64
|
51
|
+
digest = OpenSSL::Digest::SHA256.digest(verifier)
|
52
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
53
|
+
rescue StandardError => e
|
54
|
+
raise Error, "Failed to generate challenge: #{e.message}"
|
55
|
+
end
|
56
|
+
|
57
|
+
# Verifies that a challenge matches a verifier
|
58
|
+
# @param challenge [String] The code challenge to verify
|
59
|
+
# @param verifier [String] The code verifier to check against
|
60
|
+
# @return [Boolean] true if challenge matches verifier
|
61
|
+
# @raise [Error] if inputs are invalid
|
62
|
+
def verify(challenge, verifier)
|
63
|
+
# Generate challenge from verifier and compare
|
64
|
+
calculated = generate_challenge(verifier)
|
65
|
+
secure_compare(calculated, challenge)
|
66
|
+
rescue Error
|
67
|
+
# Re-raise PKCE errors
|
68
|
+
raise
|
69
|
+
rescue StandardError => e
|
70
|
+
raise Error, "Challenge verification failed: #{e.message}"
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def validate_verifier_length!(length)
|
76
|
+
return if length.is_a?(Integer) && length.between?(MIN_VERIFIER_LENGTH, MAX_VERIFIER_LENGTH)
|
77
|
+
|
78
|
+
raise Error, "Verifier length must be between #{MIN_VERIFIER_LENGTH} and #{MAX_VERIFIER_LENGTH}"
|
79
|
+
end
|
80
|
+
|
81
|
+
def validate_verifier!(verifier)
|
82
|
+
raise Error, "Verifier cannot be nil" if verifier.nil?
|
83
|
+
raise Error, "Verifier cannot be empty" if verifier.empty?
|
84
|
+
|
85
|
+
length = verifier.length
|
86
|
+
validate_verifier_length!(length)
|
87
|
+
|
88
|
+
return if verifier.match?(ALLOWED_VERIFIER_CHARS)
|
89
|
+
|
90
|
+
raise Error, "Verifier contains invalid characters"
|
91
|
+
end
|
92
|
+
|
93
|
+
# Constant-time string comparison to prevent timing attacks
|
94
|
+
def secure_compare(str1, str2)
|
95
|
+
return false unless str1.bytesize == str2.bytesize
|
96
|
+
|
97
|
+
left = str1.unpack("C*")
|
98
|
+
right = str2.unpack("C*")
|
99
|
+
result = 0
|
100
|
+
left.zip(right) { |x, y| result |= x ^ y }
|
101
|
+
result.zero?
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -0,0 +1,175 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module ServerMetadata
|
5
|
+
# Handles fetching and validation of AT Protocol OAuth Authorization Server metadata.
|
6
|
+
# An Authorization Server in atproto can be either a PDS instance or a separate "entryway" server
|
7
|
+
# that handles authentication for multiple PDS instances.
|
8
|
+
#
|
9
|
+
# The Authorization Server metadata is fetched from the well-known endpoint
|
10
|
+
# /.well-known/oauth-authorization-server and must conform to RFC 8414 plus additional
|
11
|
+
# requirements specific to the AT Protocol OAuth profile.
|
12
|
+
#
|
13
|
+
# @example Fetching and validating Authorization Server metadata
|
14
|
+
# begin
|
15
|
+
# auth_server = AtprotoAuth::ServerMetadata::AuthorizationServer.from_issuer("https://auth.example.com")
|
16
|
+
# puts "Authorization endpoint: #{auth_server.authorization_endpoint}"
|
17
|
+
# puts "Supported scopes: #{auth_server.scopes_supported}"
|
18
|
+
# rescue AtprotoAuth::InvalidAuthorizationServer => e
|
19
|
+
# puts "Failed to validate authorization server: #{e.message}"
|
20
|
+
# end
|
21
|
+
#
|
22
|
+
# @see https://atproto.com/specs/oauth#authorization-servers Documentation of Authorization Server requirements
|
23
|
+
class AuthorizationServer
|
24
|
+
REQUIRED_FIELDS = %w[
|
25
|
+
issuer
|
26
|
+
authorization_endpoint
|
27
|
+
token_endpoint
|
28
|
+
response_types_supported
|
29
|
+
grant_types_supported
|
30
|
+
code_challenge_methods_supported
|
31
|
+
token_endpoint_auth_methods_supported
|
32
|
+
token_endpoint_auth_signing_alg_values_supported
|
33
|
+
scopes_supported
|
34
|
+
dpop_signing_alg_values_supported
|
35
|
+
pushed_authorization_request_endpoint
|
36
|
+
].freeze
|
37
|
+
|
38
|
+
attr_reader :issuer, :authorization_endpoint, :token_endpoint,
|
39
|
+
:pushed_authorization_request_endpoint, :response_types_supported,
|
40
|
+
:grant_types_supported, :code_challenge_methods_supported,
|
41
|
+
:token_endpoint_auth_methods_supported,
|
42
|
+
:token_endpoint_auth_signing_alg_values_supported,
|
43
|
+
:scopes_supported, :dpop_signing_alg_values_supported
|
44
|
+
|
45
|
+
def initialize(metadata)
|
46
|
+
validate_and_set_metadata!(metadata)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Fetches and validates Authorization Server metadata from an issuer URL
|
50
|
+
# @param issuer [String] Authorization Server issuer URL
|
51
|
+
# @return [AuthorizationServer] new instance with fetched metadata
|
52
|
+
# @raise [InvalidAuthorizationServer] if metadata is invalid
|
53
|
+
def self.from_issuer(issuer)
|
54
|
+
response = fetch_metadata(issuer)
|
55
|
+
metadata = parse_metadata(response[:body])
|
56
|
+
validate_issuer!(metadata["issuer"], issuer)
|
57
|
+
new(metadata)
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def validate_and_set_metadata!(metadata) # rubocop:disable Metrics/AbcSize
|
63
|
+
REQUIRED_FIELDS.each do |field|
|
64
|
+
raise InvalidAuthorizationServer, "#{field} is required" unless metadata[field]
|
65
|
+
end
|
66
|
+
|
67
|
+
@issuer = validate_issuer!(metadata["issuer"])
|
68
|
+
@authorization_endpoint = validate_https_url!(metadata["authorization_endpoint"])
|
69
|
+
@token_endpoint = validate_https_url!(metadata["token_endpoint"])
|
70
|
+
@pushed_authorization_request_endpoint = validate_https_url!(metadata["pushed_authorization_request_endpoint"])
|
71
|
+
|
72
|
+
validate_response_types!(metadata["response_types_supported"])
|
73
|
+
validate_grant_types!(metadata["grant_types_supported"])
|
74
|
+
validate_code_challenge_methods!(metadata["code_challenge_methods_supported"])
|
75
|
+
validate_token_endpoint_auth_methods!(metadata["token_endpoint_auth_methods_supported"])
|
76
|
+
validate_token_endpoint_auth_signing_algs!(metadata["token_endpoint_auth_signing_alg_values_supported"])
|
77
|
+
validate_dpop_signing_algs!(metadata["dpop_signing_alg_values_supported"])
|
78
|
+
validate_scopes!(metadata["scopes_supported"])
|
79
|
+
|
80
|
+
# Store validated values
|
81
|
+
@response_types_supported = metadata["response_types_supported"]
|
82
|
+
@grant_types_supported = metadata["grant_types_supported"]
|
83
|
+
@code_challenge_methods_supported = metadata["code_challenge_methods_supported"]
|
84
|
+
@token_endpoint_auth_methods_supported = metadata["token_endpoint_auth_methods_supported"]
|
85
|
+
@token_endpoint_auth_signing_alg_values_supported = metadata["token_endpoint_auth_signing_alg_values_supported"]
|
86
|
+
@scopes_supported = metadata["scopes_supported"]
|
87
|
+
@dpop_signing_alg_values_supported = metadata["dpop_signing_alg_values_supported"]
|
88
|
+
|
89
|
+
# Required boolean fields
|
90
|
+
validate_boolean_field!(metadata, "authorization_response_iss_parameter_supported", true)
|
91
|
+
validate_boolean_field!(metadata, "require_pushed_authorization_requests", true)
|
92
|
+
validate_boolean_field!(metadata, "client_id_metadata_document_supported", true)
|
93
|
+
end
|
94
|
+
|
95
|
+
def validate_issuer!(issuer)
|
96
|
+
is_valid = OriginUrl.new(issuer).valid?
|
97
|
+
raise InvalidAuthorizationServer, "invalid issuer URL format" unless is_valid
|
98
|
+
|
99
|
+
issuer
|
100
|
+
end
|
101
|
+
|
102
|
+
def validate_https_url!(url)
|
103
|
+
uri = URI(url)
|
104
|
+
raise InvalidAuthorizationServer, "URL must use HTTPS" unless uri.scheme == "https"
|
105
|
+
|
106
|
+
url
|
107
|
+
end
|
108
|
+
|
109
|
+
def validate_response_types!(types)
|
110
|
+
raise InvalidAuthorizationServer, "must support 'code' response type" unless types.include?("code")
|
111
|
+
end
|
112
|
+
|
113
|
+
def validate_grant_types!(types)
|
114
|
+
required = %w[authorization_code refresh_token]
|
115
|
+
missing = required - types
|
116
|
+
raise InvalidAuthorizationServer, "missing grant types: #{missing.join(", ")}" if missing.any?
|
117
|
+
end
|
118
|
+
|
119
|
+
def validate_code_challenge_methods!(methods)
|
120
|
+
raise InvalidAuthorizationServer, "must support S256 PKCE" unless methods.include?("S256")
|
121
|
+
end
|
122
|
+
|
123
|
+
def validate_token_endpoint_auth_methods!(methods)
|
124
|
+
required = %w[private_key_jwt none]
|
125
|
+
missing = required - methods
|
126
|
+
raise InvalidAuthorizationServer, "missing auth methods: #{missing.join(", ")}" if missing.any?
|
127
|
+
end
|
128
|
+
|
129
|
+
def validate_token_endpoint_auth_signing_algs!(algs)
|
130
|
+
raise InvalidAuthorizationServer, "must support ES256" unless algs.include?("ES256")
|
131
|
+
raise InvalidAuthorizationServer, "must not allow 'none'" if algs.include?("none")
|
132
|
+
end
|
133
|
+
|
134
|
+
def validate_dpop_signing_algs!(algs)
|
135
|
+
raise InvalidAuthorizationServer, "must support ES256 for DPoP" unless algs.include?("ES256")
|
136
|
+
end
|
137
|
+
|
138
|
+
def validate_scopes!(scopes)
|
139
|
+
required = %w[atproto]
|
140
|
+
missing = required - scopes
|
141
|
+
raise InvalidAuthorizationServer, "missing scopes: #{missing.join(", ")}" if missing.any?
|
142
|
+
end
|
143
|
+
|
144
|
+
def validate_boolean_field!(metadata, field, required_value)
|
145
|
+
actual = metadata[field]
|
146
|
+
return if actual == required_value
|
147
|
+
|
148
|
+
raise InvalidAuthorizationServer, "#{field} must be #{required_value}"
|
149
|
+
end
|
150
|
+
|
151
|
+
class << self
|
152
|
+
private
|
153
|
+
|
154
|
+
def fetch_metadata(issuer)
|
155
|
+
metadata_url = URI.join(issuer, "/.well-known/oauth-authorization-server")
|
156
|
+
AtprotoAuth.configuration.http_client.get(metadata_url.to_s)
|
157
|
+
rescue HttpClient::HttpError => e
|
158
|
+
raise InvalidAuthorizationServer, "Failed to fetch authorization server metadata: #{e.message}"
|
159
|
+
end
|
160
|
+
|
161
|
+
def parse_metadata(body)
|
162
|
+
JSON.parse(body)
|
163
|
+
rescue JSON::ParserError => e
|
164
|
+
raise InvalidAuthorizationServer, "Invalid JSON in authorization server metadata: #{e.message}"
|
165
|
+
end
|
166
|
+
|
167
|
+
def validate_issuer!(metadata_issuer, request_issuer)
|
168
|
+
return if metadata_issuer == request_issuer
|
169
|
+
|
170
|
+
raise InvalidAuthorizationServer, "issuer mismatch: #{metadata_issuer} != #{request_issuer}"
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module ServerMetadata
|
5
|
+
# The `OriginUrl` class provides validation logic for URLs that must conform
|
6
|
+
# to the AT Protocol OAuth "simple origin URL" requirements. These requirements
|
7
|
+
# are common between Resource and Authorization Servers and ensure that the URL
|
8
|
+
# is valid and secure for use in the protocol. This class validates that the URL:
|
9
|
+
# - Uses the HTTPS scheme.
|
10
|
+
# - Points to the root path (either an empty path or "/").
|
11
|
+
# - Does not include a query string or fragment.
|
12
|
+
# - Does not include user or password credentials.
|
13
|
+
# - May include a non-default port but disallows the default HTTPS port (443).
|
14
|
+
#
|
15
|
+
# This model centralizes the URL validation logic to promote reusability and
|
16
|
+
# consistency between different server classes.
|
17
|
+
class OriginUrl
|
18
|
+
attr_reader :url, :uri
|
19
|
+
|
20
|
+
def initialize(url)
|
21
|
+
@url = url
|
22
|
+
@uri = URI(url)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Determines if a URL conforms to AT Protocol OAuth "simple origin URL" requirements
|
26
|
+
# @return [Boolean] true if the URL is a valid origin URL
|
27
|
+
def valid?
|
28
|
+
https_scheme? &&
|
29
|
+
root_path? &&
|
30
|
+
!uri.query &&
|
31
|
+
!uri.fragment &&
|
32
|
+
!uri.userinfo &&
|
33
|
+
(!explicit_port? || uri.port != 443)
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def https_scheme?
|
39
|
+
uri.scheme == "https"
|
40
|
+
end
|
41
|
+
|
42
|
+
def root_path?
|
43
|
+
uri.path.empty? || uri.path == "/"
|
44
|
+
end
|
45
|
+
|
46
|
+
def explicit_port?
|
47
|
+
url.match?(/:\d+/)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module ServerMetadata
|
5
|
+
# This class represents a Resource Server (PDS) and is responsible for
|
6
|
+
# validating and managing its metadata. It ensures that the authorization
|
7
|
+
# server URLs provided are valid and compliant with expected standards.
|
8
|
+
# The class also includes functionality to fetch and parse metadata from a
|
9
|
+
# remote URL, raising specific errors for invalid or malformed metadata.
|
10
|
+
class ResourceServer
|
11
|
+
attr_reader :authorization_servers
|
12
|
+
|
13
|
+
def initialize(metadata)
|
14
|
+
@authorization_servers = validate_authorization_servers!(metadata["authorization_servers"])
|
15
|
+
end
|
16
|
+
|
17
|
+
# Fetches and validates Resource Server metadata from a URL
|
18
|
+
# @param url [String] PDS URL to fetch metadata from
|
19
|
+
# @return [ResourceServer] new instance with fetched metadata
|
20
|
+
# @raise [InvalidAuthorizationServer] if metadata is invalid
|
21
|
+
def self.from_url(url)
|
22
|
+
response = fetch_metadata(url)
|
23
|
+
new(parse_metadata(response[:body]))
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def validate_authorization_servers!(servers)
|
29
|
+
ensure_servers_exist(servers)
|
30
|
+
ensure_exactly_one_server(servers)
|
31
|
+
validate_server_url_format(servers.first)
|
32
|
+
servers
|
33
|
+
end
|
34
|
+
|
35
|
+
def ensure_servers_exist(servers)
|
36
|
+
return if servers.is_a?(Array)
|
37
|
+
|
38
|
+
raise InvalidAuthorizationServer, "authorization_servers missing"
|
39
|
+
end
|
40
|
+
|
41
|
+
def ensure_exactly_one_server(servers)
|
42
|
+
return if servers.size == 1
|
43
|
+
|
44
|
+
raise InvalidAuthorizationServer, "must have exactly one authorization server"
|
45
|
+
end
|
46
|
+
|
47
|
+
def validate_server_url_format(server_url)
|
48
|
+
return if OriginUrl.new(server_url).valid?
|
49
|
+
|
50
|
+
raise InvalidAuthorizationServer, "invalid authorization server URL format for #{server_url}"
|
51
|
+
end
|
52
|
+
|
53
|
+
class << self
|
54
|
+
private
|
55
|
+
|
56
|
+
def fetch_metadata(url)
|
57
|
+
metadata_url = URI.join(url, "/.well-known/oauth-protected-resource")
|
58
|
+
AtprotoAuth.configuration.http_client.get(metadata_url.to_s)
|
59
|
+
rescue HttpClient::HttpError => e
|
60
|
+
raise InvalidAuthorizationServer, "Failed to fetch resource server metadata: #{e.message}"
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse_metadata(body)
|
64
|
+
JSON.parse(body)
|
65
|
+
rescue JSON::ParserError => e
|
66
|
+
raise InvalidAuthorizationServer, "Invalid JSON in resource server metadata: #{e.message}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
# Provides functionality for fetching and validating AT Protocol OAuth server metadata
|
5
|
+
# from both Resource Servers (PDS instances) and Authorization Servers (PDS/entryway).
|
6
|
+
#
|
7
|
+
# The flow for resolving an account's authorization server is:
|
8
|
+
# 1. Start with PDS URL
|
9
|
+
# 2. Fetch Resource Server metadata from /.well-known/oauth-protected-resource
|
10
|
+
# 3. Get Authorization Server URL from authorization_servers array
|
11
|
+
# 4. Fetch Authorization Server metadata from /.well-known/oauth-authorization-server
|
12
|
+
#
|
13
|
+
# @example Resolving authorization server from PDS URL
|
14
|
+
# resource_server = AtprotoAuth::ServerMetadata::ResourceServer.from_url("https://pds.example.com")
|
15
|
+
# auth_server_url = resource_server.authorization_servers.first
|
16
|
+
# auth_server = AtprotoAuth::ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
|
17
|
+
#
|
18
|
+
# The module includes three main classes:
|
19
|
+
# - {ResourceServer} - Handles PDS metadata validation and authorization server discovery
|
20
|
+
# - {AuthorizationServer} - Handles authorization server metadata validation
|
21
|
+
# - {OriginUrl} - Validates URLs conform to AT Protocol's "simple origin URL" requirements
|
22
|
+
module ServerMetadata
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "time"
|
5
|
+
require "monitor"
|
6
|
+
|
7
|
+
module AtprotoAuth
|
8
|
+
module State
|
9
|
+
# Tracks state for an OAuth authorization flow session
|
10
|
+
class Session
|
11
|
+
include MonitorMixin
|
12
|
+
|
13
|
+
attr_reader :session_id, :state_token, :client_id, :scope,
|
14
|
+
:pkce_verifier, :pkce_challenge, :auth_server,
|
15
|
+
:did, :tokens
|
16
|
+
|
17
|
+
# Creates a new OAuth session
|
18
|
+
# @param client_id [String] OAuth client ID
|
19
|
+
# @param scope [String] Requested scope
|
20
|
+
# @param auth_server [AuthorizationServer, nil] Optional pre-resolved auth server
|
21
|
+
# @param did [String, nil] Optional pre-resolved DID
|
22
|
+
def initialize(client_id:, scope:, auth_server: nil, did: nil)
|
23
|
+
super() # Initialize MonitorMixin
|
24
|
+
|
25
|
+
@session_id = SecureRandom.uuid
|
26
|
+
@state_token = SecureRandom.urlsafe_base64(32)
|
27
|
+
@client_id = client_id
|
28
|
+
@scope = scope
|
29
|
+
@auth_server = auth_server
|
30
|
+
@did = did
|
31
|
+
|
32
|
+
# Generate PKCE values
|
33
|
+
@pkce_verifier = PKCE.generate_verifier
|
34
|
+
@pkce_challenge = PKCE.generate_challenge(@pkce_verifier)
|
35
|
+
|
36
|
+
@tokens = nil
|
37
|
+
end
|
38
|
+
|
39
|
+
# Updates the authorization server for this session
|
40
|
+
# @param server [AuthorizationServer] The resolved auth server
|
41
|
+
# @return [void]
|
42
|
+
# @raise [SessionError] if session is already bound to different server
|
43
|
+
def authorization_server=(server)
|
44
|
+
synchronize do
|
45
|
+
if @auth_server && @auth_server.issuer != server.issuer
|
46
|
+
raise SessionError, "Session already bound to different authorization server"
|
47
|
+
end
|
48
|
+
|
49
|
+
@auth_server = server
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Updates the user's DID for this session
|
54
|
+
# @param did [String] The resolved DID
|
55
|
+
# @return [void]
|
56
|
+
# @raise [SessionError] if session already has different DID
|
57
|
+
def did=(did)
|
58
|
+
synchronize do
|
59
|
+
raise SessionError, "Session already bound to different DID" if @did && @did != did
|
60
|
+
|
61
|
+
@did = did
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Updates tokens for this session
|
66
|
+
# @param tokens [TokenSet] New token set
|
67
|
+
# @return [void]
|
68
|
+
# @raise [SessionError] if tokens don't match session DID
|
69
|
+
def tokens=(tokens)
|
70
|
+
synchronize do
|
71
|
+
raise SessionError, "Token subject doesn't match session DID" if @did && tokens.sub != @did
|
72
|
+
|
73
|
+
@tokens = tokens
|
74
|
+
@did ||= tokens.sub
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
# Whether this session has valid access tokens
|
79
|
+
# @return [Boolean]
|
80
|
+
def authorized?
|
81
|
+
synchronize do
|
82
|
+
!@tokens.nil? && !@tokens.expired?
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Whether this session can refresh its tokens
|
87
|
+
# @return [Boolean]
|
88
|
+
def renewable?
|
89
|
+
synchronize do
|
90
|
+
!@tokens.nil? && @tokens.renewable?
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Validates a state token against this session
|
95
|
+
# @param state [String] State token to validate
|
96
|
+
# @return [Boolean]
|
97
|
+
def validate_state(state)
|
98
|
+
return false unless state
|
99
|
+
|
100
|
+
# Use secure comparison to prevent timing attacks
|
101
|
+
secure_compare(@state_token, state)
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
def secure_compare(str1, str2)
|
107
|
+
return false unless str1.bytesize == str2.bytesize
|
108
|
+
|
109
|
+
left = str1.unpack("C*")
|
110
|
+
right = str2.unpack("C*")
|
111
|
+
result = 0
|
112
|
+
left.zip(right) { |x, y| result |= x ^ y }
|
113
|
+
result.zero?
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "time"
|
5
|
+
require "monitor"
|
6
|
+
|
7
|
+
module AtprotoAuth
|
8
|
+
module State
|
9
|
+
# Manages active OAuth sessions
|
10
|
+
class SessionManager
|
11
|
+
include MonitorMixin
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
super # Initialize MonitorMixin
|
15
|
+
@sessions = {}
|
16
|
+
end
|
17
|
+
|
18
|
+
# Creates and stores a new session
|
19
|
+
# @param client_id [String] OAuth client ID
|
20
|
+
# @param scope [String] Requested scope
|
21
|
+
# @param auth_server [AuthorizationServer, nil] Optional pre-resolved auth server
|
22
|
+
# @param did [String, nil] Optional pre-resolved DID
|
23
|
+
# @return [Session] The created session
|
24
|
+
def create_session(client_id:, scope:, auth_server: nil, did: nil)
|
25
|
+
session = Session.new(
|
26
|
+
client_id: client_id,
|
27
|
+
scope: scope,
|
28
|
+
auth_server: auth_server,
|
29
|
+
did: did
|
30
|
+
)
|
31
|
+
|
32
|
+
synchronize do
|
33
|
+
@sessions[session.session_id] = session
|
34
|
+
end
|
35
|
+
|
36
|
+
session
|
37
|
+
end
|
38
|
+
|
39
|
+
# Retrieves a session by ID
|
40
|
+
# @param session_id [String] Session ID to look up
|
41
|
+
# @return [Session, nil] The session if found
|
42
|
+
def get_session(session_id)
|
43
|
+
synchronize do
|
44
|
+
@sessions[session_id]
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Finds a session by state token
|
49
|
+
# @param state [String] State token to look up
|
50
|
+
# @return [Session, nil] The session if found
|
51
|
+
def get_session_by_state(state)
|
52
|
+
synchronize do
|
53
|
+
@sessions.values.find { |session| session.validate_state(state) }
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Removes a session
|
58
|
+
# @param session_id [String] Session ID to remove
|
59
|
+
# @return [void]
|
60
|
+
def remove_session(session_id)
|
61
|
+
synchronize do
|
62
|
+
@sessions.delete(session_id)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Removes all expired sessions
|
67
|
+
# @return [void]
|
68
|
+
def cleanup_expired
|
69
|
+
synchronize do
|
70
|
+
@sessions.delete_if { |_, session| !session.renewable? && session.tokens&.expired? }
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module AtprotoAuth
|
4
|
+
module State
|
5
|
+
# Represents a set of OAuth tokens and their associated metadata
|
6
|
+
class TokenSet
|
7
|
+
attr_reader :access_token, :refresh_token, :token_type,
|
8
|
+
:scope, :expires_at, :sub
|
9
|
+
|
10
|
+
# Creates a new TokenSet from a token response
|
11
|
+
# @param access_token [String] The access token
|
12
|
+
# @param token_type [String] Token type (must be "DPoP")
|
13
|
+
# @param expires_in [Integer] Token lifetime in seconds
|
14
|
+
# @param refresh_token [String, nil] Optional refresh token
|
15
|
+
# @param scope [String] Space-separated list of granted scopes
|
16
|
+
# @param sub [String] DID of the authenticated user
|
17
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
18
|
+
access_token:,
|
19
|
+
token_type:,
|
20
|
+
expires_in:,
|
21
|
+
scope:,
|
22
|
+
sub:,
|
23
|
+
refresh_token: nil
|
24
|
+
)
|
25
|
+
validate_token_type!(token_type)
|
26
|
+
validate_required!("access_token", access_token)
|
27
|
+
validate_required!("scope", scope)
|
28
|
+
validate_required!("sub", sub)
|
29
|
+
validate_expires_in!(expires_in)
|
30
|
+
|
31
|
+
@access_token = access_token
|
32
|
+
@refresh_token = refresh_token
|
33
|
+
@token_type = token_type
|
34
|
+
@scope = scope
|
35
|
+
@sub = sub
|
36
|
+
@expires_at = Time.now + expires_in
|
37
|
+
end
|
38
|
+
|
39
|
+
# Whether this token set includes a refresh token
|
40
|
+
# @return [Boolean]
|
41
|
+
def renewable?
|
42
|
+
!@refresh_token.nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
# Whether the access token has expired
|
46
|
+
# @return [Boolean]
|
47
|
+
def expired?(buffer = 30)
|
48
|
+
Time.now >= (@expires_at - buffer)
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def validate_token_type!(type)
|
54
|
+
raise ArgumentError, "token_type must be DPoP" unless type == "DPoP"
|
55
|
+
end
|
56
|
+
|
57
|
+
def validate_required!(name, value)
|
58
|
+
raise ArgumentError, "#{name} is required" if value.nil? || value.empty?
|
59
|
+
end
|
60
|
+
|
61
|
+
def validate_expires_in!(expires_in)
|
62
|
+
return if expires_in.is_a?(Integer) && expires_in.positive?
|
63
|
+
|
64
|
+
raise ArgumentError, "expires_in must be positive integer"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|