authrocket 2.4.1 → 3.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.
@@ -5,7 +5,11 @@ module AuthRocket
5
5
  require_relative 'controller_helper'
6
6
 
7
7
  ActiveSupport.on_load(:action_controller) do
8
- include AuthRocket::ControllerHelper
8
+ if self == ActionController::Base
9
+ include AuthRocket::ControllerHelper
10
+ helper AuthRocket::ControllerHelper
11
+ before_action :process_inbound_token
12
+ end
9
13
  end
10
14
  end
11
15
 
@@ -2,25 +2,41 @@ module AuthRocket
2
2
  class Realm < Resource
3
3
  crud :all, :find, :create, :update, :delete
4
4
 
5
- has_many :app_hooks
6
5
  has_many :auth_providers
6
+ has_many :client_apps
7
+ has_many :connections
8
+ has_many :domains
7
9
  has_many :events
10
+ has_many :hooks
11
+ has_many :invitations
8
12
  has_many :jwt_keys
9
- has_many :login_policies
13
+ has_many :named_permissions
10
14
  has_many :orgs
15
+ has_many :resource_links
11
16
  has_many :users
12
17
 
13
- attr :api_key_minutes, :api_key_policy, :api_key_prefix, :custom, :name
14
- attr :jwt_fields, :require_unique_emails, :resource_links, :session_minutes
15
- attr :session_type, :state, :username_validation_human
18
+ attr :custom, :environment, :name, :public_name, :state
19
+ attr :email_verification, :org_mode, :signup
20
+ attr :name_field, :org_name_field, :password_field, :username_field
21
+ attr :branding, :color_1, :logo, :logo_icon, :privacy_policy, :stylesheet, :terms_of_service
22
+ attr :access_token_minutes, :jwt_algo, :jwt_minutes, :jwt_scopes, :session_minutes
16
23
  attr :jwt_key # readonly
17
- attr :jwt_secret # readonly, deprecated
18
- attr :jwt_data # deprecated
24
+
25
+
26
+ def named_permissions
27
+ reload unless @attribs[:named_permissions]
28
+ @attribs[:named_permissions]
29
+ end
30
+
31
+ def resource_links
32
+ reload unless @attribs[:resource_links]
33
+ @attribs[:resource_links]
34
+ end
19
35
 
20
36
 
21
37
  def reset!(params={})
22
- params = parse_request_params(params).merge credentials: api_creds
23
- parsed, _ = request(:post, "#{url}/reset", params)
38
+ params = parse_request_params(params).reverse_merge credentials: api_creds
39
+ parsed, _ = request(:post, "#{resource_path}/reset", params)
24
40
  load(parsed)
25
41
  errors.empty? ? self : false
26
42
  end
@@ -0,0 +1,10 @@
1
+ module AuthRocket
2
+ class ResourceLink < Resource
3
+ crud :find, :create, :update, :delete
4
+
5
+ belongs_to :realm
6
+
7
+ attr :link_url, :resource_type, :title
8
+
9
+ end
10
+ end
@@ -5,73 +5,180 @@ module AuthRocket
5
5
  class Session < Resource
6
6
  crud :all, :find, :create, :delete
7
7
 
8
+ belongs_to :client_app
8
9
  belongs_to :user
9
10
 
10
11
  attr :token # readonly
11
- attr_datetime :created_at, :expires_at # readonly
12
+ attr_datetime :created_at, :expires_at
12
13
 
13
14
  def request_data
14
15
  self[:request]
15
16
  end
16
17
 
17
18
 
18
- # options - :within - (in seconds) Maximum time since the token was originally issued
19
- # - credentials: {jwt_secret: StringOrKey} - used to verify the token
20
- # - :algo - one of HS256, RS256 (default: auto-detect based on :jwt_secret)
19
+ # options - :algo - one of HS256, RS256 (default: auto-detect based on :jwt_key)
20
+ # - :within - (in seconds) Maximum time since the token was (re)issued
21
+ # - credentials: {jwt_key: StringOrKey} - used to verify the token
21
22
  def self.from_token(token, options={})
22
- secret = (options[:credentials]||credentials||{})[:jwt_secret]
23
- raise Error, "missing :jwt_secret (or AUTHROCKET_JWT_SECRET)" unless secret
24
- return unless token
23
+ secret = options.dig(:credentials, :jwt_key) || credentials[:jwt_key]
24
+ if lr_url = options.dig(:credentials, :loginrocket_url) || credentials[:loginrocket_url]
25
+ lr_url = lr_url.dup
26
+ lr_url.concat '/' unless lr_url.ends_with?('/')
27
+ lr_url.concat 'connect/jwks'
28
+ end
25
29
 
26
30
  algo = options[:algo]
27
31
  if secret.is_a?(String) && secret.length > 256
32
+ unless secret.starts_with?('-----BEGIN ')
33
+ secret = "-----BEGIN PUBLIC KEY-----\n#{secret}\n-----END PUBLIC KEY-----"
34
+ end
28
35
  secret = OpenSSL::PKey.read secret
29
36
  end
30
37
  algo ||= 'RS256' if secret.is_a?(OpenSSL::PKey::RSA)
31
- algo ||= 'HS256'
38
+ algo ||= 'HS256' if secret
39
+
40
+ jwks_eligible = algo.in?([nil, 'RS256']) && secret.blank? && lr_url
41
+
42
+ raise Error, "Missing jwt_key; set LOGINROCKET_URL, AUTHROCKET_JWT_KEY, or pass in credentials: {loginrocket_url: ...} or {jwt_key: ...}" if secret.blank? && !jwks_eligible
43
+ return if token.blank?
32
44
 
33
- jwt, _ = JWT.decode token, secret, true, algorithm: algo
45
+ if jwks_eligible
46
+ base_params = {token: token, algo: 'RS256', within: options[:within], local_creds: options[:credentials]}
47
+ load_jwk_set(lr_url, use_cached: true).each do |secret|
48
+ begin
49
+ return parse_jwt secret: secret, **base_params
50
+ rescue JWT::DecodeError
51
+ end
52
+ end
53
+ load_jwk_set(lr_url, use_cached: false).each do |secret|
54
+ begin
55
+ return parse_jwt secret: secret, **base_params
56
+ rescue JWT::DecodeError
57
+ end
58
+ end
59
+ nil
60
+ else
61
+ begin
62
+ parse_jwt token: token, secret: secret, algo: algo, within: options[:within], local_creds: options[:credentials]
63
+ rescue JWT::DecodeError
64
+ nil
65
+ end
66
+ end
67
+ end
34
68
 
35
- if within = options.delete(:within)
69
+ # private
70
+ # raises an exception if eligible for retry using different token
71
+ # returns Session on success
72
+ # returns nil on a definitive token-parsed-but-invalid
73
+ def self.parse_jwt(token:, secret:, algo:, within:, local_creds: nil)
74
+ opts = {
75
+ algorithm: algo,
76
+ leeway: 5,
77
+ iss: "https://authrocket.com",
78
+ verify_iss: true,
79
+ }
80
+
81
+ jwt, _ = JWT.decode token, secret, true, opts
82
+
83
+ if within
84
+ # this ensures token was created recently
85
+ # :iat is set to Time.now every time a token is created by the AR api
36
86
  return if jwt['iat'] < Time.now.to_i - within
37
87
  end
38
88
 
39
89
  user = User.new({
40
- id: jwt['uid'],
41
- realm_id: jwt['aud'],
42
- username: jwt['un'],
43
- first_name: jwt['fn'],
44
- last_name: jwt['ln'],
45
- name: jwt['n'],
90
+ id: jwt['sub'],
91
+ realm_id: jwt['rid'],
92
+ username: jwt['preferred_username'],
93
+ first_name: jwt['given_name'],
94
+ last_name: jwt['family_name'],
95
+ name: jwt['name'],
96
+ email: jwt['email'],
97
+ email_verification: jwt['email_verified'] ? 'verified' : 'none',
98
+ reference: jwt['ref'],
46
99
  custom: jwt['cs'],
47
- memberships: jwt['m'] && jwt['m'].map do |m|
100
+ memberships: jwt['orgs'] && jwt['orgs'].map do |m|
48
101
  Membership.new({
49
- permissions: m['p'],
50
- custom: m['cs'],
51
- user_id: jwt['uid'],
102
+ id: m['mid'],
103
+ permissions: m['perm'],
104
+ selected: m['selected'],
105
+ user_id: jwt['sub'],
52
106
  org_id: m['oid'],
53
- org: m['o'] && Org.new({
107
+ org: Org.new({
54
108
  id: m['oid'],
55
- realm_id: jwt['aud'],
56
- name: m['o'],
57
- custom: m['ocs'],
58
- }),
59
- })
109
+ realm_id: jwt['rid'],
110
+ name: m['name'],
111
+ reference: m['ref'],
112
+ custom: m['cs'],
113
+ }, local_creds),
114
+ }, local_creds)
60
115
  end,
61
- }, options[:credentials])
116
+ }, local_creds)
62
117
  session = new({
63
- id: jwt['tk'],
118
+ id: jwt['sid'],
64
119
  created_at: jwt['iat'],
65
120
  expires_at: jwt['exp'],
66
121
  token: token,
67
- user_id: jwt['uid'],
122
+ user_id: jwt['sub'],
68
123
  user: user
69
- }, options[:credentials])
70
-
124
+ }, local_creds)
125
+
71
126
  session
72
- rescue JWT::DecodeError
127
+ rescue JWT::ExpiredSignature, JWT::ImmatureSignature, JWT::InvalidAudError,
128
+ JWT::InvalidIatError, JWT::InvalidIssuerError
129
+ # successfully parsed, but invalid claims
73
130
  nil
74
131
  end
75
132
 
133
+ @_jwks ||= {}
134
+ JWKS_MUTEX = Mutex.new
135
+ MIN_ATTEMPT_WINDOW = 71 # seconds
136
+
137
+ # private
138
+ # use_cached - if there is a cached result, use it regardless of last cache load time
139
+ def self.load_jwk_set(uri, use_cached:)
140
+ keys, last_time = @_jwks.dig(uri, :keys), @_jwks.dig(uri, :time)
141
+ last_time ||= 0
142
+
143
+ return keys if use_cached && last_time > 0
144
+ return keys if Time.now.to_f - MIN_ATTEMPT_WINDOW < last_time
145
+
146
+ JWKS_MUTEX.synchronize do
147
+ # recheck in case we locked while being loaded in another process
148
+ newer_keys, newer_time = @_jwks.dig(uri, :keys), @_jwks.dig(uri, :time)
149
+ newer_time ||= 0
150
+
151
+ return newer_keys if newer_time > last_time
152
+
153
+ path = URI.parse(uri).path
154
+ headers = build_headers({}, {})
155
+ rest_opts = {
156
+ connect_timeout: 8,
157
+ headers: headers,
158
+ method: :get,
159
+ path: path,
160
+ read_timeout: 15,
161
+ url: uri,
162
+ write_timeout: 15,
163
+ }
164
+ response = execute_request(rest_opts)
165
+ parsed = parse_response(response)
166
+ # => {data: json, errors: errors, metadata: metadata}
167
+ certs = parsed[:data][:keys].map do |h|
168
+ crt = "-----BEGIN PUBLIC KEY-----\n#{h['x5c'][0]}\n-----END PUBLIC KEY-----"
169
+ OpenSSL::PKey.read crt
170
+ end
171
+
172
+ @_jwks[uri] = {keys: certs, time: Time.now.to_f}
173
+ keys ||= []
174
+ just_added = certs - keys
175
+ if just_added.any?
176
+ just_added
177
+ else
178
+ certs
179
+ end
180
+ end
181
+ end
182
+
76
183
  end
77
184
  end
@@ -0,0 +1,9 @@
1
+ module AuthRocket
2
+ class Token < Resource
3
+
4
+ belongs_to :user
5
+
6
+ attr :token
7
+
8
+ end
9
+ end
@@ -8,9 +8,9 @@ module AuthRocket
8
8
  has_many :memberships
9
9
  has_many :sessions
10
10
 
11
- attr :custom, :email, :email_verification, :first_name
12
- attr :last_name, :name, :password, :password_confirmation
13
- attr :reference, :state, :user_type, :username
11
+ attr :custom, :email, :email_verification, :first_name, :last_name, :name
12
+ attr :reference, :state, :username
13
+ attr :password, :password_confirmation # writeonly
14
14
  attr_datetime :created_at, :last_login_at
15
15
 
16
16
 
@@ -20,93 +20,127 @@ module AuthRocket
20
20
  end
21
21
 
22
22
  def orgs
23
- memberships.map(&:org).compact
23
+ memberships.map(&:org)
24
24
  end
25
25
 
26
26
  def find_org(id)
27
27
  orgs.detect{|o| o.id == id } || raise(RecordNotFound)
28
28
  end
29
29
 
30
- def human? ; user_type=='human' ; end
31
- def api? ; user_type=='api' ; end
32
-
33
30
 
34
31
  class << self
32
+ # id - email|username|id
33
+
34
+ # params - {password: '...'}
35
+ # returns: Session || Token
36
+ def authenticate(id, params)
37
+ params = parse_request_params(params, json_root: json_root)
38
+ parsed, creds = request(:post, "#{resource_path}/#{CGI.escape id}/authenticate", params)
39
+ obj = factory(parsed, creds)
40
+ raise RecordInvalid, obj if obj.errors?
41
+ obj
42
+ end
35
43
 
36
- def authenticate(username, password, params={})
37
- params = parse_request_params(params).merge password: password
38
- parsed, creds = request(:post, "#{url}/#{CGI.escape username}/authenticate", params)
39
- if parsed[:errors].any?
40
- raise ValidationError, parsed[:errors]
41
- end
42
- new(parsed, creds)
44
+ # params - {token: 'kli:...', code: '000000'}
45
+ # returns: Session
46
+ def authenticate_token(params)
47
+ params = parse_request_params(params, json_root: json_root)
48
+ parsed, creds = request(:post, "#{resource_path}/authenticate_token", params)
49
+ obj = factory(parsed, creds)
50
+ raise RecordInvalid, obj if obj.errors?
51
+ obj
43
52
  end
44
53
 
45
- def authenticate_key(api_key, params={})
46
- params = parse_request_params(params).merge api_key: api_key
47
- parsed, creds = request(:post, "#{url}/authenticate_key", params)
48
- if parsed[:errors].any?
49
- raise ValidationError, parsed[:errors]
50
- end
51
- new(parsed, creds)
54
+ # returns: Token
55
+ def generate_password_token(id, params={})
56
+ params = parse_request_params(params)
57
+ parsed, creds = request(:post, "#{resource_path}/#{CGI.escape id}/generate_password_token", params)
58
+ obj = factory(parsed, creds)
59
+ raise RecordInvalid, obj if obj.errors?
60
+ obj
52
61
  end
53
62
 
54
- # params - {username: '...', token: 'kli_...', code: '000000'}
55
- def authenticate_code(params)
63
+ # params - {token: '...', password: '...', password_confirmation: '...'}
64
+ # returns: Session || Token
65
+ def reset_password_with_token(params)
56
66
  params = parse_request_params(params, json_root: json_root)
57
- username = params[json_root].delete(:username) || '--'
58
- parsed, creds = request(:post, "#{url}/#{CGI.escape username}/authenticate_code", params)
59
- if parsed[:errors].any?
60
- raise ValidationError, parsed[:errors]
61
- end
62
- new(parsed, creds)
67
+ parsed, creds = request(:post, "#{resource_path}/reset_password_with_token", params)
68
+ obj = factory(parsed, creds)
69
+ raise RecordInvalid, obj if obj.errors?
70
+ obj
63
71
  end
64
72
 
65
- def generate_password_token(username, params={})
73
+ # returns: Token
74
+ def request_email_verification(id, params={})
66
75
  params = parse_request_params(params)
67
- parsed, creds = request(:post, "#{url}/#{CGI.escape username}/generate_password_token", params)
68
- if parsed[:errors].any?
69
- raise ValidationError, parsed[:errors]
70
- end
71
- new(parsed, creds)
76
+ parsed, creds = request(:post, "#{resource_path}/#{CGI.escape id}/request_email_verification", params)
77
+ obj = factory(parsed, creds)
78
+ raise RecordInvalid, obj if obj.errors?
79
+ obj
72
80
  end
73
81
 
74
- # params - {username: '...', token: '...', password: '...', password_confirmation: '...'}
75
- def reset_password_with_token(params)
82
+ # params - {token: '...'}
83
+ # returns: User
84
+ def verify_email(params)
76
85
  params = parse_request_params(params, json_root: json_root)
77
- username = params[json_root].delete(:username) || '--'
78
- parsed, creds = request(:post, "#{url}/#{CGI.escape username}/reset_password_with_token", params)
79
- if parsed[:errors].any?
80
- raise ValidationError, parsed[:errors]
81
- end
82
- new(parsed, creds)
86
+ parsed, creds = request(:post, "#{resource_path}/verify_email", params)
87
+ obj = factory(parsed, creds)
88
+ raise RecordInvalid, obj if obj.errors?
89
+ obj
83
90
  end
84
91
 
85
92
  end
86
93
 
87
- # params - {current_password: 'old', password: 'new', password_confirmation: 'new'}
88
- def update_password(params)
89
- params = parse_request_params(params, json_root: json_root).merge credentials: api_creds
90
- parsed, _ = request(:put, "#{url}/update_password", params)
94
+
95
+ # params - {token: '...'}
96
+ def accept_invitation(params)
97
+ params = parse_request_params(params, json_root: json_root).reverse_merge credentials: api_creds
98
+ parsed, _ = request(:post, "#{resource_path}/accept_invitation", params)
91
99
  load(parsed)
92
100
  errors.empty? ? self : false
93
101
  end
94
102
 
95
- def request_email_verification(params={})
96
- params = parse_request_params(params).merge credentials: api_creds
97
- parsed, _ = request(:post, "#{url}/request_email_verification", params)
103
+ # params - {current_password: 'old', password: 'new', password_confirmation: 'new'}
104
+ def update_password(params)
105
+ params = parse_request_params(params, json_root: json_root).reverse_merge credentials: api_creds
106
+ parsed, _ = request(:put, "#{resource_path}/update_password", params)
98
107
  load(parsed)
99
108
  errors.empty? ? self : false
100
109
  end
101
110
 
102
- # params - {token: '...'}
103
- def verify_email(params)
104
- params = parse_request_params(params, json_root: json_root).merge credentials: api_creds
105
- parsed, _ = request(:post, "#{url}/verify_email", params)
111
+ # params - {email:, first_name:, last_name:, password:, password_confirmation:, username:}
112
+ def update_profile(params)
113
+ params = parse_request_params(params, json_root: json_root).reverse_merge credentials: api_creds
114
+ parsed, _ = request(:put, "#{resource_path}/profile", params)
106
115
  load(parsed)
107
116
  errors.empty? ? self : false
108
117
  end
109
118
 
110
119
 
120
+ # returns: Session || Token
121
+ # (Session.user !== self)
122
+ def authenticate(params)
123
+ self.class.authenticate id, params.reverse_merge(credentials: api_creds)
124
+ rescue RecordInvalid => ex
125
+ errors.merge! ex.errors
126
+ false
127
+ end
128
+
129
+ # returns: Token
130
+ def generate_password_token(params={})
131
+ self.class.generate_password_token id, params.reverse_merge(credentials: api_creds)
132
+ rescue RecordInvalid => ex
133
+ errors.merge! ex.errors
134
+ false
135
+ end
136
+
137
+ # returns: Token
138
+ def request_email_verification(params={})
139
+ self.class.request_email_verification id, params.reverse_merge(credentials: api_creds)
140
+ rescue RecordInvalid => ex
141
+ errors.merge! ex.errors
142
+ false
143
+ end
144
+
111
145
  end
112
146
  end