~ren/magentasso-rb

70d0ab54c00a08461078536880ae2d091eb19dc5 — Lauren Jenkinson 1 year, 2 months ago 169c88e
Initial code commit
A .rspec => .rspec +3 -0
@@ 0,0 1,3 @@
--format documentation
--color
--require spec_helper

A .rubocop.yml => .rubocop.yml +15 -0
@@ 0,0 1,15 @@
AllCops:
  TargetRubyVersion: 2.4
  SuggestExtensions: false
  NewCops: enable

Style/StringLiterals:
  Enabled: true
  EnforcedStyle: double_quotes

Style/StringLiteralsInInterpolation:
  Enabled: true
  EnforcedStyle: double_quotes

Layout/LineLength:
  Max: 120

A CHANGELOG.md => CHANGELOG.md +5 -0
@@ 0,0 1,5 @@
## [Unreleased]

## [0.1.0] - 2021-03-04

- Initial release

A Gemfile => Gemfile +11 -0
@@ 0,0 1,11 @@
# frozen_string_literal: true

source "https://rubygems.org"

gemspec

gem "pry", "~> 0.14"
gem "rake", "~> 13.0"
gem "rspec", "~> 3.0"
gem "rubocop", "~> 1.7"
gem "rubocop-rspec", "~> 2.2"

A Gemfile.lock => Gemfile.lock +67 -0
@@ 0,0 1,67 @@
PATH
  remote: .
  specs:
    magentasso-rb (0.1.0)
      base32 (~> 0.3.4)

GEM
  remote: https://rubygems.org/
  specs:
    ast (2.4.2)
    base32 (0.3.4)
    coderay (1.1.3)
    diff-lcs (1.4.4)
    method_source (1.0.0)
    parallel (1.20.1)
    parser (3.0.0.0)
      ast (~> 2.4.1)
    pry (0.14.0)
      coderay (~> 1.1)
      method_source (~> 1.0)
    rainbow (3.0.0)
    rake (13.0.3)
    regexp_parser (2.1.1)
    rexml (3.2.4)
    rspec (3.10.0)
      rspec-core (~> 3.10.0)
      rspec-expectations (~> 3.10.0)
      rspec-mocks (~> 3.10.0)
    rspec-core (3.10.1)
      rspec-support (~> 3.10.0)
    rspec-expectations (3.10.1)
      diff-lcs (>= 1.2.0, < 2.0)
      rspec-support (~> 3.10.0)
    rspec-mocks (3.10.2)
      diff-lcs (>= 1.2.0, < 2.0)
      rspec-support (~> 3.10.0)
    rspec-support (3.10.2)
    rubocop (1.11.0)
      parallel (~> 1.10)
      parser (>= 3.0.0.0)
      rainbow (>= 2.2.2, < 4.0)
      regexp_parser (>= 1.8, < 3.0)
      rexml
      rubocop-ast (>= 1.2.0, < 2.0)
      ruby-progressbar (~> 1.7)
      unicode-display_width (>= 1.4.0, < 3.0)
    rubocop-ast (1.4.1)
      parser (>= 2.7.1.5)
    rubocop-rspec (2.2.0)
      rubocop (~> 1.0)
      rubocop-ast (>= 1.1.0)
    ruby-progressbar (1.11.0)
    unicode-display_width (2.0.0)

PLATFORMS
  x86_64-linux

DEPENDENCIES
  magentasso-rb!
  pry (~> 0.14)
  rake (~> 13.0)
  rspec (~> 3.0)
  rubocop (~> 1.7)
  rubocop-rspec (~> 2.2)

BUNDLED WITH
   2.2.11

A Rakefile => Rakefile +10 -0
@@ 0,0 1,10 @@
# frozen_string_literal: true

require "bundler/gem_tasks"
require "rspec/core/rake_task"
require "rubocop/rake_task"

RSpec::Core::RakeTask.new(:spec)
RuboCop::RakeTask.new

task default: %i[spec rubocop]

A lib/magentasso.rb => lib/magentasso.rb +154 -0
@@ 0,0 1,154 @@
# frozen_string_literal: true

require "json"
require "base32"
require "base64"
require "openssl"
require "securerandom"

# A library to implement a MagentaSSO provider or client.
module MagentaSSO
  # The base MagentaSSO error.
  class MagentaError < StandardError; end

  # Raised by verification methods when a signature is incorrect.
  class SignatureError < MagentaError; end

  class << self
    # Encode the +payload+ and generate a signature with the +secret+.
    def encode_and_sign(payload, secret)
      secret = Base32.decode(secret)

      payload = JSON.generate(payload)
      payload = Base64.urlsafe_encode64(payload, padding: false)
      signature = OpenSSL::HMAC.digest("SHA256", secret, payload)
      signature = Base64.urlsafe_encode64(signature, padding: false)

      [payload, signature]
    end

    # Verify the +signature+ using the +secret+, and return the decoded +payload+.
    def verify_and_decode(payload, signature, secret)
      secret = Base32.decode(secret)

      signature = Base64.urlsafe_decode64(signature)
      our_signature = OpenSSL::HMAC.digest("SHA256", secret, payload)
      raise MagentaSSO::SignatureError unless signature == our_signature

      payload = Base64.urlsafe_decode64(payload)
      JSON.parse(payload)
    end
  end

  # A Magenta authentication request.
  class Request
    attr_accessor :client_id, :client_secret, :nonce, :scopes, :callback_url

    def initialize(client_id, client_secret, nonce, scopes, callback_url)
      @client_id = client_id
      @client_secret = client_secret
      @nonce = nonce || SecureRandom.random_number(9_999_999)
      @scopes = [scopes].flatten
      @callback_url = callback_url
    end

    def self.verify(payload, signature, client_secret)
      payload = ::MagentaSSO.verify_and_decode(payload, signature, client_secret)

      new(
        payload["client_id"],
        client_secret,
        payload["nonce"],
        payload["scopes"],
        payload["callback_url"]
      )
    end

    def sign
      payload = {
        client_id: @client_id,
        nonce: @nonce,
        scopes: @scopes,
        callback_url: @callback_url
      }

      ::MagentaSSO.encode_and_sign(payload, @client_secret)
    end

    def query_params
      payload, signature = sign

      {
        client: @client_id,
        payload: payload,
        signature: signature
      }
    end
  end

  # A Magenta authentication response
  class Response
    attr_accessor :client_id, :client_secret, :nonce, :user_data, :scope_data

    def initialize(client_id, client_secret, nonce, user_data, scope_data)
      @client_id = client_id
      @client_secret = client_secret
      @nonce = nonce
      @user_data = user_data
      @scope_data = scope_data
    end

    def self.verify(payload, signature, client_secret)
      payload = ::MagentaSSO.verify_and_decode(payload, signature, client_secret)

      new(
        payload["client_id"],
        client_secret,
        payload["nonce"],
        payload["user_data"],
        payload["scope_data"]
      )
    end

    def sign
      payload = {
        client_id: @client_id,
        nonce: @nonce,
        user_data: @user_data,
        scope_data: @scope_data
      }

      ::MagentaSSO.encode_and_sign(payload, @client_secret)
    end

    def query_params
      payload, signature = sign

      {
        payload: payload,
        signature: signature
      }
    end

    def external_id
      @user_data&.[]("external_id")
    end

    def email_address
      @user_data&.[]("email")
    end

    def profile_name
      return nil unless @scope_data.key?("profile")

      return [@scope_data["profile"]["name_combined"], nil] if @scope_data["profile"].key?("name_combined")

      [
        @scope_data["profile"]["name_first"],
        @scope_data["profile"]["name_last"]
      ]
    end
  end
end

require "magentasso/version"

A lib/magentasso/version.rb => lib/magentasso/version.rb +5 -0
@@ 0,0 1,5 @@
# frozen_string_literal: true

module MagentaSSO
  VERSION = "0.1.0"
end

A magentasso-rb.gemspec => magentasso-rb.gemspec +29 -0
@@ 0,0 1,29 @@
# frozen_string_literal: true

require_relative "lib/magentasso/version"

Gem::Specification.new do |spec|
  spec.name          = "magentasso-rb"
  spec.version       = MagentaSSO::VERSION
  spec.authors       = ["Lauren Jenkinson"]
  spec.email         = ["lauren@iris.ac.nz"]

  spec.summary       = "A Ruby library to implement a MagentaSSO provider or client"
  spec.homepage      = "https://sr.ht/~ren/magenta"
  spec.license       = "MIT"

  spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")

  spec.metadata["homepage_uri"] = spec.homepage
  spec.metadata["source_code_uri"] = "https://git.sr.ht/~ren/magentasso-rb"
  spec.metadata["changelog_uri"] = "https://git.sr.ht/~ren/magentasso-rb/tree/main/item/CHANGELOG.md"

  spec.files = Dir.chdir(File.expand_path(__dir__)) do
    `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
  end
  spec.bindir        = "exe"
  spec.executables   = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
  spec.require_paths = ["lib"]

  spec.add_dependency "base32", "~> 0.3.4"
end

A spec/magentasso_request_spec.rb => spec/magentasso_request_spec.rb +32 -0
@@ 0,0 1,32 @@
# frozen_string_literal: true

RSpec.describe MagentaSSO::Request do
  it "signs a request" do
    request = MagentaSSO::Request.new(
      "test",
      MagentaSSO::TEST_SECRET,
      123_456,
      ["profile"],
      "https://example.com/sso"
    )

    _, signature = request.sign
    expect(signature).to eq "QrPlvDbAZk0aH46Wl2qbGWpBL1EqU8H6QTgemxdR-kM"
  end

  it "verifies a signed request" do
    payload =
      "eyJjbGllbnRfaWQiOiJ0ZXN0Iiwibm9uY2UiOjEyMzQ1Niwic2NvcGVzIjpbInByb" \
      "2ZpbGUiXSwiY2FsbGJhY2tfdXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9zc28ifQ"
    signature = "QrPlvDbAZk0aH46Wl2qbGWpBL1EqU8H6QTgemxdR-kM"

    request = MagentaSSO::Request.verify(
      payload,
      signature,
      MagentaSSO::TEST_SECRET
    )

    expect(request.scopes).to eq(["profile"])
    expect(request.nonce).to eq 123_456
  end
end

A spec/magentasso_response_spec.rb => spec/magentasso_response_spec.rb +68 -0
@@ 0,0 1,68 @@
# frozen_string_literal: true

RSpec.describe MagentaSSO::Response do # rubocop:disable Metrics/BlockLength
  it "signs a response" do
    response = MagentaSSO::Response.new(
      "test",
      MagentaSSO::TEST_SECRET,
      123_456,
      { "external_id" => 1, "email" => "test@example.com" },
      { "profile" => { "name_combined" => "Test User" } }
    )

    _, signature = response.sign
    expect(signature).to eq "dfVZ0jF8tGlWc7umZVstuqb9Y731-NRyA3J2Ye_CqE8"
  end

  it "verifies a signed response" do
    payload =
      "eyJjbGllbnRfaWQiOiJ0ZXN0Iiwibm9uY2UiOjEyMzQ1NiwidXNlcl9kYXRhIjp7ImV4d" \
      "GVybmFsX2lkIjoxLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifSwic2NvcGVfZGF0YS" \
      "I6eyJwcm9maWxlIjp7Im5hbWVfY29tYmluZWQiOiJUZXN0IFVzZXIifX19"
    signature = "dfVZ0jF8tGlWc7umZVstuqb9Y731-NRyA3J2Ye_CqE8"

    response = MagentaSSO::Response.verify(
      payload,
      signature,
      MagentaSSO::TEST_SECRET
    )

    expect(response.nonce).to eq 123_456
  end

  it "returns the external ID" do
    response = MagentaSSO::Response.new(
      "test",
      MagentaSSO::TEST_SECRET,
      123_456,
      { "external_id" => 1, "email" => "test@example.com" },
      { "profile" => { "name_combined" => "Test User" } }
    )

    expect(response.external_id).to eq 1
  end

  it "returns the combined name" do
    response = MagentaSSO::Response.new(
      "test",
      MagentaSSO::TEST_SECRET,
      123_456,
      { "external_id" => 1, "email" => "test@example.com" },
      { "profile" => { "name_combined" => "Test User", "name_first" => "Test", "name_last" => "User" } }
    )

    expect(response.profile_name).to eq(["Test User", nil])
  end

  it "returns separated first and last names if no combined name exists" do
    response = MagentaSSO::Response.new(
      "test",
      MagentaSSO::TEST_SECRET,
      123_456,
      { "external_id" => 1, "email" => "test@example.com" },
      { "profile" => { "name_first" => "Test", "name_last" => "User" } }
    )

    expect(response.profile_name).to eq(%w[Test User])
  end
end

A spec/magentasso_spec.rb => spec/magentasso_spec.rb +33 -0
@@ 0,0 1,33 @@
# frozen_string_literal: true

RSpec.describe MagentaSSO do
  it "has a version number" do
    expect(MagentaSSO::VERSION).not_to be nil
  end

  describe "#encode_and_sign" do
    it "encodes the payload JSON" do
      payload, = MagentaSSO.encode_and_sign({ "test" => true }, MagentaSSO::TEST_SECRET)
      decoded_payload = JSON.parse(Base64.urlsafe_decode64(payload))

      expect(decoded_payload).to eq({ "test" => true })
    end

    it "signs the payload" do
      _, signature = MagentaSSO.encode_and_sign({ "test" => true }, MagentaSSO::TEST_SECRET)
      expect(signature).to eq "AHlrP-r0AtZ_zK-uQTUqdBPH85q-Ezu3mDUdlSnndMQ"
    end
  end

  describe "#verify_and_decode" do
    it "verifies the signature and returns the decoded payload" do
      payload = MagentaSSO.verify_and_decode(
        "eyJ0ZXN0Ijp0cnVlfQ",
        "AHlrP-r0AtZ_zK-uQTUqdBPH85q-Ezu3mDUdlSnndMQ",
        MagentaSSO::TEST_SECRET
      )

      expect(payload).to eq({ "test" => true })
    end
  end
end

A spec/spec_helper.rb => spec/spec_helper.rb +17 -0
@@ 0,0 1,17 @@
# frozen_string_literal: true

require "magentasso"

MagentaSSO::TEST_SECRET = "YUOC5NYQ4QNZFDP5TD2YSIBZDZ7QV2ZRAM7ETTKC3QMG4GKQO4NG5ZY4I4SEWXGM"

RSpec.configure do |config|
  # Enable flags like --only-failures and --next-failure
  config.example_status_persistence_file_path = ".rspec_status"

  # Disable RSpec exposing methods globally on `Module` and `main`
  config.disable_monkey_patching!

  config.expect_with :rspec do |c|
    c.syntax = :expect
  end
end