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 +4 -4
- data/README.md +6 -2
- data/lib/aha/auth/client.rb +62 -20
- data/lib/aha/auth/configuration.rb +12 -3
- data/lib/aha/auth/token_cache.rb +7 -5
- data/lib/aha/auth/users_resource.rb +3 -3
- data/lib/aha/auth.rb +6 -19
- data/lib/aha/version.rb +1 -1
- data/lib/generators/aha_builder_core/auth/USAGE +1 -1
- data/lib/generators/aha_builder_core/auth/templates/authentication.rb.tt +2 -4
- data/lib/generators/aha_builder_core/auth/templates/sessions_controller.rb.tt +11 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 913c608a336e01e9d8ac3084627502ccdbb27de2e1e29be6e820f9121c810b30
|
|
4
|
+
data.tar.gz: 0f9fae30f6eb0295b361a778166adba1f5ab85410060993d4731a334bed35446
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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`):
|
data/lib/aha/auth/client.rb
CHANGED
|
@@ -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
|
-
|
|
55
|
-
rescue ExpiredTokenError
|
|
56
|
+
rescue ExpiredTokenError
|
|
56
57
|
# Token has expired, attempt refresh
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
19
|
-
|
|
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
|
-
@
|
|
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!
|
data/lib/aha/auth/token_cache.rb
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
@@ -16,7 +16,7 @@ Example:
|
|
|
16
16
|
And add routes:
|
|
17
17
|
get "login" => "sessions#new"
|
|
18
18
|
get "callback" => "sessions#callback"
|
|
19
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-02-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|