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 +7 -0
- data/LICENSE +21 -0
- data/README.md +187 -0
- data/lib/aha/auth/client.rb +206 -0
- data/lib/aha/auth/configuration.rb +39 -0
- data/lib/aha/auth/errors.rb +63 -0
- data/lib/aha/auth/session.rb +46 -0
- data/lib/aha/auth/sessions_resource.rb +65 -0
- data/lib/aha/auth/token_cache.rb +105 -0
- data/lib/aha/auth/user.rb +35 -0
- data/lib/aha/auth/users_resource.rb +98 -0
- data/lib/aha/auth/version.rb +7 -0
- data/lib/aha/auth.rb +112 -0
- data/lib/aha_builder_core.rb +3 -0
- metadata +138 -0
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
|
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
|
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: []
|