aha_builder_core 1.0.15 → 1.0.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ab96bb0d7db25dcab119a0fc590ddfdc0a60b2984ae041f42f7380f9da1a229
4
- data.tar.gz: 83b6752ca39af681cf7a617855b9cd3b89b2a83eef98c28c7e440b9018389bfb
3
+ metadata.gz: 913c608a336e01e9d8ac3084627502ccdbb27de2e1e29be6e820f9121c810b30
4
+ data.tar.gz: 0f9fae30f6eb0295b361a778166adba1f5ab85410060993d4731a334bed35446
5
5
  SHA512:
6
- metadata.gz: da4a0a3dd5af21d498c34fab9c845928f017c8489c6cc857986fa70bc01fc318e440b1c555288afb872c8a29d46b6ed43dff0e8e8723f5d562635f598b7bcbff
7
- data.tar.gz: 8c27600959bfa94734744c8ef7b2f27fd22173da39a04eef001835261d695368b31cc1959f4496e5a481b5c792ff0d199884e6d265b7d98771ff30f725483b3a
6
+ metadata.gz: d3aef9032b88ce77e637cecfba97720bcc47d00e2b0c340408df0fd1f1f60248707bcdf27a91150ddec008c034970f845fea601a756869a0002a03eda505eccf
7
+ data.tar.gz: 1070ef7ede56f66267aab822a8843beabe40fc3717faba0a3844b5a982072499e68b62fd392f045d2bce4d2c1d3ae3935299bc47259ae82d1e4cda655c95e4c2
data/README.md CHANGED
@@ -18,7 +18,7 @@ No configuration is necessary and no environment variables are necessary.
18
18
 
19
19
  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.
20
20
 
21
- Protection against CSRF atacks is handled internally by the Aha::Auth library and there is no need to use a nonce in the state parameter.
21
+ Protection against CSRF attacks is handled internally by the Aha::Auth library and there is no need to use a nonce in the state parameter.
22
22
 
23
23
  ### Generate Login URL
24
24
 
@@ -88,7 +88,6 @@ local_user.update!(
88
88
  # Store tokens in session or database
89
89
  session[:session_token] = result[:session_token]
90
90
  session[:refresh_token] = result[:refresh_token]
91
- session[:user_id] = local_user.id
92
91
 
93
92
  # Redirect to application
94
93
  redirect_to root_path
@@ -115,10 +114,15 @@ end
115
114
 
116
115
  ### Logout
117
116
 
117
+ Implement logout by making a Ruby call to the library.
118
+
118
119
  ```ruby
119
120
  Aha::Auth.logout(session_token: session_token)
120
121
  ```
121
122
 
123
+ After logout the user should be redirected back to the login page. The front-end
124
+ should not use XHR for the logout request since it can't handle a redirect.
125
+
122
126
  ## User Management
123
127
 
124
128
  Server-to-server operations (requires `api_key`):
@@ -49,31 +49,32 @@ module Aha
49
49
  # @param refresh_token [String, nil] Optional refresh token for automatic refresh
50
50
  # @return [Session] Session validation result
51
51
  def validate_session(session_token, refresh_token: nil)
52
+ claims = nil
53
+
52
54
  begin
53
55
  claims = decode_and_verify_token(session_token)
54
- return Session.from_claims(claims) if claims
55
- rescue ExpiredTokenError
56
+ rescue ExpiredTokenError
56
57
  # Token has expired, attempt refresh
57
58
  end
58
59
 
59
- if refresh_token
60
- begin
61
- tokens = authenticate_with_refresh_token(refresh_token: refresh_token)
62
- new_claims = decode_and_verify_token(tokens[:session_token])
63
-
64
- return Session.from_claims(
65
- new_claims || claims,
66
- refreshed: true,
67
- new_session_token: tokens[:session_token],
68
- new_refresh_token: tokens[:refresh_token]
69
- )
70
- rescue Error
71
- return Session.invalid
60
+ if claims
61
+ # Valid session - check if we should proactively refresh based on lifetime
62
+ if refresh_token && token_needs_refresh?(claims)
63
+ # If refresh fails, we still want to return the existing session as valid
64
+ # until it fully expires, so we don't disrupt active users
65
+ return attempt_refresh(refresh_token) || Session.from_claims(claims)
72
66
  end
67
+
68
+ return Session.from_claims(claims)
69
+ end
70
+
71
+ if refresh_token
72
+ return attempt_refresh(refresh_token) || Session.invalid
73
73
  end
74
74
 
75
75
  Session.invalid
76
- rescue InvalidTokenError, ExpiredTokenError
76
+ rescue InvalidTokenError, ExpiredTokenError => e
77
+ Rails.logger.info("Session token invalid or expired #{e.message}")
77
78
  Session.invalid
78
79
  end
79
80
 
@@ -82,9 +83,12 @@ module Aha
82
83
  # @param session_token [String] The session token to revoke
83
84
  # @return [Boolean] true if successful
84
85
  def logout(session_token:)
85
- get("/api/core/auth_ui/logout", headers: { "Authorization" => "Bearer #{session_token}" })
86
+ claims = decode_and_verify_token(session_token)
87
+ user_id = claims["sub"] # JWT subject is the user_id
88
+
89
+ post("/api/core/auth/users/#{user_id}/logout", {})
86
90
  true
87
- rescue ApiError
91
+ rescue InvalidTokenError, ExpiredTokenError, ApiError
88
92
  false
89
93
  end
90
94
 
@@ -97,6 +101,36 @@ module Aha
97
101
 
98
102
  private
99
103
 
104
+ def attempt_refresh(refresh_token)
105
+ tokens = authenticate_with_refresh_token(refresh_token: refresh_token)
106
+ new_claims = decode_and_verify_token(tokens[:session_token])
107
+ Session.from_claims(
108
+ new_claims,
109
+ refreshed: true,
110
+ new_session_token: tokens[:session_token],
111
+ new_refresh_token: tokens[:refresh_token]
112
+ )
113
+ rescue Error => e
114
+ Rails.logger.error("Session refresh failed: #{e.message}")
115
+ nil
116
+ end
117
+
118
+ # Determine if a token is approaching expiration and should be proactively refreshed
119
+ #
120
+ # @param claims [Hash] The decoded JWT claims
121
+ # @return [Boolean] true if the token should be refreshed
122
+ def token_needs_refresh?(claims)
123
+ iat = claims["iat"]
124
+ exp = claims["exp"]
125
+ return false unless iat && exp
126
+
127
+ lifetime = exp - iat
128
+ return false if lifetime <= 0
129
+
130
+ elapsed = Time.now.to_i - iat
131
+ elapsed.to_f / lifetime >= @configuration.refresh_lifetime_fraction
132
+ end
133
+
100
134
  def decode_and_verify_token(token)
101
135
  # First decode without verification to get the header
102
136
  header = JWT.decode(token, nil, false).last
@@ -106,7 +140,7 @@ module Aha
106
140
  public_key = @token_cache.get_key(kid) { fetch_jwks }
107
141
 
108
142
  unless public_key
109
- # Try refreshing the cache in case there's a new key
143
+ # Force refresh the cache in case there's a new key
110
144
  @token_cache.refresh! { fetch_jwks }
111
145
  public_key = @token_cache.get_key(kid) { fetch_jwks }
112
146
  raise InvalidTokenError, "Unknown key ID: #{kid}" unless public_key
@@ -115,7 +149,11 @@ module Aha
115
149
  # Verify the token
116
150
  options = {
117
151
  algorithm: ALGORITHM,
118
- verify_expiration: true
152
+ aud: @configuration.client_id.to_s,
153
+ verify_expiration: true,
154
+ verify_aud: true,
155
+ verify_iat: true,
156
+ required_claims: %w[sub sid aud iat exp],
119
157
  }
120
158
 
121
159
  decoded = JWT.decode(token, public_key, true, options)
@@ -157,6 +195,10 @@ module Aha
157
195
 
158
196
  def http_client
159
197
  @http_client ||= Faraday.new(url: @configuration.server_url) do |conn|
198
+ if @configuration.enable_logging && @configuration.logger
199
+ conn.response :logger, @configuration.logger, bodies: true
200
+ end
201
+
160
202
  conn.request :retry, max: 2, interval: 0.5, backoff_factor: 2
161
203
  conn.options.timeout = @configuration.timeout
162
204
 
@@ -15,19 +15,28 @@ module Aha
15
15
  # How long to cache JWKS keys (default: 1 hour)
16
16
  attr_accessor :jwks_cache_ttl
17
17
 
18
- # Number of seconds before token expiry to trigger refresh (default: 120)
19
- attr_accessor :refresh_threshold
18
+ # Fraction of token lifetime after which to attempt proactive refresh (default: 0.5)
19
+ # e.g. at 0.5, a 15-minute token triggers refresh after 7.5 minutes
20
+ attr_accessor :refresh_lifetime_fraction
20
21
 
21
22
  # HTTP timeout in seconds (default: 30)
22
23
  attr_accessor :timeout
23
24
 
25
+ # Enable logging of HTTP requests and responses (default: false)
26
+ attr_accessor :enable_logging
27
+
28
+ # Logger instance for HTTP logging (default: Rails.logger)
29
+ attr_accessor :logger
30
+
24
31
  def initialize
25
32
  @server_url = ENV.fetch("AHA_CORE_SERVER_URL", "https://secure.aha.io/api/core")
26
33
  @api_key = ENV.fetch("AHA_CORE_API_KEY", nil)
27
34
  @client_id = ENV.fetch("APPLICATION_ID", nil)
28
35
  @jwks_cache_ttl = 3600 # 1 hour
29
- @refresh_threshold = 120 # 2 minutes
36
+ @refresh_lifetime_fraction = 0.5
30
37
  @timeout = 30
38
+ @enable_logging = false
39
+ @logger = Rails.logger
31
40
  end
32
41
 
33
42
  def validate!
@@ -34,11 +34,6 @@ module Aha
34
34
  end
35
35
  end
36
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
37
  # Clear the cache
43
38
  def clear!
44
39
  @mutex.synchronize do
@@ -49,6 +44,10 @@ module Aha
49
44
 
50
45
  private
51
46
 
47
+ def stale?
48
+ @fetched_at.nil? || (Time.now.utc - @fetched_at) > @ttl
49
+ end
50
+
52
51
  def refresh_if_needed(&fetcher)
53
52
  fetch_keys(&fetcher) if stale?
54
53
  end
@@ -59,7 +58,10 @@ module Aha
59
58
 
60
59
  @keys = {}
61
60
  jwks["keys"].each do |key_data|
61
+ # Valdate the key is appropriate for our use.
62
62
  next unless key_data["kty"] == "RSA"
63
+ next unless key_data["use"] == "sig"
64
+ next unless key_data["alg"] == "RS256"
63
65
 
64
66
  kid = key_data["kid"]
65
67
  @keys[kid] = build_rsa_key(key_data)
@@ -11,9 +11,9 @@ module Aha
11
11
  # Create a new user
12
12
  #
13
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
14
+ # @param first_name [String] User's first name (optional)
15
+ # @param last_name [String] User's last name (optional)
16
+ # @param password [String] User's password (optional)
17
17
  # @return [User] The created user
18
18
  def create(email:, first_name:, last_name:, password:)
19
19
  response = post(
data/lib/aha/auth.rb CHANGED
@@ -77,26 +77,13 @@ module Aha
77
77
  def authenticate_with_code(code:, cookies:)
78
78
  # Split the code and nonce if present
79
79
  actual_code, nonce = code.split(".", 2)
80
+
81
+ raise "CSRF verification failed: unable to verify nonce" unless nonce
80
82
 
81
- # Verify CSRF protection if nonce is present
82
- if nonce
83
- cookie_nonce = cookies[:auth_nonce]
84
-
85
- # Verify nonce matches
86
- if cookie_nonce.blank?
87
- raise "CSRF verification failed: nonce missing in cookie"
88
- end
89
-
90
- if cookie_nonce != nonce
91
- raise "CSRF verification failed: nonce mismatch"
92
- end
93
-
94
- # Clear the nonce from session after verification
95
- cookies.delete(:auth_nonce)
96
- else
97
- # If we fon't have a none, we can't verify CSRF.
98
- raise "CSRF verification failed: unable to verify nonce"
99
- end
83
+ cookie_nonce = cookies[:auth_nonce]
84
+ cookies.delete(:auth_nonce)
85
+ raise "CSRF verification failed: nonce missing in cookie" if cookie_nonce.blank?
86
+ raise "CSRF verification failed: nonce mismatch" if cookie_nonce != nonce
100
87
 
101
88
  client.authenticate_with_code(code: actual_code)
102
89
  end
data/lib/aha/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aha
4
- VERSION = "1.0.15"
4
+ VERSION = "1.0.17"
5
5
  end
@@ -16,7 +16,7 @@ Example:
16
16
  And add routes:
17
17
  get "login" => "sessions#new"
18
18
  get "callback" => "sessions#callback"
19
- delete "logout" => "sessions#logout"
19
+ get "logout" => "sessions#logout"
20
20
 
21
21
  You can add custom attributes to the User model:
22
22
  bin/rails generate aha_builder_core:auth company:string role:string
@@ -23,7 +23,7 @@ module Authentication
23
23
  session[:refresh_token] = session_result.new_refresh_token
24
24
  end
25
25
 
26
- @current_user = User.find_by(id: session[:user_id])
26
+ @current_user = User.find_by(id: session_result.user_id)
27
27
  Current.user = @current_user
28
28
  else
29
29
  clear_session
@@ -50,9 +50,7 @@ module Authentication
50
50
  end
51
51
 
52
52
  def clear_session
53
- session.delete(:session_token)
54
- session.delete(:refresh_token)
55
- session.delete(:user_id)
53
+ reset_session
56
54
  @current_user = nil
57
55
  end
58
56
  end
@@ -4,7 +4,7 @@ class SessionsController < ApplicationController
4
4
 
5
5
  def new
6
6
  redirect_to Aha::Auth.login_url(
7
- state: { return_to: params[:return_to] || root_path }.to_json,
7
+ state: { return_to: relative_url(params[:return_to] || root_path) }.to_json,
8
8
  cookies:
9
9
  ), allow_other_host: true
10
10
  end
@@ -25,10 +25,9 @@ class SessionsController < ApplicationController
25
25
 
26
26
  session[:session_token] = result[:session_token]
27
27
  session[:refresh_token] = result[:refresh_token]
28
- session[:user_id] = user.id
29
28
 
30
29
  state = JSON.parse(params[:state]) rescue {}
31
- redirect_to state["return_to"] || root_path
30
+ redirect_to relative_url(state["return_to"] || root_path)
32
31
  else
33
32
  redirect_to login_path, alert: "Authentication failed. Please try again."
34
33
  end
@@ -49,4 +48,13 @@ class SessionsController < ApplicationController
49
48
  clear_session
50
49
  redirect_to root_path, notice: "Successfully signed out."
51
50
  end
51
+
52
+ private
53
+
54
+ def relative_url(url)
55
+ uri = URI.parse(url)
56
+ [[uri.path, uri.query].compact.join("?"), uri.fragment].compact.join("#")
57
+ rescue StandardError => e
58
+ nil
59
+ end
52
60
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aha_builder_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.15
4
+ version: 1.0.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aha! Labs Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-16 00:00:00.000000000 Z
11
+ date: 2026-02-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport