From ada5b89055156e0082fcf74a3224dc4dd957369e Mon Sep 17 00:00:00 2001 From: Nipun Paradkar Date: Fri, 16 Jan 2026 14:09:56 +0530 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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