From 214bf12e07d8790910d4eb06aeb6f54c5d462552 Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Sun, 18 Jan 2026 01:40:34 +0530 Subject: [PATCH 1/9] Fetch ID Token via Authorization Code Grant Added the ability to fetch the ID Token via the Authorization Code Grant. The class responsible for doing the fetching is `::Vauth::AuthCodeGrant`. It returns a `::Vauth::IdentityToken` via its `#identity_token` method. `::Vauth::AuthCodeGrant` currently only has the ability to deal with the ID Token, as I'm only focusing on Open ID Connect for now. In the future, it should also have a method to fetch the Access Token. The `::Vauth::Client` struct holds the information that allows the grant object to make the request. Currently, I'm not sure if there's a better way to encapsulate the information handled via the `::Vauth::Client` class, but its refactor is on my mind if I find a better way. Other notable changes are related to RuboCop. I didn't like the defaults, so I changed it to my liking. The end goal is to collect all these and extract a gem out of it. Installed the `debug` gem as well for obvious reasons. It also created a `Gemfile.lock` that wasn't present earlier for some reason. Installed the `jwt` gem as well to decode the ID Token received from the OAuth2 Provider. Generated the binstub for Rake to make it easier to run the tests as well as RuboCop. Also had to remove a spec that was failing. It was just an example spec. --- .rubocop.yml | 12 +++++ Gemfile | 1 + Gemfile.lock | 83 ++++++++++++++++++++++++++++++ bin/rake | 27 ++++++++++ lib/vauth/auth_code_grant.rb | 33 ++++++++++++ lib/vauth/client.rb | 11 ++++ lib/vauth/identity_token.rb | 21 ++++++++ test/test_vauth.rb | 4 -- test/vauth/auth_code_grant_test.rb | 49 ++++++++++++++++++ test/vauth/client_test.rb | 22 ++++++++ test/vauth/identity_token_test.rb | 29 +++++++++++ vauth.gemspec | 3 +- 12 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 Gemfile.lock create mode 100755 bin/rake create mode 100644 lib/vauth/auth_code_grant.rb create mode 100644 lib/vauth/client.rb create mode 100644 lib/vauth/identity_token.rb create mode 100644 test/vauth/auth_code_grant_test.rb create mode 100644 test/vauth/client_test.rb create mode 100644 test/vauth/identity_token_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 537f3da..5df1f71 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,3 +6,15 @@ Style/StringLiterals: Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes + +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: comma + +Metrics/BlockLength: + Max: 45 + +Layout/FirstArrayElementIndentation: + EnforcedStyle: consistent 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..abdacba --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,83 @@ +PATH + remote: . + specs: + vauth (0.1.0) + jwt (~> 3.1, >= 3.1.2) + +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) + +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/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/auth_code_grant.rb b/lib/vauth/auth_code_grant.rb new file mode 100644 index 0000000..4d7f020 --- /dev/null +++ b/lib/vauth/auth_code_grant.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "json" +require "net/http" +require "vauth/identity_token" + +module Vauth + class AuthCodeGrant # :nodoc: + def initialize(client, code) + @client = client + @code = code + end + + def identity_token + ::Vauth::IdentityToken.new(parsed_response["id_token"]) + end + + private + + attr_reader :client, :code + + 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 + end +end diff --git a/lib/vauth/client.rb b/lib/vauth/client.rb new file mode 100644 index 0000000..500b12e --- /dev/null +++ b/lib/vauth/client.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "uri" + +module Vauth + Client = Struct.new(:id, :secret, :token_uri) do + 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..992d636 --- /dev/null +++ b/lib/vauth/identity_token.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +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/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/auth_code_grant_test.rb b/test/vauth/auth_code_grant_test.rb new file mode 100644 index 0000000..68114d5 --- /dev/null +++ b/test/vauth/auth_code_grant_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" +require "vauth/client" +require "vauth/auth_code_grant" +require "vauth/identity_token" + +describe ::Vauth::AuthCodeGrant do + subject { ::Vauth::AuthCodeGrant.new(client, code) } + + let(:client) { ::Vauth::Client.new(client_id, client_secret, token_uri) } + let(:code) { "code-for-auth-code-grant" } + 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 "#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/client_test.rb b/test/vauth/client_test.rb new file mode 100644 index 0000000..fd19961 --- /dev/null +++ b/test/vauth/client_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "test_helper" +require "uri" + +require "vauth/client" + +describe ::Vauth::Client do + subject do + ::Vauth::Client.new( + "oauth2-server-given-client-id", + "oauth2-server-given-client-secret", + "https://oauth2-server.com/token" + ) + 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..0e9ffc0 --- /dev/null +++ b/test/vauth/identity_token_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "test_helper" +require "jwt" + +require "vauth/identity_token" + +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..ab105ac 100644 --- a/vauth.gemspec +++ b/vauth.gemspec @@ -33,8 +33,7 @@ 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" # For more information and examples about making a new gem, check out our # guide at: https://bundler.io/guides/creating_gem.html From ada5b89055156e0082fcf74a3224dc4dd957369e Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Fri, 16 Jan 2026 14:09:56 +0530 Subject: [PATCH 2/9] Construct Authorization URL via Authorization Request Created the `::Vauth::AuthorizationRequest` class which currently has the ability to construct the Authorization URL via the `#url` method. It needs the `::Vauth::Client` to construct the URL. Also had to modify the `::Vauth::Client` struct to now hold the `:authorization_uri` value. Also fixed some RuboCop issues in other places, and modified some default RuboCop rules. --- .rubocop.yml | 12 +++++++++ lib/vauth/auth_code_grant.rb | 2 +- lib/vauth/authorization_request.rb | 30 ++++++++++++++++++++++ lib/vauth/client.rb | 2 +- test/vauth/auth_code_grant_test.rb | 5 ++-- test/vauth/authorization_request_test.rb | 32 ++++++++++++++++++++++++ test/vauth/client_test.rb | 3 ++- 7 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 lib/vauth/authorization_request.rb create mode 100644 test/vauth/authorization_request_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 5df1f71..e94c38b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,3 +18,15 @@ Metrics/BlockLength: Layout/FirstArrayElementIndentation: EnforcedStyle: consistent + +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: comma + +Style/TrailingCommaInArguments: + EnforcedStyleForMultiline: comma + +Style/StringConcatenation: + Enabled: false + +Layout/MultilineOperationIndentation: + Enabled: false diff --git a/lib/vauth/auth_code_grant.rb b/lib/vauth/auth_code_grant.rb index 4d7f020..f858361 100644 --- a/lib/vauth/auth_code_grant.rb +++ b/lib/vauth/auth_code_grant.rb @@ -26,7 +26,7 @@ def parsed_response client_secret: client.secret, scope: "openid", code: code, - }).body + }).body, ) end end diff --git a/lib/vauth/authorization_request.rb b/lib/vauth/authorization_request.rb new file mode 100644 index 0000000..9cd82b9 --- /dev/null +++ b/lib/vauth/authorization_request.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "uri" + +module Vauth + class AuthorizationRequest # :nodoc: + def initialize(client) + @client = client + end + + def url(redirect_to:) + auth_uri = URI(client.authorization_uri) + auth_uri.query = URI.encode_www_form([ + %w[response_type code], + ["client_id", client.id], + ["redirect_uri", redirect_to], + %w[scope openid], + ["state", state], + ]) + + String(auth_uri) + end + + def state; end + + private + + attr_reader :client + end +end diff --git a/lib/vauth/client.rb b/lib/vauth/client.rb index 500b12e..1fd2bee 100644 --- a/lib/vauth/client.rb +++ b/lib/vauth/client.rb @@ -3,7 +3,7 @@ require "uri" module Vauth - Client = Struct.new(:id, :secret, :token_uri) do + Client = Struct.new(:id, :secret, :authorization_uri, :token_uri) do def token_uri URI(self[:token_uri]) end diff --git a/test/vauth/auth_code_grant_test.rb b/test/vauth/auth_code_grant_test.rb index 68114d5..7bf3843 100644 --- a/test/vauth/auth_code_grant_test.rb +++ b/test/vauth/auth_code_grant_test.rb @@ -8,8 +8,9 @@ describe ::Vauth::AuthCodeGrant do subject { ::Vauth::AuthCodeGrant.new(client, code) } - let(:client) { ::Vauth::Client.new(client_id, client_secret, token_uri) } + let(:client) { ::Vauth::Client.new(client_id, client_secret, authorization_uri, token_uri) } let(:code) { "code-for-auth-code-grant" } + 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" } @@ -32,7 +33,7 @@ client_secret: client_secret, scope: "openid", code: code, - } + }, ]) ::Net::HTTP.stub :post_form, net_http_mock do diff --git a/test/vauth/authorization_request_test.rb b/test/vauth/authorization_request_test.rb new file mode 100644 index 0000000..cdffbf3 --- /dev/null +++ b/test/vauth/authorization_request_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "test_helper" +require "vauth/authorization_request" + +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 +end diff --git a/test/vauth/client_test.rb b/test/vauth/client_test.rb index fd19961..c8d5a73 100644 --- a/test/vauth/client_test.rb +++ b/test/vauth/client_test.rb @@ -10,7 +10,8 @@ ::Vauth::Client.new( "oauth2-server-given-client-id", "oauth2-server-given-client-secret", - "https://oauth2-server.com/token" + "https://oauth2-server.com/auth", + "https://oauth2-server.com/token", ) end From 9eac08fd66783b384affd6654c4e7c6e90920102 Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Mon, 19 Jan 2026 07:38:48 +0530 Subject: [PATCH 3/9] Generate/verify random state for CSRF protection Added the ability to generate the state of a request to the `::Vauth::AuthorizationRequest` class. When a state is not passed, it represents a new request to be made by the Resource Owner. When a state is passed, it represents a previously made request by the Resource Owner. The ability to verify is given to the `::Vauth::AuthCodeGrant` object. It needs the request instance, and the received state from the redirect. The state value returned by the `::Vauth::AuthorizationRequest#state` method should be stored in a secured storage (like Rails sessions) before redirecting the Resource Owner to the Authorization URI. If the stored and received states don't match, then the `::Vauth::AuthCodeGrant` instance is not available and an error is thrown. The thought behind this is that if there's a state mismatch, then you're not given the grant to fetch the ID Token (and Access Token, but it's not implemented). Also modified a RuboCop rule for the block length metric. I'm just gonna keep increasing the number for now to see how big the test blocks get. I might just disable this metric for tests, but at the same time I don't want the tests to get out of hand. Let's see! --- .rubocop.yml | 2 +- lib/vauth.rb | 3 +-- lib/vauth/auth_code_grant.rb | 16 +++++++++++--- lib/vauth/authorization_request.rb | 13 ++++++----- test/vauth/auth_code_grant_test.rb | 22 ++++++++++++++++++- test/vauth/authorization_request_test.rb | 28 ++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 13 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index e94c38b..03def8a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,7 +14,7 @@ Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma Metrics/BlockLength: - Max: 45 + Max: 60 Layout/FirstArrayElementIndentation: EnforcedStyle: consistent diff --git a/lib/vauth.rb b/lib/vauth.rb index 6492b6f..258597e 100644 --- a/lib/vauth.rb +++ b/lib/vauth.rb @@ -3,6 +3,5 @@ require_relative "vauth/version" module Vauth - class Error < StandardError; end - # Your code goes here... + class StateMismatchError < StandardError; end end diff --git a/lib/vauth/auth_code_grant.rb b/lib/vauth/auth_code_grant.rb index f858361..589e8fd 100644 --- a/lib/vauth/auth_code_grant.rb +++ b/lib/vauth/auth_code_grant.rb @@ -6,9 +6,11 @@ module Vauth class AuthCodeGrant # :nodoc: - def initialize(client, code) - @client = client + def initialize(request, code, state) + @request = request @code = code + + verify_state!(request.state, state) end def identity_token @@ -17,7 +19,11 @@ def identity_token private - attr_reader :client, :code + attr_reader :request, :code + + def client + request.client + end def parsed_response @parsed_response = JSON.parse( @@ -29,5 +35,9 @@ def parsed_response }).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 index 9cd82b9..ded0f6d 100644 --- a/lib/vauth/authorization_request.rb +++ b/lib/vauth/authorization_request.rb @@ -4,10 +4,13 @@ module Vauth class AuthorizationRequest # :nodoc: - def initialize(client) + def initialize(client, state = nil) @client = client + @state = state end + attr_reader :client + def url(redirect_to:) auth_uri = URI(client.authorization_uri) auth_uri.query = URI.encode_www_form([ @@ -21,10 +24,8 @@ def url(redirect_to:) String(auth_uri) end - def state; end - - private - - attr_reader :client + def state + @state ||= SecureRandom.hex(16) + end end end diff --git a/test/vauth/auth_code_grant_test.rb b/test/vauth/auth_code_grant_test.rb index 7bf3843..7a4b046 100644 --- a/test/vauth/auth_code_grant_test.rb +++ b/test/vauth/auth_code_grant_test.rb @@ -4,18 +4,38 @@ require "vauth/client" require "vauth/auth_code_grant" require "vauth/identity_token" +require "vauth/authorization_request" describe ::Vauth::AuthCodeGrant do - subject { ::Vauth::AuthCodeGrant.new(client, code) } + subject { ::Vauth::AuthCodeGrant.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::AuthCodeGrant.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::AuthCodeGrant.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 diff --git a/test/vauth/authorization_request_test.rb b/test/vauth/authorization_request_test.rb index cdffbf3..eed8a85 100644 --- a/test/vauth/authorization_request_test.rb +++ b/test/vauth/authorization_request_test.rb @@ -29,4 +29,32 @@ _(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 From fd64878b7f770b39daad94a8ec2485c16689322f Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Mon, 19 Jan 2026 08:58:40 +0530 Subject: [PATCH 4/9] Wrap Authorization URI with `URI` `token_uri` was wrapped, but `authorization_uri` wasn't. Also, I'm not sure if everytime `::Vauth::Client#token_uri` or `::Vauth::Client#authorization_uri` is called, it should return a new instance of `URI`. I feel like it should be the responsibility of the consumers of this class to do the duplication if they need it. But, I'm not stressing about it right now, so we should be good! --- lib/vauth/authorization_request.rb | 11 ++++++++--- lib/vauth/client.rb | 4 ++++ test/vauth/client_test.rb | 6 ++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/vauth/authorization_request.rb b/lib/vauth/authorization_request.rb index ded0f6d..04d2a1a 100644 --- a/lib/vauth/authorization_request.rb +++ b/lib/vauth/authorization_request.rb @@ -12,8 +12,7 @@ def initialize(client, state = nil) attr_reader :client def url(redirect_to:) - auth_uri = URI(client.authorization_uri) - auth_uri.query = URI.encode_www_form([ + authorization_uri.query = URI.encode_www_form([ %w[response_type code], ["client_id", client.id], ["redirect_uri", redirect_to], @@ -21,11 +20,17 @@ def url(redirect_to:) ["state", state], ]) - String(auth_uri) + 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 index 1fd2bee..1d22b81 100644 --- a/lib/vauth/client.rb +++ b/lib/vauth/client.rb @@ -4,6 +4,10 @@ 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 diff --git a/test/vauth/client_test.rb b/test/vauth/client_test.rb index c8d5a73..c1bddb8 100644 --- a/test/vauth/client_test.rb +++ b/test/vauth/client_test.rb @@ -15,6 +15,12 @@ ) 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") From 079200291eee83c1272c8682093ad6146ff224b7 Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Mon, 19 Jan 2026 15:40:08 +0530 Subject: [PATCH 5/9] Rename `::Vauth::AuthCodeGrant` Renamed it to `::Vauth::AuthorizationCodeGrant` for no particular reason other than feeling like "Auth" should be "Authorization". --- ...{auth_code_grant.rb => authorization_code_grant.rb} | 2 +- ..._grant_test.rb => authorization_code_grant_test.rb} | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename lib/vauth/{auth_code_grant.rb => authorization_code_grant.rb} (95%) rename test/vauth/{auth_code_grant_test.rb => authorization_code_grant_test.rb} (84%) diff --git a/lib/vauth/auth_code_grant.rb b/lib/vauth/authorization_code_grant.rb similarity index 95% rename from lib/vauth/auth_code_grant.rb rename to lib/vauth/authorization_code_grant.rb index 589e8fd..fe79b6d 100644 --- a/lib/vauth/auth_code_grant.rb +++ b/lib/vauth/authorization_code_grant.rb @@ -5,7 +5,7 @@ require "vauth/identity_token" module Vauth - class AuthCodeGrant # :nodoc: + class AuthorizationCodeGrant # :nodoc: def initialize(request, code, state) @request = request @code = code diff --git a/test/vauth/auth_code_grant_test.rb b/test/vauth/authorization_code_grant_test.rb similarity index 84% rename from test/vauth/auth_code_grant_test.rb rename to test/vauth/authorization_code_grant_test.rb index 7a4b046..416e480 100644 --- a/test/vauth/auth_code_grant_test.rb +++ b/test/vauth/authorization_code_grant_test.rb @@ -2,12 +2,12 @@ require "test_helper" require "vauth/client" -require "vauth/auth_code_grant" +require "vauth/authorization_code_grant" require "vauth/identity_token" require "vauth/authorization_request" -describe ::Vauth::AuthCodeGrant do - subject { ::Vauth::AuthCodeGrant.new(request, code, state) } +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) } @@ -25,14 +25,14 @@ request = ::Vauth::AuthorizationRequest.new(client) received_state = request.state - _ { ::Vauth::AuthCodeGrant.new(request, code, received_state) }.must_be_silent + _ { ::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::AuthCodeGrant.new(request, code, received_state) }.must_raise ::Vauth::StateMismatchError + _ { ::Vauth::AuthorizationCodeGrant.new(request, code, received_state) }.must_raise ::Vauth::StateMismatchError end end From 3842a4e72cb4c88e1e0950a43d36b009d8bfb7d7 Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Mon, 19 Jan 2026 20:03:27 +0530 Subject: [PATCH 6/9] Integrate Zeitwerk code loader For no specific reason than the fact that I hate keeping track of what dependencies need to be required in a file. --- Gemfile.lock | 2 ++ lib/vauth.rb | 4 +++- lib/vauth/authorization_code_grant.rb | 1 - lib/vauth/identity_token.rb | 2 ++ test/test_helper.rb | 5 +++++ test/vauth/authorization_code_grant_test.rb | 4 ---- test/vauth/authorization_request_test.rb | 1 - test/vauth/client_test.rb | 3 --- test/vauth/identity_token_test.rb | 3 --- vauth.gemspec | 1 + 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index abdacba..ab6bf26 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: vauth (0.1.0) jwt (~> 3.1, >= 3.1.2) + zeitwerk (~> 2.7, >= 2.7.4) GEM remote: https://rubygems.org/ @@ -66,6 +67,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.2.0) + zeitwerk (2.7.4) PLATFORMS ruby diff --git a/lib/vauth.rb b/lib/vauth.rb index 258597e..bc3d9c5 100644 --- a/lib/vauth.rb +++ b/lib/vauth.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -require_relative "vauth/version" +require "zeitwerk" + +Zeitwerk::Loader.for_gem.setup module Vauth class StateMismatchError < StandardError; end diff --git a/lib/vauth/authorization_code_grant.rb b/lib/vauth/authorization_code_grant.rb index fe79b6d..35d78ee 100644 --- a/lib/vauth/authorization_code_grant.rb +++ b/lib/vauth/authorization_code_grant.rb @@ -2,7 +2,6 @@ require "json" require "net/http" -require "vauth/identity_token" module Vauth class AuthorizationCodeGrant # :nodoc: diff --git a/lib/vauth/identity_token.rb b/lib/vauth/identity_token.rb index 992d636..a515de6 100644 --- a/lib/vauth/identity_token.rb +++ b/lib/vauth/identity_token.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "jwt" + module Vauth class IdentityToken # :nodoc: def initialize(jwt_id_token) 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/vauth/authorization_code_grant_test.rb b/test/vauth/authorization_code_grant_test.rb index 416e480..e214d6c 100644 --- a/test/vauth/authorization_code_grant_test.rb +++ b/test/vauth/authorization_code_grant_test.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true require "test_helper" -require "vauth/client" -require "vauth/authorization_code_grant" -require "vauth/identity_token" -require "vauth/authorization_request" describe ::Vauth::AuthorizationCodeGrant do subject { ::Vauth::AuthorizationCodeGrant.new(request, code, state) } diff --git a/test/vauth/authorization_request_test.rb b/test/vauth/authorization_request_test.rb index eed8a85..88d3015 100644 --- a/test/vauth/authorization_request_test.rb +++ b/test/vauth/authorization_request_test.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "test_helper" -require "vauth/authorization_request" describe ::Vauth::AuthorizationRequest do subject { ::Vauth::AuthorizationRequest.new(client) } diff --git a/test/vauth/client_test.rb b/test/vauth/client_test.rb index c1bddb8..8b450a0 100644 --- a/test/vauth/client_test.rb +++ b/test/vauth/client_test.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true require "test_helper" -require "uri" - -require "vauth/client" describe ::Vauth::Client do subject do diff --git a/test/vauth/identity_token_test.rb b/test/vauth/identity_token_test.rb index 0e9ffc0..3b33f40 100644 --- a/test/vauth/identity_token_test.rb +++ b/test/vauth/identity_token_test.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true require "test_helper" -require "jwt" - -require "vauth/identity_token" describe ::Vauth::IdentityToken do let(:id_token_jwt) do diff --git a/vauth.gemspec b/vauth.gemspec index ab105ac..8388a80 100644 --- a/vauth.gemspec +++ b/vauth.gemspec @@ -34,6 +34,7 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] 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 From 6d85bb4cbfad5e7eef47bac77adb332b53624ca9 Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Mon, 19 Jan 2026 21:27:32 +0530 Subject: [PATCH 7/9] Set the version to `0.1.0.alpha1` Updated the gemspec file with the necessary information, and tested it by building the gem. Had to then gitignore the `.gem` file. --- .gitignore | 1 + lib/vauth/version.rb | 2 +- vauth.gemspec | 16 +++++++++------- 3 files changed, 11 insertions(+), 8 deletions(-) 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/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/vauth.gemspec b/vauth.gemspec index 8388a80..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. From c24899f9069dd6b1611d95912edc09dc2d7bffc9 Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Tue, 20 Jan 2026 09:37:29 +0530 Subject: [PATCH 8/9] Update the README file with the initial information Added a warning to tell everyone not to use this in production. Hopefully, the slopcoders read this. Added all the usage instructions, and also updated the references to the repo on GitHub. --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) 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). From bb9f11f6545b328193190d6acf92eeb4714b6d0f Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Tue, 20 Jan 2026 09:54:17 +0530 Subject: [PATCH 9/9] Sync `Gemfile.lock` by running `bundle install` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index ab6bf26..63ea982 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - vauth (0.1.0) + vauth (0.1.0.alpha1) jwt (~> 3.1, >= 3.1.2) zeitwerk (~> 2.7, >= 2.7.4)