From b849f136f6961d922e84e9266467aab43053d5cc Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Fri, 17 Jul 2026 15:46:23 +0930 Subject: [PATCH 1/8] Reduce token TTL to 1 hour --- app/models/admin/device_authorization.rb | 15 ++++++++------- docs/api-access.md | 2 +- spec/models/admin/device_authorization_spec.rb | 6 +++--- .../direct_uploads_controller_spec.rb | 2 +- spec/requests/admin/tokens_controller_spec.rb | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/models/admin/device_authorization.rb b/app/models/admin/device_authorization.rb index 2bef8749b..977cb7f44 100644 --- a/app/models/admin/device_authorization.rb +++ b/app/models/admin/device_authorization.rb @@ -2,7 +2,8 @@ module Admin class DeviceAuthorization < ApplicationRecord - EXPIRES_IN = 10.minutes + EXPIRES_IN = 10.minutes + TOKEN_EXPIRES_IN = 1.hour class TokenError < StandardError attr_reader :code @@ -17,7 +18,7 @@ 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) { admin_user&.last_sign_in_at } validates :device_code_digest, presence: true, uniqueness: true validates :request_expires_at, presence: true @@ -52,7 +53,7 @@ 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) + def self.issue_access_token!(device_code:) device_authorization = find_by(device_code_digest: digest(device_code.to_s)) raise TokenError.new("invalid_grant") unless device_authorization @@ -64,12 +65,12 @@ def self.issue_access_token!(device_code:, token_expires_in: 12.hours) end access_token = device_authorization.generate_token_for(:api_access) - device_authorization.consume!(token_expires_in:) + device_authorization.consume! { access_token:, token_type: "Bearer", - expires_in: token_expires_in.to_i, + expires_in: (device_authorization.token_expires_at - Time.current).round, } end end @@ -89,11 +90,11 @@ 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, ) end diff --git a/docs/api-access.md b/docs/api-access.md index 7815751c4..f8e90a821 100644 --- a/docs/api-access.md +++ b/docs/api-access.md @@ -1,6 +1,6 @@ # Koi API Access -Koi supports an RFC 8628 device flow to create a bearer token for admin access with a 12h max duration: +Koi supports an RFC 8628 device flow to create a bearer token for admin access with a 1h max duration: 1. Start the device flow with no cookies or session state. 2. Parse `device_code`, `user_code`, and `verification_uri_complete` from the JSON response. diff --git a/spec/models/admin/device_authorization_spec.rb b/spec/models/admin/device_authorization_spec.rb index 9ef354da7..d4b455069 100644 --- a/spec/models/admin/device_authorization_spec.rb +++ b/spec/models/admin/device_authorization_spec.rb @@ -124,7 +124,7 @@ expect(payload).to include( access_token: a_kind_of(String), token_type: "Bearer", - expires_in: 43_200, + expires_in: 3600, ) end @@ -164,10 +164,10 @@ expect(described_class.find_by_token_for(:api_access, token)).to eq(device_authorization) end - it "is rejected after 12 hours" do + it "is rejected after an hour" do token = device_authorization.generate_token_for(:api_access) - travel(12.hours + 1.second) do + travel(1.hour + 1.second) do expect(described_class.find_by_token_for(:api_access, token)).to be_nil end end diff --git a/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb b/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb index 6a581b694..ecf9bb66f 100644 --- a/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb +++ b/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb @@ -35,7 +35,7 @@ let(:access_token) { device_authorization.generate_token_for(:api_access) } before do - device_authorization.consume!(token_expires_in: 12.hours) + device_authorization.consume! end it { is_expected.to be_successful } diff --git a/spec/requests/admin/tokens_controller_spec.rb b/spec/requests/admin/tokens_controller_spec.rb index 9254d74ea..de1bb1b53 100644 --- a/spec/requests/admin/tokens_controller_spec.rb +++ b/spec/requests/admin/tokens_controller_spec.rb @@ -80,7 +80,7 @@ def device_code_digest expect(response.parsed_body).to include( "access_token" => a_kind_of(String), "token_type" => "Bearer", - "expires_in" => 43_200, + "expires_in" => 3600, ) end From b8a5f29609a805fc8cda27ad9766009f0a435e64 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Mon, 20 Jul 2026 15:41:07 +0930 Subject: [PATCH 2/8] Identity: JWT authentication for admin users --- Gemfile.lock | 1 + app/controllers/admin/tokens_controller.rb | 21 ++ app/models/koi/identity.rb | 39 +++ app/models/koi/identity/assertion.rb | 64 ++++ app/models/koi/identity/principal.rb | 37 +++ app/models/koi/identity/provider.rb | 111 +++++++ katalyst-koi.gemspec | 1 + lib/koi/config.rb | 11 + lib/koi/engine.rb | 1 + spec/lib/koi/config_spec.rb | 36 +++ spec/models/koi/identity_spec.rb | 304 ++++++++++++++++++ spec/rails_helper.rb | 2 + spec/requests/admin/tokens_controller_spec.rb | 195 +++++++++++ 13 files changed, 823 insertions(+) create mode 100644 app/models/koi/identity.rb create mode 100644 app/models/koi/identity/assertion.rb create mode 100644 app/models/koi/identity/principal.rb create mode 100644 app/models/koi/identity/provider.rb create mode 100644 spec/models/koi/identity_spec.rb diff --git a/Gemfile.lock b/Gemfile.lock index 2dd91ec89..3e8e66d23 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/admin/tokens_controller.rb b/app/controllers/admin/tokens_controller.rb index aa8fd8a4d..2652fb343 100644 --- a/app/controllers/admin/tokens_controller.rb +++ b/app/controllers/admin/tokens_controller.rb @@ -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 @@ -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 @@ -23,5 +26,23 @@ def authorize_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") + + admin_user = Admin::User.find_by(assertion.principal.attributes_for_find) + + return render(json: { error: "invalid_grant" }, status: :bad_request) if admin_user.nil? + + device_authorization, device_code = Admin::DeviceAuthorization.issue!( + requested_ip: request.remote_ip, + user_agent: request.user_agent, + ) + device_authorization.approve!(admin_user:) + + render json: Admin::DeviceAuthorization.issue_access_token!(device_code:) + rescue JWT::DecodeError + render json: { error: "invalid_grant" }, status: :bad_request + end end end diff --git a/app/models/koi/identity.rb b/app/models/koi/identity.rb new file mode 100644 index 000000000..533d1f430 --- /dev/null +++ b/app/models/koi/identity.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "jwt" +require "net/http" + +module Koi + module Identity + def authorize_bearer_token!(token, audience:) + assertion = Assertion.new(token) + provider = provider_for(assertion) + + provider.audience ||= audience + raise JWT::InvalidAudError, "no expected audience" if provider.audience.blank? + + assertion.verify!(provider) + rescue JWT::DecodeError => e + Rails.logger.warn("#{e.class}: Koi::Identity rejected assertion #{assertion.inspect}: #{e.message}") + raise + end + + def provider_for(assertion) + providers.find { |provider| provider.issuer == assertion.issuer } || + raise(JWT::InvalidIssuerError, "unknown issuer #{assertion.issuer}") + end + + def providers + Koi.config.identity.providers.map do |name, config| + provider = Provider.new(name:, **config) + unless provider.valid? + raise ArgumentError, "Invalid identity provider #{name}: #{provider.errors.full_messages.to_sentence}" + end + + provider + end + end + + module_function :authorize_bearer_token!, :provider_for, :providers + end +end diff --git a/app/models/koi/identity/assertion.rb b/app/models/koi/identity/assertion.rb new file mode 100644 index 000000000..025bb7779 --- /dev/null +++ b/app/models/koi/identity/assertion.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Koi + module Identity + class Assertion + # @return String + attr_reader :token + + # @return Principal + attr_reader :principal + + def initialize(token) + @token = token + @claims, @header = JWT.decode(token, nil, false) + @state = :unverified + end + + def verified? + @state == :verified + end + + def claims + @claims + end + + def header + @header + end + + def verify!(provider) + JWT.decode( + @token, nil, true, + algorithms: provider.algorithms, + jwks: provider.method(:key_set), + aud: provider.audience, + sub: provider.subject, + leeway: provider.leeway.to_i, + verify_aud: true, + verify_jti: provider.method(:consume_jti), + verify_sub: true + ) + + @state = :verified + @principal = provider.principal_for(self) + + self + end + + def iss + @claims["iss"] + end + alias_method :issuer, :iss + + def sub + @claims["sub"] + end + alias_method :subject, :sub + + def inspect + "<#{self.class.name} iss=#{iss.inspect} sub=#{sub.inspect}>" + end + end + end +end diff --git a/app/models/koi/identity/principal.rb b/app/models/koi/identity/principal.rb new file mode 100644 index 000000000..e31b850d7 --- /dev/null +++ b/app/models/koi/identity/principal.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Koi + module Identity + class Principal + def initialize(assertion) + @assertion = assertion + end + + def attributes_for_find + { id: nil } + end + + class Aws < Principal + def email + tag("email") + end + + def name + tag("name") + end + + def attributes_for_find + { email: } + end + + private + + def tag(name) + raise JWT::VerificationError, "unverified assertion" unless @assertion.verified? + + @assertion.claims.dig("https://sts.amazonaws.com/", "principal_tags", name) + end + end + end + end +end diff --git a/app/models/koi/identity/provider.rb b/app/models/koi/identity/provider.rb new file mode 100644 index 000000000..a3ede7112 --- /dev/null +++ b/app/models/koi/identity/provider.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Koi + module Identity + class Provider + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :name, :string + attribute :issuer, :string + attribute :keys, :string + attribute :audience, :string + attribute :subject, :string + attribute :scope, :string + + # Acceptable signature algorithms: asymmetric families only, so a + # provider's public key can never be replayed as an HMAC secret and + # unsigned (none) tokens are rejected before key lookup. + attribute :algorithms, default: -> { %w[ES256 ES384 ES512 RS256 RS384 RS512 PS256 PS384 PS512].freeze } + + # Allowed clock drift for verification + attribute :leeway, default: -> { 15.seconds } + + validates :keys, inclusion: { in: %w[env discover] } + + # Upper bound on how long a key removed from the issuer's JWKS remains + # trusted. Newly rotated-in keys are picked up immediately, as an + # unknown kid invalidates the cache. + KEY_SET_TTL = 1.hour + + # A cached key set younger than this cannot be invalidated, so a stream + # of unknown-kid assertions cannot force a discovery refetch per + # request. A rotated-in key may take this long to be honoured. + INVALIDATION_GRACE = 5.minutes + + # Generates a typed reader for the assertion claims based on the provider. + def principal_for(assertion) + case URI.parse(issuer).host + when /\.sts\.global\.api\.aws\z/ + Principal::Aws.new(assertion) + else + Principal.new(assertion) + end + end + + # This provider's JWKS: pinned from ENV or fetched via OIDC discovery + # and cached. Passed to JWT.decode as its jwks loader, which retries + # with invalidate: true when a presented kid is missing from the set. + def key_set(options = {}) + invalidate_key_set if options[:invalidate] + + JWT::JWK::Set.new(jwks) + end + + # Atomically claims an assertion's jti for its replayable lifetime: + # the first presentation writes the key, a replay finds it taken and + # is rejected. jti uniqueness is only promised within an issuer + # (RFC 7519), so entries are scoped per provider. Requires a cache + # store shared by all app processes. + def consume_jti(jti, claims) + jti.present? && + Rails.cache.write("koi/identity/jti/#{name}/#{jti}", true, + unless_exist: true, + expires_in: Time.zone.at(claims["exp"].to_i) + leeway - Time.current) + end + + private + + def jwks + case keys + when "env" + JSON.parse(ENV.fetch("KOI_API_JWKS_#{name.upcase}")) + when "discover" + cached_discovery.fetch("jwks") + end + end + + def cached_discovery + Rails.cache.fetch(key_set_cache_key, expires_in: KEY_SET_TTL) do + { "fetched_at" => Time.current.to_i, "jwks" => discover_jwks } + end + end + + def discover_jwks + discovery = JSON.parse(Net::HTTP.get(URI.parse("#{issuer}/.well-known/openid-configuration"))) + + # Mix-up defence: only the configured issuer's own keys are trusted. + unless discovery["issuer"] == issuer + raise JWT::JWKError, "#{name} discovery names issuer #{discovery['issuer'].inspect}, expected #{issuer}" + end + + JSON.parse(Net::HTTP.get(URI.parse(discovery.fetch("jwks_uri")))) + rescue JWT::JWKError + raise + rescue StandardError => e + raise JWT::JWKError, "key discovery for #{name} failed: #{e.message} (#{e.class})" + end + + def invalidate_key_set + cached = Rails.cache.read(key_set_cache_key) + return if cached && cached.fetch("fetched_at") > INVALIDATION_GRACE.ago.to_i + + Rails.cache.delete(key_set_cache_key) + end + + def key_set_cache_key + "koi/identity/jwks/#{name}" + end + end + end +end diff --git a/katalyst-koi.gemspec b/katalyst-koi.gemspec index 2e68a048a..9cc029934 100644 --- a/katalyst-koi.gemspec +++ b/katalyst-koi.gemspec @@ -28,6 +28,7 @@ Gem::Specification.new do |s| # Authorization s.add_dependency "bcrypt" + s.add_dependency "jwt", ">= 3.0" s.add_dependency "rotp" s.add_dependency "rqrcode" s.add_dependency "useragent" diff --git a/lib/koi/config.rb b/lib/koi/config.rb index f5afcf06f..725eb9db1 100644 --- a/lib/koi/config.rb +++ b/lib/koi/config.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true +require "active_support/core_ext/hash/deep_merge" require "active_support/core_ext/numeric" +require "active_support/ordered_options" module Koi class Config @@ -29,6 +31,15 @@ def initialize @image_size_limit = 10.megabytes end + # Identity & access settings for OIDC authentication. + def identity + @identity ||= ActiveSupport::OrderedOptions.new.merge!(providers: {}) + end + + def identity=(options) + identity.deep_merge!(options.to_h) + end + # Load config/koi.yml, if present def load(app) app.config_for(:koi).each do |attribute, value| diff --git a/lib/koi/engine.rb b/lib/koi/engine.rb index 816901cf7..13e4cb414 100644 --- a/lib/koi/engine.rb +++ b/lib/koi/engine.rb @@ -5,6 +5,7 @@ require "katalyst-govuk-formbuilder" require "katalyst/navigation" require "katalyst/tables" +require "jwt" require "lexxy" require "pagy" require "rotp" diff --git a/spec/lib/koi/config_spec.rb b/spec/lib/koi/config_spec.rb index b78ecc900..f6ad10bee 100644 --- a/spec/lib/koi/config_spec.rb +++ b/spec/lib/koi/config_spec.rb @@ -54,6 +54,42 @@ expect { config.load(Rails.application) }.to raise_error(/YAML syntax error/) end end + + context "with an identity section" do + it "exposes providers as an OrderedOptions namespace" do + File.write(path, <<~YAML) + shared: + identity: + providers: + example: + issuer: example-issuer + test: + identity: + providers: + example: + audience: example-aud + YAML + + config.load(Rails.application) + + # config_for deep-merges shared into the environment, so the provider + # keeps its shared issuer and gains the environment's audience. + expect(config.identity.providers[:example]).to eq(issuer: "example-issuer", audience: "example-aud") + end + end + end + + describe "the identity namespace" do + it "defaults providers to empty" do + expect(config.identity.providers).to eq({}) + end + + it "deep-merges assignments instead of clobbering the namespace" do + config.identity = { providers: { avr: { issuer: "avr" } } } + config.identity = { providers: { avr: { audience: "aud" } } } + + expect(config.identity.providers[:avr]).to eq(issuer: "avr", audience: "aud") + end end describe "assigning an unknown setting directly" do diff --git a/spec/models/koi/identity_spec.rb b/spec/models/koi/identity_spec.rb new file mode 100644 index 000000000..445c3a234 --- /dev/null +++ b/spec/models/koi/identity_spec.rb @@ -0,0 +1,304 @@ +# frozen_string_literal: true + +require "rails_helper" +require "jwt" + +RSpec.describe Koi::Identity do + include ActiveSupport::Testing::TimeHelpers + + describe ".authorize_bearer_token!" do + let(:issuer) { "https://00000000-0000-0000-0000-000000000000.tokens.sts.global.api.aws" } + let(:audience) { "https://localhost/admin" } + let(:email) { "developer@katalyst.com.au" } + + let(:role_arn) do + "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com" \ + "/ap-southeast-2/AWSReservedSSO_Engineer_0123456789abcdef" + end + + let(:signing_key) { OpenSSL::PKey::EC.generate("secp384r1") } + let(:jwk) { JWT::JWK.new(signing_key) } + + let(:claims) do + now = Time.zone.now.to_i + { + iss: issuer, + sub: role_arn, + aud: audience, + iat: now, + exp: now + 300, + jti: SecureRandom.uuid, + "https://sts.amazonaws.com/" => { + "principal_tags" => { "email" => email }, + }, + } + end + let(:assertion) { JWT.encode(claims, signing_key, "ES384", { kid: jwk.kid }) } + + def authorize(assertion = self.assertion, audience: self.audience) + described_class.authorize_bearer_token!(assertion, audience:) + end + + # The replay guard needs a real cache store; the test environment's + # default is :null_store, which never holds a jti. + def with_memory_cache + original = Rails.cache + Rails.cache = ActiveSupport::Cache::MemoryStore.new + yield + ensure + Rails.cache = original + end + + before do + Koi.config.identity = { + providers: { + katalyst_agents: { + issuer:, + keys: "discover", + audience:, + subject: role_arn, + scope: "admin_user", + }, + }, + } + + stub_request(:get, "#{issuer}/.well-known/openid-configuration") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { issuer:, jwks_uri: "#{issuer}/keys" }.to_json) + stub_request(:get, "#{issuer}/keys") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { keys: [jwk.export] }.to_json) + end + + after { Koi.config.instance_variable_set(:@identity, nil) } + + it "returns a validated assertion carrying the identity claim" do + expect(authorize.principal).to have_attributes(email:) + end + + it "verifies assertions signed with either key during rotation overlap" do + rotated_key = OpenSSL::PKey::EC.generate("secp384r1") + rotated_jwk = JWT::JWK.new(rotated_key) + stub_request(:get, "#{issuer}/keys") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { keys: [jwk.export, rotated_jwk.export] }.to_json) + + assertion = JWT.encode(claims, rotated_key, "ES384", { kid: rotated_jwk.kid }) + + expect(authorize(assertion).principal).to have_attributes(email:) + end + + it "verifies RS256 assertions (the algorithm AWS issuers sign with)" do + rsa_key = OpenSSL::PKey::RSA.new(2048) + rsa_jwk = JWT::JWK.new(rsa_key) + stub_request(:get, "#{issuer}/keys") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { keys: [rsa_jwk.export] }.to_json) + + assertion = JWT.encode(claims, rsa_key, "RS256", { kid: rsa_jwk.kid }) + + expect(authorize(assertion).principal).to have_attributes(email:) + end + + it "rejects a malformed assertion" do + expect { authorize("not-a-jwt") }.to raise_error(an_instance_of(JWT::DecodeError)) + end + + it "rejects an assertion signed by an unregistered key" do + assertion = JWT.encode(claims, OpenSSL::PKey::EC.generate("secp384r1"), "ES384", { kid: jwk.kid }) + + expect { authorize(assertion) }.to raise_error(JWT::VerificationError) + end + + it "rejects an unsigned (alg: none) assertion" do + expect { authorize(JWT.encode(claims, nil, "none")) } + .to raise_error(JWT::IncorrectAlgorithm) + end + + it "rejects an HMAC assertion using the public key as its secret" do + assertion = JWT.encode(claims, signing_key.public_to_pem, "HS384", { kid: jwk.kid }) + + expect { authorize(assertion) }.to raise_error(JWT::IncorrectAlgorithm) + end + + it "rejects an assertion with an unknown kid" do + assertion = JWT.encode(claims, signing_key, "ES384", { kid: "unknown-kid" }) + + expect { authorize(assertion) }.to raise_error(an_instance_of(JWT::DecodeError)) + end + + it "rejects an expired assertion" do + claims[:iat] = 10.minutes.ago.to_i + claims[:exp] = 5.minutes.ago.to_i + + expect { authorize }.to raise_error(JWT::ExpiredSignature) + end + + it "accepts clock skew within tolerance" do + claims[:iat] = 10.seconds.from_now.to_i + + expect(authorize.principal).to have_attributes(email:) + end + + it "rejects an audience mismatch" do + claims[:aud] = "https://elsewhere.example.com/admin" + + expect { authorize }.to raise_error(JWT::InvalidAudError) + end + + it "uses the caller-supplied audience when the provider does not pin one" do + Koi.config.identity = { providers: { katalyst_agents: { audience: nil } } } + + expect(authorize.principal).to have_attributes(email:) + end + + it "rejects verification without an expected audience" do + Koi.config.identity = { providers: { katalyst_agents: { audience: nil } } } + + expect { authorize(audience: nil) }.to raise_error(JWT::InvalidAudError) + end + + it "rejects an issuer that matches no provider" do + claims[:iss] = "https://11111111-1111-1111-1111-111111111111.tokens.sts.global.api.aws" + + expect { authorize }.to raise_error(JWT::InvalidIssuerError) + end + + it "raises for a provider whose key source is unknown" do + Koi.config.identity = { + providers: { partner: { issuer: "https://partner.example.com", keys: "database" } }, + } + + expect { authorize }.to raise_error(ArgumentError, /partner/) + end + + it "rejects a subject other than the provider's expected subject" do + claims[:sub] = "arn:aws:iam::123456789012:role/unrelated-task-role" + + expect { authorize }.to raise_error(JWT::InvalidSubError) + end + + it "rejects a replayed jti the second time it is presented", :aggregate_failures do + with_memory_cache do + expect(authorize.principal).to have_attributes(email:) + + expect { authorize }.to raise_error(JWT::InvalidJtiError) + end + end + + it "caches the key set across verifications" do + with_memory_cache do + authorize + + claims[:jti] = SecureRandom.uuid + authorize(JWT.encode(claims, signing_key, "ES384", { kid: jwk.kid })) + + expect(WebMock).to have_requested(:get, "#{issuer}/keys").once + end + end + + it "refetches the key set after its cache TTL expires", :aggregate_failures do + with_memory_cache do + authorize + + travel Koi::Identity::Provider::KEY_SET_TTL + 1.second do + now = Time.zone.now.to_i + fresh = JWT.encode(claims.merge(iat: now, exp: now + 300, jti: SecureRandom.uuid), + signing_key, "ES384", { kid: jwk.kid }) + + expect(authorize(fresh)).to be_verified + expect(WebMock).to have_requested(:get, "#{issuer}/keys").twice + end + end + end + + it "fails closed when the issuer is unreachable with a cold cache" do + stub_request(:get, "#{issuer}/.well-known/openid-configuration").to_timeout + + expect { authorize }.to raise_error(JWT::JWKError) + end + + it "rejects a discovery response naming a different issuer" do + stub_request(:get, "#{issuer}/.well-known/openid-configuration") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { issuer: "https://elsewhere.example.com", jwks_uri: "#{issuer}/keys" }.to_json) + + expect { authorize }.to raise_error(JWT::JWKError, /\Akatalyst_agents discovery names issuer/) + end + + it "does not refetch for repeated unknown kids inside the invalidation grace", :aggregate_failures do + with_memory_cache do + authorize + + stranger = OpenSSL::PKey::EC.generate("secp384r1") + stranger_jwk = JWT::JWK.new(stranger) + 2.times do + unknown = JWT.encode(claims.merge(jti: SecureRandom.uuid), stranger, "ES384", + { kid: stranger_jwk.kid }) + + expect { authorize(unknown) }.to raise_error(JWT::DecodeError) + end + + expect(WebMock).to have_requested(:get, "#{issuer}/keys").once + end + end + + it "verifies pinned-key providers from ENV without HTTP, even with discovery down", :aggregate_failures do + stub_request(:get, "#{issuer}/.well-known/openid-configuration").to_timeout + + partner_key = OpenSSL::PKey::EC.generate("secp384r1") + partner_jwk = JWT::JWK.new(partner_key) + ENV["KOI_API_JWKS_PARTNER"] = { keys: [partner_jwk.export] }.to_json + Koi.config.identity = { + providers: { + partner: { + issuer: "partner", + keys: "env", + audience:, + subject: "partner-production", + scope: "admin_user", + }, + }, + } + + assertion = JWT.encode(claims.merge(iss: "partner", sub: "partner-production"), + partner_key, "ES384", { kid: partner_jwk.kid }) + + expect(authorize(assertion)).to be_verified + expect(WebMock).not_to have_requested(:get, /partner/) + ensure + ENV.delete("KOI_API_JWKS_PARTNER") + end + + it "refetches a cached key set for an unknown kid once the invalidation grace passes" do + with_memory_cache do + authorize + + rotated_key = OpenSSL::PKey::EC.generate("secp384r1") + rotated_jwk = JWT::JWK.new(rotated_key) + stub_request(:get, "#{issuer}/keys") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { keys: [jwk.export, rotated_jwk.export] }.to_json) + + travel Koi::Identity::Provider::INVALIDATION_GRACE + 1.second do + now = Time.zone.now.to_i + assertion = JWT.encode(claims.merge(iat: now, exp: now + 300, jti: SecureRandom.uuid), + rotated_key, "ES384", { kid: rotated_jwk.kid }) + + expect(authorize(assertion).principal).to have_attributes(email:) + end + end + end + + it "accepts a distinct jti from the same service", :aggregate_failures do + with_memory_cache do + expect(authorize.principal).to have_attributes(email:) + + claims[:jti] = SecureRandom.uuid + fresh = JWT.encode(claims, signing_key, "ES384", { kid: jwk.kid }) + + expect(authorize(fresh).principal).to have_attributes(email:) + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 31a0cc1b8..a2ac28a06 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -4,6 +4,8 @@ ENV["RAILS_ENV"] ||= "test" require File.expand_path("dummy/spec/rails_helper", __dir__) +require "webmock/rspec" + Dir[Koi::Engine.root.join("spec", "support", "**", "*.rb")].each do |f| require f unless f.include?("templates") end diff --git a/spec/requests/admin/tokens_controller_spec.rb b/spec/requests/admin/tokens_controller_spec.rb index de1bb1b53..707927823 100644 --- a/spec/requests/admin/tokens_controller_spec.rb +++ b/spec/requests/admin/tokens_controller_spec.rb @@ -114,4 +114,199 @@ def device_code_digest ) end end + + describe "POST /admin/tokens with a signed jwt" do + let(:issuer) { "https://00000000-0000-0000-0000-000000000000.tokens.sts.global.api.aws" } + let(:admin) { create(:admin) } + + def action(as: :json, + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: self.assertion, + **params) + post(admin_tokens_path, as:, params: { grant_type:, assertion:, **params }) + end + + before do + Koi.config.identity = { + providers: { + katalyst_agents: { + issuer:, + keys: "discover", + subject: "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com" \ + "/ap-southeast-2/AWSReservedSSO_Engineer_0123456789abcdef", + scope: "admin_user", + }, + }, + } + end + + after { Koi.config.instance_variable_set(:@identity, nil) } + + context "with a simulated AWS issuer" do + let(:audience) { "http://www.example.com/admin" } + let(:signing_key) { OpenSSL::PKey::EC.generate("secp384r1") } + let(:jwk) { JWT::JWK.new(signing_key) } + + def claims(iss: issuer, + sub: "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com" \ + "/ap-southeast-2/AWSReservedSSO_Engineer_0123456789abcdef", + aud: audience, + iat: Time.zone.now.to_i, + exp: iat + 300, + jti: SecureRandom.uuid, + admin: self.admin) + { + iss:, + sub:, + aud:, + iat:, + exp:, + jti:, + "https://sts.amazonaws.com/" => { + "principal_tags" => { "email" => admin.email, "name" => admin.name }, + }, + } + end + + def assertion(signing_key: self.signing_key, kid: jwk.kid, **) + JWT.encode(claims(**), signing_key, "ES384", { kid: }) + end + + before do + stub_request(:get, "#{issuer}/.well-known/openid-configuration") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { issuer:, jwks_uri: "#{issuer}/keys" }.to_json) + stub_request(:get, "#{issuer}/keys") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { keys: [jwk.export] }.to_json) + end + + it "exchanges the assertion for a bearer token", :aggregate_failures do + action + + expect(response).to have_http_status(:success) + expect(response.parsed_body).to include( + "access_token" => a_kind_of(String), + "token_type" => "Bearer", + "expires_in" => 3600, + ) + end + + it "does not touch the Rails session", :aggregate_failures do + expect { action }.not_to change(Admin::Session, :count) + expect(response.headers["Set-Cookie"]).to be_blank + end + + def token + action + response.parsed_body.fetch("access_token") + end + + it "issues a token that authenticates admin API requests without a session", :aggregate_failures do + get "/admin/dashboard", headers: { "Authorization" => "Bearer #{token}" } + + expect(response).to have_http_status(:success) + expect(response.headers["Set-Cookie"]).to be_blank + end + + it "invalidates issued tokens alongside the admin's others when they sign in again" do + bearer = token + admin.update!(last_sign_in_at: 1.second.from_now) + + get "/admin/dashboard", headers: { "Authorization" => "Bearer #{bearer}" } + + expect(response).to have_http_status(:unauthorized) + end + + it "rejects invalid assertions without detail", :aggregate_failures do + action(assertion: assertion(signing_key: OpenSSL::PKey::EC.generate("secp384r1"))) + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body).to eq("error" => "invalid_grant") + end + + it "rate limits token exchanges" do + allow(described_class.cache_store).to receive(:increment).and_return(21) + + action + + expect(response).to have_http_status(:too_many_requests) + end + + it "logs rejections with issuer and subject", :aggregate_failures do + allow(Rails.logger).to receive(:warn) + + action(assertion: assertion(sub: "arn:aws:iam::123456789012:role/unrelated-task-role")) + + expect(response).to have_http_status(:bad_request) + expect(Rails.logger).to have_received(:warn) + .with(a_string_including(issuer).and(a_string_including("unrelated-task-role"))) + end + + it "matches admins through email normalization" do + loud = Struct.new(:email, :name).new(" #{admin.email.upcase} ", admin.name) + + action(assertion: assertion(admin: loud)) + + expect(response).to have_http_status(:success) + end + + it "rejects an assertion minted for another site without detail", :aggregate_failures do + action(assertion: assertion(aud: "https://elsewhere.example.com/admin")) + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body).to eq("error" => "invalid_grant") + end + + it "prefers the provider's pinned audience over the requesting site" do + Koi.config.identity = { providers: { katalyst_agents: { audience: "https://pinned.example.com/admin" } } } + + action(assertion: assertion(aud: "https://pinned.example.com/admin")) + + expect(response).to have_http_status(:success) + end + + it "rejects an assertion for an archived admin without detail", :aggregate_failures do + admin.archive! + + action + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body).to eq("error" => "invalid_grant") + end + + it "rejects an assertion from a provider with no identity mapping without detail", :aggregate_failures do + Koi.config.identity = { + providers: { + partner: { + issuer: "https://partner.example.com", + keys: "discover", + audience:, + subject: claims[:sub], + scope: "admin_user", + }, + }, + } + stub_request(:get, "https://partner.example.com/.well-known/openid-configuration") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { issuer: "https://partner.example.com", + jwks_uri: "https://partner.example.com/keys" }.to_json) + stub_request(:get, "https://partner.example.com/keys") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { keys: [jwk.export] }.to_json) + + action(assertion: assertion(iss: "https://partner.example.com")) + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body).to eq("error" => "invalid_grant") + end + + it "rejects an assertion whose identity claim matches no admin without detail", :aggregate_failures do + action(assertion: assertion(admin: build(:admin, email: "nobody@katalyst.com.au"))) + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body).to eq("error" => "invalid_grant") + end + end + end end From d1d6d3e909411dd8b3c82f8b078a5a9528c8bdf7 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Tue, 21 Jul 2026 13:19:47 +0930 Subject: [PATCH 3/8] Identity: add members configuration for mapping subjects to principals --- app/models/koi/identity.rb | 18 ++++++++- app/models/koi/identity/assertion.rb | 15 ++++--- app/models/koi/identity/principal.rb | 14 ++++++- app/models/koi/identity/provider.rb | 32 +++++---------- lib/koi/config.rb | 2 +- spec/models/koi/identity_spec.rb | 20 ++++++---- spec/requests/admin/tokens_controller_spec.rb | 40 +++++++++++++------ 7 files changed, 91 insertions(+), 50 deletions(-) diff --git a/app/models/koi/identity.rb b/app/models/koi/identity.rb index 533d1f430..7a4beaf35 100644 --- a/app/models/koi/identity.rb +++ b/app/models/koi/identity.rb @@ -23,6 +23,22 @@ def provider_for(assertion) raise(JWT::InvalidIssuerError, "unknown issuer #{assertion.issuer}") end + # Generates a reader for the assertion claims based on configuration. + def principal_for(provider, assertion) + principal_type = case URI.parse(provider.issuer).host + when /\.sts\.global\.api\.aws\z/ + Principal::Aws + else + Principal + end + + Koi.config.identity.members + .select { |_, config| config[:provider].to_s == provider.name } + .filter_map do |name, config| + principal_type.new(assertion:, name:, **config) if config[:subject] == assertion.subject + end.first + end + def providers Koi.config.identity.providers.map do |name, config| provider = Provider.new(name:, **config) @@ -34,6 +50,6 @@ def providers end end - module_function :authorize_bearer_token!, :provider_for, :providers + module_function :authorize_bearer_token!, :provider_for, :principal_for, :providers end end diff --git a/app/models/koi/identity/assertion.rb b/app/models/koi/identity/assertion.rb index 025bb7779..69596389c 100644 --- a/app/models/koi/identity/assertion.rb +++ b/app/models/koi/identity/assertion.rb @@ -33,15 +33,19 @@ def verify!(provider) algorithms: provider.algorithms, jwks: provider.method(:key_set), aud: provider.audience, - sub: provider.subject, leeway: provider.leeway.to_i, verify_aud: true, - verify_jti: provider.method(:consume_jti), - verify_sub: true + verify_jti: provider.method(:consume_jti) ) - @state = :verified - @principal = provider.principal_for(self) + # ensure that we can map the claim to a valid principal using the claim's subject + @principal = Identity.principal_for(provider, self) + + if principal.blank? || principal.subject.blank? + raise(JWT::InvalidSubError, "unknown subject #{subject} for provider #{provider.name}") + end + + @state = :verified self end @@ -59,6 +63,7 @@ def sub def inspect "<#{self.class.name} iss=#{iss.inspect} sub=#{sub.inspect}>" end + alias :to_s :inspect end end end diff --git a/app/models/koi/identity/principal.rb b/app/models/koi/identity/principal.rb index e31b850d7..fd2884a4d 100644 --- a/app/models/koi/identity/principal.rb +++ b/app/models/koi/identity/principal.rb @@ -3,7 +3,19 @@ module Koi module Identity class Principal - def initialize(assertion) + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :name, :string + attribute :provider, :string + attribute :subject, :string + attribute :scope, :string + + attr_reader :assertion + + def initialize(assertion:, **) + super(**) + @assertion = assertion end diff --git a/app/models/koi/identity/provider.rb b/app/models/koi/identity/provider.rb index a3ede7112..9b634da9a 100644 --- a/app/models/koi/identity/provider.rb +++ b/app/models/koi/identity/provider.rb @@ -6,12 +6,20 @@ class Provider include ActiveModel::Model include ActiveModel::Attributes + # Upper bound on how long a key removed from the issuer's JWKS remains + # trusted. Newly rotated-in keys are picked up immediately, as an + # unknown kid invalidates the cache. + KEY_SET_TTL = 1.hour + + # A cached key set younger than this cannot be invalidated, so a stream + # of unknown-kid assertions cannot force a discovery refetch per + # request. A rotated-in key may take this long to be honoured. + INVALIDATION_GRACE = 5.minutes + attribute :name, :string attribute :issuer, :string attribute :keys, :string attribute :audience, :string - attribute :subject, :string - attribute :scope, :string # Acceptable signature algorithms: asymmetric families only, so a # provider's public key can never be replayed as an HMAC secret and @@ -23,26 +31,6 @@ class Provider validates :keys, inclusion: { in: %w[env discover] } - # Upper bound on how long a key removed from the issuer's JWKS remains - # trusted. Newly rotated-in keys are picked up immediately, as an - # unknown kid invalidates the cache. - KEY_SET_TTL = 1.hour - - # A cached key set younger than this cannot be invalidated, so a stream - # of unknown-kid assertions cannot force a discovery refetch per - # request. A rotated-in key may take this long to be honoured. - INVALIDATION_GRACE = 5.minutes - - # Generates a typed reader for the assertion claims based on the provider. - def principal_for(assertion) - case URI.parse(issuer).host - when /\.sts\.global\.api\.aws\z/ - Principal::Aws.new(assertion) - else - Principal.new(assertion) - end - end - # This provider's JWKS: pinned from ENV or fetched via OIDC discovery # and cached. Passed to JWT.decode as its jwks loader, which retries # with invalidate: true when a presented kid is missing from the set. diff --git a/lib/koi/config.rb b/lib/koi/config.rb index 725eb9db1..be467ac06 100644 --- a/lib/koi/config.rb +++ b/lib/koi/config.rb @@ -33,7 +33,7 @@ def initialize # Identity & access settings for OIDC authentication. def identity - @identity ||= ActiveSupport::OrderedOptions.new.merge!(providers: {}) + @identity ||= ActiveSupport::OrderedOptions.new.merge!(providers: {}, members: {}) end def identity=(options) diff --git a/spec/models/koi/identity_spec.rb b/spec/models/koi/identity_spec.rb index 445c3a234..d7e728f98 100644 --- a/spec/models/koi/identity_spec.rb +++ b/spec/models/koi/identity_spec.rb @@ -52,14 +52,15 @@ def with_memory_cache before do Koi.config.identity = { providers: { - katalyst_agents: { + katalyst_aws: { issuer:, keys: "discover", audience:, - subject: role_arn, - scope: "admin_user", }, }, + members: { + engineers: { provider: :katalyst_aws, scope: "admin/user", subject: role_arn }, + }, } stub_request(:get, "#{issuer}/.well-known/openid-configuration") @@ -147,13 +148,13 @@ def with_memory_cache end it "uses the caller-supplied audience when the provider does not pin one" do - Koi.config.identity = { providers: { katalyst_agents: { audience: nil } } } + Koi.config.identity = { providers: { katalyst_aws: { audience: nil } } } expect(authorize.principal).to have_attributes(email:) end it "rejects verification without an expected audience" do - Koi.config.identity = { providers: { katalyst_agents: { audience: nil } } } + Koi.config.identity = { providers: { katalyst_aws: { audience: nil } } } expect { authorize(audience: nil) }.to raise_error(JWT::InvalidAudError) end @@ -223,7 +224,7 @@ def with_memory_cache .to_return(headers: { "Content-Type" => "application/json" }, body: { issuer: "https://elsewhere.example.com", jwks_uri: "#{issuer}/keys" }.to_json) - expect { authorize }.to raise_error(JWT::JWKError, /\Akatalyst_agents discovery names issuer/) + expect { authorize }.to raise_error(JWT::JWKError, /\Akatalyst_aws discovery names issuer/) end it "does not refetch for repeated unknown kids inside the invalidation grace", :aggregate_failures do @@ -255,8 +256,13 @@ def with_memory_cache issuer: "partner", keys: "env", audience:, + }, + }, + members: { + partner: { + provider: "partner", + scope: "admin/user", subject: "partner-production", - scope: "admin_user", }, }, } diff --git a/spec/requests/admin/tokens_controller_spec.rb b/spec/requests/admin/tokens_controller_spec.rb index 707927823..1ee1f32e6 100644 --- a/spec/requests/admin/tokens_controller_spec.rb +++ b/spec/requests/admin/tokens_controller_spec.rb @@ -117,6 +117,10 @@ def device_code_digest describe "POST /admin/tokens with a signed jwt" do let(:issuer) { "https://00000000-0000-0000-0000-000000000000.tokens.sts.global.api.aws" } + let(:role_arn) do + "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com" \ + "/ap-southeast-2/AWSReservedSSO_Engineer_0123456789abcdef" + end let(:admin) { create(:admin) } def action(as: :json, @@ -129,14 +133,14 @@ def action(as: :json, before do Koi.config.identity = { providers: { - katalyst_agents: { + aws: { issuer:, - keys: "discover", - subject: "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com" \ - "/ap-southeast-2/AWSReservedSSO_Engineer_0123456789abcdef", - scope: "admin_user", + keys: "discover", }, }, + members: { + engineers: { provider: :aws, scope: "admin/user", subject: role_arn }, + }, } end @@ -148,8 +152,7 @@ def action(as: :json, let(:jwk) { JWT::JWK.new(signing_key) } def claims(iss: issuer, - sub: "arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com" \ - "/ap-southeast-2/AWSReservedSSO_Engineer_0123456789abcdef", + sub: role_arn, aud: audience, iat: Time.zone.now.to_i, exp: iat + 300, @@ -259,7 +262,7 @@ def token end it "prefers the provider's pinned audience over the requesting site" do - Koi.config.identity = { providers: { katalyst_agents: { audience: "https://pinned.example.com/admin" } } } + Koi.config.identity = { providers: { aws: { audience: "https://pinned.example.com/admin" } } } action(assertion: assertion(aud: "https://pinned.example.com/admin")) @@ -279,11 +282,8 @@ def token Koi.config.identity = { providers: { partner: { - issuer: "https://partner.example.com", - keys: "discover", - audience:, - subject: claims[:sub], - scope: "admin_user", + issuer: "https://partner.example.com", + keys: "discover", }, }, } @@ -307,6 +307,20 @@ def token expect(response).to have_http_status(:bad_request) expect(response.parsed_body).to eq("error" => "invalid_grant") end + + it "binds the grant to the member's matched admin", :aggregate_failures do + action + + expect(response).to have_http_status(:success) + expect(Admin::DeviceAuthorization.last.admin_user).to eq(admin) + end + + it "rejects a verified subject matching no member without detail", :aggregate_failures do + action(assertion: assertion(sub: "arn:aws:iam::123456789012:role/unmatched-role")) + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body).to eq("error" => "invalid_grant") + end end end end From 613a6bb7c7c7125160620efc8746ee35d3eb3619 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Tue, 21 Jul 2026 13:21:13 +0930 Subject: [PATCH 4/8] Identity: role based access --- .../admin/device_authorizations_controller.rb | 2 +- app/controllers/admin/tokens_controller.rb | 12 +- app/controllers/concerns/koi/controller.rb | 11 ++ app/models/admin/device_authorization.rb | 79 ++++++++++--- app/models/admin/role.rb | 23 ++++ app/models/koi/current.rb | 10 ++ app/models/koi/identity.rb | 18 +-- app/models/koi/identity/principal.rb | 65 ++++++----- .../20260721000001_create_admin_roles.rb | 12 ++ ...min_role_to_admin_device_authorizations.rb | 25 ++++ ...rincipal_to_admin_device_authorizations.rb | 7 ++ lib/koi/middleware/admin_authentication.rb | 2 +- spec/factories/admin_roles.rb | 7 ++ .../models/admin/device_authorization_spec.rb | 97 ++++++++++++++-- spec/models/admin/role_spec.rb | 18 +++ spec/models/koi/identity/principal_spec.rb | 39 +++++++ spec/requests/admin/tokens_controller_spec.rb | 109 ++++++++++++++++++ 17 files changed, 462 insertions(+), 74 deletions(-) create mode 100644 app/models/admin/role.rb create mode 100644 db/migrate/20260721000001_create_admin_roles.rb create mode 100644 db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb create mode 100644 db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb create mode 100644 spec/factories/admin_roles.rb create mode 100644 spec/models/admin/role_spec.rb create mode 100644 spec/models/koi/identity/principal_spec.rb diff --git a/app/controllers/admin/device_authorizations_controller.rb b/app/controllers/admin/device_authorizations_controller.rb index 6709f9679..3dcf6bc88 100644 --- a/app/controllers/admin/device_authorizations_controller.rb +++ b/app/controllers/admin/device_authorizations_controller.rb @@ -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, ) diff --git a/app/controllers/admin/tokens_controller.rb b/app/controllers/admin/tokens_controller.rb index 2652fb343..50752d819 100644 --- a/app/controllers/admin/tokens_controller.rb +++ b/app/controllers/admin/tokens_controller.rb @@ -22,7 +22,7 @@ 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 @@ -30,17 +30,11 @@ def authorize_device_code def authorize_bearer_token assertion = Koi::Identity.authorize_bearer_token!(params[:assertion], audience: "#{request.base_url}/admin") - admin_user = Admin::User.find_by(assertion.principal.attributes_for_find) - - return render(json: { error: "invalid_grant" }, status: :bad_request) if admin_user.nil? - - device_authorization, device_code = Admin::DeviceAuthorization.issue!( + render json: Admin::DeviceAuthorization.issue_token!( + principal: assertion.principal, requested_ip: request.remote_ip, user_agent: request.user_agent, ) - device_authorization.approve!(admin_user:) - - render json: Admin::DeviceAuthorization.issue_access_token!(device_code:) rescue JWT::DecodeError render json: { error: "invalid_grant" }, status: :bad_request end diff --git a/app/controllers/concerns/koi/controller.rb b/app/controllers/concerns/koi/controller.rb index 4e933ba8b..16122d7d2 100644 --- a/app/controllers/concerns/koi/controller.rb +++ b/app/controllers/concerns/koi/controller.rb @@ -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 diff --git a/app/models/admin/device_authorization.rb b/app/models/admin/device_authorization.rb index 977cb7f44..4ab110c83 100644 --- a/app/models/admin/device_authorization.rb +++ b/app/models/admin/device_authorization.rb @@ -2,8 +2,8 @@ module Admin class DeviceAuthorization < ApplicationRecord - EXPIRES_IN = 10.minutes - TOKEN_EXPIRES_IN = 1.hour + REQUEST_EXPIRES_IN = 10.minutes + TOKEN_EXPIRES_IN = 1.hour class TokenError < StandardError attr_reader :code @@ -18,12 +18,17 @@ def initialize(code) enum :status, %w[pending approved denied consumed].index_with(&:to_s) - generates_token_for(:api_access, expires_in: TOKEN_EXPIRES_IN) { 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", @@ -31,13 +36,23 @@ def initialize(code) 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:, ) @@ -53,7 +68,8 @@ def self.generate_user_code "#{SecureRandom.alphanumeric(4).upcase}-#{SecureRandom.alphanumeric(4).upcase}" end - def self.issue_access_token!(device_code:) + # 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 @@ -64,19 +80,38 @@ def self.issue_access_token!(device_code:) raise TokenError.new(error) end - access_token = device_authorization.generate_token_for(:api_access) device_authorization.consume! + end + end - { - access_token:, - token_type: "Bearer", - expires_in: (device_authorization.token_expires_at - Time.current).round, - } + # 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/(?[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? @@ -96,6 +131,8 @@ def consume! consumed_at: Time.current, token_expires_at: TOKEN_EXPIRES_IN.from_now, ) + + token_payload end def approve!(admin_user:) @@ -116,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 diff --git a/app/models/admin/role.rb b/app/models/admin/role.rb new file mode 100644 index 000000000..b14f6d3a1 --- /dev/null +++ b/app/models/admin/role.rb @@ -0,0 +1,23 @@ +# 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 + end +end diff --git a/app/models/koi/current.rb b/app/models/koi/current.rb index 84110cd16..464c824b5 100644 --- a/app/models/koi/current.rb +++ b/app/models/koi/current.rb @@ -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 diff --git a/app/models/koi/identity.rb b/app/models/koi/identity.rb index 7a4beaf35..e2f41a531 100644 --- a/app/models/koi/identity.rb +++ b/app/models/koi/identity.rb @@ -23,19 +23,13 @@ def provider_for(assertion) raise(JWT::InvalidIssuerError, "unknown issuer #{assertion.issuer}") end - # Generates a reader for the assertion claims based on configuration. + # Resolves the member matching the verified (provider, subject) into a + # principal, typed by the issuer. def principal_for(provider, assertion) - principal_type = case URI.parse(provider.issuer).host - when /\.sts\.global\.api\.aws\z/ - Principal::Aws - else - Principal - end - - Koi.config.identity.members - .select { |_, config| config[:provider].to_s == provider.name } - .filter_map do |name, config| - principal_type.new(assertion:, name:, **config) if config[:subject] == assertion.subject + Koi.config.identity.members.values + .select { |config| config[:provider].to_s == provider.name } + .filter_map do |config| + Principal.from_assertion(config:, provider:, assertion:) end.first end diff --git a/app/models/koi/identity/principal.rb b/app/models/koi/identity/principal.rb index fd2884a4d..a2906fb85 100644 --- a/app/models/koi/identity/principal.rb +++ b/app/models/koi/identity/principal.rb @@ -6,44 +6,55 @@ class Principal include ActiveModel::Model include ActiveModel::Attributes - attribute :name, :string - attribute :provider, :string - attribute :subject, :string - attribute :scope, :string - - attr_reader :assertion - - def initialize(assertion:, **) - super(**) + def self.from_assertion(config:, provider:, assertion:) + # Note: consider pattern matching to extract name/email in the future + return nil unless config[:subject] == assertion.subject + + attributes = { + provider: config[:provider], + scope: config[:scope], + subject: assertion.subject, + } + + case URI.parse(provider.issuer).host + when /\.sts\.global\.api\.aws\z/ + attributes.merge!( + **assertion.claims.dig("https://sts.amazonaws.com/", "principal_tags")&.slice("name", "email"), + ) + end - @assertion = assertion + Principal.new(**attributes) end - def attributes_for_find - { id: nil } + def self.dump(principal) + principal&.attributes&.to_json end - class Aws < Principal - def email - tag("email") - end + def self.load(json) + return if json.blank? - def name - tag("name") - end + Principal.new(**JSON.parse(json).slice(*attribute_names)) + end - def attributes_for_find - { email: } - end + # Required attributes + attribute :provider, :string + attribute :subject, :string + attribute :scope, :string - private + # Optional extensions, required for user authentication + attribute :name, :string + attribute :email, :string - def tag(name) - raise JWT::VerificationError, "unverified assertion" unless @assertion.verified? + def attributes_for_find + { email: } + end - @assertion.claims.dig("https://sts.amazonaws.com/", "principal_tags", name) - end + def inspect + "<#{self.class.name} provider=#{provider.inspect} scope=#{scope.inspect} subject=#{subject.inspect} " \ + "name=#{name.inspect} email=#{email.inspect}>" end + + alias :to_s :inspect end end end diff --git a/db/migrate/20260721000001_create_admin_roles.rb b/db/migrate/20260721000001_create_admin_roles.rb new file mode 100644 index 000000000..8ecf5de05 --- /dev/null +++ b/db/migrate/20260721000001_create_admin_roles.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateAdminRoles < ActiveRecord::Migration[8.0] + def change + create_table :admin_roles do |t| + t.string :slug, null: false, index: { unique: true } + t.datetime :tokens_revoked_at + + t.timestamps + end + end +end diff --git a/db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb b/db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb new file mode 100644 index 000000000..a56fafb4d --- /dev/null +++ b/db/migrate/20260721000002_add_admin_role_to_admin_device_authorizations.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddAdminRoleToAdminDeviceAuthorizations < ActiveRecord::Migration[8.0] + def change + add_reference :admin_device_authorizations, :admin_role, null: true, foreign_key: true + + add_check_constraint :admin_device_authorizations, + "admin_user_id IS NULL OR admin_role_id IS NULL", + name: "admin_device_authorizations_single_actor" + + # allow device authorizations to be created without the request/approval flow + change_column_null :admin_device_authorizations, :device_code_digest, true + change_column_null :admin_device_authorizations, :request_expires_at, true + change_column_null :admin_device_authorizations, :user_code, true + + # regenerate the unique indexes to ignore nulls + remove_index :admin_device_authorizations, :device_code_digest, unique: true + add_index :admin_device_authorizations, :device_code_digest, + unique: true, where: "device_code_digest IS NOT NULL" + + remove_index :admin_device_authorizations, :user_code, unique: true + add_index :admin_device_authorizations, :user_code, + unique: true, where: "user_code IS NOT NULL" + end +end diff --git a/db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb b/db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb new file mode 100644 index 000000000..2669002cd --- /dev/null +++ b/db/migrate/20260721000003_add_principal_to_admin_device_authorizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPrincipalToAdminDeviceAuthorizations < ActiveRecord::Migration[8.0] + def change + add_column :admin_device_authorizations, :principal, :text + end +end diff --git a/lib/koi/middleware/admin_authentication.rb b/lib/koi/middleware/admin_authentication.rb index 791c74df2..3042d6cec 100644 --- a/lib/koi/middleware/admin_authentication.rb +++ b/lib/koi/middleware/admin_authentication.rb @@ -48,7 +48,7 @@ def requires_authentication?(request) end def authenticated? - Koi::Current.admin_user.present? + Koi::Current.actor.present? end def find_device_authentication(token:) diff --git a/spec/factories/admin_roles.rb b/spec/factories/admin_roles.rb new file mode 100644 index 000000000..e27564f72 --- /dev/null +++ b/spec/factories/admin_roles.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :admin_role, class: "Admin::Role" do + sequence(:slug) { |n| "role_#{n}" } + end +end diff --git a/spec/models/admin/device_authorization_spec.rb b/spec/models/admin/device_authorization_spec.rb index d4b455069..d4cd71aaf 100644 --- a/spec/models/admin/device_authorization_spec.rb +++ b/spec/models/admin/device_authorization_spec.rb @@ -8,6 +8,14 @@ subject(:device_authorization) { build(:admin_device_authorization) } it { is_expected.to belong_to(:admin_user).class_name("Admin::User").optional } + it { is_expected.to belong_to(:admin_role).class_name("Admin::Role").optional } + + it "forbids a grant holding both an admin and a role" do + grant = create(:admin_device_authorization) + + expect { grant.update!(status: :consumed, admin_user: create(:admin), admin_role: create(:admin_role)) } + .to raise_error(ActiveRecord::StatementInvalid) + end it { is_expected.to validate_presence_of(:device_code_digest) } it { is_expected.to validate_presence_of(:user_code) } @@ -72,12 +80,12 @@ end end - describe ".issue_access_token!" do + describe ".consume_request!" do let(:device_code) { "device-code-123" } it "raises invalid_grant for an unknown device code" do expect do - described_class.issue_access_token!(device_code:) + described_class.consume_request!(device_code:) end.to raise_error(described_class::TokenError, "invalid_grant") end @@ -85,7 +93,7 @@ create(:admin_device_authorization, device_code_digest: described_class.digest(device_code)) expect do - described_class.issue_access_token!(device_code:) + described_class.consume_request!(device_code:) end.to raise_error(described_class::TokenError, "authorization_pending") end @@ -93,7 +101,7 @@ create(:admin_device_authorization, :denied, device_code_digest: described_class.digest(device_code)) expect do - described_class.issue_access_token!(device_code:) + described_class.consume_request!(device_code:) end.to raise_error(described_class::TokenError, "access_denied") end @@ -106,7 +114,7 @@ ) expect do - described_class.issue_access_token!(device_code:) + described_class.consume_request!(device_code:) end.to raise_error(described_class::TokenError, "invalid_grant") end @@ -119,7 +127,7 @@ device_code_digest: described_class.digest(device_code), ) - payload = described_class.issue_access_token!(device_code:) + payload = described_class.consume_request!(device_code:) expect(payload).to include( access_token: a_kind_of(String), @@ -132,7 +140,7 @@ device_authorization = create(:admin_device_authorization, :approved, device_code_digest: described_class.digest(device_code)) - payload = described_class.issue_access_token!(device_code:) + payload = described_class.consume_request!(device_code:) expect(described_class.find_by_token_for(:api_access, payload.fetch(:access_token))) .to eq(device_authorization) @@ -145,7 +153,7 @@ device_code_digest: described_class.digest(device_code), ) - described_class.issue_access_token!(device_code:) + described_class.consume_request!(device_code:) expect(device_authorization.reload).to have_attributes( status: "consumed", @@ -155,6 +163,79 @@ end end + describe ".issue_token!" do + let(:admin) { create(:admin) } + + def principal(scope: "admin/user", email: admin.email, **) + Koi::Identity::Principal.new(scope:, email:, **) + end + + it "returns the token payload" do + payload = described_class.issue_token!(principal:) + + expect(payload).to include( + access_token: a_kind_of(String), + token_type: "Bearer", + expires_in: 3600, + ) + end + + it "records a consumed grant without a device-code request" do + described_class.issue_token!(principal:, requested_ip: "127.0.0.1", user_agent: "RSpec") + + expect(described_class.last).to have_attributes( + admin_user: admin, + status: "consumed", + consumed_at: be_present, + token_expires_at: be_present, + device_code_digest: nil, + user_code: nil, + request_expires_at: nil, + requested_ip: "127.0.0.1", + user_agent: "RSpec", + ) + end + + it "returns a token that authenticates the grant" do + payload = described_class.issue_token!(principal:) + + expect(described_class.find_by_token_for(:api_access, payload.fetch(:access_token))) + .to eq(described_class.last) + end + + it "raises for a user principal that matches no admin" do + expect do + described_class.issue_token!(principal: principal(email: "unknown@example.com")) + end.to raise_error(JWT::VerificationError, /unknown user/) + end + + it "binds the grant to the materialized role for a role-scoped principal" do + principal = principal(scope: "admin/role/event_editor") + + described_class.issue_token!(principal:) + + expect(described_class.last).to have_attributes( + admin_role: Admin::Role.find_by!(slug: "event_editor"), + admin_user: nil, + ) + end + + it "raises for an unknown scope" do + principal = principal(scope: "admin/other") + + expect do + described_class.issue_token!(principal:) + end.to raise_error(ArgumentError, /unknown scope/) + end + + it "snapshots the principal onto the grant" do + described_class.issue_token!(principal: principal(provider: "komet", subject: "komet-production")) + + expect(described_class.last.principal) + .to have_attributes(provider: "komet", subject: "komet-production", scope: "admin/user") + end + end + describe "API access tokens" do subject(:device_authorization) { create(:admin_device_authorization, :approved) } diff --git a/spec/models/admin/role_spec.rb b/spec/models/admin/role_spec.rb new file mode 100644 index 000000000..737509dde --- /dev/null +++ b/spec/models/admin/role_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::Role do + describe ".materialize" do + it "creates the row on first access and finds it thereafter", :aggregate_failures do + expect { described_class.materialize("event_editor") }.to change(described_class, :count).by(1) + expect { described_class.materialize("event_editor") }.not_to change(described_class, :count) + end + + it "finds the row when another writer wins the race" do + existing = described_class.create!(slug: "event_editor") + + expect(described_class.materialize("event_editor")).to eq(existing) + end + end +end diff --git a/spec/models/koi/identity/principal_spec.rb b/spec/models/koi/identity/principal_spec.rb new file mode 100644 index 000000000..57cf69db6 --- /dev/null +++ b/spec/models/koi/identity/principal_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Koi::Identity::Principal do + describe "snapshot round-trip" do + it "rehydrates provider, subject, and scope" do + principal = described_class.new( + provider: "komet", + subject: "komet-production", + scope: "admin/role/event_editor", + ) + + restored = described_class.load(described_class.dump(principal)) + + expect(restored) + .to be_an_instance_of(described_class) + .and have_attributes(provider: "komet", subject: "komet-production", scope: "admin/role/event_editor") + end + + it "rehydrates an AWS principal with its identity tags" do + principal = described_class.new( + provider: "aws", + subject: "arn:aws:iam::123456789012:role/engineer", + scope: "admin/user", + email: "developer@katalyst.com.au", + ) + + restored = described_class.load(described_class.dump(principal)) + + expect(restored) + .to be_an_instance_of(described_class).and have_attributes(email: "developer@katalyst.com.au") + end + + it "loads a blank snapshot as nil" do + expect(described_class.load(nil)).to be_nil + end + end +end diff --git a/spec/requests/admin/tokens_controller_spec.rb b/spec/requests/admin/tokens_controller_spec.rb index 1ee1f32e6..bfee9ef3d 100644 --- a/spec/requests/admin/tokens_controller_spec.rb +++ b/spec/requests/admin/tokens_controller_spec.rb @@ -315,6 +315,13 @@ def token expect(Admin::DeviceAuthorization.last.admin_user).to eq(admin) end + it "snapshots the verified principal onto the grant at issuance" do + action + + expect(Admin::DeviceAuthorization.last.principal) + .to be_a(Koi::Identity::Principal).and(have_attributes(subject: role_arn, email: admin.email)) + end + it "rejects a verified subject matching no member without detail", :aggregate_failures do action(assertion: assertion(sub: "arn:aws:iam::123456789012:role/unmatched-role")) @@ -322,5 +329,107 @@ def token expect(response.parsed_body).to eq("error" => "invalid_grant") end end + + context "with a pinned-key role provider" do + let(:audience) { "http://www.example.com/admin" } + let(:signing_key) { OpenSSL::PKey::EC.generate("secp384r1") } + let(:jwk) { JWT::JWK.new(signing_key) } + + def claims(iss: "komet", + sub: "komet-production", + aud: audience, + iat: Time.zone.now.to_i, + exp: iat + 300, + jti: SecureRandom.uuid) + { iss:, sub:, aud:, iat:, exp:, jti: } + end + + def assertion(**) + JWT.encode(claims(**), signing_key, "ES384", { kid: jwk.kid }) + end + + def token + action + response.parsed_body.fetch("access_token") + end + + before do + ENV["KOI_API_JWKS_KOMET"] = { keys: [jwk.export] }.to_json + Koi.config.identity = { + providers: { + komet: { + issuer: "komet", + keys: "env", + }, + }, + members: { + komet: { provider: :komet, scope: "admin/role/event_editor", subject: "komet-production" }, + }, + } + end + + after { ENV.delete("KOI_API_JWKS_KOMET") } + + it "exchanges the assertion for a bearer token", :aggregate_failures do + action + + expect(response).to have_http_status(:success) + expect(response.parsed_body).to include( + "access_token" => a_kind_of(String), + "token_type" => "Bearer", + "expires_in" => 3600, + ) + end + + it "materializes the role on first issuance and finds it thereafter", :aggregate_failures do + expect { action }.to change(Admin::Role, :count).by(1) + expect { action }.not_to change(Admin::Role, :count) + end + + it "binds the grant to the role, not an admin", :aggregate_failures do + action + + grant = Admin::DeviceAuthorization.last + expect(grant).to have_attributes(admin_role: Admin::Role.find_by(slug: "event_editor"), admin_user: nil) + end + + it "issues a token that authenticates the dashboard as the role", :aggregate_failures do + get "/admin/dashboard", headers: { "Authorization" => "Bearer #{token}" } + + expect(response).to have_http_status(:success) + expect(response.headers["Set-Cookie"]).to be_blank + end + + it "denies role tokens anything outside their surface with 403" do + get "/admin/admin_users", headers: { "Authorization" => "Bearer #{token}" } + + expect(response).to have_http_status(:forbidden) + end + + it "adds machine request attribution to the request instrumentation payload" do + bearer = token + payload = nil + subscriber = ActiveSupport::Notifications.subscribe("process_action.action_controller") do |event| + payload = event.payload + end + + get "/admin/dashboard", headers: { "Authorization" => "Bearer #{bearer}" } + + expect(payload).to include(principal: { provider: "komet", subject: "komet-production" }) + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end + + it "invalidates outstanding tokens when the role's tokens are revoked", :aggregate_failures do + bearer = token + get "/admin/dashboard", headers: { "Authorization" => "Bearer #{bearer}" } + expect(response).to have_http_status(:success) + + Admin::Role.find_by!(slug: "event_editor").update!(tokens_revoked_at: Time.current) + + get "/admin/dashboard", headers: { "Authorization" => "Bearer #{bearer}" } + expect(response).to have_http_status(:unauthorized) + end + end end end From 797835c4eebf5e5ca6ce98a2daba79960eac6e06 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Tue, 21 Jul 2026 21:21:31 +0930 Subject: [PATCH 5/8] Identity: validate config at load time --- app/models/admin/role.rb | 6 ++++ app/models/koi/identity.rb | 35 ++++++++++++++++++----- app/models/koi/identity/member.rb | 30 ++++++++++++++++++++ app/models/koi/identity/principal.rb | 20 ------------- app/models/koi/identity/provider.rb | 13 +++++++++ lib/koi/engine.rb | 7 +++++ spec/models/admin/role_spec.rb | 21 ++++++++++++++ spec/models/koi/identity_spec.rb | 42 ++++++++++++++++++++++++++++ 8 files changed, 147 insertions(+), 27 deletions(-) create mode 100644 app/models/koi/identity/member.rb diff --git a/app/models/admin/role.rb b/app/models/admin/role.rb index b14f6d3a1..6d4edbd42 100644 --- a/app/models/admin/role.rb +++ b/app/models/admin/role.rb @@ -19,5 +19,11 @@ class Role < ApplicationRecord 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 end end diff --git a/app/models/koi/identity.rb b/app/models/koi/identity.rb index e2f41a531..e6e2c7c64 100644 --- a/app/models/koi/identity.rb +++ b/app/models/koi/identity.rb @@ -5,6 +5,8 @@ module Koi module Identity + module_function + def authorize_bearer_token!(token, audience:) assertion = Assertion.new(token) provider = provider_for(assertion) @@ -24,13 +26,16 @@ def provider_for(assertion) end # Resolves the member matching the verified (provider, subject) into a - # principal, typed by the issuer. + # principal, carrying whatever identity attributes the issuer publishes. def principal_for(provider, assertion) - Koi.config.identity.members.values - .select { |config| config[:provider].to_s == provider.name } - .filter_map do |config| - Principal.from_assertion(config:, provider:, assertion:) - end.first + member = members.find { |m| m.provider == provider.name && m.subject == assertion.subject } + + return if member.nil? + + Principal.new(provider: provider.name, + scope: member.scope, + subject: assertion.subject, + **provider.identity_attributes(assertion.claims)) end def providers @@ -44,6 +49,22 @@ def providers end end - module_function :authorize_bearer_token!, :provider_for, :principal_for, :providers + def members + provider_names = Koi.config.identity.providers.keys.map(&:to_s) + + Koi.config.identity.members.map do |name, config| + member = Member.new(name:, provider_names:, **config) + unless member.valid? + raise ArgumentError, "Invalid identity member #{name}: #{member.errors.full_messages.to_sentence}" + end + + member + end + end + + # Role slugs granted by config; roles outside this set are not assumable. + def role_slugs + members.filter_map(&:role_slug) + end end end diff --git a/app/models/koi/identity/member.rb b/app/models/koi/identity/member.rb new file mode 100644 index 000000000..ccfd54319 --- /dev/null +++ b/app/models/koi/identity/member.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Koi + module Identity + # A named trust rule from config: pairs a provider and an exact subject + # with the scope a verified assertion may act as. + class Member + include ActiveModel::Model + include ActiveModel::Attributes + + SCOPES = %r{\Aadmin/user\z|\Aadmin/role/(?[a-z0-9_]+)\z} + + attribute :name, :string + attribute :provider, :string + attribute :scope, :string + attribute :subject, :string + + # Provider names declared in config, for cross-checking references. + attr_accessor :provider_names + + validates :provider, :scope, :subject, presence: true + validates :provider, inclusion: { in: ->(member) { member.provider_names || [] }, allow_blank: true } + validates :scope, format: { with: SCOPES, allow_blank: true } + + def role_slug + scope&.[](SCOPES, :slug) + end + end + end +end diff --git a/app/models/koi/identity/principal.rb b/app/models/koi/identity/principal.rb index a2906fb85..906e2a386 100644 --- a/app/models/koi/identity/principal.rb +++ b/app/models/koi/identity/principal.rb @@ -6,26 +6,6 @@ class Principal include ActiveModel::Model include ActiveModel::Attributes - def self.from_assertion(config:, provider:, assertion:) - # Note: consider pattern matching to extract name/email in the future - return nil unless config[:subject] == assertion.subject - - attributes = { - provider: config[:provider], - scope: config[:scope], - subject: assertion.subject, - } - - case URI.parse(provider.issuer).host - when /\.sts\.global\.api\.aws\z/ - attributes.merge!( - **assertion.claims.dig("https://sts.amazonaws.com/", "principal_tags")&.slice("name", "email"), - ) - end - - Principal.new(**attributes) - end - def self.dump(principal) principal&.attributes&.to_json end diff --git a/app/models/koi/identity/provider.rb b/app/models/koi/identity/provider.rb index 9b634da9a..5bacd6830 100644 --- a/app/models/koi/identity/provider.rb +++ b/app/models/koi/identity/provider.rb @@ -40,6 +40,19 @@ def key_set(options = {}) JWT::JWK::Set.new(jwks) end + # Identity attributes are issuer-specific: AWS issuers carry + # admin-controlled principal tags; other issuers assert no identity + # beyond their subject. Keyed by the verified issuer — never by claim + # shape, which any trusted signer could imitate. + def identity_attributes(claims) + case URI.parse(issuer.to_s).host + when /\.sts\.global\.api\.aws\z/ + claims.dig("https://sts.amazonaws.com/", "principal_tags")&.slice("name", "email") || {} + else + {} + end + end + # Atomically claims an assertion's jti for its replayable lifetime: # the first presentation writes the key, a replay finds it taken and # is rejected. jti uniqueness is only promised within an issuer diff --git a/lib/koi/engine.rb b/lib/koi/engine.rb index 13e4cb414..2efda6848 100644 --- a/lib/koi/engine.rb +++ b/lib/koi/engine.rb @@ -65,6 +65,13 @@ class Engine < ::Rails::Engine initializer "koi.config" do |app| Koi.config.load(app) + + # Constructing providers and members validates the trust config, so a + # config error fails the deploy rather than a partner's exchange. + app.config.to_prepare do + Koi::Identity.providers + Koi::Identity.members + end end initializer "koi.content" do diff --git a/spec/models/admin/role_spec.rb b/spec/models/admin/role_spec.rb index 737509dde..5b60c836d 100644 --- a/spec/models/admin/role_spec.rb +++ b/spec/models/admin/role_spec.rb @@ -15,4 +15,25 @@ expect(described_class.materialize("event_editor")).to eq(existing) end end + + describe "#orphaned?" do + before do + Koi.config.identity = { + providers: { komet: { issuer: "komet", keys: "env" } }, + members: { + komet: { provider: :komet, scope: "admin/role/event_editor", subject: "komet-production" }, + }, + } + end + + after { Koi.config.instance_variable_set(:@identity, nil) } + + it "reports a row orphaned once no member grants its slug" do + expect(described_class.create!(slug: "retired")).to be_orphaned + end + + it "reports a granted row as current" do + expect(described_class.materialize("event_editor")).not_to be_orphaned + end + end end diff --git a/spec/models/koi/identity_spec.rb b/spec/models/koi/identity_spec.rb index d7e728f98..606f3ae59 100644 --- a/spec/models/koi/identity_spec.rb +++ b/spec/models/koi/identity_spec.rb @@ -307,4 +307,46 @@ def with_memory_cache end end end + + describe "boot validation" do + after { Koi.config.instance_variable_set(:@identity, nil) } + + # Constructing providers and members validates them, as the engine does + # on to_prepare. + def validate!(members: {}) + Koi.config.identity = { + providers: { komet: { issuer: "komet", keys: "env" } }, + members: { + komet: { provider: :komet, scope: "admin/role/event_editor", subject: "komet-production" }, + **members, + }, + } + + described_class.providers + described_class.members + end + + it "validates trust config without touching the database" do + queries = [] + callback = lambda do |_name, _start, _finish, _id, payload| + queries << payload[:sql] unless payload[:name] == "SCHEMA" + end + + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") { validate! } + + expect(queries).to be_empty + end + + it "rejects a member naming an undeclared provider at boot" do + rogue = { provider: :missing, scope: "admin/user", subject: "rogue-production" } + + expect { validate!(members: { rogue: }) }.to raise_error(ArgumentError, /rogue/) + end + + it "rejects a member whose scope is outside the allowlist at boot" do + rogue = { provider: :komet, scope: "admin/other", subject: "rogue-production" } + + expect { validate!(members: { rogue: }) }.to raise_error(ArgumentError, /rogue/) + end + end end From 6a55d9f1c3e9c9c499c3e750f1c115fe1a70933e Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Tue, 21 Jul 2026 21:41:22 +0930 Subject: [PATCH 6/8] Identity: UI for admin roles --- .../admin/admin_roles_controller.rb | 49 ++++++++ app/models/admin/role.rb | 12 ++ app/models/koi/identity/provider.rb | 35 +++++- app/views/admin/admin_roles/index.html.erb | 14 +++ app/views/admin/admin_roles/show.html.erb | 41 +++++++ config/routes.rb | 2 + spec/factories/admin_device_authorizations.rb | 18 +++ spec/models/admin/role_spec.rb | 36 +++++- spec/models/koi/identity_spec.rb | 19 ++- .../admin/admin_roles_controller_spec.rb | 109 ++++++++++++++++++ 10 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 app/controllers/admin/admin_roles_controller.rb create mode 100644 app/views/admin/admin_roles/index.html.erb create mode 100644 app/views/admin/admin_roles/show.html.erb create mode 100644 spec/requests/admin/admin_roles_controller_spec.rb diff --git a/app/controllers/admin/admin_roles_controller.rb b/app/controllers/admin/admin_roles_controller.rb new file mode 100644 index 000000000..1e27ae362 --- /dev/null +++ b/app/controllers/admin/admin_roles_controller.rb @@ -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 diff --git a/app/models/admin/role.rb b/app/models/admin/role.rb index 6d4edbd42..a9b201d7c 100644 --- a/app/models/admin/role.rb +++ b/app/models/admin/role.rb @@ -25,5 +25,17 @@ def self.materialize(slug) 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 diff --git a/app/models/koi/identity/provider.rb b/app/models/koi/identity/provider.rb index 5bacd6830..c1e7795b2 100644 --- a/app/models/koi/identity/provider.rb +++ b/app/models/koi/identity/provider.rb @@ -30,6 +30,7 @@ class Provider attribute :leeway, default: -> { 15.seconds } validates :keys, inclusion: { in: %w[env discover] } + validate :pinned_keys_parse, if: -> { keys == "env" } # This provider's JWKS: pinned from ENV or fetched via OIDC discovery # and cached. Passed to JWT.decode as its jwks loader, which retries @@ -40,6 +41,24 @@ def key_set(options = {}) JWT::JWK::Set.new(jwks) end + # Trusted-keys summary for the roles page: RFC 7638 thumbprints, plus + # when a discovered set was cached. Viewing may prime the discovery + # cache — the same fail-closed path verification uses — and an + # unreachable issuer reports itself rather than raising. + def key_status + case keys + when "env" + { fingerprints: fingerprints(jwks) } + when "discover" + cached = cached_discovery + + { fingerprints: fingerprints(cached.fetch("jwks")), + fetched_at: Time.zone.at(cached.fetch("fetched_at")) } + end + rescue JWT::JWKError => e + { error: e.message } + end + # Identity attributes are issuer-specific: AWS issuers carry # admin-controlled principal tags; other issuers assert no identity # beyond their subject. Keyed by the verified issuer — never by claim @@ -67,10 +86,24 @@ def consume_jti(jti, claims) private + def pinned_keys_parse + JWT::JWK::Set.new(JSON.parse(ENV.fetch(env_name))) + rescue KeyError, JSON::ParserError, JWT::JWKError => e + errors.add(:keys, "ENV #{env_name} is unavailable or invalid (#{e.message})") + end + + def fingerprints(jwks) + JWT::JWK::Set.new(jwks).map { |jwk| JWT::JWK::Thumbprint.new(jwk).generate } + end + + def env_name + "KOI_API_JWKS_#{name.upcase}" + end + def jwks case keys when "env" - JSON.parse(ENV.fetch("KOI_API_JWKS_#{name.upcase}")) + JSON.parse(ENV.fetch(env_name)) when "discover" cached_discovery.fetch("jwks") end diff --git a/app/views/admin/admin_roles/index.html.erb b/app/views/admin/admin_roles/index.html.erb new file mode 100644 index 000000000..934cfc7bc --- /dev/null +++ b/app/views/admin/admin_roles/index.html.erb @@ -0,0 +1,14 @@ +<%# locals: (collection:) %> + +<% content_for(:header) do %> +

Admin roles

+<% end %> + +<%= table_query_with(collection:) %> + +<%= table_with(collection:) do |row| %> + <% row.link(:slug, url: :admin_admin_role_path) %> + <% row.date(:created_at, label: "Materialized") %> + <% row.datetime(:last_authenticated_at, label: "Last authenticated") %> + <% row.boolean(:orphaned?, label: "Orphaned") %> +<% end %> diff --git a/app/views/admin/admin_roles/show.html.erb b/app/views/admin/admin_roles/show.html.erb new file mode 100644 index 000000000..d94728318 --- /dev/null +++ b/app/views/admin/admin_roles/show.html.erb @@ -0,0 +1,41 @@ +<%# locals: (role:, members:, providers:) %> + +<% content_for(:header) do %> + <%= breadcrumb_list do %> +
  • <%= link_to("Admin roles", admin_admin_roles_path) %>
  • + <% end %> + +

    <%= role.slug %>

    +<% end %> + +<%= summary_table_with(model: role) do |row| %> + <% row.text(:slug) %> + <% row.date(:created_at, label: "Materialized") %> + <% row.datetime(:last_authenticated_at, label: "Last authenticated") %> + <% row.boolean(:orphaned?) %> + <% row.datetime(:tokens_revoked_at) %> +<% end %> + +

    Trust

    + +<%= table_with(collection: members) do |row| %> + <% row.text(:name, label: "Member") %> + <% row.text(:subject) %> + <% row.text(:provider) %> +<% end %> + +<%= table_with(collection: providers) do |row, provider| %> + <% row.text(:name, label: "Provider") %> + <% row.text(:issuer) %> + <% row.text(:keys) do %> + <% status = provider.key_status %> + <% if status[:fingerprints] %> + <%= "#{provider.keys}: #{status[:fingerprints].join(', ')}" %> + <% if status[:fetched_at] %> + (fetched <%= l(status[:fetched_at], format: :short) %>) + <% end %> + <% else %> + <%= "#{provider.keys}: #{status[:error]}" %> + <% end %> + <% end %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 667a0ab46..88f934c7e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,6 +10,8 @@ resources :tokens, only: %i[create], module: :sessions end + resources :admin_roles, only: %i[index show] + resource :cache, only: %i[destroy] resource :dashboard, only: %i[show] diff --git a/spec/factories/admin_device_authorizations.rb b/spec/factories/admin_device_authorizations.rb index 8b41f48f6..7fe1bc75b 100644 --- a/spec/factories/admin_device_authorizations.rb +++ b/spec/factories/admin_device_authorizations.rb @@ -25,5 +25,23 @@ consumed_at { Time.current } admin_user end + + # An authorization issued for an admin role + trait :admin_role do + admin_role + principal do + Koi::Identity::Principal.new( + provider: "komet", + scope: "admin/role/#{admin_role.slug}", + subject: "komet-production", + ) + end + status { :consumed } + consumed_at { Time.current } + token_expires_at { consumed_at + 1.hour } + device_code_digest { nil } + user_code { nil } + request_expires_at { nil } + end end end diff --git a/spec/models/admin/role_spec.rb b/spec/models/admin/role_spec.rb index 5b60c836d..fb558d0cf 100644 --- a/spec/models/admin/role_spec.rb +++ b/spec/models/admin/role_spec.rb @@ -16,7 +16,21 @@ end end - describe "#orphaned?" do + describe "#last_authenticated_at" do + it "reports the role's most recent token issuance" do + role = create(:admin_role) + create(:admin_device_authorization, :admin_role, admin_role: role, consumed_at: 2.days.ago) + latest = create(:admin_device_authorization, :admin_role, admin_role: role, consumed_at: 1.hour.ago) + + expect(role.last_authenticated_at).to be_within(1.second).of(latest.consumed_at) + end + + it "is nil before first issuance" do + expect(create(:admin_role).last_authenticated_at).to be_nil + end + end + + describe "trust config" do before do Koi.config.identity = { providers: { komet: { issuer: "komet", keys: "env" } }, @@ -28,12 +42,24 @@ after { Koi.config.instance_variable_set(:@identity, nil) } - it "reports a row orphaned once no member grants its slug" do - expect(described_class.create!(slug: "retired")).to be_orphaned + describe "#orphaned?" do + it "reports a row orphaned once no member grants its slug" do + expect(described_class.create!(slug: "retired")).to be_orphaned + end + + it "reports a granted row as current" do + expect(described_class.materialize("event_editor")).not_to be_orphaned + end end - it "reports a granted row as current" do - expect(described_class.materialize("event_editor")).not_to be_orphaned + describe "#members" do + it "lists the members granting the role" do + expect(described_class.materialize("event_editor").members.map(&:name)).to eq(%w[komet]) + end + + it "is empty for an orphaned role" do + expect(described_class.create!(slug: "retired").members).to be_empty + end end end end diff --git a/spec/models/koi/identity_spec.rb b/spec/models/koi/identity_spec.rb index 606f3ae59..a401db75a 100644 --- a/spec/models/koi/identity_spec.rb +++ b/spec/models/koi/identity_spec.rb @@ -309,7 +309,12 @@ def with_memory_cache end describe "boot validation" do - after { Koi.config.instance_variable_set(:@identity, nil) } + before { ENV["KOI_API_JWKS_KOMET"] = { keys: [] }.to_json } + + after do + ENV.delete("KOI_API_JWKS_KOMET") + Koi.config.instance_variable_set(:@identity, nil) + end # Constructing providers and members validates them, as the engine does # on to_prepare. @@ -337,6 +342,18 @@ def validate!(members: {}) expect(queries).to be_empty end + it "rejects an env provider whose key set is missing at boot" do + ENV.delete("KOI_API_JWKS_KOMET") + + expect { validate! }.to raise_error(ArgumentError, /KOI_API_JWKS_KOMET/) + end + + it "rejects an env provider whose key set does not parse at boot" do + ENV["KOI_API_JWKS_KOMET"] = "not-a-key-set" + + expect { validate! }.to raise_error(ArgumentError, /KOI_API_JWKS_KOMET/) + end + it "rejects a member naming an undeclared provider at boot" do rogue = { provider: :missing, scope: "admin/user", subject: "rogue-production" } diff --git a/spec/requests/admin/admin_roles_controller_spec.rb b/spec/requests/admin/admin_roles_controller_spec.rb new file mode 100644 index 000000000..e778fb4c1 --- /dev/null +++ b/spec/requests/admin/admin_roles_controller_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::AdminRolesController do + let(:role) { create(:admin_role) } + + include_context "with admin session" + + describe "GET /admin/admin_roles" do + let(:action) { get admin_admin_roles_path } + + it_behaves_like "requires admin" + + it "renders successfully" do + role + action + + expect(response).to have_http_status(:success) + end + + it_behaves_like "with bearer token authentication" do + it "fails with an authentication error" do + get(admin_admin_roles_path, headers:) + + expect(response).to have_http_status(:forbidden) + end + end + end + + describe "GET /admin/admin_roles/:id" do + let(:action) { get admin_admin_role_path(role) } + + it_behaves_like "requires admin" + + it "renders successfully" do + action + + expect(response).to have_http_status(:success) + end + + context "with members granting the role" do + let(:role) { create(:admin_role, slug: "event_editor") } + let(:issuer) { "https://00000000-0000-0000-0000-000000000000.tokens.sts.global.api.aws" } + let(:task_role_arn) { "arn:aws:iam::123456789012:role/avr-legacy-task" } + + let(:komet_jwk) { JWT::JWK.new(OpenSSL::PKey::EC.generate("secp384r1")) } + let(:aws_jwk) { JWT::JWK.new(OpenSSL::PKey::EC.generate("secp384r1")) } + + def thumbprint(jwk) + JWT::JWK::Thumbprint.new(jwk).generate + end + + before do + ENV["KOI_API_JWKS_KOMET"] = { keys: [komet_jwk.export] }.to_json + Koi.config.identity = { + providers: { + aws: { issuer:, keys: "discover" }, + komet: { issuer: "komet", keys: "env" }, + }, + members: { + avr_legacy: { provider: :aws, scope: "admin/role/event_editor", subject: task_role_arn }, + komet: { provider: :komet, scope: "admin/role/event_editor", subject: "komet-production" }, + }, + } + + stub_request(:get, "#{issuer}/.well-known/openid-configuration") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { issuer:, jwks_uri: "#{issuer}/keys" }.to_json) + stub_request(:get, "#{issuer}/keys") + .to_return(headers: { "Content-Type" => "application/json" }, + body: { keys: [aws_jwk.export] }.to_json) + end + + after do + ENV.delete("KOI_API_JWKS_KOMET") + Koi.config.instance_variable_set(:@identity, nil) + end + + it "renders the role's trust detail", :aggregate_failures do + action + + expect(response.body).to include("komet-production").and include(task_role_arn) + expect(response.body).to include(issuer) + end + + it "renders pinned key fingerprints" do + action + + expect(response.body).to include(thumbprint(komet_jwk)) + end + + it "renders discovered key cache state" do + action + + expect(response.body).to include(thumbprint(aws_jwk)).and include("fetched") + end + + it "reports an unreachable issuer without failing", :aggregate_failures do + stub_request(:get, "#{issuer}/.well-known/openid-configuration").to_timeout + + action + + expect(response).to have_http_status(:success) + expect(response.body).to include("key discovery for aws failed") + end + end + end +end From 54d8bdcc20237a4eba75640df063163e16085a12 Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Wed, 15 Jul 2026 13:15:04 +0930 Subject: [PATCH 7/8] Documentation for integrating with planned API role-based authentication --- docs/api-partner-integration.md | 206 ++++++++ docs/passwordless-api-authentication.md | 598 ++++++++++++++++++++++++ 2 files changed, 804 insertions(+) create mode 100644 docs/api-partner-integration.md create mode 100644 docs/passwordless-api-authentication.md diff --git a/docs/api-partner-integration.md b/docs/api-partner-integration.md new file mode 100644 index 000000000..c89de6312 --- /dev/null +++ b/docs/api-partner-integration.md @@ -0,0 +1,206 @@ +# Integrating with the Koi admin API + +A walkthrough for third-party systems authenticating to a Koi-powered admin +API with a pre-shared public key. Examples use +`https://example.com/admin` and an imaginary integrating service named +Komet — swap in your own site and the integration name you agree with our +ops during onboarding. + +The short version: you hold a private key, we hold the matching public key. +You sign a short-lived assertion, exchange it for a bearer token, and use +that token for about an hour. No passwords, no API keys, nothing sensitive +ever crosses the wire or sits in a config file on our side. + +## Onboarding + +Generate a keypair. The private key is yours: store and manage it under your +own secrets policies — the only thing that matters to us is that you never +send it. Nothing you share with us is secret. + +```sh +openssl ecparam -name secp384r1 -genkey -noout -out komet-private.pem +openssl ec -in komet-private.pem -pubout -out komet-public.pem + +# base64-encode for your ENV / secrets manager, then discard the files +base64 < komet-private.pem # → KOMET_PRIVATE_KEY +base64 < komet-public.pem # → KOMET_PUBLIC_KEY +``` + +Send us three things (email is fine — none of this is secret): + +1. The public key (the `KOMET_PUBLIC_KEY` value as-is is fine) +2. A key identifier of your choosing, e.g. `komet-2026-07` +3. The subject name your integration will authenticate as, e.g. + `komet-production` + +We'll agree on the subject name, register the key, and confirm +back your **issuer** (`komet`), your **audience** +(`https://example.com/admin`), and the role your tokens will carry. +Registration lands when we update our server configuration and deploy. + +```mermaid +sequenceDiagram + participant You as Your ops + participant Ops as Our ops + participant Site as example.com + + You->>You: generate keypair, private key never shared + You->>Ops: public key + key id + subject names (email) + Ops->>Ops: review, commit to infrastructure config + Ops->>Site: deploy + Ops-->>You: confirmed: issuer, audience, role, endpoint +``` + +## Authenticating + +Two steps, repeated roughly hourly, per server: +sign an assertion, exchange it for a bearer token. + +```mermaid +sequenceDiagram + participant App as Your server + participant Site as example.com/admin + + loop every ~55 minutes + App->>App: sign assertion (5 min expiry, fresh jti) + App->>Site: POST /admin/tokens (jwt-bearer grant) + Site-->>App: { access_token, expires_in: 3600 } + loop until token expires + App->>Site: API calls, Authorization: Bearer … + Site-->>App: JSON + end + end +``` + +### 1. Sign an assertion + +A JWT signed with your private key. Keep the expiry at five minutes or less, +generate a fresh `jti` every time (they're single-use), and put your key id +in the header. + +```ruby +require "base64" +require "jwt" +require "securerandom" + +private_key = OpenSSL::PKey.read(Base64.decode64(ENV.fetch("KOMET_PRIVATE_KEY"))) + +now = Time.now.to_i +assertion = JWT.encode( + { + iss: "komet", + sub: "komet-production", + aud: "https://example.com/admin", + iat: now, + exp: now + 300, + jti: SecureRandom.uuid, + }, + private_key, + "ES384", + { kid: "komet-2026-07" }, +) +``` + +### 2. Exchange it for a bearer token + +```sh +curl -sS -X POST https://example.com/admin/tokens \ + -H "Accept: application/json" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ + -d "assertion=$ASSERTION" +``` + +```json +{ + "access_token": "eyJ…", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +Cache the token and reuse it until shortly before `expires_in` elapses. Mint +an assertion per exchange, not per request — one assertion, one token, many +API calls. + +### 3. Call the API + +```sh +curl -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Accept: application/json" \ + https://example.com/admin/pages +``` + +```sh +curl -X PATCH https://example.com/admin/pages/42 \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"page": {"title": "About us"}}' +``` + +Your token carries the role we agreed at onboarding (for Komet that's page +editing), so expect 403s outside that surface. Every call is attributed to +your subject name in our audit logs. + +A minimal client: + +```ruby +class KoiClient + BASE = "https://example.com/admin" + + def token + @token = fetch_token if @token.nil? || @expires_at < Time.now + 60 + @token + end + + private + + def fetch_token + response = Net::HTTP.post_form( + URI("#{BASE}/tokens"), + "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion" => sign_assertion, # as above + ) + body = JSON.parse(response.body) + @expires_at = Time.now + body.fetch("expires_in") + body.fetch("access_token") + end +end +``` + +## When things go wrong + +The token endpoint answers `400 {"error": "invalid_grant"}` for any +rejected assertion, deliberately without detail. Work through this list: + +| Check | Common cause | +|---|---| +| Server clock | `exp` already passed on arrival — sync NTP | +| `aud` | Must be exactly `https://example.com/admin` | +| `kid` header | Must match the key id you registered | +| `jti` | Missing or reused — generate a fresh UUID per assertion | +| `sub` | Doesn't exactly match the agreed subject name | + +On API calls, a `401` means your token expired or was revoked — exchange a +new assertion and retry. A `403` means the token is fine but the endpoint is +outside your role. `429` on the token endpoint means you're exchanging too +often; cache the token. + +## Key rotation + +Generate a new keypair, send us the new public key with a new key id +(`komet-2026-12`), and keep signing with the old key until we confirm the new +one is live. Both keys work during the overlap — the `kid` header picks the +right one — then we drop the old key. Same channel, same turnaround. + +If a private key is ever compromised, tell us immediately: we remove the key +and kill any outstanding tokens within minutes. + +## Checklist + +- [ ] Keypair generated; private key secured under your own secrets policies +- [ ] Public key, key id, and subject names sent to our ops +- [ ] Confirmation received: issuer, audience, role +- [ ] Assertion signing implemented (ES384, ≤5 min expiry, fresh `jti`, `kid` header) +- [ ] Token cached and refreshed before expiry +- [ ] `401` → re-exchange; `429` → check your token cache +- [ ] Rotation calendar note before your key's planned retirement diff --git a/docs/passwordless-api-authentication.md b/docs/passwordless-api-authentication.md new file mode 100644 index 000000000..fa3307dba --- /dev/null +++ b/docs/passwordless-api-authentication.md @@ -0,0 +1,598 @@ +# Password-less API authentication + +Unattended API access to Koi sites without passwords or long-lived shared +secrets. The core flow — the `identity` trust config, assertion +verification, the jwt-bearer grant, user- and role-scoped issuance, OIDC +discovery, and read-only admin visibility — is implemented and verified +live on a deployed site across all three trust shapes: user-scoped via a +production AWS issuer, role-scoped via a developer AWS identity, and a +pinned certificate-based partner. The guide for integrating partners is +[api-partner-integration.md](api-partner-integration.md); background on +Koi's other mechanisms and the alternatives considered is in the +[appendix](#appendix-background-and-alternatives-considered). + +## The design + +One token endpoint, one trust registry, one proof type, short-lived bearer +tokens. A caller proves who it is with a **signed assertion**: a JWT carrying +issuer, subject, audience, and expiry claims, signed by a private key Koi +never holds. Koi verifies the signature against trusted public keys, checks +the claims against per-service trust rules, and mints a short-lived bearer +token that the existing authentication middleware already accepts. Trust +*rules* change only by deploy; key *material* comes from one of two sources +per service (see below). + +Assertions come from three kinds of signer, all verified identically: + +- **Ad-hoc partners** (e.g. AVR servers): the partner generates a keypair, + sends us the public key out of band, and self-issues assertions. +- **OIDC platforms** (e.g. GitHub Actions): the platform issues its workloads + identity tokens and publishes verification keys at a well-known HTTPS + location derived from the issuer URL. +- **AWS principals**: since November 2025, AWS IAM Outbound Identity + Federation lets any permitted principal call `sts:GetWebIdentityToken` and + receive a JWT signed by AWS, verifiable against an account-specific issuer + with standard OIDC discovery endpoints. SSO engineer sessions, ECS task + roles, and local developer sessions all become ordinary OIDC assertions — + no AWS-specific verification path exists in Koi. + +### (a) Trust rules — `config/koi.yml` + +Environment-scoped declarative configuration, loaded via `config_for` at +boot so that AWS trust is shared across all environments while AVR +differs between staging and production. Koi +already loads `config/koi.yml` into `Koi.config`; trust config lives under a +dedicated `identity:` section, exposed as `Koi.config.identity`, with two +namespaces: `providers` (issuers we accept assertions from — verification +only) and `members` (named trust rules: who may authenticate, and what they +become). Illustrative shape: + +```yaml +shared: + identity: + providers: + # Verification only — who can sign. What a verified principal may + # become is declared under members. + aws: + # account-specific issuer, from GetOutboundWebIdentityFederationInfo; + # neither issuers nor subjects are secret. + issuer: https://a1789190-8dcb-4973-9aae-7acf5c30ee9f.tokens.sts.global.api.aws + keys: discover # OIDC discovery from the issuer + + members: + # Katalyst engineers: the token acts as the matched admin. The subject + # is the exact SSO permission-set role ARN — it gates who may + # authenticate and carries no email; identity comes from + # admin-controlled tags (see "User mapping" below). + engineers: + provider: aws + scope: admin/user + subject: arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/ap-southeast-2/AWSReservedSSO_Engineer_0123456789abcdef + +production: + identity: + providers: + avr: + issuer: avr + keys: env # pinned: KOI_API_JWKS_AVR + members: + # Members merge by name across shared/environment sections: these add + # to the shared engineers entry rather than replacing it. + avr: + provider: avr + scope: admin/role/event_editor + subject: avr-production + avr_legacy: + provider: aws + scope: admin/role/event_editor + # an ECS task-role sub is a bare role ARN (no assumed-role/task-id + # segment) + subject: arn:aws:iam::123456789012:role/avr-legacy-task + +staging: + identity: + providers: + avr: + issuer: avr + keys: env + members: + avr: + provider: avr + scope: admin/role/event_editor + subject: avr-staging +``` + +Semantics: + +- **Providers verify; members authorize.** A provider is an issuer and its + key material — one entry per issuer, resolved from the assertion's `iss`. + What a verified principal may *become* is declared by named members, each + pairing a provider and an exact subject with a scope — so one issuer (an + AWS account) serves engineers acting as admins and task roles acting as + machines without duplicating trust entries. Members merge by name across + `shared:` and environment sections, so an environment adds trust without + restating what it inherits; the name is a config handle — the merge key + and how the roles page labels a grant — while attribution records the + provider and subject. +- **Subjects are exact, and matched after verification.** A verified + assertion's `(provider, sub)` pair must match a member — none is a + rejection; should several members declare the same subject, the first + declared wins. The subject gates *which principal* may authenticate (a + specific SSO permission set or task role) and is what audit attribution + records. Pattern or wildcard matching is + deliberately absent until an integration concretely needs it. The subject + does not carry identity: an AWS-issued `sub` is a bare role ARN with no + email. +- **Verification is one `JWT.decode`.** The provider supplies the policy the + library enforces: an asymmetric-only algorithm allowlist (per-provider + override available; unsigned tokens and HMAC key-confusion are rejected by + construction), its key set as the library's JWKS loader, the expected + audience, 15 seconds of clock leeway, and single-use `jti` consumption + backed by the shared cache, scoped per issuer. Every assertion must carry + a fresh `jti` — platform tokens included. The library's typed errors are + the rejection taxonomy; the endpoint collapses them all to an opaque + `invalid_grant`. +- **The expected audience is the requesting site.** By default a token must + name `{base_url}/admin` of the request it is exchanged on — trusting the + request host is sound because Rails host authorization + (`config.host_authorization`) is part of the standard deploy. A provider + may pin `audience:` explicitly (for tokens not minted for the serving + host), and verification fails closed when no expected audience exists — + the JWT library would otherwise skip the check entirely for a nil + audience. +- **The member's scope names the actor.** `scope: admin/user` makes the + token act as the matched admin; `scope: admin/role/` makes it act + as that machine role (see + [Machine actors](#machine-actors-assumable-roles)). The grammar is + `[/]` — the type segment is the underscored + actor class (`admin/user` → `Admin::User`), dispatched through an + allowlist, never constantized. `admin/user` carries no instance segment + because identity comes from the claims; `admin/role/` names its + instance because identity comes from config. +- **User mapping reads admin-controlled identity claims.** The principal is + one flat shape — provider, subject, scope, plus optional `name` and + `email` — and identity extraction is keyed by issuer when the principal is + built: AWS issuers lift `principal_tags` (`email`, `name` — populated by + Identity Center attribute mapping) into those fields. An `admin/user` + member matches the principal's email against `Admin::User#email` via the + model's normalization, excluding archived admins. Identity never comes + from caller-chosen claims (`request_tags`) or the subject; a principal + whose issuer lifts no email can never match an admin. Role members need + no identity mapping. +- **Config errors fail boot.** The trust registry is validated on every + boot and reload (`to_prepare` constructs the providers and members): + entries are POROs with model validations — providers must be + well-formed, pinned key sets must parse from their `KOI_API_JWKS_*` + variable, and every member must name a declared provider and an + allowlisted scope. The mistakes this catches would otherwise be silently + inert (a member that can never match); each instead fails the deploy + with an error naming the offender. Validation reads config and ENV — no + database, no discovery HTTP. Provider entries are additionally validated + when materialized for a verification, so drift after boot still fails + the exchange that touches it with a hard error — never a silent no-op. + +### (b) Key material — pinned or discovered + +Each service declares where its verification keys come from: + +- **`keys: env` — pinned.** The service reads a JSON key set from + `KOI_API_JWKS_` (service name upcased); a missing or + unparseable set fails boot. For ad-hoc partners the + keys are committed to Terraform config and reviewed as a PR (public keys + are not sensitive). Every key carries a `kid`; overlapping keys during + rotation resolve unambiguously. Changing keys is a deploy — appropriate + for partners whose keys change rarely and deliberately. +- **`keys: discover` — OIDC discovery.** The issuer URL must be HTTPS; Koi + fetches `{issuer}/.well-known/openid-configuration`, follows it to the + key set, and caches the result in the shared Rails cache for one hour. + The discovery response must name the configured issuer (mix-up defence) — + only the issuer's own keys are ever trusted. An assertion presenting a + `kid` missing from a cached set older than the invalidation grace (five + minutes) busts the cache and refetches — so a rotated-in key is honoured + within minutes, a removed key survives at most the TTL, and a stream of + garbage `kid`s cannot force a refetch per request. Fetch failure fails + closed: + that provider's exchanges are rejected until its issuer is reachable — + expired cache entries are never served, and other providers are + unaffected. Issuer key rotation is thus absorbed automatically — + appropriate for platforms (GitHub, AWS accounts) that rotate on their own + schedule. This is the one deliberate runtime egress in the design: HTTPS + to an issuer URL that is itself pinned in reviewed config, so a + compromised cache can never *widen* trust beyond the configured issuer, + audience, and subject rules. + +Terraform's role narrows to what it is good at: enabling outbound federation +on our AWS accounts (the `aws_iam_outbound_web_identity_federation` resource, +which returns each account's issuer URL — stable per account, so it is +committed directly into `config/koi.yml` rather than threaded through ENV), +configuring Identity Center attribute mapping so an engineer's email rides +in `principal_tags.email`, rendering pinned key sets for ad-hoc partners, +and managing the IAM policies that grant `sts:GetWebIdentityToken` with +audience/duration condition keys (e.g. engineers may only mint tokens whose +audience is one of our sites). A principal outside those grants — another +AWS account included — cannot mint tokens for our sites; verified by hand. + +### (c) Koi admin visibility — read-only + +`/admin/admin_roles` is the trust surface. The index materializes every +granted role and lists each row: slug, when it materialized, when it last +authenticated +(token issuance — requests made with an outstanding token never touch the +row), and whether it is orphaned (declared once, since removed from +config; the row is retained for history). A role's show page details its +trust: every member granting the role — subject and provider — with the +provider's verification detail (issuer, key source; pinned providers show +key fingerprints, discovered providers their cached fingerprints and fetch +time). Rendering may prime the discovery cache — the same fail-closed path +verification uses — and an unreachable issuer reports itself on the page +rather than erroring. Strictly read-only — changing +trust is a config change and deploy, and revoking a role's outstanding +tokens is a rare, operator-level action: touch `tokens_revoked_at` from +the console. The page requires an admin session (bearer tokens are +refused), and answers "what can act as this role, and how is it +verified?" without reading Terraform or YAML. + +User-scoped trust (`admin/user` members) deliberately has no UI: it is +largely orthogonal to the questions asked about roles, and its +authoritative record is the reviewed config, validated at boot. + +### Issued tokens + +The token endpoint — `POST /admin/tokens`, the jwt-bearer grant beside the +existing device-code grant — issues tokens through the same primitive as +the device flow: each successful exchange records a consumed grant bound to +its actor — the matched admin for `admin/user` members, the materialized +role for `admin/role/` members — and returns its `api_access` bearer +token. Every issued token — assertion-grant and device-flow alike — lives +one hour, the lifetime of the token definition itself; clients re-exchange +rather than hold long-lived credentials. The middleware's bearer path +authenticates the grant's actor (`Koi::Current.actor`), every grant leaves +an auditable record (requesting IP, user agent, actor), and the issued +token's TTL is independent of the assertion's (AWS proofs can live for +seconds; the issued token still lives its hour). +Role-scoped issuance shares the same grant record: the admin-user reference +is optional, a role reference sits beside it, and an approved grant +belongs to exactly one of the two (pending device-flow grants have neither +until approval; holding both is forbidden at the database). The token's +revocation claim comes from the actor — the user's sign-in time or the role +row's `tokens_revoked_at` — so signing in again invalidates a user's +outstanding tokens, and touching the role row invalidates a role's. + +Bearer tokens of either scope stop at session-only surfaces: admin, +profile, and credential management require a cookie session and answer +token-authenticated requests with `403` — the deliberately coarse surface +boundary ahead of the permissions model, and the promised distinction +between `401` (bad token) and `403` (good token, wrong surface). + +Assertions are ephemeral, so every grant also records a snapshot of the +verified principal, serialized by the principal itself: provider, subject, +and scope, plus the optional identity fields (`name`, `email`) lifted from +admin-controlled claims — so a role assumed by a person stays attributable +to them. The snapshot is written at issuance and read-only thereafter; it +rehydrates into a principal for attribution on later requests — +`Koi::Current.principal`, request logs, and the admin pages can name +"`event_editor`, assumed by `avr-production`" long after the assertion is +gone. Attribution reaches logs as data, not messages: the controller +appends the stored principal's provider and subject to the request's +`process_action` payload (`append_info_to_payload` — pure Rails +instrumentation), where structured loggers emit them as fields on the +per-request event. No separate log line per authenticated request, and no +coupling to any logging gem. + +## Machine actors: assumable roles + +Role-scoped tokens need something to act *as* — an entity the future +permissions model can bind to and audit records can point at. Two shapes +were considered and rejected: + +- **Reusing admin users** (the service-account pattern): every existing + attribution mechanism would work for free, but the user model is + saturated with authentication affordances — passwords, passkeys, OTP, + reset flows, sign-in by email — that a machine identity must be carefully + fenced out of. The fencing costs more than the reuse saves, and a machine + user reachable by password reset is a standing hazard. +- **Memory-only roles** (pure config, ActiveModel): matches the koi.yml + philosophy, but fails on traceability. Attribution needs a foreign key + that survives renames; sessions and tokens are rows and cannot belong to + an object that evaporates at boot; and revocation wants server-side state + to flip. + +The chosen shape is an **assumable role**, AWS-style: an `Admin::Role`, +sitting parallel to `Admin::User`. A role has no authentication principal — +nothing signs in *as* it, it is only ever assumed via a trust entry — but +sessions and tokens belong to it, and the anticipated permissions model (a +coarse none/read/write/all default plus per-module overrides) hangs off the +same entity. + +**Config is the source of definition; the database is the source of +identity.** Roles are declared by the members that grant them: a role +exists when some member's scope names it (`admin/role/`), and the +declared set is the union of granted slugs — no separate declaration until +the permissions model wants per-role config. A member naming an undeclared +provider fails boot like any other config error — a pure config check, no +database involved. The database row, keyed by a stable slug, is what +sessions, tokens, and audit records reference. + +- **Materialization is lazy.** Boot performs no database work. The first + time a role is accessed through the registry — typically first token + issuance — the row is created or found (create-first against the unique + slug index, race-safe because roles are never deleted). Test runs, `assets:precompile`, and unrelated rake tasks + never touch the database for roles. What lazy materialization implies for + the config schema is deliberately deferred (see unknowns). +- **Sync never deletes.** A role removed from config stops being assumable + immediately — config is authoritative for what may be granted — but its + row and history remain, flagged as orphaned in the admin UI. A renamed + slug is a new role; history stays with the old row. +- **Revocation lever.** Issued tokens embed a claim sourced from the role + row (`tokens_revoked_at`); touching that timestamp — a console + operation — instantly invalidates every outstanding token for the role, + the machine analogue of the sign-in-time invalidation that user tokens + already use. The asymmetry is deliberate: *granting* access is config + review plus deploy, *revoking* it is a timestamp. +- **Sessions and tokens are actor-polymorphic.** Ownership references an + actor that is either an admin user or a role — two optional references, + exactly one set once granted — and `Koi::Current` exposes an `actor` + alongside `admin_user`. A role grant assumed by a person stays + attributable to them through the grant's principal snapshot — the pattern + for "engineer temporarily acts as support" with full attribution, at no + change to the actor model. + +Whether permission *values* end up code-managed or admin-editable remains +open: they can start as config read through the registry and later become +editable columns. The role row exists either way — the actor decision is +safe to make now precisely because it does not foreclose the policy +decision. + +## Unknowns + +1. **The permissions model itself.** The machine-actor question is decided + ([roles](#machine-actors-assumable-roles)), but permission semantics are + not: the none/read/write/all default, module-override granularity, which + controllers/actions each module gates, and how a user-scoped token + composes with that user's own permissions. This is the largest adjacent + work item and only its actor entity is designed here. +2. **Role declaration schema.** Lazy materialization means roles are read + through config on access; what the `roles:` section holds beyond a slug + (permission defaults, descriptions, ownership) is deferred until the + permissions model lands — the schema must stay compatible with rows + being created lazily and never deleted. +3. **AWS enablement governance.** Enabling outbound federation is + account-level: any principal granted `sts:GetWebIdentityToken` can then + assert its identity to external services. The enabling Terraform change + should land together with SCP/condition-key guardrails (allowed + audiences, durations, algorithms), and each additional AWS account is + another issuer URL and trust entry. +4. **Audit.** What gets recorded on issuance and rejection (service, + subject, decision), and whether recent activity appears on the read-only + admin page. Issued user-scoped grants already leave a device-authorization + record; rejections currently leave only logs. +5. **Operational limits.** `koi.yml` schema validation approach; ENV value + size for pinned key sets on the deployment platform. + +## Rabbit holes + +Explicitly out of scope; each is a large system that short TTLs, reviewed +config, and deploys are standing in for: + +- **Becoming a general OAuth2/OIDC authorization server** — client + registration, consent, refresh tokens, introspection. +- **Mutable trust in the admin UI.** Read-only is a feature: config review + and the deploy pipeline are the change control and audit trail. +- **A fine-grained permission/policy engine.** Roles are coarse, + code-defined bundles. +- **Token revocation infrastructure** (revocation lists, introspection + endpoints) — short lifetimes instead. +- **Proof-of-possession binding of issued tokens** (sender-constrained + tokens, mTLS, per-request signing) — issued tokens are plain bearer + tokens over TLS. +- **Pluggable key-source frameworks.** Exactly two sources: `env` and + `discover`. A third appears when a concrete integration demands it. +- **Interactive AWS SSO sign-in for the admin UI.** Real ambition, separate + design — the same `principal_tags.email` identity this design relies on + would carry it, but this design does not build it. + +## Worked examples + +### A. AVR servers in an untrusted cloud → `event_editor` + +Ad-hoc partner: pinned keys (`keys: env`). The operator generates a keypair +per server; private keys never leave the servers. Public keys travel out of +band and become reviewed Terraform config. Staging and production accept +different subjects and audiences via their `koi.yml` sections. + +```mermaid +sequenceDiagram + participant AVR as AVR server (untrusted cloud) + participant Op as AVR operator + participant Eng as Katalyst engineer + participant TF as Terraform + participant Koi as Koi site + + Note over Op,TF: Provisioning — once per key, out of band + Op->>Op: generate keypair, private key stays on server + Op->>Eng: public key + subject name (email is fine — not secret) + Eng->>TF: commit key to terraform config, PR review + TF->>Koi: apply renders KOI_API_JWKS_AVR, deploy restarts app + + Note over AVR,Koi: Authentication — repeated, unattended + AVR->>AVR: sign assertion {iss: "avr", sub: "avr-production",
    aud: site, exp: +5m, jti: one-shot} + AVR->>Koi: POST token endpoint (assertion grant) + Koi->>Koi: resolve provider by iss, select pinned key by kid, verify signature + Koi->>Koi: match exact subject, check aud, freshness, consume jti + Koi-->>AVR: bearer token {service: avr, role: event_editor, exp: +1h} + AVR->>Koi: API calls with Authorization: Bearer … + Koi->>Koi: middleware verifies token, authorisation limited to event editing +``` + +Key rotation: operator sends a new public key, it lands in Terraform +alongside the old one (both valid via `kid`), servers switch, the old key is +removed in a follow-up apply. Compromise response: remove the key and deploy +to stop new issuance, and touch the `event_editor` role's revocation +timestamp to kill outstanding tokens immediately. + +### B. Katalyst admin agents via AWS identity → user-scoped token + +A developer's agent holds AWS credentials from their SSO session. It asks +AWS to mint an identity token naming the Koi site as audience, and exchanges +that for a Koi token. No keys are provisioned anywhere: Koi discovers AWS's +verification keys from the account's issuer URL, and the agent's only +credential is its existing SSO session. The user's email rides in the +`principal_tags.email` claim, populated by Identity Center attribute mapping; +the subject only gates the permission set. + +```mermaid +sequenceDiagram + participant Agent as Agent (developer machine) + participant STS as AWS STS (regional) + participant Koi as Koi site + participant Iss as Account issuer (.well-known) + participant DB as Admin users + + Agent->>STS: GetWebIdentityToken(audience: site,
    duration: 300s) using SSO session + STS-->>Agent: JWT {iss: account issuer, aud: site, sub: …AWSReservedSSO_Engineer_…,
    principal_tags.email: person, exp: +5m} + Agent->>Koi: POST token endpoint (assertion grant, JWT) + Koi->>Koi: resolve provider aws by iss + Koi->>Iss: fetch/refresh discovery keys (cached, only on miss) + Koi->>Koi: verify signature, aud, exp; match exact subject (permission set);
    read principal_tags.email claim + Koi->>DB: email → active admin user? + DB-->>Koi: Admin::User found + Koi-->>Agent: bearer token bound to that admin user, exp +1h + Agent->>Koi: API calls attributed to stephen.nelson@katalyst.com.au +``` + +Defence in depth sits on the AWS side too: the IAM policy granting +`sts:GetWebIdentityToken` uses condition keys so engineers can only mint +tokens whose audience is one of our sites. Revocation follows the person — +removing the admin user or their SSO access closes the door. + +### C. Katalyst-managed AVR legacy app via ECS task role → `event_editor` + +Same flow as B with a different principal and service-scoped result. There +are no secrets anywhere: AWS injects rotating task-role credentials into the +container, and those mint the identity token. + +```mermaid +sequenceDiagram + participant App as AVR legacy app (ECS container) + participant STS as AWS STS (regional) + participant Koi as Koi site + + App->>STS: GetWebIdentityToken(audience: site) using task-role credentials + STS-->>App: JWT {iss: account issuer, sub: …:role/avr-legacy-task} + App->>Koi: POST token endpoint (assertion grant, JWT) + Koi->>Koi: match member avr_legacy by iss+subject,
    verify via cached discovery keys + Koi-->>App: bearer token {role: event_editor, exp: +1h} + App->>Koi: event-editing API calls +``` + +Rotation and revocation are AWS-side (task-role credentials rotate +automatically; delete the role or its `sts:GetWebIdentityToken` grant to +revoke). Koi-side the trust rule is one `koi.yml` entry. + +### D. Local development against a dev instance + +The `shared:` section means the `aws` provider exists in development, and +the expected audience derives from the requesting host — so a token minted +for `https://localhost/admin` just works, and the production code path +runs unchanged locally with no development-specific config or bypass: a +developer's AWS session is their local credential too. + +```mermaid +sequenceDiagram + participant Dev as Developer tool (local) + participant SSO as AWS SSO + participant STS as AWS STS (regional) + participant Koi as Dev instance (localhost) + + Dev->>SSO: aws sso login (browser, once per session) + Dev->>STS: GetWebIdentityToken(audience: https://localhost/admin) + STS-->>Dev: JWT for the developer's SSO session + Dev->>Koi: POST token endpoint (assertion grant) + Koi->>Koi: verify via discovery, read principal_tags.email → admin user + Koi-->>Dev: user-scoped bearer token +``` + +The IAM audience condition needs to permit localhost audiences for developer +roles — a deliberate, visible choice in the Terraform-managed policy. + +--- + +## Appendix: background and alternatives considered + +### Where Koi is today + +Koi's authentication is human-centred: interactive admin sessions backed by a +database record and signed cookie, established via password (optional TOTP), +passkey, or a signed one-time link; plus a device authorization flow +(RFC 8628 shape) where a client requests a device code, a human approves in a +browser, and the client receives a one-hour bearer token. + +Two primitives carry over to this design: the middleware already has a +bearer-token request path distinct from cookie sessions, and tokens are +self-encoded signed tokens (generated and verified with the application +secret, embedded expiry, no token table). Every current flow needs a human at +a browser; the gap this design fills is unattended authentication. + +### Design goals + +- No long-lived credentials in transit — anything on the wire is short-lived + or single-use. +- Cheap, per-integration revocation. +- Small operational footprint — a single Rails app, no separate authorization + server, no key-management infrastructure. +- Requests attributable to a named integration or person, never a shared + "the API" identity. + +### Alternatives considered and set aside + +**Forwarded cloud identity check.** Before November 2025, AWS could not +issue identity tokens for IAM principals, and the standard workaround (used +by Google Cloud and HashiCorp Vault to federate AWS identities) was for the +caller to *sign* a "who am I?" request to AWS's identity endpoint without +sending it, hand the signed request to the verifier, and have the verifier +forward it to AWS and read back the identity. It works, but at real cost: a +second verification code path, runtime calls to AWS on the hot path, +response parsing, and a subtle requirement to bind the target site into the +signature to prevent cross-site replay. `sts:GetWebIdentityToken` (AWS IAM +Outbound Identity Federation) supersedes it entirely for our purposes; the +technique remains relevant only for platforms that cannot issue identity +tokens. + +**Pin all keys at apply time.** An earlier revision of this design pinned +*every* service's keys via Terraform, including snapshots of issuers' +published key sets, to keep the runtime free of outbound requests. Walked +back: for genuine OIDC issuers (GitHub, AWS accounts) apply-time snapshots +turn the issuer's key rotation into our outage, mitigated only by scheduled +re-applies. Discovery-with-caching against a *pinned issuer URL* keeps the +part that mattered (trust rules only change by deploy) while absorbing +rotation. Pinning remains the right tool for ad-hoc partner keys, which +rotate rarely and deliberately. + +**Per-request signing (no tokens).** Every API request carries a signature +over method, path, body digest, and timestamp; the server recomputes and +compares. Stateless and highly theft-resistant — a captured request yields +nothing reusable — but pushes canonical-request signing (header ordering, +body digests, URL normalisation) onto every client, and clock skew becomes an +operational concern. Wrong default for casual tooling; revisit only if a +single high-value partner demands sender-constrained requests. + +**First-party client secrets.** The admin UI generates a high-entropy secret +shown once, stores a digest, and the client exchanges id+secret for a bearer +token. Universally compatible ("API keys done carefully") but reintroduces +the long-lived shared secret everything else here avoids. Set aside because +every caller we currently foresee can do better; keep in the back pocket for +a future partner who genuinely can't sign anything. + +**Mutual TLS.** Authentication in the TLS handshake via client certificates; +strongest channel binding, near-zero application code. Set aside because the +weight moves to certificate lifecycle and proxy configuration — +infrastructure burden this design exists to avoid — and managed TLS +termination often can't do it at all. + +### Why one verifier + +All retained and set-aside options converge on the same final step: verify +some proof, mint a short-lived bearer token the middleware already accepts. +The design keeps exactly one registry and one endpoint so that adding or +removing a trust source never restructures the system — the device flow's +"human approved this code" is just the proof that came first. From b2652488f6958b6c55b3ea9a3ba7084c5e4b4bfa Mon Sep 17 00:00:00 2001 From: Stephen Nelson Date: Wed, 22 Jul 2026 08:53:39 +0930 Subject: [PATCH 8/8] Identity: verify exp when accepting tokens Important to prevent accepting tokens that have no expiry and might be replayed after the kid cache expires. --- app/models/koi/identity/assertion.rb | 21 +++++++++++++++------ app/models/koi/identity/provider.rb | 6 ++++++ docs/api-partner-integration.md | 1 + docs/passwordless-api-authentication.md | 8 +++++--- spec/models/koi/identity_spec.rb | 12 ++++++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/app/models/koi/identity/assertion.rb b/app/models/koi/identity/assertion.rb index 69596389c..18d43a772 100644 --- a/app/models/koi/identity/assertion.rb +++ b/app/models/koi/identity/assertion.rb @@ -28,16 +28,25 @@ def header end def verify!(provider) + # The library validates required_claims after verify_jti, but + # consume_jti derives its cache TTL from exp — so require it first. + raise JWT::MissingRequiredClaim, "missing required claim exp" if claims["exp"].nil? + JWT.decode( @token, nil, true, - algorithms: provider.algorithms, - jwks: provider.method(:key_set), - aud: provider.audience, - leeway: provider.leeway.to_i, - verify_aud: true, - verify_jti: provider.method(:consume_jti) + algorithms: provider.algorithms, + jwks: provider.method(:key_set), + aud: provider.audience, + leeway: provider.leeway.to_i, + required_claims: %w[exp], + verify_aud: true, + verify_jti: provider.method(:consume_jti) ) + if claims["exp"].to_i > provider.max_expiry.from_now.to_i + raise JWT::InvalidPayload, "assertion expiry is more than #{provider.max_expiry.inspect} away" + end + # ensure that we can map the claim to a valid principal using the claim's subject @principal = Identity.principal_for(provider, self) diff --git a/app/models/koi/identity/provider.rb b/app/models/koi/identity/provider.rb index c1e7795b2..e7419e15d 100644 --- a/app/models/koi/identity/provider.rb +++ b/app/models/koi/identity/provider.rb @@ -29,6 +29,12 @@ class Provider # Allowed clock drift for verification attribute :leeway, default: -> { 15.seconds } + # Upper bound on assertion lifetime. Exchange is immediate, so a + # distant expiry indicates an integration minting long-lived + # credentials; require assertions to be short-lived instead + # (RFC 7523 §3). + attribute :max_expiry, default: -> { 1.hour } + validates :keys, inclusion: { in: %w[env discover] } validate :pinned_keys_parse, if: -> { keys == "env" } diff --git a/docs/api-partner-integration.md b/docs/api-partner-integration.md index c89de6312..f36b1fb5b 100644 --- a/docs/api-partner-integration.md +++ b/docs/api-partner-integration.md @@ -175,6 +175,7 @@ rejected assertion, deliberately without detail. Work through this list: | Check | Common cause | |---|---| | Server clock | `exp` already passed on arrival — sync NTP | +| `exp` | Missing, or more than an hour away — keep it ≤5 minutes | | `aud` | Must be exactly `https://example.com/admin` | | `kid` header | Must match the key id you registered | | `jti` | Missing or reused — generate a fresh UUID per assertion | diff --git a/docs/passwordless-api-authentication.md b/docs/passwordless-api-authentication.md index fa3307dba..4c0840de2 100644 --- a/docs/passwordless-api-authentication.md +++ b/docs/passwordless-api-authentication.md @@ -127,9 +127,11 @@ Semantics: library enforces: an asymmetric-only algorithm allowlist (per-provider override available; unsigned tokens and HMAC key-confusion are rejected by construction), its key set as the library's JWKS loader, the expected - audience, 15 seconds of clock leeway, and single-use `jti` consumption - backed by the shared cache, scoped per issuer. Every assertion must carry - a fresh `jti` — platform tokens included. The library's typed errors are + audience, a required expiry no more than an hour out (assertions are + exchanged immediately, so a distant expiry is a misconfiguration minting + long-lived credentials), 15 seconds of clock leeway, and single-use `jti` + consumption backed by the shared cache, scoped per issuer. Every + assertion must carry a fresh `jti` — platform tokens included. The library's typed errors are the rejection taxonomy; the endpoint collapses them all to an opaque `invalid_grant`. - **The expected audience is the requesting site.** By default a token must diff --git a/spec/models/koi/identity_spec.rb b/spec/models/koi/identity_spec.rb index a401db75a..a08c72de7 100644 --- a/spec/models/koi/identity_spec.rb +++ b/spec/models/koi/identity_spec.rb @@ -135,6 +135,18 @@ def with_memory_cache expect { authorize }.to raise_error(JWT::ExpiredSignature) end + it "rejects an assertion without an expiry" do + claims.delete(:exp) + + expect { authorize }.to raise_error(JWT::MissingRequiredClaim) + end + + it "rejects an assertion whose expiry is too distant" do + claims[:exp] = 2.hours.from_now.to_i + + expect { authorize }.to raise_error(an_instance_of(JWT::InvalidPayload)) + end + it "accepts clock skew within tolerance" do claims[:iat] = 10.seconds.from_now.to_i