katalyst-koi 5.4.0 → 5.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 559fd46fee1b7874a6593314f73ca7c2f205b31650457f35b171ba4165ff3e91
4
- data.tar.gz: 7a8ad3000aa8d852adf95943574b0a56aa1eafd72446a9f9cd0abe107d968072
3
+ metadata.gz: 9a64f8ea56e122d169ec52b8c304c79a1b81289bba216d89d2b97c9f0aaf9e24
4
+ data.tar.gz: e07ac3a269c0cf2958f33fb9e3da25dec86a84d27f25f3d0314ecc4d4fed5f4f
5
5
  SHA512:
6
- metadata.gz: 4f039daf2363cf733d2d271f6a544b843d0ab1b1746db24f2be2177065a8bd4d9495bb7570def596ee86b674558f7dd8632d846522fe6a00b678019752858a17
7
- data.tar.gz: d1c3be39ebf0fdfceb631a9fd86ffbf86e655f18b6b489b92243b261d4b52deb9165a99ea40b1c3ed62bb7c8fae5457fb5033fd2abd42c474320b3da04c2fdce
6
+ metadata.gz: c337a6f84e5ce47739dc9242207b9eafd2a0fbf6c9bf3f5b89338910175b3598dc575037849fb1a844e1a1608b54cb9e08e891286047b1548bbe127484ae446a
7
+ data.tar.gz: 84b32b36bd70147f24f0b975df0b722ba37f860185c1df483c1947c55ec17c74d1332e16b29d30b95f1f0f59021dced986db12ae005f7962349f3cd30df74246
data/README.md CHANGED
@@ -1,25 +1,19 @@
1
- # Koi
1
+ # Katalyst Koi
2
2
 
3
3
  Koi is a framework for building Rails admin functionality.
4
4
 
5
5
  ## Installation
6
6
 
7
- Add this line to your application's Gemfile:
7
+ In most cases, Koi is installed using `https://github.com/katalyst/koi-template`.
8
8
 
9
- ```ruby
10
- gem "katalyst-koi"
11
- ```
9
+ ## API Access
12
10
 
13
- And then execute:
14
-
15
- ```sh
16
- bundle install
17
- ```
11
+ See [docs/api-access.md](docs/api-access.md).
18
12
 
19
13
  ## Contributing
20
14
 
21
- Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/katalyst-tables.
15
+ Bug reports and pull requests are welcome on GitHub at https://github.com/katalyst/koi.
22
16
 
23
17
  ## License
24
18
 
25
- Katalyst Tables is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
19
+ Koi is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Admin
4
4
  class AdminUsersController < ApplicationController
5
+ before_action :requires_session_authentication!
5
6
  before_action :set_admin_user, only: %i[show edit update destroy]
6
7
 
7
8
  attr_reader :admin_user
@@ -4,6 +4,7 @@ module Admin
4
4
  class CredentialsController < ApplicationController
5
5
  include Koi::Controller::HasWebauthn
6
6
 
7
+ before_action :requires_session_authentication!
7
8
  before_action :set_admin_user, only: %i[new create]
8
9
  before_action :set_credential, only: %i[show update destroy]
9
10
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorizationsController < ApplicationController
5
+ EXPIRES_IN = 10.minutes
6
+ INTERVAL = 5
7
+
8
+ rate_limit to: 3, within: 1.minute, only: :create
9
+ skip_before_action :verify_authenticity_token, only: :create
10
+
11
+ before_action :set_device_authorization, only: %i[show update]
12
+
13
+ attr_reader :device_authorization
14
+
15
+ delegate :admin_user, to: ::Koi::Current
16
+
17
+ def show
18
+ render locals: { device_authorization: }
19
+ end
20
+
21
+ def create
22
+ device_authorization, device_code = Admin::DeviceAuthorization.issue!(
23
+ requested_ip: request.remote_ip,
24
+ user_agent: request.user_agent,
25
+ )
26
+
27
+ render json: {
28
+ device_code:,
29
+ user_code: device_authorization.user_code,
30
+ verification_uri: admin_device_authorization_url(device_authorization.user_code),
31
+ verification_uri_complete: admin_device_authorization_url(device_authorization.user_code),
32
+ expires_in: EXPIRES_IN.to_i,
33
+ interval: INTERVAL,
34
+ }
35
+ end
36
+
37
+ def update
38
+ if device_authorization.actionable?
39
+ case params[:decision]
40
+ when "approve"
41
+ device_authorization.approve!(admin_user:)
42
+ else
43
+ device_authorization.deny!(admin_user:)
44
+ end
45
+
46
+ redirect_to(admin_device_authorization_path(device_authorization.user_code), status: :see_other)
47
+ else
48
+ render(:show, status: :unprocessable_content, locals: { device_authorization: })
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def set_device_authorization
55
+ @device_authorization = Admin::DeviceAuthorization.find_by!(user_code: params[:user_code])
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceTokensController < ApplicationController
5
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
6
+
7
+ rate_limit to: 20, within: 1.minute, only: :create
8
+ skip_before_action :verify_authenticity_token, only: :create
9
+
10
+ def create
11
+ return render(json: { error: "invalid_request" }, status: :bad_request) unless params[:grant_type] == GRANT_TYPE
12
+
13
+ render json: Admin::DeviceAuthorization.issue_access_token!(device_code: params[:device_code])
14
+ rescue Admin::DeviceAuthorization::TokenError => e
15
+ render json: { error: e.code }, status: :bad_request
16
+ end
17
+ end
18
+ end
@@ -4,6 +4,8 @@ module Admin
4
4
  class OtpsController < ApplicationController
5
5
  alias_method :admin_user, :current_admin
6
6
 
7
+ before_action :requires_session_authentication!
8
+
7
9
  def new
8
10
  admin_user.otp_secret = ROTP::Base32.random
9
11
 
@@ -4,6 +4,8 @@ module Admin
4
4
  class ProfilesController < ApplicationController
5
5
  include Koi::Controller::HasWebauthn
6
6
 
7
+ before_action :requires_session_authentication!
8
+
7
9
  alias_method :admin_user, :current_admin
8
10
 
9
11
  def show
@@ -41,7 +41,7 @@ module Admin
41
41
  end
42
42
 
43
43
  def destroy
44
- record_sign_out!(current_admin_user)
44
+ record_sign_out!(Koi::Current.admin_user)
45
45
 
46
46
  session[:admin_user_id] = nil
47
47
 
@@ -103,11 +103,11 @@ module Admin
103
103
  def authenticate_local_admin
104
104
  return if admin_signed_in? || !Rails.env.development?
105
105
 
106
- @current_admin_user = Admin::User.find_by(email: "#{ENV.fetch('USER', nil)}@katalyst.com.au")
106
+ Koi::Current.admin_user = Admin::User.find_by(email: "#{ENV.fetch('USER', nil)}@katalyst.com.au")
107
107
 
108
108
  return unless admin_signed_in?
109
109
 
110
- session[:admin_user_id] = current_admin_user.id
110
+ session[:admin_user_id] = Koi::Current.admin_user.id
111
111
 
112
112
  flash.delete(:redirect) if (redirect = flash[:redirect])
113
113
 
@@ -14,23 +14,20 @@ module Koi
14
14
  end
15
15
 
16
16
  def admin_signed_in?
17
- current_admin_user.present?
18
- rescue ActiveRecord::RecordNotFound
19
- false
17
+ Koi::Current.admin_user.present?
20
18
  end
21
19
 
22
20
  def current_admin_user
23
- return @current_admin_user if instance_variable_defined?(:@current_admin_user)
24
- return @current_admin_user = nil unless session.has_key?(:admin_user_id)
25
-
26
- @current_admin_user = Admin::User.find(session[:admin_user_id])
27
- ensure
28
- session.delete(:admin_user_id) unless @current_admin_user
21
+ Koi::Current.admin_user
29
22
  end
30
23
 
31
24
  # @deprecated Use current_admin_user instead
32
25
  alias_method :current_admin, :current_admin_user
33
26
 
27
+ def requires_session_authentication!
28
+ head(:forbidden) if session[:admin_user_id].blank?
29
+ end
30
+
34
31
  module Test
35
32
  # Include in view specs to stub out the current admin user
36
33
  module ViewHelper
@@ -40,11 +37,11 @@ module Koi
40
37
  before do
41
38
  view.singleton_class.module_eval do
42
39
  def admin_signed_in?
43
- current_admin_user.present?
40
+ Koi::Current.admin_user.present?
44
41
  end
45
42
 
46
43
  def current_admin_user
47
- respond_to?(:admin_user) ? admin_user : nil
44
+ Koi::Current.admin_user = (respond_to?(:admin_user) ? admin_user : nil)
48
45
  end
49
46
 
50
47
  alias_method :current_admin, :current_admin_user
@@ -27,7 +27,7 @@ module Koi
27
27
  end
28
28
 
29
29
  def webauthn_registration_options_value
30
- user = current_admin_user.tap do |u|
30
+ user = Koi::Current.admin_user.tap do |u|
31
31
  u.update!(webauthn_id: WebAuthn.generate_user_id) unless u.webauthn_id
32
32
  end
33
33
 
@@ -74,7 +74,8 @@ module Koi
74
74
  session.delete(:registration_challenge),
75
75
  )
76
76
 
77
- current_admin_user
77
+ Koi::Current
78
+ .admin_user
78
79
  .credentials
79
80
  .create_with(nickname: webauthn_nickname,
80
81
  public_key: webauthn_credential.public_key,
@@ -38,6 +38,13 @@ module Koi
38
38
  layout -> { turbo_frame_request? ? "koi/frame" : "koi/application" }
39
39
 
40
40
  protect_from_forgery with: :exception
41
+ skip_forgery_protection if: :bearer_token_request?
42
+ end
43
+
44
+ private
45
+
46
+ def bearer_token_request?
47
+ request.authorization.to_s.match?(/\ABearer .+\z/)
41
48
  end
42
49
  end
43
50
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorizationsCleanupJob < Koi::ApplicationJob
5
+ def perform
6
+ Admin::DeviceAuthorization.where(created_at: ...7.days.ago).delete_all
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Admin
4
+ class DeviceAuthorization < ApplicationRecord
5
+ EXPIRES_IN = 10.minutes
6
+
7
+ class TokenError < StandardError
8
+ attr_reader :code
9
+
10
+ def initialize(code)
11
+ @code = code
12
+ super
13
+ end
14
+ end
15
+
16
+ self.table_name = :admin_device_authorizations
17
+
18
+ belongs_to :admin_user, class_name: "Admin::User", optional: true
19
+
20
+ enum :status, %w[pending approved denied consumed].index_with(&:to_s)
21
+
22
+ validates :device_code_digest, presence: true, uniqueness: true
23
+ validates :request_expires_at, presence: true
24
+ validates :status, presence: true, inclusion: { in: statuses.values }
25
+ validates :user_code, presence: true, uniqueness: true
26
+
27
+ def self.issue!(requested_ip:, user_agent:)
28
+ device_code = SecureRandom.urlsafe_base64(32)
29
+
30
+ device_authorization = create!(
31
+ device_code_digest: digest(device_code),
32
+ user_code: generate_user_code,
33
+ request_expires_at: EXPIRES_IN.from_now,
34
+ requested_ip:,
35
+ user_agent:,
36
+ )
37
+
38
+ [device_authorization, device_code]
39
+ end
40
+
41
+ def self.digest(device_code)
42
+ Digest::SHA256.hexdigest(device_code)
43
+ end
44
+
45
+ def self.generate_user_code
46
+ "#{SecureRandom.alphanumeric(4).upcase}-#{SecureRandom.alphanumeric(4).upcase}"
47
+ end
48
+
49
+ def self.issue_access_token!(device_code:, token_expires_in: 12.hours)
50
+ device_authorization = find_by(device_code_digest: digest(device_code.to_s))
51
+ raise TokenError.new("invalid_grant") unless device_authorization
52
+
53
+ device_authorization.with_lock do
54
+ device_authorization.reload
55
+
56
+ if (error = device_authorization.token_error)
57
+ raise TokenError.new(error)
58
+ end
59
+
60
+ access_token = device_authorization.admin_user.generate_token_for(:api_access)
61
+ device_authorization.consume!(token_expires_in:)
62
+
63
+ {
64
+ access_token:,
65
+ token_type: "Bearer",
66
+ expires_in: token_expires_in.to_i,
67
+ }
68
+ end
69
+ end
70
+
71
+ def expired?
72
+ request_expires_at <= Time.current
73
+ end
74
+
75
+ def issuable?
76
+ approved? && !expired?
77
+ end
78
+
79
+ def token_error
80
+ return "authorization_pending" if pending?
81
+ return "access_denied" if denied?
82
+
83
+ "invalid_grant" if consumed? || expired?
84
+ end
85
+
86
+ def consume!(token_expires_in:)
87
+ update!(
88
+ status: "consumed",
89
+ consumed_at: Time.current,
90
+ token_expires_at: token_expires_in.from_now,
91
+ )
92
+ end
93
+
94
+ def approve!(admin_user:)
95
+ update!(
96
+ status: "approved",
97
+ approved_at: Time.current,
98
+ admin_user:,
99
+ )
100
+ end
101
+
102
+ def deny!(admin_user:)
103
+ update!(
104
+ status: "denied",
105
+ admin_user:,
106
+ )
107
+ end
108
+
109
+ def actionable?
110
+ pending? && !expired?
111
+ end
112
+ end
113
+ end
@@ -14,6 +14,7 @@ module Admin
14
14
  # disable validations for password_digest
15
15
  has_secure_password validations: false
16
16
 
17
+ generates_token_for(:api_access, expires_in: 12.hours) { current_sign_in_at }
17
18
  generates_token_for(:password_reset, expires_in: 30.minutes) { current_sign_in_at }
18
19
 
19
20
  has_many :credentials, inverse_of: :admin, class_name: "Admin::Credential", dependent: :destroy
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Koi
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ # @return [Admin::User]
6
+ attribute :admin_user
7
+ end
8
+ end
@@ -0,0 +1,38 @@
1
+ <%# locals: (device_authorization:) %>
2
+
3
+ <%= content_for(:roadblock) do %>
4
+ <header>
5
+ <icon aria-hidden="true" class="icon" data-icon="koi">&nbsp;</icon>
6
+ <h1>API access request</h1>
7
+ </header>
8
+
9
+ <% if device_authorization.pending? && !device_authorization.expired? %>
10
+ <p>
11
+ A device is requesting a short-lived API token.
12
+ </p>
13
+ <dl>
14
+ <dt>Code</dt><dd><%= device_authorization.user_code %></dd>
15
+ </dl>
16
+
17
+ <div class="actions">
18
+ <%= button_to("Approve",
19
+ admin_device_authorization_path(device_authorization.user_code),
20
+ method: :patch,
21
+ params: { decision: "approve" },
22
+ class: "button") %>
23
+ <%= button_to("Deny",
24
+ admin_device_authorization_path(device_authorization.user_code),
25
+ method: :patch,
26
+ params: { decision: "deny" },
27
+ class: "button button--secondary") %>
28
+ </div>
29
+ <% end %>
30
+
31
+ <%= summary_table_with(model: device_authorization) do |row| %>
32
+ <% row.enum(:status) %>
33
+ <% row.text(:requested_ip) %>
34
+ <% row.text(:user_agent) %>
35
+ <% row.datetime(:request_expires_at) %>
36
+ <% row.datetime(:token_expires_at) %>
37
+ <% end %>
38
+ <% end %>
@@ -14,10 +14,10 @@
14
14
  keydown.enter->navigation#go keydown.esc->navigation#clear">
15
15
  </div>
16
16
 
17
- <% if current_admin_user %>
17
+ <% if Koi::Current.admin_user %>
18
18
  <ul class="navigation-group | flow" role="list">
19
19
  <li>
20
- <span><%= current_admin_user.name %></span>
20
+ <span><%= Koi::Current.admin_user.name %></span>
21
21
  <ul class="navigation-list | flow" role="list">
22
22
  <li><%= link_to("Profile", main_app.admin_profile_path) %></li>
23
23
  <li><%= link_to("Log out", main_app.admin_session_path, data: { turbo_method: :delete }) %></li>
data/config/routes.rb CHANGED
@@ -13,6 +13,9 @@ Rails.application.routes.draw do
13
13
  resource :cache, only: %i[destroy]
14
14
  resource :dashboard, only: %i[show]
15
15
 
16
+ resources :device_authorizations, param: :user_code, only: %i[create show update]
17
+ resources :device_tokens, only: %i[create]
18
+
16
19
  resource :profile, only: %i[show edit update], shallow: true do
17
20
  resources :credentials, only: %i[show new create update destroy]
18
21
  resource :otp, only: %i[new create destroy]
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAdminDeviceAuthorizations < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :admin_device_authorizations do |t|
6
+ t.string :device_code_digest, null: false, index: { unique: true }
7
+ t.string :user_code, null: false, index: { unique: true }
8
+ t.string :status, null: false, default: "pending"
9
+ t.datetime :request_expires_at, null: false
10
+ t.datetime :approved_at
11
+ t.datetime :consumed_at
12
+ t.datetime :token_expires_at
13
+ t.references :admin_user, null: true, foreign_key: { to_table: :admins }
14
+ t.string :requested_ip
15
+ t.string :user_agent
16
+
17
+ t.timestamps
18
+ end
19
+ end
20
+ end
@@ -19,27 +19,71 @@ module Koi
19
19
  request = ActionDispatch::Request.new(env)
20
20
  session = ActionDispatch::Request::Session.find(request)
21
21
 
22
- if requires_authentication?(request) && !authenticated?(session)
23
- # Set the redirection path for returning the user to their requested path after login
24
- if request.get?
25
- request.flash[:redirect] = request.fullpath
26
- request.commit_flash
27
- end
22
+ # Always retrieve user to ensure we are not vulnerable to timing attacks
23
+ Koi::Current.admin_user = if bearer_token(request).present?
24
+ bearer_admin_user(request)
25
+ else
26
+ session_admin_user(session)
27
+ end
28
28
 
29
- [303, { "Location" => "/admin/session/new" }, []]
29
+ # Remove from session if not found
30
+ session.delete(:admin_user_id) if session.has_key?(:admin_user_id) && !authenticated?
31
+
32
+ if requires_authentication?(request) && !authenticated?
33
+ unauthorized_response(request)
30
34
  else
31
35
  @app.call(env)
32
36
  end
37
+ ensure
38
+ Koi::Current.admin_user = nil
33
39
  end
34
40
 
35
41
  private
36
42
 
37
43
  def requires_authentication?(request)
38
- !request.path.starts_with?("/admin/session")
44
+ !request.path.starts_with?("/admin/session") && !device_flow_request?(request)
45
+ end
46
+
47
+ def authenticated?
48
+ Koi::Current.admin_user.present?
49
+ end
50
+
51
+ def bearer_admin_user(request)
52
+ token = bearer_token(request)
53
+ return if token.blank?
54
+
55
+ request.session_options[:skip] = true
56
+
57
+ Admin::User.find_by_token_for(:api_access, token)
58
+ end
59
+
60
+ def session_admin_user(session)
61
+ Admin::User.find_by(id: session[:admin_user_id])
62
+ end
63
+
64
+ def bearer_token(request)
65
+ return nil if request.authorization.blank?
66
+
67
+ request.authorization.match(/^Bearer (?<token>.+)$/)&.named_captures&.fetch("token", nil)
68
+ end
69
+
70
+ def unauthorized_response(request)
71
+ if bearer_token(request).present?
72
+ # If the user provided a token, it was not valid, and the request requires authentication
73
+ [401, {}, []]
74
+ else
75
+ if request.get?
76
+ # Set the redirection path for returning the user to their requested path after login
77
+ request.flash[:redirect] = request.fullpath
78
+ request.commit_flash
79
+ end
80
+
81
+ [303, { "Location" => "/admin/session/new" }, []]
82
+ end
39
83
  end
40
84
 
41
- def authenticated?(session)
42
- session[:admin_user_id].present?
85
+ def device_flow_request?(request)
86
+ request.post? && %w[/admin/device_authorizations /admin/device_tokens].include?(request.path)
43
87
  end
44
88
  end
45
89
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :admin_device_authorization, class: "Admin::DeviceAuthorization" do
5
+ sequence(:device_code_digest) { |n| "digest-#{n}" }
6
+ sequence(:user_code) { |n| "CODE-#{n.to_s.rjust(4, '0')}" }
7
+ status { :pending }
8
+ request_expires_at { 10.minutes.from_now }
9
+ requested_ip { "127.0.0.1" }
10
+ user_agent { "RSpec" }
11
+
12
+ trait :approved do
13
+ status { :approved }
14
+ approved_at { Time.current }
15
+ admin_user
16
+ end
17
+
18
+ trait :denied do
19
+ status { :denied }
20
+ admin_user
21
+ end
22
+
23
+ trait :consumed do
24
+ status { :consumed }
25
+ consumed_at { Time.current }
26
+ admin_user
27
+ end
28
+ end
29
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-koi
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.4.0
4
+ version: 5.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive
@@ -356,6 +356,8 @@ files:
356
356
  - app/controllers/admin/caches_controller.rb
357
357
  - app/controllers/admin/credentials_controller.rb
358
358
  - app/controllers/admin/dashboards_controller.rb
359
+ - app/controllers/admin/device_authorizations_controller.rb
360
+ - app/controllers/admin/device_tokens_controller.rb
359
361
  - app/controllers/admin/otps_controller.rb
360
362
  - app/controllers/admin/profiles_controller.rb
361
363
  - app/controllers/admin/sessions_controller.rb
@@ -389,13 +391,16 @@ files:
389
391
  - app/javascript/koi/elements/index.js
390
392
  - app/javascript/koi/elements/toolbar.js
391
393
  - app/javascript/koi/utils/transition.js
394
+ - app/jobs/admin/device_authorizations_cleanup_job.rb
392
395
  - app/jobs/koi/application_job.rb
393
396
  - app/models/admin/collection.rb
394
397
  - app/models/admin/credential.rb
398
+ - app/models/admin/device_authorization.rb
395
399
  - app/models/admin/user.rb
396
400
  - app/models/application_record.rb
397
401
  - app/models/concerns/koi/model/archivable.rb
398
402
  - app/models/concerns/koi/model/otp.rb
403
+ - app/models/koi/current.rb
399
404
  - app/models/url_rewrite.rb
400
405
  - app/models/well_known.rb
401
406
  - app/views/admin/admin_users/_form.html.erb
@@ -408,6 +413,7 @@ files:
408
413
  - app/views/admin/credentials/new.html.erb
409
414
  - app/views/admin/credentials/show.html.erb
410
415
  - app/views/admin/dashboards/show.html.erb
416
+ - app/views/admin/device_authorizations/show.html.erb
411
417
  - app/views/admin/otps/_form.html.erb
412
418
  - app/views/admin/otps/create.turbo_stream.erb
413
419
  - app/views/admin/otps/new.html.erb
@@ -463,6 +469,7 @@ files:
463
469
  - db/migrate/20231211005214_add_status_code_to_url_rewrites.rb
464
470
  - db/migrate/20241214060913_add_otp_secret_to_admin_users.rb
465
471
  - db/migrate/20250204060748_create_well_knowns.rb
472
+ - db/migrate/20260413014834_create_admin_device_authorizations.rb
466
473
  - db/seeds.rb
467
474
  - lib/generators/koi/admin/USAGE
468
475
  - lib/generators/koi/admin/admin_generator.rb
@@ -499,6 +506,7 @@ files:
499
506
  - lib/koi/middleware/admin_authentication.rb
500
507
  - lib/koi/middleware/url_redirect.rb
501
508
  - lib/koi/release.rb
509
+ - spec/factories/admin_device_authorizations.rb
502
510
  - spec/factories/admins.rb
503
511
  - spec/factories/url_rewrites.rb
504
512
  - spec/factories/well_knowns.rb
@@ -521,7 +529,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
521
529
  - !ruby/object:Gem::Version
522
530
  version: '0'
523
531
  requirements: []
524
- rubygems_version: 4.0.3
532
+ rubygems_version: 4.0.6
525
533
  specification_version: 4
526
534
  summary: Koi CMS admin framework
527
535
  test_files: []