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