katalyst-koi 5.8.3 → 5.9.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 +4 -4
- data/app/assets/builds/koi/admin.css +1 -0
- data/app/assets/stylesheets/koi/blocks/clipboard.css +43 -16
- data/app/assets/stylesheets/koi/blocks/index.css +1 -0
- data/app/controllers/admin/admin_roles_controller.rb +49 -0
- data/app/controllers/admin/background_jobs_controller.rb +13 -2
- data/app/controllers/admin/device_authorizations_controller.rb +1 -1
- data/app/controllers/admin/recurring_tasks_controller.rb +1 -2
- data/app/controllers/admin/sessions/tokens_controller.rb +45 -0
- data/app/controllers/admin/tokens_controller.rb +25 -26
- data/app/controllers/concerns/koi/controller.rb +22 -0
- data/app/models/admin/device_authorization.rb +66 -18
- data/app/models/admin/role.rb +41 -0
- data/app/models/background_job.rb +10 -1
- data/app/models/koi/current.rb +10 -0
- data/app/models/koi/identity/assertion.rb +78 -0
- data/app/models/koi/identity/member.rb +30 -0
- data/app/models/koi/identity/principal.rb +40 -0
- data/app/models/koi/identity/provider.rb +151 -0
- data/app/models/koi/identity.rb +70 -0
- data/app/models/recurring_task.rb +16 -0
- data/app/views/admin/admin_roles/index.html.erb +14 -0
- data/app/views/admin/admin_roles/show.html.erb +41 -0
- data/app/views/admin/{tokens → sessions/tokens}/create.turbo_stream.erb +2 -2
- data/config/routes.rb +5 -3
- data/db/migrate/20260721000001_create_admin_roles.rb +12 -0
- data/db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb +25 -0
- data/db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb +7 -0
- data/lib/koi/config.rb +32 -0
- data/lib/koi/engine.rb +12 -0
- data/lib/koi/extensions/object_rendering.rb +11 -11
- data/lib/koi/middleware/admin_authentication.rb +2 -2
- data/spec/factories/admin_device_authorizations.rb +18 -0
- data/spec/factories/admin_roles.rb +7 -0
- metadata +33 -5
- data/app/controllers/admin/device_tokens_controller.rb +0 -18
- /data/app/views/admin/{tokens → sessions/tokens}/show.html.erb +0 -0
|
@@ -47,11 +47,23 @@ module Admin
|
|
|
47
47
|
|
|
48
48
|
def render_state(state)
|
|
49
49
|
states = BackgroundJob.states
|
|
50
|
-
|
|
50
|
+
collection = Collection.new(sorting: default_sort(state))
|
|
51
|
+
@collection = collection.with_params(params).apply(states.fetch(state).strict_loading)
|
|
51
52
|
|
|
52
53
|
render locals: { collection:, state_counts: states.transform_values(&:count) }
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
def default_sort(state)
|
|
57
|
+
case state
|
|
58
|
+
when :completed
|
|
59
|
+
"finished_at desc"
|
|
60
|
+
when :failed
|
|
61
|
+
"updated_at desc"
|
|
62
|
+
else
|
|
63
|
+
"scheduled_at desc"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
55
67
|
def set_background_job
|
|
56
68
|
job = SolidQueue::Job.find_by!(active_job_id: params.expect(:active_job_id))
|
|
57
69
|
@background_job = BackgroundJob.new(job)
|
|
@@ -62,7 +74,6 @@ module Admin
|
|
|
62
74
|
|
|
63
75
|
attribute :class_name, :string
|
|
64
76
|
attribute :queue_name, :string
|
|
65
|
-
attribute :scheduled_at, :date
|
|
66
77
|
end
|
|
67
78
|
end
|
|
68
79
|
end
|
|
@@ -19,7 +19,7 @@ module Admin
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def create
|
|
22
|
-
device_authorization, device_code = Admin::DeviceAuthorization.
|
|
22
|
+
device_authorization, device_code = Admin::DeviceAuthorization.create_request!(
|
|
23
23
|
requested_ip: request.remote_ip,
|
|
24
24
|
user_agent: request.user_agent,
|
|
25
25
|
)
|
|
@@ -7,7 +7,7 @@ module Admin
|
|
|
7
7
|
attr_reader :recurring_task
|
|
8
8
|
|
|
9
9
|
def index
|
|
10
|
-
collection = Collection.with_params(params).apply(
|
|
10
|
+
collection = Collection.with_params(params).apply(RecurringTask.scope)
|
|
11
11
|
|
|
12
12
|
render locals: { collection: }
|
|
13
13
|
end
|
|
@@ -43,7 +43,6 @@ module Admin
|
|
|
43
43
|
|
|
44
44
|
attribute :key, :string
|
|
45
45
|
attribute :schedule, :string
|
|
46
|
-
attribute :last_enqueued_time, :date
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
class JobsCollection < Admin::Collection
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Admin
|
|
4
|
+
module Sessions
|
|
5
|
+
class TokensController < ApplicationController
|
|
6
|
+
include Koi::Controller::RecordsAuthentication
|
|
7
|
+
|
|
8
|
+
before_action :set_admin_user, only: %i[create]
|
|
9
|
+
|
|
10
|
+
attr_reader :admin_user
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
|
|
14
|
+
render locals: { admin_user:, token: params[:token] }
|
|
15
|
+
else
|
|
16
|
+
redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def create
|
|
21
|
+
render locals: { token: admin_user.generate_token_for(:password_reset) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update
|
|
25
|
+
if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
|
|
26
|
+
create_admin_session!(admin_user)
|
|
27
|
+
|
|
28
|
+
if admin_user.credentials.any?
|
|
29
|
+
redirect_to(admin_root_path, status: :see_other)
|
|
30
|
+
else
|
|
31
|
+
redirect_to(new_admin_profile_credential_path, status: :see_other)
|
|
32
|
+
end
|
|
33
|
+
else
|
|
34
|
+
redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def set_admin_user
|
|
41
|
+
@admin_user = Admin::User.find(params.expect(:admin_user_id))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -2,42 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module Admin
|
|
4
4
|
class TokensController < ApplicationController
|
|
5
|
-
|
|
5
|
+
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device-code"
|
|
6
|
+
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
rate_limit to: 20, within: 1.minute, only: :create
|
|
9
|
+
skip_before_action :verify_authenticity_token, only: :create
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
def create
|
|
12
|
+
case params[:grant_type]
|
|
13
|
+
when DEVICE_CODE_GRANT_TYPE
|
|
14
|
+
authorize_device_code
|
|
15
|
+
when JWT_BEARER_GRANT_TYPE
|
|
16
|
+
authorize_bearer_token
|
|
14
17
|
else
|
|
15
|
-
|
|
18
|
+
render(json: { error: "invalid_request" }, status: :bad_request)
|
|
16
19
|
end
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
render locals: { token: admin_user.generate_token_for(:password_reset) }
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def update
|
|
24
|
-
if (@admin_user = Admin::User.find_by_token_for(:password_reset, params[:token]))
|
|
25
|
-
create_admin_session!(admin_user)
|
|
22
|
+
private
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
end
|
|
32
|
-
else
|
|
33
|
-
redirect_to(new_admin_session_path, status: :see_other, notice: I18n.t("koi.auth.token_invalid"))
|
|
34
|
-
end
|
|
24
|
+
def authorize_device_code
|
|
25
|
+
render json: Admin::DeviceAuthorization.consume_request!(device_code: params[:device_code])
|
|
26
|
+
rescue Admin::DeviceAuthorization::TokenError => e
|
|
27
|
+
render json: { error: e.code }, status: :bad_request
|
|
35
28
|
end
|
|
36
29
|
|
|
37
|
-
|
|
30
|
+
def authorize_bearer_token
|
|
31
|
+
assertion = Koi::Identity.authorize_bearer_token!(params[:assertion], audience: "#{request.base_url}/admin")
|
|
38
32
|
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
render json: Admin::DeviceAuthorization.issue_token!(
|
|
34
|
+
principal: assertion.principal,
|
|
35
|
+
requested_ip: request.remote_ip,
|
|
36
|
+
user_agent: request.user_agent,
|
|
37
|
+
)
|
|
38
|
+
rescue JWT::DecodeError
|
|
39
|
+
render json: { error: "invalid_grant" }, status: :bad_request
|
|
41
40
|
end
|
|
42
41
|
end
|
|
43
42
|
end
|
|
@@ -39,6 +39,8 @@ module Koi
|
|
|
39
39
|
|
|
40
40
|
protect_from_forgery with: :exception
|
|
41
41
|
skip_forgery_protection if: :bearer_token_request?
|
|
42
|
+
|
|
43
|
+
after_action :set_release_headers
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
private
|
|
@@ -46,5 +48,25 @@ module Koi
|
|
|
46
48
|
def bearer_token_request?
|
|
47
49
|
request.authorization.to_s.match?(/\ABearer .+\z/)
|
|
48
50
|
end
|
|
51
|
+
|
|
52
|
+
# HTML responses carry the release in meta tags (see Koi::Release.meta_tags);
|
|
53
|
+
# JSON consumers get the same information as response headers.
|
|
54
|
+
def set_release_headers
|
|
55
|
+
return unless response.media_type == "application/json"
|
|
56
|
+
|
|
57
|
+
response.set_header("X-Application-Version", Koi::Release.version)
|
|
58
|
+
response.set_header("X-Application-Revision", Koi::Release.revision)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Surface the grant's stored principal on the request's process_action
|
|
62
|
+
# payload so structured loggers attribute machine requests to the
|
|
63
|
+
# verified identity that minted the token.
|
|
64
|
+
def append_info_to_payload(payload)
|
|
65
|
+
super
|
|
66
|
+
|
|
67
|
+
if (principal = Koi::Current.principal)
|
|
68
|
+
payload[:principal] = { provider: principal.provider, subject: principal.subject }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
49
71
|
end
|
|
50
72
|
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Admin
|
|
4
4
|
class DeviceAuthorization < ApplicationRecord
|
|
5
|
-
|
|
5
|
+
REQUEST_EXPIRES_IN = 10.minutes
|
|
6
|
+
TOKEN_EXPIRES_IN = 1.hour
|
|
6
7
|
|
|
7
8
|
class TokenError < StandardError
|
|
8
9
|
attr_reader :code
|
|
@@ -17,12 +18,17 @@ module Admin
|
|
|
17
18
|
|
|
18
19
|
enum :status, %w[pending approved denied consumed].index_with(&:to_s)
|
|
19
20
|
|
|
20
|
-
generates_token_for(:api_access, expires_in:
|
|
21
|
+
generates_token_for(:api_access, expires_in: TOKEN_EXPIRES_IN) do
|
|
22
|
+
admin_user&.last_sign_in_at || admin_role&.tokens_revoked_at
|
|
23
|
+
end
|
|
21
24
|
|
|
22
|
-
validates :device_code_digest, presence: true, uniqueness: true
|
|
23
|
-
validates :request_expires_at, presence: true
|
|
24
25
|
validates :status, presence: true, inclusion: { in: statuses.values }
|
|
25
|
-
|
|
26
|
+
|
|
27
|
+
with_options(if: :pending?) do
|
|
28
|
+
validates :device_code_digest, presence: true, uniqueness: true
|
|
29
|
+
validates :user_code, presence: true, uniqueness: true
|
|
30
|
+
validates :request_expires_at, presence: true
|
|
31
|
+
end
|
|
26
32
|
|
|
27
33
|
belongs_to :admin_user,
|
|
28
34
|
class_name: "Admin::User",
|
|
@@ -30,13 +36,23 @@ module Admin
|
|
|
30
36
|
inverse_of: :device_authorizations,
|
|
31
37
|
optional: true
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
belongs_to :admin_role,
|
|
40
|
+
class_name: "Admin::Role",
|
|
41
|
+
inverse_of: :device_authorizations,
|
|
42
|
+
optional: true
|
|
43
|
+
|
|
44
|
+
# Snapshot of the verified principal, as captured by `issue_token!`.
|
|
45
|
+
serialize :principal, coder: Koi::Identity::Principal
|
|
46
|
+
attr_readonly :principal
|
|
47
|
+
|
|
48
|
+
# Creates a new un-approved request.
|
|
49
|
+
def self.create_request!(requested_ip:, user_agent:)
|
|
34
50
|
device_code = SecureRandom.urlsafe_base64(32)
|
|
35
51
|
|
|
36
52
|
device_authorization = create!(
|
|
37
53
|
device_code_digest: digest(device_code),
|
|
38
54
|
user_code: generate_user_code,
|
|
39
|
-
request_expires_at:
|
|
55
|
+
request_expires_at: REQUEST_EXPIRES_IN.from_now,
|
|
40
56
|
requested_ip:,
|
|
41
57
|
user_agent:,
|
|
42
58
|
)
|
|
@@ -52,7 +68,8 @@ module Admin
|
|
|
52
68
|
"#{SecureRandom.alphanumeric(4).upcase}-#{SecureRandom.alphanumeric(4).upcase}"
|
|
53
69
|
end
|
|
54
70
|
|
|
55
|
-
|
|
71
|
+
# Consume an approved request, returns the issued token payload.
|
|
72
|
+
def self.consume_request!(device_code:)
|
|
56
73
|
device_authorization = find_by(device_code_digest: digest(device_code.to_s))
|
|
57
74
|
raise TokenError.new("invalid_grant") unless device_authorization
|
|
58
75
|
|
|
@@ -63,19 +80,38 @@ module Admin
|
|
|
63
80
|
raise TokenError.new(error)
|
|
64
81
|
end
|
|
65
82
|
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
device_authorization.consume!
|
|
84
|
+
end
|
|
85
|
+
end
|
|
68
86
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
87
|
+
# Issue a new token directly from a validated assertion, returns the issued token payload.
|
|
88
|
+
def self.issue_token!(principal:, **)
|
|
89
|
+
case principal.scope
|
|
90
|
+
when "admin/user"
|
|
91
|
+
admin_user = Admin::User.find_by(principal.attributes_for_find)
|
|
92
|
+
|
|
93
|
+
unless admin_user
|
|
94
|
+
Rails.logger.warn("Koi::Identity rejected #{principal}: no matching admin user")
|
|
95
|
+
raise JWT::VerificationError, "unknown user for #{principal}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
new(admin_user:, principal:, **).consume!
|
|
99
|
+
when %r{\Aadmin/role/(?<slug>[a-z0-9_]+)\z}
|
|
100
|
+
admin_role = Admin::Role.materialize(Regexp.last_match(:slug))
|
|
101
|
+
|
|
102
|
+
new(admin_role:, principal:, **).consume!
|
|
103
|
+
else
|
|
104
|
+
raise ArgumentError, "unknown scope #{principal.scope.inspect}"
|
|
74
105
|
end
|
|
75
106
|
end
|
|
76
107
|
|
|
108
|
+
# @return [Admin::User, Admin::Role, nil]
|
|
109
|
+
def actor
|
|
110
|
+
admin_user || admin_role
|
|
111
|
+
end
|
|
112
|
+
|
|
77
113
|
def expired?
|
|
78
|
-
request_expires_at <= Time.current
|
|
114
|
+
request_expires_at.present? && request_expires_at <= Time.current
|
|
79
115
|
end
|
|
80
116
|
|
|
81
117
|
def issuable?
|
|
@@ -89,12 +125,14 @@ module Admin
|
|
|
89
125
|
"invalid_grant" if consumed? || expired?
|
|
90
126
|
end
|
|
91
127
|
|
|
92
|
-
def consume!
|
|
128
|
+
def consume!
|
|
93
129
|
update!(
|
|
94
130
|
status: "consumed",
|
|
95
131
|
consumed_at: Time.current,
|
|
96
|
-
token_expires_at:
|
|
132
|
+
token_expires_at: TOKEN_EXPIRES_IN.from_now,
|
|
97
133
|
)
|
|
134
|
+
|
|
135
|
+
token_payload
|
|
98
136
|
end
|
|
99
137
|
|
|
100
138
|
def approve!(admin_user:)
|
|
@@ -115,5 +153,15 @@ module Admin
|
|
|
115
153
|
def actionable?
|
|
116
154
|
pending? && !expired?
|
|
117
155
|
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def token_payload(access_token = generate_token_for(:api_access))
|
|
160
|
+
{
|
|
161
|
+
access_token:,
|
|
162
|
+
token_type: "Bearer",
|
|
163
|
+
expires_in: (token_expires_at - Time.current).round,
|
|
164
|
+
}
|
|
165
|
+
end
|
|
118
166
|
end
|
|
119
167
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Admin
|
|
4
|
+
# A machine actor, assumable only via an identity member's grant — nothing
|
|
5
|
+
# signs in as a role. Config declares roles (a role exists when a member's
|
|
6
|
+
# scope names it); the row is the stable identity that grants and audit
|
|
7
|
+
# records reference, materialized on first use and never deleted.
|
|
8
|
+
class Role < ApplicationRecord
|
|
9
|
+
self.table_name = :admin_roles
|
|
10
|
+
|
|
11
|
+
validates :slug, presence: true
|
|
12
|
+
|
|
13
|
+
has_many :device_authorizations,
|
|
14
|
+
class_name: "Admin::DeviceAuthorization",
|
|
15
|
+
foreign_key: :admin_role_id,
|
|
16
|
+
inverse_of: :admin_role,
|
|
17
|
+
dependent: :destroy
|
|
18
|
+
|
|
19
|
+
def self.materialize(slug)
|
|
20
|
+
create_or_find_by!(slug:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Config is authoritative for grants: a row whose slug no longer appears
|
|
24
|
+
# in any member's scope is not assumable, but remains for attribution.
|
|
25
|
+
def orphaned?
|
|
26
|
+
Koi::Identity.role_slugs.exclude?(slug)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Members granting this role in the current trust config.
|
|
30
|
+
def members
|
|
31
|
+
Koi::Identity.members.select { |member| member.role_slug == slug }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Roles authenticate by exchanging an assertion for a token; requests
|
|
35
|
+
# made with the token never touch this row, so issuance is the freshest
|
|
36
|
+
# signal available.
|
|
37
|
+
def last_authenticated_at
|
|
38
|
+
device_authorizations.maximum(:consumed_at)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
class BackgroundJob
|
|
4
4
|
include ActiveModel::Model
|
|
5
5
|
|
|
6
|
+
module Scopes
|
|
7
|
+
def admin_search(query)
|
|
8
|
+
where(
|
|
9
|
+
"solid_queue_jobs.class_name LIKE :query OR solid_queue_jobs.queue_name LIKE :query",
|
|
10
|
+
query: "%#{query}%",
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
6
15
|
# @return [SolidQueue::Job]
|
|
7
16
|
attr_reader :job
|
|
8
17
|
|
|
@@ -18,7 +27,7 @@ class BackgroundJob
|
|
|
18
27
|
blocked: SolidQueue::Job.where.associated(:blocked_execution),
|
|
19
28
|
failed: SolidQueue::Job.failed,
|
|
20
29
|
completed: SolidQueue::Job.finished,
|
|
21
|
-
}
|
|
30
|
+
}.transform_values { |jobs| jobs.extending(Scopes) }
|
|
22
31
|
end
|
|
23
32
|
|
|
24
33
|
# @param [SolidQueue::Job] job
|
data/app/models/koi/current.rb
CHANGED
|
@@ -8,6 +8,16 @@ module Koi
|
|
|
8
8
|
# @return [Admin::Session, nil]
|
|
9
9
|
attribute :session
|
|
10
10
|
|
|
11
|
+
# @return [Admin::User, Admin::Role, nil]
|
|
12
|
+
def actor
|
|
13
|
+
device_authorization&.actor || session&.admin
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Koi::Identity::Principal, nil]
|
|
17
|
+
def principal
|
|
18
|
+
device_authorization&.principal
|
|
19
|
+
end
|
|
20
|
+
|
|
11
21
|
# @return [Admin::User, nil]
|
|
12
22
|
def admin_user
|
|
13
23
|
device_authorization&.admin_user || session&.admin
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Koi
|
|
4
|
+
module Identity
|
|
5
|
+
class Assertion
|
|
6
|
+
# @return String
|
|
7
|
+
attr_reader :token
|
|
8
|
+
|
|
9
|
+
# @return Principal
|
|
10
|
+
attr_reader :principal
|
|
11
|
+
|
|
12
|
+
def initialize(token)
|
|
13
|
+
@token = token
|
|
14
|
+
@claims, @header = JWT.decode(token, nil, false)
|
|
15
|
+
@state = :unverified
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def verified?
|
|
19
|
+
@state == :verified
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def claims
|
|
23
|
+
@claims
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def header
|
|
27
|
+
@header
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def verify!(provider)
|
|
31
|
+
# The library validates required_claims after verify_jti, but
|
|
32
|
+
# consume_jti derives its cache TTL from exp — so require it first.
|
|
33
|
+
raise JWT::MissingRequiredClaim, "missing required claim exp" if claims["exp"].nil?
|
|
34
|
+
|
|
35
|
+
JWT.decode(
|
|
36
|
+
@token, nil, true,
|
|
37
|
+
algorithms: provider.algorithms,
|
|
38
|
+
jwks: provider.method(:key_set),
|
|
39
|
+
aud: provider.audience,
|
|
40
|
+
leeway: provider.leeway.to_i,
|
|
41
|
+
required_claims: %w[exp],
|
|
42
|
+
verify_aud: true,
|
|
43
|
+
verify_jti: provider.method(:consume_jti)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if claims["exp"].to_i > provider.max_expiry.from_now.to_i
|
|
47
|
+
raise JWT::InvalidPayload, "assertion expiry is more than #{provider.max_expiry.inspect} away"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# ensure that we can map the claim to a valid principal using the claim's subject
|
|
51
|
+
@principal = Identity.principal_for(provider, self)
|
|
52
|
+
|
|
53
|
+
if principal.blank? || principal.subject.blank?
|
|
54
|
+
raise(JWT::InvalidSubError, "unknown subject #{subject} for provider #{provider.name}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@state = :verified
|
|
58
|
+
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def iss
|
|
63
|
+
@claims["iss"]
|
|
64
|
+
end
|
|
65
|
+
alias_method :issuer, :iss
|
|
66
|
+
|
|
67
|
+
def sub
|
|
68
|
+
@claims["sub"]
|
|
69
|
+
end
|
|
70
|
+
alias_method :subject, :sub
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"<#{self.class.name} iss=#{iss.inspect} sub=#{sub.inspect}>"
|
|
74
|
+
end
|
|
75
|
+
alias :to_s :inspect
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Koi
|
|
4
|
+
module Identity
|
|
5
|
+
# A named trust rule from config: pairs a provider and an exact subject
|
|
6
|
+
# with the scope a verified assertion may act as.
|
|
7
|
+
class Member
|
|
8
|
+
include ActiveModel::Model
|
|
9
|
+
include ActiveModel::Attributes
|
|
10
|
+
|
|
11
|
+
SCOPES = %r{\Aadmin/user\z|\Aadmin/role/(?<slug>[a-z0-9_]+)\z}
|
|
12
|
+
|
|
13
|
+
attribute :name, :string
|
|
14
|
+
attribute :provider, :string
|
|
15
|
+
attribute :scope, :string
|
|
16
|
+
attribute :subject, :string
|
|
17
|
+
|
|
18
|
+
# Provider names declared in config, for cross-checking references.
|
|
19
|
+
attr_accessor :provider_names
|
|
20
|
+
|
|
21
|
+
validates :provider, :scope, :subject, presence: true
|
|
22
|
+
validates :provider, inclusion: { in: ->(member) { member.provider_names || [] }, allow_blank: true }
|
|
23
|
+
validates :scope, format: { with: SCOPES, allow_blank: true }
|
|
24
|
+
|
|
25
|
+
def role_slug
|
|
26
|
+
scope&.[](SCOPES, :slug)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Koi
|
|
4
|
+
module Identity
|
|
5
|
+
class Principal
|
|
6
|
+
include ActiveModel::Model
|
|
7
|
+
include ActiveModel::Attributes
|
|
8
|
+
|
|
9
|
+
def self.dump(principal)
|
|
10
|
+
principal&.attributes&.to_json
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.load(json)
|
|
14
|
+
return if json.blank?
|
|
15
|
+
|
|
16
|
+
Principal.new(**JSON.parse(json).slice(*attribute_names))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Required attributes
|
|
20
|
+
attribute :provider, :string
|
|
21
|
+
attribute :subject, :string
|
|
22
|
+
attribute :scope, :string
|
|
23
|
+
|
|
24
|
+
# Optional extensions, required for user authentication
|
|
25
|
+
attribute :name, :string
|
|
26
|
+
attribute :email, :string
|
|
27
|
+
|
|
28
|
+
def attributes_for_find
|
|
29
|
+
{ email: }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inspect
|
|
33
|
+
"<#{self.class.name} provider=#{provider.inspect} scope=#{scope.inspect} subject=#{subject.inspect} " \
|
|
34
|
+
"name=#{name.inspect} email=#{email.inspect}>"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
alias :to_s :inspect
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|