Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ PATH
katalyst-koi (5.8.3)
bcrypt
importmap-rails
jwt (>= 3.0)
katalyst-content (>= 3.2)
katalyst-govuk-formbuilder (>= 1.28.0, < 2)
katalyst-html-attributes
Expand Down
49 changes: 49 additions & 0 deletions app/controllers/admin/admin_roles_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

module Admin
class AdminRolesController < ApplicationController
before_action :requires_session_authentication!
before_action :set_role, only: %i[show]
before_action :materialize_roles, only: %i[index]

attr_reader :role

def index
collection = Collection.new.with_params(params).apply(Admin::Role.strict_loading)

render locals: { collection: }
end

def show
render locals: { role:, members:, providers: }
end

private

def set_role
@role = Admin::Role.find(params.expect(:id))
end

def members
role.members
end

def providers
names = members.map(&:provider)

Koi::Identity.providers.select { |provider| names.include?(provider.name) }
end

def materialize_roles
Koi::Identity.role_slugs.each do |slug|
Admin::Role.materialize(slug)
end
end

class Collection < Admin::Collection
config.sorting = :slug

attribute :slug, :string
end
end
end
2 changes: 1 addition & 1 deletion app/controllers/admin/device_authorizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def show
end

def create
device_authorization, device_code = Admin::DeviceAuthorization.issue!(
device_authorization, device_code = Admin::DeviceAuthorization.create_request!(
requested_ip: request.remote_ip,
user_agent: request.user_agent,
)
Expand Down
17 changes: 16 additions & 1 deletion app/controllers/admin/tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
module Admin
class TokensController < ApplicationController
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device-code"
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"

rate_limit to: 20, within: 1.minute, only: :create
skip_before_action :verify_authenticity_token, only: :create
Expand All @@ -11,6 +12,8 @@ def create
case params[:grant_type]
when DEVICE_CODE_GRANT_TYPE
authorize_device_code
when JWT_BEARER_GRANT_TYPE
authorize_bearer_token
else
render(json: { error: "invalid_request" }, status: :bad_request)
end
Expand All @@ -19,9 +22,21 @@ def create
private

def authorize_device_code
render json: Admin::DeviceAuthorization.issue_access_token!(device_code: params[:device_code])
render json: Admin::DeviceAuthorization.consume_request!(device_code: params[:device_code])
rescue Admin::DeviceAuthorization::TokenError => e
render json: { error: e.code }, status: :bad_request
end

def authorize_bearer_token
assertion = Koi::Identity.authorize_bearer_token!(params[:assertion], audience: "#{request.base_url}/admin")

render json: Admin::DeviceAuthorization.issue_token!(
principal: assertion.principal,
requested_ip: request.remote_ip,
user_agent: request.user_agent,
)
rescue JWT::DecodeError
render json: { error: "invalid_grant" }, status: :bad_request
end
end
end
11 changes: 11 additions & 0 deletions app/controllers/concerns/koi/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,16 @@ module Controller
def bearer_token_request?
request.authorization.to_s.match?(/\ABearer .+\z/)
end

# Surface the grant's stored principal on the request's process_action
# payload so structured loggers attribute machine requests to the
# verified identity that minted the token.
def append_info_to_payload(payload)
super

if (principal = Koi::Current.principal)
payload[:principal] = { provider: principal.provider, subject: principal.subject }
end
end
end
end
84 changes: 66 additions & 18 deletions app/models/admin/device_authorization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

module Admin
class DeviceAuthorization < ApplicationRecord
EXPIRES_IN = 10.minutes
REQUEST_EXPIRES_IN = 10.minutes
TOKEN_EXPIRES_IN = 1.hour

class TokenError < StandardError
attr_reader :code
Expand All @@ -17,26 +18,41 @@ def initialize(code)

enum :status, %w[pending approved denied consumed].index_with(&:to_s)

generates_token_for(:api_access, expires_in: 12.hours) { admin_user&.last_sign_in_at }
generates_token_for(:api_access, expires_in: TOKEN_EXPIRES_IN) do
admin_user&.last_sign_in_at || admin_role&.tokens_revoked_at
end

validates :device_code_digest, presence: true, uniqueness: true
validates :request_expires_at, presence: true
validates :status, presence: true, inclusion: { in: statuses.values }
validates :user_code, presence: true, uniqueness: true

with_options(if: :pending?) do
validates :device_code_digest, presence: true, uniqueness: true
validates :user_code, presence: true, uniqueness: true
validates :request_expires_at, presence: true
end

belongs_to :admin_user,
class_name: "Admin::User",
counter_cache: true,
inverse_of: :device_authorizations,
optional: true

def self.issue!(requested_ip:, user_agent:)
belongs_to :admin_role,
class_name: "Admin::Role",
inverse_of: :device_authorizations,
optional: true

# Snapshot of the verified principal, as captured by `issue_token!`.
serialize :principal, coder: Koi::Identity::Principal
attr_readonly :principal

# Creates a new un-approved request.
def self.create_request!(requested_ip:, user_agent:)
device_code = SecureRandom.urlsafe_base64(32)

device_authorization = create!(
device_code_digest: digest(device_code),
user_code: generate_user_code,
request_expires_at: EXPIRES_IN.from_now,
request_expires_at: REQUEST_EXPIRES_IN.from_now,
requested_ip:,
user_agent:,
)
Expand All @@ -52,7 +68,8 @@ def self.generate_user_code
"#{SecureRandom.alphanumeric(4).upcase}-#{SecureRandom.alphanumeric(4).upcase}"
end

def self.issue_access_token!(device_code:, token_expires_in: 12.hours)
# Consume an approved request, returns the issued token payload.
def self.consume_request!(device_code:)
device_authorization = find_by(device_code_digest: digest(device_code.to_s))
raise TokenError.new("invalid_grant") unless device_authorization

Expand All @@ -63,19 +80,38 @@ def self.issue_access_token!(device_code:, token_expires_in: 12.hours)
raise TokenError.new(error)
end

access_token = device_authorization.generate_token_for(:api_access)
device_authorization.consume!(token_expires_in:)
device_authorization.consume!
end
end

{
access_token:,
token_type: "Bearer",
expires_in: token_expires_in.to_i,
}
# Issue a new token directly from a validated assertion, returns the issued token payload.
def self.issue_token!(principal:, **)
case principal.scope
when "admin/user"
admin_user = Admin::User.find_by(principal.attributes_for_find)

unless admin_user
Rails.logger.warn("Koi::Identity rejected #{principal}: no matching admin user")
raise JWT::VerificationError, "unknown user for #{principal}"
end

new(admin_user:, principal:, **).consume!
when %r{\Aadmin/role/(?<slug>[a-z0-9_]+)\z}
admin_role = Admin::Role.materialize(Regexp.last_match(:slug))

new(admin_role:, principal:, **).consume!
else
raise ArgumentError, "unknown scope #{principal.scope.inspect}"
end
end

# @return [Admin::User, Admin::Role, nil]
def actor
admin_user || admin_role
end

def expired?
request_expires_at <= Time.current
request_expires_at.present? && request_expires_at <= Time.current
end

def issuable?
Expand All @@ -89,12 +125,14 @@ def token_error
"invalid_grant" if consumed? || expired?
end

def consume!(token_expires_in:)
def consume!
update!(
status: "consumed",
consumed_at: Time.current,
token_expires_at: token_expires_in.from_now,
token_expires_at: TOKEN_EXPIRES_IN.from_now,
)

token_payload
end

def approve!(admin_user:)
Expand All @@ -115,5 +153,15 @@ def deny!(admin_user:)
def actionable?
pending? && !expired?
end

private

def token_payload(access_token = generate_token_for(:api_access))
{
access_token:,
token_type: "Bearer",
expires_in: (token_expires_at - Time.current).round,
}
end
end
end
41 changes: 41 additions & 0 deletions app/models/admin/role.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Admin
# A machine actor, assumable only via an identity member's grant — nothing
# signs in as a role. Config declares roles (a role exists when a member's
# scope names it); the row is the stable identity that grants and audit
# records reference, materialized on first use and never deleted.
class Role < ApplicationRecord
self.table_name = :admin_roles

validates :slug, presence: true

has_many :device_authorizations,
class_name: "Admin::DeviceAuthorization",
foreign_key: :admin_role_id,
inverse_of: :admin_role,
dependent: :destroy

def self.materialize(slug)
create_or_find_by!(slug:)
end

# Config is authoritative for grants: a row whose slug no longer appears
# in any member's scope is not assumable, but remains for attribution.
def orphaned?
Koi::Identity.role_slugs.exclude?(slug)
end

# Members granting this role in the current trust config.
def members
Koi::Identity.members.select { |member| member.role_slug == slug }
end

# Roles authenticate by exchanging an assertion for a token; requests
# made with the token never touch this row, so issuance is the freshest
# signal available.
def last_authenticated_at
device_authorizations.maximum(:consumed_at)
end
end
end
10 changes: 10 additions & 0 deletions app/models/koi/current.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ class Current < ActiveSupport::CurrentAttributes
# @return [Admin::Session, nil]
attribute :session

# @return [Admin::User, Admin::Role, nil]
def actor
device_authorization&.actor || session&.admin
end

# @return [Koi::Identity::Principal, nil]
def principal
device_authorization&.principal
end

# @return [Admin::User, nil]
def admin_user
device_authorization&.admin_user || session&.admin
Expand Down
Loading