From 539dfb0a7fb96bcb801174c64fdb3e3a4053ff3e Mon Sep 17 00:00:00 2001 From: James Miller Date: Sat, 14 Oct 2023 13:04:10 -0700 Subject: [PATCH] =?UTF-8?q?add=20ruby=20bindings=20=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 23 ++++ .gitignore | 22 +++ .standard.yml | 1 + Gemfile | 13 ++ LICENSE | 20 +++ README.md | 238 +++++++++++++++++++++++++++++++++ Rakefile | 5 + hotsock.gemspec | 26 ++++ lib/hotsock.rb | 36 +++++ lib/hotsock/config.rb | 48 +++++++ lib/hotsock/issuer.rb | 39 ++++++ lib/hotsock/publisher.rb | 71 ++++++++++ lib/hotsock/version.rb | 5 + test/helper.rb | 30 +++++ test/hotsock/config_test.rb | 40 ++++++ test/hotsock/issuer_test.rb | 123 +++++++++++++++++ test/hotsock/publisher_test.rb | 79 +++++++++++ test/hotsock_test.rb | 54 ++++++++ 18 files changed, 873 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .standard.yml create mode 100644 Gemfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 hotsock.gemspec create mode 100644 lib/hotsock.rb create mode 100644 lib/hotsock/config.rb create mode 100644 lib/hotsock/issuer.rb create mode 100644 lib/hotsock/publisher.rb create mode 100644 lib/hotsock/version.rb create mode 100644 test/helper.rb create mode 100644 test/hotsock/config_test.rb create mode 100644 test/hotsock/issuer_test.rb create mode 100644 test/hotsock/publisher_test.rb create mode 100644 test/hotsock_test.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d642168 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Test +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + test: + strategy: + matrix: + ruby-version: + - "3.1" + - "3.2" + - "3.3" + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + - run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebfacef --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.rvmrc +.ruby-version +tags +*.swp +dump.rdb +.rbx +coverage/ +vendor/ +.bundle/ +.sass-cache/ +tmp/ +pkg/*.gem +.byebug_history +development.log +/Dockerfile +/Makefile +/docker-compose.yml +Gemfile.lock +*.DS_Store +doc/ +.yardoc/ +*.gem diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 0000000..72b2693 --- /dev/null +++ b/.standard.yml @@ -0,0 +1 @@ +ruby_version: 3.1 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..05e3735 --- /dev/null +++ b/Gemfile @@ -0,0 +1,13 @@ +source "https://rubygems.org" + +gemspec + +group :development, :test do + gem "rake" + gem "standard", require: false +end + +group :test do + gem "mocktail" + gem "tldr" +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5f88da4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) JK Tech, Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..623fdd1 --- /dev/null +++ b/README.md @@ -0,0 +1,238 @@ +# Hotsock Ruby Library + +The Hotsock Ruby library provides convenient access to [Hotsock](https://www.hotsock.io) message publishing APIs and JWT signing from applications written in Ruby. + +## Installation + +You can install the gem with: + +```sh +gem install hotsock +``` + +### Requirements + +- Ruby 3.1+. + +### Bundler + +```ruby +source "https://rubygems.org" + +gem "hotsock" +``` + +## Usage + +The library needs to be configured with information specific to your Hotsock installation. + +```ruby +require "hotsock" + +# Setup the default configuration +Hotsock.configure do |config| + config.publish_function_arn = "..." + config.aws_region = "us-east-1" + # ... see below for all `configure` options +end + +# Publish a message +Hotsock.publish_message( + channel: "user.1", + event: "user.updated", + data: user.attributes + # ... see below for all `publish_message` options +) + +# Issue a JWT. `issue_token` takes an options hash of claims that will be +# included in the token payload. +token = Hotsock.issue_token( + channels: { + "user.#{current_user.id}": { + subscribe: true + } + }, + exp: Time.now.to_i + 30, + iat: Time.now.to_i, + scope: "connect", + uid: current_user.id.to_s, + umd: current_user.metadata_hash +) +# => "eyJ0eXAiOiJKV1QiLCJraWQiOiI5NTYxNmI3MCIsI..." +``` + +### Multiple configurations + +For apps that need to use multiple configurations during the lifetime of a process, like when interacting with multiple Hotsock installations, it's also possible to configure any number of publishers or issuers. + +```ruby +require "hotsock" + +eastConfig = Hotsock::Config.new +eastConfig.aws_region = "us-east-1" +eastConfig.publish_function_arn = "arn:aws:lambda:us-east-1:111111111111:function:Hotsock-Publishing-J718-PublishFunction-t8ix" + +westConfig = Hotsock::Config.new +westConfig.aws_region = "us-west-2" +westConfig.publish_function_arn = "arn:aws:lambda:us-west-2:111111111111:function:Hotsock-Publishing-UUA5-PublishFunction-f5h8" + +eastPublisher = Hotsock::Publisher.new(eastConfig) +eastPublisher.publish_message(...) + +westPublisher = Hotsock::Publisher.new(westConfig) +westPublisher.publish_message(...) +``` + +It's safe (and recommended) to use a single instance of `Hotsock::Publisher` or `Hotsock::Issuer` across threads. Do not create a publisher for each message or an issuer for each token. Doing so will cause performance issues when obtaining AWS credentials. It will also slow down token issuing because the private key will need to be loaded for each token. + +### `configure` + +You typically call `configure` once when your application is starting up. If using Rails, place your call to `configure` in an initializer. + +```ruby +require "hotsock" + +Hotsock.configure do |config| + # The Amazon Resource Name (Arn) of the Lambda function used to publish + # Hotsock messages. Grab the value from `PublishFunctionArn` in your + # installation's CloudFormation stack output. (required) + config.publish_function_arn = "arn:aws:lambda:us-east-1:111111111111:function:Hotsock-Publishing-J718-PublishFunction-t8ix" + + # The AWS region where your Hotsock installation resides. (required) + config.aws_region = "us-east-1" + + # If using static IAM user credentials to authorize access to invoke the + # message publishing Lambda function, specify the user's Access Key ID and + # Secret Access Key. (optional) + config.aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" + config.aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + + # If the IAM principal (user or role) that you are authorizing as must assume + # another role to publish messages to the Lambda function, specify the role + # that must be assumed. (optional) + config.aws_assume_role_arn = "arn:aws:iam::111111111111:role/MyRoleToAssume" + + # If specifying `aws_assume_role_arn`, you can specify a session name. If + # unspecified and assuming a role, this will be set to + # "hotsock-ruby-#{Hotsock::VERSION}" + # (optional) + config.aws_assume_role_session_name = "my-application-name" + + # If required by your administrator when assuming a role, specify an + # External ID. (optional) + config.aws_assume_role_external_id = "6f4c10321f" + + # If using this library for signing tokens, this is the private key. + # Committing this key to source control is not recommended. Instead consider + # using environment variables, Rails encrypted credentials, AWS Parameter + # Store, etc. and loading this key from there. For ES256 (ECDSA using P-256 + # and SHA-256), this key must be in PEM format. Don't use the key below! + # Generate your own! (optional) + config.issuer_private_key = "-----BEGIN EC PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg72ab3fXPvtD2iIQQ\n/RWiZh8WA6T9u6JNhEuy1DPSFpuhRANCAASmEDhCts7/LkmooXH1tMhyh9Qn94e3\ny3e/UtmnnAYMPwro8iySvqEUrYaDUqQ3iMjYpf+mvxOFmCy97MsBj/pu\n-----END EC PRIVATE KEY-----" + + # The algorithm to use when signing with the above key. Defaults to ES256. + # Supports HS256, HS384, HS512, ES256, ES384, ES512, RS256, RS384, RS512. + config.issuer_key_algorithm = "ES256" + + # Sets the `kid` JWT header to this value for all issued tokens. (optional) + config.issuer_key_id = "95616b70" + + # Sets the default value of the `aud` JWT payload claim to this value for + # issued tokens. Override by setting `aud` in the claims hash passed to + # `issue_token`. Ensure this matches the required audience claim required by + # your Hotsock installation configuration. (optional) + config.issuer_aud_claim = "hotsock" + + # Sets the default value of the `iat` JWT payload claim to the timestamp when + # the token is generated. Override by setting `iat` in the claims hash passed + # to `issue_token`. (optional, false by default) + config.issuer_iat_claim = true + + # Sets the default value of the `iss` JWT payload claim to this value for + # issued tokens. Override by setting `iss` in the claims hash passed to + # `issue_token`. (optional) + config.issuer_iss_claim = "my-application-name" + + # Sets the default value of the `jti` JWT payload claim to a unique ID (UUID) + # when the token is generated. Override by setting `jti` in the claims hash + # passed to `issue_token`. (optional, false by default) + config.issuer_jti_claim = true + + # Sets the default value of the `exp` JWT payload claim to this many seconds + # from the time that the token was issued. Override by setting `exp` in the + # claims hash passed to `issue_token`. (optional) + config.issuer_token_ttl = 10 +end +``` + +### `Hotsock.publish_message` or `Hotsock::Publisher#publish_message` + +The `publish_message` method directly invokes the AWS Lambda function specified in `config.publish_function_arn`. Supported attributes are [documented here](https://www.hotsock.io/docs/server-api/publish-messages). + +`publish_message` returns the raw `Aws::Lambda::Types::InvocationResponse` struct. The reason for this is because the actual response body is rarely needed - parsing the JSON and returning another object for each message would consume unnecessary cycles in the majority of cases. + +A case where you may want the response is when `eager_id_generation` is `true`. In this case you can access the payload like the following. This can obviously be shortened in a real application, but is illustrated in multiple steps here to clarify how it works. + +```ruby +lambda_response = Hotsock.publish_message(channel: "mychannel", event: "myevent", eager_id_generation: true) +# => #, +# executed_version="$LATEST"> +hotsock_response = lambda_response.payload.read +# => "{\"id\":\"01HBM6KJCPNZK11H79ZSHGAEE9\",\"channel\":\"mychannel\",\"event\":\"myevent\"}" +message_id = JSON.load(response)["id"] +# => "01HBM6KJCPNZK11H79ZSHGAEE9" +``` + +### `Hotsock.issue_token` or `Hotsock::Issuer#issue_token` + +The `issue_token` method locally signs and returns a JSON Web Token (JWT) using the key specified in `config.issuer_private_key`. It takes a single argument with a `Hash` of payload claims. This can be used to issue a JWT for anything, but provides some configuration options with Hotsock in mind. Hotsock-supported token claims are [documented here](https://www.hotsock.io/docs/connection/claims). + +At a minimum, Hotsock requires an `exp` claim to produce a valid token. Here's an example issuing a token that is valid for 30 seconds. You'll likely want additional claims. + +```ruby +Hotsock.issue_token(exp: Time.now.to_i + 30, scope: "connect") +# => "eyJ0eXAiOiJKV1QiLCJraWQiOiI5NTYxNmI3MCIsImFsZyI6IkhTMjU2In0.eyJleHAiOjE2OTYxMTcwNTIsInNjb3BlIjoiY29ubmVjdCJ9.CRam2nIGu55tIGRdXmU2rBpg2IVWzrBRmroSVquhg5I" +``` + +This translates to the following decoded token. + +```json +{ + "typ": "JWT", + "kid": "95616b70", + "alg": "ES256" +} +{ + "exp": 1696117052, + "scope": "connect" +} +``` + +## AWS Permissions + +If your application is running on EC2, ECS, Lambda, or another service that provides a built-in role (recommended), there is no need to specify credentials when calling `Hotsock.configure`. They will be loaded and refreshed automatically from the instance, task, or function role. + +Regardless of the AWS principal type, this role or user must be granted `lambda:InvokeFunction` permission to publish messages to the Hotsock publisher Lambda function. The policy might look something like this (replace the example function Arn with your `PublishFunctionArn`) and attach the policy to your role or user: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["lambda:InvokeFunction"], + "Effect": "Allow", + "Resource": [ + "arn:aws:lambda:us-east-1:111111111111:function:Hotsock-Publishing-J718-PublishFunction-t8ix" + ] + } + ] +} +``` + +## License + +See [LICENSE](LICENSE). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..e5cdb08 --- /dev/null +++ b/Rakefile @@ -0,0 +1,5 @@ +require "bundler/gem_tasks" +require "standard/rake" +require "tldr/rake" + +task default: [:tldr, "standard:fix"] diff --git a/hotsock.gemspec b/hotsock.gemspec new file mode 100644 index 0000000..b689c3a --- /dev/null +++ b/hotsock.gemspec @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(::File.join(::File.dirname(__FILE__), "lib")) + +require "hotsock/version" + +Gem::Specification.new do |spec| + spec.name = "hotsock" + spec.version = Hotsock::VERSION + spec.authors = ["James Miller"] + spec.email = ["support@hotsock.io"] + spec.homepage = "https://www.hotsock.io" + spec.summary = "Ruby bindings for the Hotsock message publishing APIs and JWT signing" + spec.description = "Hotsock is a real-time WebSockets service for your web and mobile applications, fully-managed and self-hosted in your AWS account." + spec.license = "MIT" + spec.required_ruby_version = ">= 3.1" + + ignored = Regexp.union( + /\A\.git/, + /\Atest/ + ) + spec.files = `git ls-files`.split("\n").reject { |f| ignored.match(f) } + + spec.add_dependency "jwt", "~> 2.7" + spec.add_dependency "aws-sdk-lambda", "~> 1.105" +end diff --git a/lib/hotsock.rb b/lib/hotsock.rb new file mode 100644 index 0000000..d5e22c1 --- /dev/null +++ b/lib/hotsock.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "hotsock/version" +require "hotsock/config" +require "hotsock/issuer" +require "hotsock/publisher" + +module Hotsock + class << self + def configure(&block) + yield default_config + end + + def publish_message(channel:, event:, **options) + default_publisher.publish_message(channel:, event:, **options) + end + + def issue_token(payload = {}) + default_issuer.issue_token(payload) + end + + private + + def default_config + @default_config ||= Hotsock::Config.new + end + + def default_publisher + @default_publisher ||= Hotsock::Publisher.new(default_config) + end + + def default_issuer + @default_issuer ||= Hotsock::Issuer.new(default_config) + end + end +end diff --git a/lib/hotsock/config.rb b/lib/hotsock/config.rb new file mode 100644 index 0000000..ca0ea5b --- /dev/null +++ b/lib/hotsock/config.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "openssl" + +module Hotsock + class Config + def aws_region + @aws_region or raise ArgumentError, "Hotsock configuration requires an aws_region" + end + attr_writer :aws_region + + attr_accessor :aws_access_key_id + attr_accessor :aws_secret_access_key + attr_accessor :aws_assume_role_arn + attr_accessor :aws_assume_role_session_name + attr_accessor :aws_assume_role_external_id + attr_accessor :publish_function_arn + attr_accessor :issuer_aud_claim + attr_accessor :issuer_iat_claim + attr_accessor :issuer_iss_claim + attr_accessor :issuer_jti_claim + attr_accessor :issuer_token_ttl + attr_accessor :issuer_key_id + + def issuer_private_key + @issuer_private_key or raise ArgumentError, "Hotsock configuration requires issuer_private_key for JWT issuing" + end + attr_writer :issuer_private_key + + def issuer_key_algorithm + @issuer_key_algorithm || "ES256" + end + attr_writer :issuer_key_algorithm + + def issuer_key + case issuer_key_algorithm + when "HS256", "HS384", "HS512" + @issuer_key ||= issuer_private_key + when "RS256", "RS384", "RS512" + @issuer_key ||= OpenSSL::PKey::RSA.new(issuer_private_key) + when "ES256", "ES384", "ES512" + @issuer_key ||= OpenSSL::PKey::EC.new(issuer_private_key) + else + raise ArgumentError, "Issuer key algorithm must be one of HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512" + end + end + end +end diff --git a/lib/hotsock/issuer.rb b/lib/hotsock/issuer.rb new file mode 100644 index 0000000..36fc005 --- /dev/null +++ b/lib/hotsock/issuer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "jwt" +require "securerandom" + +module Hotsock + class Issuer + def initialize(config) + @config = config + end + + def issue_token(claims = {}) + headers = {typ: "JWT"} + if @config.issuer_key_id + headers[:kid] = @config.issuer_key_id + end + + payload = {} + now_i = Time.now.to_i + if @config.issuer_aud_claim + payload[:aud] = @config.issuer_aud_claim + end + if @config.issuer_iat_claim == true + payload[:iat] = now_i + end + if @config.issuer_iss_claim + payload[:iss] = @config.issuer_iss_claim + end + if @config.issuer_jti_claim == true + payload[:jti] = SecureRandom.uuid + end + if @config.issuer_token_ttl.to_i > 0 + payload[:exp] = now_i + @config.issuer_token_ttl.to_i + end + + JWT.encode(payload.merge(claims), @config.issuer_key, @config.issuer_key_algorithm, headers) + end + end +end diff --git a/lib/hotsock/publisher.rb b/lib/hotsock/publisher.rb new file mode 100644 index 0000000..eefe7cd --- /dev/null +++ b/lib/hotsock/publisher.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "json" +require "aws-sdk-lambda" +require "aws-sdk-sts" + +module Hotsock + class Publisher + def initialize(config) + @config = config + end + + def publish_message(channel:, event:, **options) + payload = {event:, channel:} + payload[:data] = options.delete(:data) if options[:data] + payload[:deduplicationId] = options.delete(:deduplication_id) if options[:deduplication_id] + payload[:eagerIdGeneration] = options.delete(:eager_id_generation) if options[:eager_id_generation] + payload[:emitPubSubEvent] = options.delete(:emit_pub_sub_event) if options[:emit_pub_sub_event] + options.each do |k, v| + payload[k] = v + end + + lambda_client.invoke( + function_name: @config.publish_function_arn, + payload: JSON.dump(payload) + ) + end + + private + + def lambda_client + return @lambda_client if @lambda_client + + options = {region: @config.aws_region} + + if @config.aws_assume_role_arn + options[:credentials] = assume_role_credentials + elsif @config.aws_access_key_id || @config.aws_secret_access_key + options[:credentials] = static_credentials + end + + @lambda_client ||= Aws::Lambda::Client.new(options) + end + + def static_credentials + Aws::Credentials.new( + @config.aws_access_key_id, + @config.aws_secret_access_key + ) + end + + def assume_role_credentials + Aws::AssumeRoleCredentials.new( + client: sts_base_client, + role_arn: @config.aws_assume_role_arn, + role_session_name: @config.aws_assume_role_session_name || "hotsock-ruby-#{Hotsock::VERSION}", + external_id: @config.aws_assume_role_external_id + ) + end + + def sts_base_client + options = {region: @config.aws_region} + + if @config.aws_access_key_id || @config.aws_secret_access_key + options[:credentials] = static_credentials + end + + Aws::STS::Client.new(options) + end + end +end diff --git a/lib/hotsock/version.rb b/lib/hotsock/version.rb new file mode 100644 index 0000000..b3723cf --- /dev/null +++ b/lib/hotsock/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Hotsock + VERSION = "1.0.0" +end diff --git a/test/helper.rb b/test/helper.rb new file mode 100644 index 0000000..3ec8adb --- /dev/null +++ b/test/helper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "bundler/setup" +Bundler.require(:default, :test) + +TEST_ES256_PRIVATE_KEY_PEM = "-----BEGIN EC PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg72ab3fXPvtD2iIQQ\n/RWiZh8WA6T9u6JNhEuy1DPSFpuhRANCAASmEDhCts7/LkmooXH1tMhyh9Qn94e3\ny3e/UtmnnAYMPwro8iySvqEUrYaDUqQ3iMjYpf+mvxOFmCy97MsBj/pu\n-----END EC PRIVATE KEY-----" +TEST_ES256_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEphA4QrbO/y5JqKFx9bTIcofUJ/eH\nt8t3v1LZp5wGDD8K6PIskr6hFK2Gg1KkN4jI2KX/pr8ThZgsvezLAY/6bg==\n-----END PUBLIC KEY-----" +TEST_RS256_PRIVATE_KEY_PEM = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA4f5wg5l2hKsTeNem/V41fGnJm6gOdrj8ym3rFkEU/wT8RDtn\nSgFEZOQpHEgQ7JL38xUfU0Y3g6aYw9QT0hJ7mCpz9Er5qLaMXJwZxzHzAahlfA0i\ncqabvJOMvQtzD6uQv6wPEyZtDTWiQi9AXwBpHssPnpYGIn20ZZuNlX2BrClciHhC\nPUIIZOQn/MmqTD31jSyjoQoV7MhhMTATKJx2XrHhR+1DcKJzQBSTAGnpYVaqpsAR\nap+nwRipr3nUTuxyGohBTSmjJ2usSeQXHI3bODIRe1AuTyHceAbewn8b462yEWKA\nRdpd9AjQW5SIVPfdsz5B6GlYQ5LdYKtznTuy7wIDAQABAoIBAQCwia1k7+2oZ2d3\nn6agCAbqIE1QXfCmh41ZqJHbOY3oRQG3X1wpcGH4Gk+O+zDVTV2JszdcOt7E5dAy\nMaomETAhRxB7hlIOnEN7WKm+dGNrKRvV0wDU5ReFMRHg31/Lnu8c+5BvGjZX+ky9\nPOIhFFYJqwCRlopGSUIxmVj5rSgtzk3iWOQXr+ah1bjEXvlxDOWkHN6YfpV5ThdE\nKdBIPGEVqa63r9n2h+qazKrtiRqJqGnOrHzOECYbRFYhexsNFz7YT02xdfSHn7gM\nIvabDDP/Qp0PjE1jdouiMaFHYnLBbgvlnZW9yuVf/rpXTUq/njxIXMmvmEyyvSDn\nFcFikB8pAoGBAPF77hK4m3/rdGT7X8a/gwvZ2R121aBcdPwEaUhvj/36dx596zvY\nmEOjrWfZhF083/nYWE2kVquj2wjs+otCLfifEEgXcVPTnEOPO9Zg3uNSL0nNQghj\nFuD3iGLTUBCtM66oTe0jLSslHe8gLGEQqyMzHOzYxNqibxcOZIe8Qt0NAoGBAO+U\nI5+XWjWEgDmvyC3TrOSf/KCGjtu0TSv30ipv27bDLMrpvPmD/5lpptTFwcxvVhCs\n2b+chCjlghFSWFbBULBrfci2FtliClOVMYrlNBdUSJhf3aYSG2Doe6Bgt1n2CpNn\n/iu37Y3NfemZBJA7hNl4dYe+f+uzM87cdQ214+jrAoGAXA0XxX8ll2+ToOLJsaNT\nOvNB9h9Uc5qK5X5w+7G7O998BN2PC/MWp8H+2fVqpXgNENpNXttkRm1hk1dych86\nEunfdPuqsX+as44oCyJGFHVBnWpm33eWQw9YqANRI+pCJzP08I5WK3osnPiwshd+\nhR54yjgfYhBFNI7B95PmEQkCgYBzFSz7h1+s34Ycr8SvxsOBWxymG5zaCsUbPsL0\n4aCgLScCHb9J+E86aVbbVFdglYa5Id7DPTL61ixhl7WZjujspeXZGSbmq0Kcnckb\nmDgqkLECiOJW2NHP/j0McAkDLL4tysF8TLDO8gvuvzNC+WQ6drO2ThrypLVZQ+ry\neBIPmwKBgEZxhqa0gVvHQG/7Od69KWj4eJP28kq13RhKay8JOoN0vPmspXJo1HY3\nCKuHRG+AP579dncdUnOMvfXOtkdM4vk0+hWASBQzM9xzVcztCa+koAugjVaLS9A+\n9uQoqEeVNTckxx0S2bYevRy7hGQmUJTyQm3j1zEUR5jpdbL83Fbq\n-----END RSA PRIVATE KEY-----" +TEST_RS256_PUBLIC_KEY_PEM = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4f5wg5l2hKsTeNem/V41\nfGnJm6gOdrj8ym3rFkEU/wT8RDtnSgFEZOQpHEgQ7JL38xUfU0Y3g6aYw9QT0hJ7\nmCpz9Er5qLaMXJwZxzHzAahlfA0icqabvJOMvQtzD6uQv6wPEyZtDTWiQi9AXwBp\nHssPnpYGIn20ZZuNlX2BrClciHhCPUIIZOQn/MmqTD31jSyjoQoV7MhhMTATKJx2\nXrHhR+1DcKJzQBSTAGnpYVaqpsARap+nwRipr3nUTuxyGohBTSmjJ2usSeQXHI3b\nODIRe1AuTyHceAbewn8b462yEWKARdpd9AjQW5SIVPfdsz5B6GlYQ5LdYKtznTuy\n7wIDAQAB\n-----END PUBLIC KEY-----" +TEST_HS256_SECRET = "d5f5ae1c9397ef7d074907d422e54aff" + +Aws.config[:lambda] = { + stub_responses: { + invoke: { + payload: StringIO.new('{"id":null}') + } + } +} + +class TLDR + include Mocktail::DSL +end + +module Hotsock + def self.reset_config! + remove_instance_variable(:@default_config) if instance_variable_defined?(:@default_config) + remove_instance_variable(:@default_issuer) if instance_variable_defined?(:@default_issuer) + remove_instance_variable(:@default_publisher) if instance_variable_defined?(:@default_publisher) + end +end diff --git a/test/hotsock/config_test.rb b/test/hotsock/config_test.rb new file mode 100644 index 0000000..61b190b --- /dev/null +++ b/test/hotsock/config_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class HotsockConfigTest < TLDR + def test_accepts_configuration_options + config = Hotsock::Config.new + config.aws_region = "us-east-1" + config.aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" + config.aws_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + config.aws_assume_role_arn = "arn:aws:iam::111111111111:role/MyRoleToAssume" + config.aws_assume_role_session_name = "my-application-name" + config.aws_assume_role_external_id = "6f4c10321f" + config.publish_function_arn = "arn:aws:lambda:us-east-1:111111111111:function:Hotsock-Publishing-J718QESEO304-PublishFunction-t8ixecGdSgel" + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + config.issuer_key_id = "95616b70" + config.issuer_aud_claim = "hotsock" + config.issuer_iat_claim = true + config.issuer_iss_claim = "me" + config.issuer_jti_claim = true + + assert_equal "us-east-1", config.aws_region + assert_equal "AKIAIOSFODNN7EXAMPLE", config.aws_access_key_id + assert_equal "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", config.aws_secret_access_key + end + + def test_requires_aws_region_if_reader_is_called + assert_raises(ArgumentError) { Hotsock::Config.new.aws_region } + end + + def test_requires_signing_private_key_if_reader_is_called + assert_raises(ArgumentError) { Hotsock::Config.new.issuer_private_key } + end + + def test_returns_valid_signing_key + config = Hotsock::Config.new + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + assert_instance_of OpenSSL::PKey::EC, config.issuer_key + end +end diff --git a/test/hotsock/issuer_test.rb b/test/hotsock/issuer_test.rb new file mode 100644 index 0000000..6061b4a --- /dev/null +++ b/test/hotsock/issuer_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "jwt" + +class HotsockIssuerES256Test < TLDR + def test_issue_a_token + config = Hotsock::Config.new + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + config.issuer_key_id = "95616b70" + config.issuer_aud_claim = "hotsock" + config.issuer_iss_claim = "me" + + issuer = Hotsock::Issuer.new(config) + exp = Time.now.to_i + 5 + token = issuer.issue_token(exp:) + decoded = JWT.decode token, OpenSSL::PKey::EC.new(TEST_ES256_PUBLIC_KEY_PEM), true, {algorithm: config.issuer_key_algorithm} + assert_equal [{"aud" => config.issuer_aud_claim, "iss" => "me", "exp" => exp}, {"typ" => "JWT", "kid" => "95616b70", "alg" => "ES256"}], decoded + end + + def test_allows_overriding_registered_claims + config = Hotsock::Config.new + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + config.issuer_aud_claim = "hotsock" + config.issuer_iss_claim = "me" + config.issuer_jti_claim = true + config.issuer_iat_claim = true + config.issuer_token_ttl = 10 + + issuer = Hotsock::Issuer.new(config) + now = Time.now + exp = now.to_i + 5 + token = issuer.issue_token(exp:, aud: "hotsock-override", iss: "you", iat: now.to_i - 5, jti: "less-unique") + decoded = JWT.decode token, OpenSSL::PKey::EC.new(TEST_ES256_PUBLIC_KEY_PEM), true, {algorithm: config.issuer_key_algorithm} + assert_equal [{"aud" => "hotsock-override", "iat" => now.to_i - 5, "iss" => "you", "jti" => "less-unique", "exp" => exp}, {"typ" => "JWT", "alg" => "ES256"}], decoded + end + + def test_allows_generating_unique_jti_claim + config = Hotsock::Config.new + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + config.issuer_jti_claim = true + + issuer = Hotsock::Issuer.new(config) + exp = Time.now.to_i + 5 + token = issuer.issue_token(exp:) + decoded = JWT.decode token, OpenSSL::PKey::EC.new(TEST_ES256_PUBLIC_KEY_PEM), true, {algorithm: config.issuer_key_algorithm} + assert decoded[0]["jti"].length == 36 + end + + def test_allows_generating_iat_claim_with_issued_at_timestamp + config = Hotsock::Config.new + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + config.issuer_iat_claim = true + + issuer = Hotsock::Issuer.new(config) + exp = Time.now.to_i + 5 + token = issuer.issue_token(exp:) + decoded = JWT.decode token, OpenSSL::PKey::EC.new(TEST_ES256_PUBLIC_KEY_PEM), true, {algorithm: config.issuer_key_algorithm} + assert decoded[0]["iat"] >= Time.now.to_i + end + + def test_allows_setting_token_ttl_for_default_token_expiration + config = Hotsock::Config.new + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + config.issuer_token_ttl = 10 + + issuer = Hotsock::Issuer.new(config) + now = Time.now + token = issuer.issue_token + decoded = JWT.decode token, OpenSSL::PKey::EC.new(TEST_ES256_PUBLIC_KEY_PEM), true, {algorithm: config.issuer_key_algorithm} + assert decoded[0]["exp"] >= now.to_i + 10 + end + + def test_cannot_issue_token_for_invalid_private_key + config = Hotsock::Config.new + config.issuer_private_key = "INVALID KEY" + issuer = Hotsock::Issuer.new(config) + assert_raises OpenSSL::PKey::ECError do + issuer.issue_token + end + end +end + +class HotsockIssuerRS256Test < TLDR + def test_issue_a_token + config = Hotsock::Config.new + config.issuer_private_key = TEST_RS256_PRIVATE_KEY_PEM + config.issuer_key_algorithm = "RS256" + + issuer = Hotsock::Issuer.new(config) + exp = Time.now.to_i + 5 + token = issuer.issue_token(exp:) + decoded = JWT.decode token, OpenSSL::PKey::RSA.new(TEST_RS256_PUBLIC_KEY_PEM), true, {algorithm: config.issuer_key_algorithm} + assert_equal [{"exp" => exp}, {"typ" => "JWT", "alg" => "RS256"}], decoded + end +end + +class HotsockIssuerHS256Test < TLDR + def test_issue_a_token + config = Hotsock::Config.new + config.issuer_private_key = TEST_HS256_SECRET + config.issuer_key_algorithm = "HS256" + + issuer = Hotsock::Issuer.new(config) + exp = Time.now.to_i + 5 + token = issuer.issue_token(exp:) + decoded = JWT.decode token, TEST_HS256_SECRET, true, {algorithm: config.issuer_key_algorithm} + assert_equal [{"exp" => exp}, {"typ" => "JWT", "alg" => "HS256"}], decoded + end +end + +class HotsockIssuerUnsupportedAlgorithmTest < TLDR + def test_raises_argument_error + config = Hotsock::Config.new + config.issuer_private_key = TEST_HS256_SECRET + config.issuer_key_algorithm = "ED25519" + + issuer = Hotsock::Issuer.new(config) + assert_raises ArgumentError do + issuer.issue_token + end + end +end diff --git a/test/hotsock/publisher_test.rb b/test/hotsock/publisher_test.rb new file mode 100644 index 0000000..37b89ed --- /dev/null +++ b/test/hotsock/publisher_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "json" + +class HotsockPublisherTest < TLDR + def setup + @config = Hotsock::Config.new.tap do |config| + config.aws_region = "us-east-1" + config.publish_function_arn = "arn:aws:lambda:us-east-1:111111111111:function:Hotsock-Publishing-J718QESEO304-PublishFunction-t8ixecGdSgel" + end + end + + def teardown + Mocktail.reset + end + + def test_publishes_a_minimal_message + lambda_client = Mocktail.of_next(Aws::Lambda::Client) + expected_payload = JSON.dump({event: "myevent", channel: "mychannel"}) + + publisher = Hotsock::Publisher.new(@config) + publisher.publish_message(event: "myevent", channel: "mychannel") + + verify { + lambda_client.invoke(function_name: @config.publish_function_arn, payload: expected_payload) + } + end + + def test_publishes_a_message_with_known_optional_parameters + lambda_client = Mocktail.of_next(Aws::Lambda::Client) + expected_payload = JSON.dump({ + event: "myevent", + channel: "mychannel", + data: "mydata", + deduplicationId: "noduplicates", + eagerIdGeneration: true, + emitPubSubEvent: true, + store: 100 + }) + + publisher = Hotsock::Publisher.new(@config) + publisher.publish_message( + event: "myevent", + channel: "mychannel", + data: "mydata", + deduplication_id: "noduplicates", + eager_id_generation: true, + emit_pub_sub_event: true, + store: 100 + ) + + verify { + lambda_client.invoke(function_name: @config.publish_function_arn, payload: expected_payload) + } + end + + def test_publishes_a_message_with_unknown_parameters + lambda_client = Mocktail.of_next(Aws::Lambda::Client) + expected_payload = JSON.dump({ + event: "myevent", + channel: "mychannel", + someNewParam: true, + anotherNewParam: "stringy" + }) + + publisher = Hotsock::Publisher.new(@config) + publisher.publish_message( + event: "myevent", + channel: "mychannel", + someNewParam: true, + anotherNewParam: "stringy" + ) + + verify { + lambda_client.invoke(function_name: @config.publish_function_arn, payload: expected_payload) + } + end +end diff --git a/test/hotsock_test.rb b/test/hotsock_test.rb new file mode 100644 index 0000000..0e8c279 --- /dev/null +++ b/test/hotsock_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative "helper" + +class HotsockTest < TLDR + run_these_together! + + def setup + Hotsock.reset_config! + end + + def test_has_a_default_config + assert_instance_of Hotsock::Config, Hotsock.send(:default_config) + end + + def test_has_a_default_issuer + assert_instance_of Hotsock::Issuer, Hotsock.send(:default_issuer) + end + + def test_has_a_default_publisher + assert_instance_of Hotsock::Publisher, Hotsock.send(:default_publisher) + end + + def test_configure_takes_a_block_to_set_default_config + Hotsock.configure do |config| + config.aws_region = "us-east-1" + end + assert_equal "us-east-1", Hotsock.send(:default_config).aws_region + end + + def test_publish_message_with_default_config + Hotsock.configure do |config| + config.aws_region = "us-east-1" + config.publish_function_arn = "arn:aws:lambda:us-east-1:111111111111:function:Hotsock-Publishing-J718QESEO304-PublishFunction-t8ixecGdSgel" + end + response = Hotsock.publish_message(event: "chat", channel: "group1", data: "hey") + assert_equal 200, response.status_code + assert_equal '{"id":null}', response.payload.read + end + + def test_issue_token_with_default_config + Hotsock.configure do |config| + config.issuer_private_key = TEST_ES256_PRIVATE_KEY_PEM + end + token = Hotsock.issue_token({foo: "bar"}) + + decoded = JWT.decode token, OpenSSL::PKey::EC.new(TEST_ES256_PRIVATE_KEY_PEM), true, {algorithm: "ES256"} + assert_equal [{"foo" => "bar"}, {"typ" => "JWT", "alg" => "ES256"}], decoded + end + + def test_it_has_a_version + assert_operator Hotsock::VERSION, :>=, "1" + end +end