diff --git a/.rubocop.yml b/.rubocop.yml index 5df1f71..03def8a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,7 +14,19 @@ Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: comma Metrics/BlockLength: - Max: 45 + 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/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/authorization_code_grant.rb similarity index 55% rename from lib/vauth/auth_code_grant.rb rename to lib/vauth/authorization_code_grant.rb index 4d7f020..fe79b6d 100644 --- a/lib/vauth/auth_code_grant.rb +++ b/lib/vauth/authorization_code_grant.rb @@ -5,10 +5,12 @@ require "vauth/identity_token" module Vauth - class AuthCodeGrant # :nodoc: - def initialize(client, code) - @client = client + class AuthorizationCodeGrant # :nodoc: + 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( @@ -26,8 +32,12 @@ def parsed_response client_secret: client.secret, scope: "openid", code: code, - }).body + }).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 index 500b12e..1d22b81 100644 --- a/lib/vauth/client.rb +++ b/lib/vauth/client.rb @@ -3,7 +3,11 @@ require "uri" module Vauth - Client = Struct.new(:id, :secret, :token_uri) do + 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/auth_code_grant_test.rb b/test/vauth/authorization_code_grant_test.rb similarity index 56% rename from test/vauth/auth_code_grant_test.rb rename to test/vauth/authorization_code_grant_test.rb index 68114d5..416e480 100644 --- a/test/vauth/auth_code_grant_test.rb +++ b/test/vauth/authorization_code_grant_test.rb @@ -2,19 +2,40 @@ 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(client, code) } +describe ::Vauth::AuthorizationCodeGrant do + subject { ::Vauth::AuthorizationCodeGrant.new(request, code, state) } - let(:client) { ::Vauth::Client.new(client_id, client_secret, token_uri) } + 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 @@ -32,7 +53,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..eed8a85 --- /dev/null +++ b/test/vauth/authorization_request_test.rb @@ -0,0 +1,60 @@ +# 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 + + 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 index fd19961..c1bddb8 100644 --- a/test/vauth/client_test.rb +++ b/test/vauth/client_test.rb @@ -10,10 +10,17 @@ ::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 + 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")