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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
74 changes: 74 additions & 0 deletions spec/api_key_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,79 @@ 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

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
35 changes: 34 additions & 1 deletion src/placeos-models/api_key.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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_serialize: true)]
property ttl : Int32? = nil

belongs_to User
belongs_to Authority

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
###############################################################################################

Expand All @@ -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!

Expand Down
Loading