diff --git a/.gitignore b/.gitignore index 9106b2a..f6216f2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ /pkg/ /spec/reports/ /tmp/ +*.gem diff --git a/.rubocop.yml b/.rubocop.yml index 537f3da..03def8a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,3 +6,27 @@ Style/StringLiterals: Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma + +Metrics/BlockLength: + Max: 60 + +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma + +Style/StringConcatenation: + Enabled: false + +Layout/MultilineOperationIndentation: + Enabled: false diff --git a/Gemfile b/Gemfile index f12b007..824db46 100644 --- a/Gemfile +++ b/Gemfile @@ -10,4 +10,5 @@ gem "rake", "~> 13.0" gem "minitest", "~> 5.16" +gem "debug" gem "rubocop", "~> 1.21" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..63ea982 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,85 @@ +PATH + remote: . + specs: + vauth (0.1.0.alpha1) + jwt (~> 3.1, >= 3.1.2) + zeitwerk (~> 2.7, >= 2.7.4) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + base64 (0.3.0) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + erb (6.0.1) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.18.0) + jwt (3.1.2) + base64 + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + minitest (5.27.0) + parallel (1.27.0) + parser (3.3.10.0) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.7.0) + psych (5.3.1) + date + stringio + racc (1.8.1) + rainbow (3.1.1) + rake (13.3.1) + rdoc (7.0.3) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rubocop (1.82.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.48.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + ruby-progressbar (1.13.0) + stringio (3.2.0) + tsort (0.2.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + zeitwerk (2.7.4) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + debug + irb + minitest (~> 5.16) + rake (~> 13.0) + rubocop (~> 1.21) + vauth! + +BUNDLED WITH + 2.6.7 diff --git a/README.md b/README.md index 10a7b02..3281de6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Vauth -TODO: Delete this and the text below, and describe your gem +> [!WARNING] +> Do not use this in production. This gem is still in the pre-release phase. The API might change without any notice. -Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/vauth`. To experiment with that code, run `bin/console` for an interactive prompt. +A collection of Ruby objects that can be stitched together to create an OAuth 2.0 flow. Currently only supports the +Authorization Code Grant flow. ## Installation @@ -22,7 +24,46 @@ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG ## Usage -TODO: Write usage instructions here +Create a Client: + +```rb +client = ::Vauth::Client.new( + "client-id-from-oauth2-provider", + "client-secret-from-oauth2-provider", + "https://oauth2-provider.com/authorization-uri", + "https://oauth2-provider.com/token-uri", +) +``` + +Pass it to create an Authorization Request: + +```rb +request = ::Vauth::AuthorizationRequest.new(client) +``` + +Store the request state via `request.state` to a secure storage, then redirect the Resource Owner (user) via +`request.url` to authorize the application. + +When the Resource Owner gets redirected back, recreate the Authorization Request from the stored state: + +```rb +request = ::Vauth::AuthorizationRequest.new(client, stored_state) +``` + +Then you should be able to get the Authorization Code Grant with the re-created request, the received code, and the +received state: + +```rb +grant = ::Vauth::AuthorizationCodeGrant.new(request, code, received_state) +``` + +The grant will give you the ID Token: + +```rb +token = grant.identity_token +``` + +The ID Token will give you the issuer via `token.issuer`, and the subject via `token.subject`. ## Development @@ -32,7 +73,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/vauth. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/vauth/blob/master/CODE_OF_CONDUCT.md). +Bug reports and pull requests are welcome on GitHub at https://github.com/radiantshaw/vauth-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/radiantshaw/vauth-ruby/blob/master/CODE_OF_CONDUCT.md). ## License @@ -40,4 +81,4 @@ The gem is available as open source under the terms of the [MIT License](https:/ ## Code of Conduct -Everyone interacting in the Vauth project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/vauth/blob/master/CODE_OF_CONDUCT.md). +Everyone interacting in the Vauth project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/radiantshaw/vauth-ruby/blob/master/CODE_OF_CONDUCT.md). diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4eb7d7b --- /dev/null +++ b/bin/rake @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rake' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rake", "rake") diff --git a/lib/vauth.rb b/lib/vauth.rb index 6492b6f..bc3d9c5 100644 --- a/lib/vauth.rb +++ b/lib/vauth.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -require_relative "vauth/version" +require "zeitwerk" + +Zeitwerk::Loader.for_gem.setup module Vauth - class Error < StandardError; end - # Your code goes here... + class StateMismatchError < StandardError; end end diff --git a/lib/vauth/authorization_code_grant.rb b/lib/vauth/authorization_code_grant.rb new file mode 100644 index 0000000..35d78ee --- /dev/null +++ b/lib/vauth/authorization_code_grant.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "json" +require "net/http" + +module Vauth + class AuthorizationCodeGrant # :nodoc: + def initialize(request, code, state) + @request = request + @code = code + + verify_state!(request.state, state) + end + + def identity_token + ::Vauth::IdentityToken.new(parsed_response["id_token"]) + end + + private + + attr_reader :request, :code + + def client + request.client + end + + def parsed_response + @parsed_response = JSON.parse( + ::Net::HTTP.post_form(client.token_uri, { + client_id: client.id, + client_secret: client.secret, + scope: "openid", + code: code, + }).body, + ) + end + + def verify_state!(sent, received) + raise ::Vauth::StateMismatchError, "Cannot grant access due to state mismatch!" unless sent == received + end + end +end diff --git a/lib/vauth/authorization_request.rb b/lib/vauth/authorization_request.rb new file mode 100644 index 0000000..04d2a1a --- /dev/null +++ b/lib/vauth/authorization_request.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "uri" + +module Vauth + class AuthorizationRequest # :nodoc: + def initialize(client, state = nil) + @client = client + @state = state + end + + attr_reader :client + + def url(redirect_to:) + authorization_uri.query = URI.encode_www_form([ + %w[response_type code], + ["client_id", client.id], + ["redirect_uri", redirect_to], + %w[scope openid], + ["state", state], + ]) + + String(authorization_uri) + end + + def state + @state ||= SecureRandom.hex(16) + end + + private + + def authorization_uri + @authorization_uri ||= client.authorization_uri + end + end +end diff --git a/lib/vauth/client.rb b/lib/vauth/client.rb new file mode 100644 index 0000000..1d22b81 --- /dev/null +++ b/lib/vauth/client.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "uri" + +module Vauth + Client = Struct.new(:id, :secret, :authorization_uri, :token_uri) do + def authorization_uri + URI(self[:authorization_uri]) + end + + def token_uri + URI(self[:token_uri]) + end + end +end diff --git a/lib/vauth/identity_token.rb b/lib/vauth/identity_token.rb new file mode 100644 index 0000000..a515de6 --- /dev/null +++ b/lib/vauth/identity_token.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "jwt" + +module Vauth + class IdentityToken # :nodoc: + def initialize(jwt_id_token) + @jwt_id_token = jwt_id_token + end + + def issuer + JWT.decode(jwt_id_token, nil, true, { algorithm: "none" })[0]["iss"] + end + + def subject + JWT.decode(jwt_id_token, nil, true, { algorithm: "none" })[0]["sub"] + end + + private + + attr_reader :jwt_id_token + end +end diff --git a/lib/vauth/version.rb b/lib/vauth/version.rb index 7ffc740..a72fcdd 100644 --- a/lib/vauth/version.rb +++ b/lib/vauth/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Vauth - VERSION = "0.1.0" + VERSION = "0.1.0.alpha1" end diff --git a/test/test_helper.rb b/test/test_helper.rb index 905e9aa..e648fc0 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,3 +4,8 @@ require "vauth" require "minitest/autorun" + +require "json" +require "jwt" +require "net/http" +require "uri" diff --git a/test/test_vauth.rb b/test/test_vauth.rb index 7184d9e..a5921c2 100644 --- a/test/test_vauth.rb +++ b/test/test_vauth.rb @@ -6,8 +6,4 @@ class TestVauth < Minitest::Test def test_that_it_has_a_version_number refute_nil ::Vauth::VERSION end - - def test_it_does_something_useful - assert false - end end diff --git a/test/vauth/authorization_code_grant_test.rb b/test/vauth/authorization_code_grant_test.rb new file mode 100644 index 0000000..e214d6c --- /dev/null +++ b/test/vauth/authorization_code_grant_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ::Vauth::AuthorizationCodeGrant do + subject { ::Vauth::AuthorizationCodeGrant.new(request, code, state) } + + let(:request) { ::Vauth::AuthorizationRequest.new(client) } + let(:client) { ::Vauth::Client.new(client_id, client_secret, authorization_uri, token_uri) } + let(:code) { "code-for-auth-code-grant" } + let(:state) { request.state } + + let(:authorization_uri) { "https://oauth2-server.com/auth" } + let(:token_uri) { "https://oauth2-server.com/auth/token" } + let(:client_id) { "oauth2-server-given-client-id" } + let(:client_secret) { "oauth2-server-given-client-secret" } + let(:identity_token) { subject.identity_token } + + describe ".new" do + it "doesn't throw an error when state is valid" do + request = ::Vauth::AuthorizationRequest.new(client) + received_state = request.state + + _ { ::Vauth::AuthorizationCodeGrant.new(request, code, received_state) }.must_be_silent + end + + it "raises an error when the state is mismatched" do + request = ::Vauth::AuthorizationRequest.new(client) + received_state = "abc123" + + _ { ::Vauth::AuthorizationCodeGrant.new(request, code, received_state) }.must_raise ::Vauth::StateMismatchError + end + end + + describe "#identity_token" do + it "returns the Identity Token with the issuer and the subject" do + response_mock = Minitest::Mock.new + response_mock.expect(:body, JSON.dump({ + id_token: JWT.encode({ + iss: "issuer-from-identity-token", + sub: "subject-from-identity-token", + }, nil, "none"), + })) + net_http_mock = Minitest::Mock.new + net_http_mock.expect(:call, response_mock, [ + URI(token_uri), + { + client_id: client_id, + client_secret: client_secret, + scope: "openid", + code: code, + }, + ]) + + ::Net::HTTP.stub :post_form, net_http_mock do + _(identity_token).must_be_kind_of ::Vauth::IdentityToken + + _(identity_token.issuer).must_equal "issuer-from-identity-token" + _(identity_token.subject).must_equal "subject-from-identity-token" + end + + net_http_mock.verify + response_mock.verify + end + end +end diff --git a/test/vauth/authorization_request_test.rb b/test/vauth/authorization_request_test.rb new file mode 100644 index 0000000..88d3015 --- /dev/null +++ b/test/vauth/authorization_request_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ::Vauth::AuthorizationRequest do + subject { ::Vauth::AuthorizationRequest.new(client) } + + let(:client) do + ::Vauth::Client.new( + "oauth2-server-client-id", + "oauth2-server-client-secret", + "https://oauth2-server.com/auth", + "https://oauth2-server.com/token", + ) + end + + describe "#url" do + it "returns the URL that the Resource Owner needs to navigate to" do + expected = "https://oauth2-server.com/auth?" + + URI.encode_www_form([ + ["response_type", "code"], + ["client_id", "oauth2-server-client-id"], + ["redirect_uri", "https://example.com/session"], + ["scope", "openid"], + ["state", subject.state], + ]) + + _(subject.url(redirect_to: "https://example.com/session")).must_equal(expected) + end + end + + describe "#client" do + it "returns the client used" do + _(subject.client).must_be_same_as client + end + end + + describe "#state" do + it "remains constant for the request" do + request = ::Vauth::AuthorizationRequest.new(client) + + _(request.state).must_equal request.state + end + + it "varies between different requests" do + first_request = ::Vauth::AuthorizationRequest.new(client) + second_request = ::Vauth::AuthorizationRequest.new(client) + + _(second_request.state).wont_equal first_request.state + end + + it "can be overriden to represent a particular earlier request" do + first_request = ::Vauth::AuthorizationRequest.new(client) + second_request = ::Vauth::AuthorizationRequest.new(client, first_request.state) + + _(second_request.state).must_equal first_request.state + end + end +end diff --git a/test/vauth/client_test.rb b/test/vauth/client_test.rb new file mode 100644 index 0000000..8b450a0 --- /dev/null +++ b/test/vauth/client_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ::Vauth::Client do + subject do + ::Vauth::Client.new( + "oauth2-server-given-client-id", + "oauth2-server-given-client-secret", + "https://oauth2-server.com/auth", + "https://oauth2-server.com/token", + ) + end + + describe "#authorization_uri" do + it "returns the passed Authorization URI" do + _(subject.authorization_uri).must_equal URI("https://oauth2-server.com/auth") + end + end + + describe "#token_uri" do + it "returns the passed Token URI" do + _(subject.token_uri).must_equal URI("https://oauth2-server.com/token") + end + end +end diff --git a/test/vauth/identity_token_test.rb b/test/vauth/identity_token_test.rb new file mode 100644 index 0000000..3b33f40 --- /dev/null +++ b/test/vauth/identity_token_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "test_helper" + +describe ::Vauth::IdentityToken do + let(:id_token_jwt) do + JWT.encode({ + iss: "some-issuer-from-oauth2-provider", + sub: "some-subject-from-oauth2-provider", + }, nil, "none") + end + + subject { ::Vauth::IdentityToken.new(id_token_jwt) } + + describe "#issuer" do + it 'returns the "iss" value from the decoded JWT' do + _(subject.issuer).must_equal "some-issuer-from-oauth2-provider" + end + end + + describe "#subject" do + it 'returns the "sub" value from the decoded JWT' do + _(subject.subject).must_equal "some-subject-from-oauth2-provider" + end + end +end diff --git a/vauth.gemspec b/vauth.gemspec index 5c0da5a..a2c0eab 100644 --- a/vauth.gemspec +++ b/vauth.gemspec @@ -8,17 +8,19 @@ Gem::Specification.new do |spec| spec.authors = ["Nipun Paradkar"] spec.email = ["nipunparadkar123@gmail.com"] - spec.summary = "TODO: Write a short summary, because RubyGems requires one." - spec.description = "TODO: Write a longer description or delete this line." - spec.homepage = "TODO: Put your gem's website or public repo URL here." + spec.summary = "Collection of objects to make OAuth 2.0 flows possible and mockable." + spec.description = <<~TEXT + Contains a bunch of objects modled after the concepts from the OAuth 2.0 spec that can be stitched together to + create an OAuth 2.0 flow that you want. This currently only supports the Authorization Code Grant as that's the + only flow that I need for myself. But it will for sure include other flows in the future to make it a complete + OAuth 2.0 end-to-end system. + TEXT + spec.homepage = "https://github.com/radiantshaw/vauth-ruby" spec.license = "MIT" spec.required_ruby_version = ">= 3.1.0" - spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + spec.metadata["source_code_uri"] = spec.homepage # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -33,8 +35,8 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" + spec.add_dependency "jwt", "~> 3.1", ">= 3.1.2" + spec.add_dependency "zeitwerk", "~> 2.7", ">= 2.7.4" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html