From 3624ec33046e1abe0355d7e3a3c9f8619ac6648a Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Mon, 15 Jun 2026 16:45:01 +0800 Subject: [PATCH 1/2] feat: [PPT-2540] add expiry support to api key --- ...5000000000_alter_api_key_add_expiry_at.sql | 10 ++++ spec/api_key_spec.cr | 50 +++++++++++++++++++ src/placeos-models/api_key.cr | 35 ++++++++++++- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 migration/db/migrations/20260615000000000_alter_api_key_add_expiry_at.sql diff --git a/migration/db/migrations/20260615000000000_alter_api_key_add_expiry_at.sql b/migration/db/migrations/20260615000000000_alter_api_key_add_expiry_at.sql new file mode 100644 index 00000000..2aa94364 --- /dev/null +++ b/migration/db/migrations/20260615000000000_alter_api_key_add_expiry_at.sql @@ -0,0 +1,10 @@ +-- +micrate Up +-- SQL in section 'Up' is executed when this migration is applied + +ALTER TABLE api_key ADD COLUMN expires_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS api_key_expires_at_index ON "api_key" USING BTREE (expires_at); + +-- +micrate Down +-- SQL section 'Down' is executed when this migration is rolled back + +ALTER TABLE api_key DROP COLUMN IF EXISTS expires_at; diff --git a/spec/api_key_spec.cr b/spec/api_key_spec.cr index a6320bdf..e73e76cd 100644 --- a/spec/api_key_spec.cr +++ b/spec/api_key_spec.cr @@ -128,5 +128,55 @@ module PlaceOS::Model key.user.not_nil!.destroy ApiKey.find?(id).should be_nil end + + describe "expiry" do + it "is not expired when expires_at is nil" do + key = Generator.api_key + key.expires_at = nil + key.save! + key.expired?.should be_false + end + + it "is not expired when expires_at is in the future" do + key = Generator.api_key + key.expires_at = Time.utc + 1.hour + key.save! + key.expired?.should be_false + end + + it "is expired when expires_at is in the past" do + key = Generator.api_key + key.expires_at = Time.utc - 1.hour + expect_raises(PgORM::Error::RecordInvalid, "`expires_at` must be in the future") do + key.save! + end + key.expired?.should be_true + end + + it "converts ttl to expires_at on create" do + key = Generator.api_key + key.ttl = 3600 + key.save! + key.expires_at.should_not be_nil + key.expires_at.not_nil!.should be > Time.utc + (key.expires_at.not_nil! - Time.utc).should be_close(3600.seconds, 5.seconds) + end + + it "uses the sooner of expires_at and ttl" do + key = Generator.api_key + key.expires_at = Time.utc + 2.hours + key.ttl = 60 + key.save! + (key.expires_at.not_nil! - Time.utc).should be_close(60.seconds, 5.seconds) + end + + it "includes expires_at in public JSON" do + key = Generator.api_key + key.expires_at = Time.utc + 1.hour + key.save! + json = JSON.parse(key.to_public_json).as_h + json.has_key?("expires_at").should be_true + end + end end end diff --git a/src/placeos-models/api_key.cr b/src/placeos-models/api_key.cr index 2f0a2767..a1467253 100644 --- a/src/placeos-models/api_key.cr +++ b/src/placeos-models/api_key.cr @@ -19,6 +19,10 @@ module PlaceOS::Model attribute secret : String = -> { Random::Secure.urlsafe_base64(32) }, mass_assignment: false + attribute expires_at : Time?, converter: Time::EpochConverterOptional, type: "integer", format: "Int64" + @[JSON::Field(ignore_deserialize: true)] + property ttl : Int32? = nil + belongs_to User belongs_to Authority @@ -38,7 +42,7 @@ module PlaceOS::Model define_to_json :public, only: [ :name, :description, :scopes, :user_id, :authority_id, :created_at, - :updated_at, + :updated_at, :expires_at, ], methods: [:user, :authority, :x_api_key, :permissions, :id] # Validation @@ -57,6 +61,7 @@ module PlaceOS::Model before_create :set_authority before_create :x_api_key before_create :hash! + before_create :convert_ttl! protected def safe_id self.new_record = true @@ -73,6 +78,26 @@ module PlaceOS::Model self end + def convert_ttl! + if ttl = @ttl + ttl_expires_at = Time.utc + ttl.seconds + if (existing = @expires_at) && existing < ttl_expires_at + # Keep existing — it's sooner + else + @expires_at = ttl_expires_at + end + @ttl = nil + end + end + + validate ->(this : ApiKey) { + if (exp = this.expires_at) && exp <= Time.utc + this.validation_error(:expires_at, message: "must be in the future") + end + if (ttl = this.ttl) && ttl < 1 + this.validation_error(:ttl, message: "must be positive") + end + } # Token Methods ############################################################################################### @@ -95,6 +120,14 @@ module PlaceOS::Model model end + def expired? : Bool + if expires_at = @expires_at + expires_at <= Time.utc + else + false + end + end + def build_jwt ident = self.user.not_nil! From 55ffc99601a4f59c9aeac8a9e9d30a3afb382f4a Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Mon, 15 Jun 2026 17:32:56 +0800 Subject: [PATCH 2/2] refactor: add tests to validate round-trips --- spec/api_key_spec.cr | 24 ++++++++++++++++++++++++ src/placeos-models/api_key.cr | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/spec/api_key_spec.cr b/spec/api_key_spec.cr index e73e76cd..cfc90ffe 100644 --- a/spec/api_key_spec.cr +++ b/spec/api_key_spec.cr @@ -177,6 +177,30 @@ module PlaceOS::Model json = JSON.parse(key.to_public_json).as_h json.has_key?("expires_at").should be_true end + + it "converts ttl to expires_at on create" do + key = Generator.api_key + key.ttl = 3600 + key.save! + key.expires_at.should_not be_nil + (key.expires_at.not_nil! - Time.utc).should be_close(3600.seconds, 5.seconds) + key.ttl.should be_nil + JSON.parse(key.to_json).as_h.has_key?("ttl").should be_false + end + + it "round-trips with ttl from JSON input, no ttl in output" do + authority = Generator.authority.save! + json_input = Generator.api_key.to_json + hash = JSON.parse(json_input).as_h + hash["ttl"] = JSON::Any.new(3600_i64) + key = ApiKey.from_trusted_json(hash.to_json) + key.user = Generator.user(authority).save! + key.save! + key.expires_at.should_not be_nil + json_out = JSON.parse(key.to_public_json).as_h + json_out.has_key?("ttl").should be_false + json_out.has_key?("expires_at").should be_true + end end end end diff --git a/src/placeos-models/api_key.cr b/src/placeos-models/api_key.cr index a1467253..285b3dbc 100644 --- a/src/placeos-models/api_key.cr +++ b/src/placeos-models/api_key.cr @@ -20,7 +20,7 @@ module PlaceOS::Model attribute secret : String = -> { Random::Secure.urlsafe_base64(32) }, mass_assignment: false attribute expires_at : Time?, converter: Time::EpochConverterOptional, type: "integer", format: "Int64" - @[JSON::Field(ignore_deserialize: true)] + @[JSON::Field(ignore_serialize: true)] property ttl : Int32? = nil belongs_to User