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
@@ -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