aha_builder_core 1.0.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7ff589bfc8b84ca502361ec7db8fcd9052a02fcef786d45bdfa8f1463ab2ce92
4
+ data.tar.gz: 4271e05f6300fda500e3062d1ae8f2ad3ab0227cff5925919d8fd2a97c4b49c1
5
+ SHA512:
6
+ metadata.gz: 3698b01b1e05371f68dd84881ac9ebda989a629def95533db97c6f48bf97803095a43ae7c6c2af6fcf6e5fe5f5700826da0a54780dccbba85860c25d92db12e8
7
+ data.tar.gz: 227dafe8165fc4562d26761a331bd4f128ec0ba10bdf1a2a8c859f15ee71c8ac95090f457bc772b3db9bede007f253523558294332b34fdd6acfe2fab4d96fd5
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Aha! Labs Inc 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # Aha Builder Core Client
2
+
3
+ Ruby client for Aha! Builder core authentication services.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "aha_builder_core", path: "engines/builder_core/client"
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ The configuration is usually read from the environment automatically so there is no need for custom configuration in most cases.
16
+
17
+ ```ruby
18
+ require "aha_builder_core"
19
+
20
+ Aha::Auth.configure do |config|
21
+ config.server_url = "https://secure.aha.io" # Auth server URL
22
+ config.client_id = "your-client-id" # ID of the builder application
23
+ config.api_key = "your-api-key" # For server-to-server auth
24
+ config.jwks_cache_ttl = 3600 # JWKS cache duration (default: 1 hour)
25
+ config.refresh_threshold = 120 # Seconds before expiry to refresh (default: 2 min)
26
+ config.timeout = 30 # HTTP timeout in seconds
27
+ end
28
+ ```
29
+
30
+ ## Authentication Flow
31
+
32
+ The authentication UI is provided completely by the core system. During authentication the user is redirected to the login page, and will return (via HTTP redirect) to the `/callback` URL when authentication is complete. Your application must implement a callback action at `/callback` to receive the code. Any value passed in as `state` is returned verbatim to the callback.
33
+
34
+ ### Generate Login URL
35
+
36
+ Redirect users to the authentication and signup UI:
37
+
38
+ ```ruby
39
+ url = Aha::Auth.login_url(
40
+ state: { return_to: "/" }.to_json,
41
+ redirect_uri: "#{ENV['APPLICATION_URL']}/callback"
42
+ )
43
+ redirect_to url
44
+ ```
45
+
46
+ ### Exchange Authorization Code
47
+
48
+ In the `/callback` action callback, you must exchange the code for a session token and refresh token:
49
+
50
+ ```ruby
51
+ result = Aha::Auth.authenticate_with_code(code: params[:code])
52
+ # => {
53
+ # session_token: "...",
54
+ # refresh_token: "...",
55
+ # expires_at: Time,
56
+ # user: {
57
+ # "id" => "user-uuid",
58
+ # "first_name" => "Jane",
59
+ # "last_name" => "Doe",
60
+ # "email" => "jane.doe@example.com",
61
+ # "email_verified" => true
62
+ # }
63
+ # }
64
+ ```
65
+
66
+ #### User Object
67
+
68
+ The `user` object returned contains the authenticated user's profile information from the Builder Core system:
69
+
70
+ - `id`: The unique identifier for the user in Builder Core
71
+ - `first_name`: User's first name
72
+ - `last_name`: User's last name
73
+ - `email`: User's email address
74
+ - `email_verified`: Boolean indicating if the email has been verified
75
+
76
+ #### Linking to Local User Records
77
+
78
+ You can use the returned user object to create or update local user records in your application:
79
+
80
+ ```ruby
81
+ # In your callback action
82
+ result = Aha::Auth.authenticate_with_code(code: params[:code])
83
+
84
+ # Find or create a local user record linked to Builder Core user
85
+ local_user = User.find_or_initialize_by(auth_identifier: result[:user]["id"])
86
+
87
+ # Update local user attributes
88
+ local_user.update!(
89
+ email: result[:user]["email"],
90
+ first_name: result[:user]["first_name"],
91
+ last_name: result[:user]["last_name"],
92
+ email_verified: result[:user]["email_verified"]
93
+ )
94
+
95
+ # Store tokens in session or database
96
+ session[:session_token] = result[:session_token]
97
+ session[:refresh_token] = result[:refresh_token]
98
+ session[:user_id] = local_user.id
99
+
100
+ # Redirect to application
101
+ redirect_to root_path
102
+ ```
103
+
104
+ ### Validate Session
105
+
106
+ Validate a session token (with automatic refresh):
107
+
108
+ ```ruby
109
+ session = Aha::Auth.validate_session(session_token, refresh_token: refresh_token)
110
+
111
+ if session.valid?
112
+ user_id = session.user_id
113
+ # If tokens were refreshed, update stored tokens
114
+ if session.refreshed?
115
+ new_session_token = session.new_session_token
116
+ new_refresh_token = session.new_refresh_token
117
+ end
118
+ else
119
+ # Redirect to login
120
+ end
121
+ ```
122
+
123
+ ### Logout
124
+
125
+ ```ruby
126
+ Aha::Auth.logout(session_token: session_token)
127
+ ```
128
+
129
+ ## User Management
130
+
131
+ Server-to-server operations (requires `api_key`):
132
+
133
+ ```ruby
134
+ # Create user
135
+ user = Aha::Auth.users.create(
136
+ email: "user@example.com",
137
+ first_name: "Jane",
138
+ last_name: "Doe",
139
+ password: "secure_password"
140
+ )
141
+
142
+ # Find user
143
+ user = Aha::Auth.users.find(user_id)
144
+
145
+ # Update user
146
+ user = Aha::Auth.users.update(user_id, first_name: "Janet")
147
+
148
+ # Delete user
149
+ Aha::Auth.users.delete(user_id)
150
+
151
+ # List users
152
+ result = Aha::Auth.users.list(page: 1, per_page: 50)
153
+ result[:users] # Array of User objects
154
+ result[:total] # Total count
155
+ ```
156
+
157
+ ## Session Management
158
+
159
+ ```ruby
160
+ # List active sessions for a user
161
+ sessions = Aha::Auth.sessions.list(user_id: user_id)
162
+
163
+ # Revoke a session
164
+ Aha::Auth.sessions.revoke(session_id)
165
+ ```
166
+
167
+ ## Error Handling
168
+
169
+ ```ruby
170
+ begin
171
+ Aha::Auth.validate_session(token)
172
+ rescue Aha::Auth::InvalidTokenError
173
+ # Token is malformed or has invalid signature
174
+ rescue Aha::Auth::ExpiredTokenError
175
+ # Token expired and refresh failed
176
+ rescue Aha::Auth::RateLimitError => e
177
+ # Rate limited, retry after e.retry_after seconds
178
+ rescue Aha::Auth::UnauthorizedError
179
+ # Invalid API key
180
+ rescue Aha::Auth::NotFoundError
181
+ # Resource not found
182
+ rescue Aha::Auth::NetworkError => e
183
+ # Connection failed, original error in e.original_error
184
+ rescue Aha::Auth::ApiError => e
185
+ # Other API error, check e.status and e.body
186
+ end
187
+ ```
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ # HTTP client for communicating with the BuilderCore auth server
6
+ class Client
7
+ ALGORITHM = "RS256"
8
+
9
+ def initialize(configuration)
10
+ @configuration = configuration
11
+ @token_cache = TokenCache.new(ttl: configuration.jwks_cache_ttl)
12
+ end
13
+
14
+ # Exchange an authorization code for tokens
15
+ #
16
+ # @param code [String] The authorization code
17
+ # @return [Hash] Token response with :session_token, :refresh_token, :expires_at
18
+ def authenticate_with_code(code:)
19
+ response = post(
20
+ "/api/core/auth/authenticate", {
21
+ grant_type: "code",
22
+ code: code
23
+ }
24
+ )
25
+
26
+ parse_token_response(response)
27
+ end
28
+
29
+ # Refresh tokens using a refresh token
30
+ #
31
+ # @param refresh_token [String] The refresh token
32
+ # @return [Hash] Token response with :session_token, :refresh_token, :expires_at
33
+ def authenticate_with_refresh_token(refresh_token:)
34
+ response = post(
35
+ "/api/core/auth/authenticate", {
36
+ grant_type: "refresh_token",
37
+ refresh_token: refresh_token
38
+ }
39
+ )
40
+
41
+ parse_token_response(response)
42
+ end
43
+
44
+ # Validate a session token locally using cached JWKS
45
+ #
46
+ # @param session_token [String] The JWT session token
47
+ # @param refresh_token [String, nil] Optional refresh token for automatic refresh
48
+ # @return [Session] Session validation result
49
+ def validate_session(session_token, refresh_token: nil)
50
+ claims = decode_and_verify_token(session_token)
51
+ return Session.invalid unless claims
52
+
53
+ # Check if token is about to expire and we have a refresh token
54
+ exp = claims["exp"]
55
+ if exp && refresh_token && should_refresh?(exp)
56
+ begin
57
+ tokens = refresh_tokens(refresh_token: refresh_token)
58
+ new_claims = decode_and_verify_token(tokens[:session_token])
59
+
60
+ return Session.from_claims(
61
+ new_claims || claims,
62
+ refreshed: true,
63
+ new_session_token: tokens[:session_token],
64
+ new_refresh_token: tokens[:refresh_token]
65
+ )
66
+ rescue Error
67
+ puts "Token refresh failed"
68
+ # Refresh failed, return session with original token if still valid
69
+ return Session.invalid if Time.at(exp).utc < Time.now.utc
70
+ end
71
+ end
72
+
73
+ Session.from_claims(claims)
74
+ rescue InvalidTokenError, ExpiredTokenError
75
+ Session.invalid
76
+ end
77
+
78
+ # Logout and revoke the session
79
+ #
80
+ # @param session_token [String] The session token to revoke
81
+ # @return [Boolean] true if successful
82
+ def logout(session_token:)
83
+ get("/api/core/auth/logout", headers: { "Authorization" => "Bearer #{session_token}" })
84
+ true
85
+ rescue ApiError
86
+ false
87
+ end
88
+
89
+ # Fetch JWKS from the server
90
+ #
91
+ # @return [Hash] The JWKS response
92
+ def fetch_jwks
93
+ response = http_client.get("/api/core/auth/jwks/#{@configuration.client_id}")
94
+ handle_response(response)
95
+ end
96
+
97
+ private
98
+
99
+ def decode_and_verify_token(token)
100
+ # First decode without verification to get the header
101
+ header = JWT.decode(token, nil, false).last
102
+ kid = header["kid"]
103
+
104
+ # Get the public key for this key ID
105
+ public_key = @token_cache.get_key(kid) { fetch_jwks }
106
+
107
+ unless public_key
108
+ # Try refreshing the cache in case there's a new key
109
+ @token_cache.refresh! { fetch_jwks }
110
+ public_key = @token_cache.get_key(kid) { fetch_jwks }
111
+ raise InvalidTokenError, "Unknown key ID: #{kid}" unless public_key
112
+ end
113
+
114
+ # Verify the token
115
+ options = {
116
+ algorithm: ALGORITHM,
117
+ verify_expiration: true
118
+ }
119
+
120
+ decoded = JWT.decode(token, public_key, true, options)
121
+ decoded.first
122
+ rescue JWT::ExpiredSignature
123
+ raise ExpiredTokenError, "Token has expired"
124
+ rescue JWT::DecodeError => e
125
+ raise InvalidTokenError, "Invalid token: #{e.message}"
126
+ end
127
+
128
+ def should_refresh?(exp)
129
+ Time.at(exp).utc - Time.now.utc <= @configuration.refresh_threshold
130
+ end
131
+
132
+ def parse_token_response(response)
133
+ {
134
+ session_token: response["session_token"],
135
+ refresh_token: response["refresh_token"],
136
+ expires_at: response["expires_at"] ? Time.iso8601(response["expires_at"]) : nil,
137
+ user: response["user"]
138
+ }
139
+ end
140
+
141
+ def get(path, headers: {})
142
+ response = http_client.get(path) do |req|
143
+ headers.each { |k, v| req.headers[k] = v }
144
+ end
145
+ handle_response(response)
146
+ end
147
+
148
+ def post(path, body)
149
+ response = http_client.post(path) do |req|
150
+ req.headers["Content-Type"] = "application/json"
151
+ req.body = JSON.generate(body)
152
+ end
153
+ handle_response(response)
154
+ end
155
+
156
+ def delete(path)
157
+ response = http_client.delete(path)
158
+ handle_response(response)
159
+ end
160
+
161
+ def http_client
162
+ @http_client ||= Faraday.new(url: @configuration.server_url) do |conn|
163
+ conn.request :retry, max: 2, interval: 0.5, backoff_factor: 2
164
+ conn.options.timeout = @configuration.timeout
165
+
166
+ if @configuration.api_key
167
+ conn.headers["Authorization"] = "Bearer #{@configuration.api_key}"
168
+ end
169
+
170
+ conn.adapter Faraday.default_adapter
171
+ end
172
+ end
173
+
174
+ def handle_response(response)
175
+ case response.status
176
+ when 200..299
177
+ return nil if response.body.nil? || response.body.to_s.empty?
178
+
179
+ JSON.parse(response.body)
180
+ when 400
181
+ raise BadRequestError.new(error_message(response), status: response.status, body: response.body)
182
+ when 401
183
+ raise UnauthorizedError.new(error_message(response), status: response.status, body: response.body)
184
+ when 403
185
+ raise ForbiddenError.new(error_message(response), status: response.status, body: response.body)
186
+ when 404
187
+ raise NotFoundError.new(error_message(response), status: response.status, body: response.body)
188
+ when 429
189
+ retry_after = response.headers["Retry-After"]&.to_i
190
+ raise RateLimitError.new("Rate limit exceeded", retry_after: retry_after)
191
+ else
192
+ raise ApiError.new(error_message(response), status: response.status, body: response.body)
193
+ end
194
+ rescue Faraday::Error => e
195
+ raise NetworkError.new("Network error: #{e.message}", original_error: e)
196
+ end
197
+
198
+ def error_message(response)
199
+ parsed = JSON.parse(response.body)
200
+ parsed["error"] || parsed["message"] || "Request failed"
201
+ rescue JSON::ParserError
202
+ "Request failed with status #{response.status}"
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ class Configuration
6
+ # The base URL of the BuilderCore auth server
7
+ attr_accessor :server_url
8
+
9
+ # API key for server-to-server authentication
10
+ attr_accessor :api_key
11
+
12
+ # Client/application identifier
13
+ attr_accessor :client_id
14
+
15
+ # How long to cache JWKS keys (default: 1 hour)
16
+ attr_accessor :jwks_cache_ttl
17
+
18
+ # Number of seconds before token expiry to trigger refresh (default: 120)
19
+ attr_accessor :refresh_threshold
20
+
21
+ # HTTP timeout in seconds (default: 30)
22
+ attr_accessor :timeout
23
+
24
+ def initialize
25
+ @server_url = ENV.fetch("AHA_CORE_SERVER_URL", nil) || "https://secure.aha.io/api/core"
26
+ @api_key = ENV.fetch("AHA_CORE_API_KEY", nil)
27
+ @client_id = ENV.fetch("APPLICATION_ID", nil)
28
+ @jwks_cache_ttl = 3600 # 1 hour
29
+ @refresh_threshold = 120 # 2 minutes
30
+ @timeout = 30
31
+ end
32
+
33
+ def validate!
34
+ raise ConfigurationError, "server_url is required" if server_url.nil? || server_url.to_s.empty?
35
+ raise ConfigurationError, "client_id is required" if client_id.nil? || client_id.to_s.empty?
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ # Base error class for all Aha::Auth errors
6
+ class Error < StandardError; end
7
+
8
+ # Raised when configuration is invalid or incomplete
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised when a token is malformed or has an invalid signature
12
+ class InvalidTokenError < Error; end
13
+
14
+ # Raised when a token has expired and refresh failed or no refresh token provided
15
+ class ExpiredTokenError < Error; end
16
+
17
+ # Raised when a session has been revoked server-side
18
+ class RevokedSessionError < Error; end
19
+
20
+ # Raised when the auth server cannot be reached
21
+ class NetworkError < Error
22
+ attr_reader :original_error
23
+
24
+ def initialize(message, original_error: nil)
25
+ super(message)
26
+ @original_error = original_error
27
+ end
28
+ end
29
+
30
+ # Raised when rate limit is exceeded
31
+ class RateLimitError < Error
32
+ attr_reader :retry_after
33
+
34
+ def initialize(message, retry_after: nil)
35
+ super(message)
36
+ @retry_after = retry_after
37
+ end
38
+ end
39
+
40
+ # Raised for API errors from the server
41
+ class ApiError < Error
42
+ attr_reader :status, :body
43
+
44
+ def initialize(message, status: nil, body: nil)
45
+ super(message)
46
+ @status = status
47
+ @body = body
48
+ end
49
+ end
50
+
51
+ # Raised when a requested resource is not found
52
+ class NotFoundError < ApiError; end
53
+
54
+ # Raised when the request is unauthorized (invalid API key)
55
+ class UnauthorizedError < ApiError; end
56
+
57
+ # Raised when the request is forbidden
58
+ class ForbiddenError < ApiError; end
59
+
60
+ # Raised when the request is invalid
61
+ class BadRequestError < ApiError; end
62
+ end
63
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ # Represents a validated session with user and session information
6
+ class Session
7
+ attr_reader :user_id, :session_id, :expires_at, :new_session_token, :new_refresh_token
8
+
9
+ def initialize(valid:, user_id: nil, session_id: nil, expires_at: nil, refreshed: false, new_session_token: nil, new_refresh_token: nil)
10
+ @valid = valid
11
+ @user_id = user_id
12
+ @session_id = session_id
13
+ @expires_at = expires_at
14
+ @refreshed = refreshed
15
+ @new_session_token = new_session_token
16
+ @new_refresh_token = new_refresh_token
17
+ end
18
+
19
+ def valid?
20
+ @valid
21
+ end
22
+
23
+ def refreshed?
24
+ @refreshed
25
+ end
26
+
27
+ # Create an invalid session result
28
+ def self.invalid
29
+ new(valid: false)
30
+ end
31
+
32
+ # Create a valid session from decoded JWT claims
33
+ def self.from_claims(claims, refreshed: false, new_session_token: nil, new_refresh_token: nil)
34
+ new(
35
+ valid: true,
36
+ user_id: claims["sub"],
37
+ session_id: claims["sid"],
38
+ expires_at: claims["exp"] ? Time.at(claims["exp"]).utc : nil,
39
+ refreshed: refreshed,
40
+ new_session_token: new_session_token,
41
+ new_refresh_token: new_refresh_token
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ # API resource for session management operations
6
+ class SessionsResource
7
+ # Represents a session from the auth server
8
+ class SessionInfo
9
+ attr_reader :id, :ip_address, :user_agent, :created_at, :expires_at
10
+
11
+ def initialize(attributes = {})
12
+ @id = attributes["id"] || attributes[:id]
13
+ @ip_address = attributes["ip_address"] || attributes[:ip_address]
14
+ @user_agent = attributes["user_agent"] || attributes[:user_agent]
15
+ @created_at = parse_time(attributes["created_at"] || attributes[:created_at])
16
+ @expires_at = parse_time(attributes["expires_at"] || attributes[:expires_at])
17
+ end
18
+
19
+ private
20
+
21
+ def parse_time(value)
22
+ return nil if value.nil?
23
+ return value if value.is_a?(Time)
24
+
25
+ Time.iso8601(value)
26
+ rescue ArgumentError
27
+ nil
28
+ end
29
+ end
30
+
31
+ def initialize(client)
32
+ @client = client
33
+ end
34
+
35
+ # List active sessions for a user
36
+ #
37
+ # @param user_id [String, Integer] The user ID
38
+ # @return [Array<SessionInfo>] List of active sessions
39
+ def list(user_id:)
40
+ response = get("/api/core/auth/sessions", user_id: user_id)
41
+ response["sessions"].map { |s| SessionInfo.new(s) }
42
+ end
43
+
44
+ # Revoke a session
45
+ #
46
+ # @param session_id [String, Integer] The session ID to revoke
47
+ # @return [Boolean] true if revoked
48
+ def revoke(session_id)
49
+ delete_request("/api/core/auth/sessions/#{session_id}")
50
+ true
51
+ end
52
+
53
+ private
54
+
55
+ def get(path, params = {})
56
+ query = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
57
+ @client.send(:get, "#{path}#{query}")
58
+ end
59
+
60
+ def delete_request(path)
61
+ @client.send(:delete, path)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Aha
6
+ module Auth
7
+ # Caches JWKS public keys for JWT verification
8
+ class TokenCache
9
+ def initialize(ttl:)
10
+ @ttl = ttl
11
+ @keys = {}
12
+ @fetched_at = nil
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # Get a public key by key ID, fetching from server if not cached
17
+ #
18
+ # @param kid [String] The key ID
19
+ # @param fetcher [Proc] A proc that fetches the JWKS from the server
20
+ # @return [OpenSSL::PKey::RSA, nil] The public key or nil if not found
21
+ def get_key(kid, &fetcher)
22
+ @mutex.synchronize do
23
+ refresh_if_needed(&fetcher)
24
+ @keys[kid]
25
+ end
26
+ end
27
+
28
+ # Force a refresh of the JWKS cache
29
+ #
30
+ # @param fetcher [Proc] A proc that fetches the JWKS from the server
31
+ def refresh!(&fetcher)
32
+ @mutex.synchronize do
33
+ fetch_keys(&fetcher)
34
+ end
35
+ end
36
+
37
+ # Check if the cache is stale
38
+ def stale?
39
+ @fetched_at.nil? || (Time.now.utc - @fetched_at) > @ttl
40
+ end
41
+
42
+ # Clear the cache
43
+ def clear!
44
+ @mutex.synchronize do
45
+ @keys = {}
46
+ @fetched_at = nil
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def refresh_if_needed(&fetcher)
53
+ fetch_keys(&fetcher) if stale?
54
+ end
55
+
56
+ def fetch_keys
57
+ jwks = yield
58
+ return unless jwks && jwks["keys"]
59
+
60
+ @keys = {}
61
+ jwks["keys"].each do |key_data|
62
+ next unless key_data["kty"] == "RSA"
63
+
64
+ kid = key_data["kid"]
65
+ @keys[kid] = build_rsa_key(key_data)
66
+ end
67
+ @fetched_at = Time.now.utc
68
+ end
69
+
70
+ def build_rsa_key(key_data)
71
+ n = OpenSSL::BN.new(base64url_decode(key_data["n"]), 2)
72
+ e = OpenSSL::BN.new(base64url_decode(key_data["e"]), 2)
73
+
74
+ # Build RSA public key from modulus (n) and exponent (e)
75
+ # Use ASN.1 sequence for OpenSSL 3.0+ compatibility
76
+ rsa_public_key = OpenSSL::ASN1::Sequence.new(
77
+ [
78
+ OpenSSL::ASN1::Integer(n),
79
+ OpenSSL::ASN1::Integer(e)
80
+ ]
81
+ )
82
+ algorithm_id = OpenSSL::ASN1::Sequence.new(
83
+ [
84
+ OpenSSL::ASN1::ObjectId("rsaEncryption"),
85
+ OpenSSL::ASN1::Null.new(nil)
86
+ ]
87
+ )
88
+ asn1 = OpenSSL::ASN1::Sequence.new(
89
+ [
90
+ algorithm_id,
91
+ OpenSSL::ASN1::BitString(rsa_public_key.to_der)
92
+ ]
93
+ )
94
+ OpenSSL::PKey::RSA.new(asn1.to_der)
95
+ end
96
+
97
+ def base64url_decode(str)
98
+ # Add padding if needed
99
+ str = str.to_s
100
+ str += "=" * (4 - (str.length % 4)) if str.length % 4 != 0
101
+ Base64.urlsafe_decode64(str)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ # Represents a user from the auth server
6
+ class User
7
+ attr_reader :id, :email, :first_name, :last_name, :email_verified, :created_at, :updated_at
8
+
9
+ def initialize(attributes = {})
10
+ @id = attributes["id"] || attributes[:id]
11
+ @email = attributes["email"] || attributes[:email]
12
+ @first_name = attributes["first_name"] || attributes[:first_name]
13
+ @last_name = attributes["last_name"] || attributes[:last_name]
14
+ @email_verified = attributes["email_verified"] || attributes[:email_verified]
15
+ @created_at = parse_time(attributes["created_at"] || attributes[:created_at])
16
+ @updated_at = parse_time(attributes["updated_at"] || attributes[:updated_at])
17
+ end
18
+
19
+ def full_name
20
+ [first_name, last_name].compact.join(" ")
21
+ end
22
+
23
+ private
24
+
25
+ def parse_time(value)
26
+ return nil if value.nil?
27
+ return value if value.is_a?(Time)
28
+
29
+ Time.iso8601(value)
30
+ rescue ArgumentError
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ # API resource for user management operations
6
+ class UsersResource
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # Create a new user
12
+ #
13
+ # @param email [String] User's email address
14
+ # @param first_name [String] User's first name
15
+ # @param last_name [String] User's last name
16
+ # @param password [String] User's password
17
+ # @return [User] The created user
18
+ def create(email:, first_name:, last_name:, password:)
19
+ response = post(
20
+ "/api/core/auth/users", {
21
+ email: email,
22
+ first_name: first_name,
23
+ last_name: last_name,
24
+ password: password
25
+ }
26
+ )
27
+
28
+ User.new(response)
29
+ end
30
+
31
+ # Find a user by ID
32
+ #
33
+ # @param id [String, Integer] The user ID
34
+ # @return [User] The user
35
+ def find(id)
36
+ response = get("/api/core/auth/users/#{id}")
37
+ User.new(response)
38
+ end
39
+
40
+ # Update a user
41
+ #
42
+ # @param id [String, Integer] The user ID
43
+ # @param attributes [Hash] Attributes to update (:email, :first_name, :last_name, :password)
44
+ # @return [User] The updated user
45
+ def update(id, **attributes)
46
+ response = put("/api/core/auth/users/#{id}", attributes)
47
+ User.new(response)
48
+ end
49
+
50
+ # Delete a user
51
+ #
52
+ # @param id [String, Integer] The user ID
53
+ # @return [Boolean] true if deleted
54
+ def delete(id)
55
+ delete_request("/api/core/auth/users/#{id}")
56
+ true
57
+ end
58
+
59
+ # List users with pagination
60
+ #
61
+ # @param page [Integer] Page number (default: 1)
62
+ # @param per_page [Integer] Users per page (default: 50)
63
+ # @return [Hash] Response with :users, :total, :page
64
+ def list(page: 1, per_page: 50)
65
+ response = get("/api/core/auth/users", page: page, per_page: per_page)
66
+
67
+ {
68
+ users: response["users"].map { |u| User.new(u) },
69
+ total: response["total"],
70
+ page: response["page"]
71
+ }
72
+ end
73
+
74
+ private
75
+
76
+ def get(path, params = {})
77
+ query = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
78
+ @client.send(:get, "#{path}#{query}")
79
+ end
80
+
81
+ def post(path, body)
82
+ @client.send(:post, path, body)
83
+ end
84
+
85
+ def put(path, body)
86
+ response = @client.send(:http_client).put(path) do |req|
87
+ req.headers["Content-Type"] = "application/json"
88
+ req.body = JSON.generate(body)
89
+ end
90
+ @client.send(:handle_response, response)
91
+ end
92
+
93
+ def delete_request(path)
94
+ @client.send(:delete, path)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aha
4
+ module Auth
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
data/lib/aha/auth.rb ADDED
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "jwt"
6
+ require "concurrent"
7
+
8
+ require_relative "auth/version"
9
+ require_relative "auth/errors"
10
+ require_relative "auth/configuration"
11
+ require_relative "auth/token_cache"
12
+ require_relative "auth/session"
13
+ require_relative "auth/user"
14
+ require_relative "auth/client"
15
+ require_relative "auth/users_resource"
16
+ require_relative "auth/sessions_resource"
17
+
18
+ module Aha
19
+ module Auth
20
+ CONFIGURATION = Concurrent::Atom.new(Configuration.new)
21
+ CLIENT = Concurrent::Atom.new(nil)
22
+ USERS_RESOURCE = Concurrent::Atom.new(nil)
23
+ SESSIONS_RESOURCE = Concurrent::Atom.new(nil)
24
+
25
+ class << self
26
+ def configuration
27
+ CONFIGURATION.value
28
+ end
29
+
30
+ def configure
31
+ yield(CONFIGURATION.value)
32
+ end
33
+
34
+ def reset_configuration!
35
+ CONFIGURATION.reset(Configuration.new)
36
+ CLIENT.reset(nil)
37
+ USERS_RESOURCE.reset(nil)
38
+ SESSIONS_RESOURCE.reset(nil)
39
+ end
40
+
41
+ # Generate login URL for redirecting users to the auth server
42
+ #
43
+ # @param state [String] Optional state parameter to pass through the auth flow
44
+ # @param redirect_uri [String] Optional redirect URI after authentication
45
+ # @return [String] The login URL
46
+ def login_url(state: nil, redirect_uri: nil)
47
+ params = { client_id: configuration.client_id }
48
+ params[:state] = state if state
49
+ params[:redirect_uri] = redirect_uri if redirect_uri
50
+
51
+ query = URI.encode_www_form(params)
52
+ "#{configuration.server_url}/auth/start?#{query}"
53
+ end
54
+
55
+ # Exchange an authorization code for tokens
56
+ #
57
+ # @param code [String] The authorization code from the callback
58
+ # @return [Hash] Token response with :session_token, :refresh_token, :expires_at, :user
59
+ def authenticate_with_code(code:)
60
+ client.authenticate_with_code(code: code)
61
+ end
62
+
63
+ # Refresh tokens using a refresh token
64
+ #
65
+ # @param refresh_token [String] The refresh token
66
+ # @return [Hash] Token response with :session_token, :refresh_token, :expires_at
67
+ def refresh_tokens(refresh_token:)
68
+ client.authenticate_with_refresh_token(refresh_token: refresh_token)
69
+ end
70
+
71
+ # Validate a session token
72
+ #
73
+ # @param session_token [String] The JWT session token
74
+ # @param refresh_token [String, nil] Optional refresh token for automatic refresh
75
+ # @return [Session] Session validation result
76
+ def validate_session(session_token, refresh_token: nil)
77
+ client.validate_session(session_token, refresh_token: refresh_token)
78
+ end
79
+
80
+ # Logout and revoke the session
81
+ #
82
+ # @param session_token [String] The session token to revoke
83
+ # @return [Boolean] true if successful
84
+ def logout(session_token:)
85
+ client.logout(session_token: session_token)
86
+ end
87
+
88
+ # Access the users API resource
89
+ #
90
+ # @return [UsersResource]
91
+ def users
92
+ USERS_RESOURCE.compare_and_set(nil, UsersResource.new(client))
93
+ USERS_RESOURCE.value
94
+ end
95
+
96
+ # Access the sessions API resource
97
+ #
98
+ # @return [SessionsResource]
99
+ def sessions
100
+ SESSIONS_RESOURCE.compare_and_set(nil, SessionsResource.new(client))
101
+ SESSIONS_RESOURCE.value
102
+ end
103
+
104
+ private
105
+
106
+ def client
107
+ CLIENT.compare_and_set(nil, Client.new(configuration))
108
+ CLIENT.value
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "aha/auth"
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aha_builder_core
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Aha! Labs Inc.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-12-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: concurrent-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '2.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '1.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: faraday
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '3.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '1.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '3.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: faraday-retry
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '1.0'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '3.0'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '1.0'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '3.0'
73
+ - !ruby/object:Gem::Dependency
74
+ name: jwt
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '2.0'
80
+ - - "<"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '3.0'
93
+ description: A Ruby gem providing convenient access to the authentication API for
94
+ session validation, user management, and authentication flows.
95
+ email:
96
+ - support@aha.io
97
+ executables: []
98
+ extensions: []
99
+ extra_rdoc_files: []
100
+ files:
101
+ - LICENSE
102
+ - README.md
103
+ - lib/aha/auth.rb
104
+ - lib/aha/auth/client.rb
105
+ - lib/aha/auth/configuration.rb
106
+ - lib/aha/auth/errors.rb
107
+ - lib/aha/auth/session.rb
108
+ - lib/aha/auth/sessions_resource.rb
109
+ - lib/aha/auth/token_cache.rb
110
+ - lib/aha/auth/user.rb
111
+ - lib/aha/auth/users_resource.rb
112
+ - lib/aha/auth/version.rb
113
+ - lib/aha_builder_core.rb
114
+ homepage: https://www.aha.io
115
+ licenses:
116
+ - MIT
117
+ metadata:
118
+ rubygems_mfa_required: 'true'
119
+ post_install_message:
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: 3.3.0
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 3.5.11
135
+ signing_key:
136
+ specification_version: 4
137
+ summary: Ruby client for Aha! Builder core services
138
+ test_files: []