A => .gitignore +12 -0
@@ 1,12 @@
+/.bundle/
+/.yardoc
+/Gemfile.lock
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+
+# rspec failure tracking
+.rspec_status
A => .rspec +2 -0
@@ 1,2 @@
+--format documentation
+--color
A => .ruby-gemset +1 -0
@@ 1,1 @@
+indieauth-token-verification
A => .ruby-version +1 -0
A => CHANGELOG.md +27 -0
@@ 1,27 @@
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [0.2.0] - 2019-01-24
+
+### Added
+- Added explicit message to IncorrectMeError.
+
+## [0.1.2] - 2018-05-18
+
+### Added
+- Use `kind_of?` rather than `code` when checking response from token endpoint.
+
+## [0.1.1] - 2018-03-04
+
+### Added
+- Forced use of SSL on the token endpoint.
+
+## [0.1.0] - 2018-02-20
+
+### Added
+- Initial release.
A => CODE_OF_CONDUCT.md +74 -0
@@ 1,74 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of experience,
+nationality, personal appearance, race, religion, or sexual identity and
+orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at coc+tokenverification@deeden.co.uk. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at [http://contributor-covenant.org/version/1/4][version]
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/
A => Gemfile +4 -0
@@ 1,4 @@
+source 'https://rubygems.org'
+
+# Specify your gem's dependencies in indieauth-token-verification.gemspec
+gemspec
A => LICENSE.txt +21 -0
@@ 1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 Stephen Rushe
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
A => README.md +60 -0
@@ 1,60 @@
+# IndieAuth::TokenVerification
+
+Verify an IndieAuth access token against a token endpoint, ensuring that the scope required is one of those associated with the token.
+
+## Installation
+
+Add this line to your application's Gemfile:
+
+```ruby
+gem 'indieauth-token-verification'
+```
+
+And then execute:
+
+ $ bundle
+
+Or install it yourself as:
+
+ $ gem install indieauth-token-verification
+
+## Configuration
+
+Use of the gem **requires** two environment variables to be specified, `TOKEN_ENDPOINT`, and `DOMAIN`.
+
+`TOKEN_ENDPOINT` specifies the token endpoint to be used to validate the access token. Failure to specify `TOKEN_ENDPOINT` will result in a `IndieAuth::TokenVerification::MissingTokenEndpointError` error being raised.
+
+`DOMAIN` specifies the domain we expect to see in the response from the validated token. It should match that specified when the token was first generated. Failure to specify `DOMAIN` will result in a `IndieAuth::TokenVerification::MissingDomainError` error being raised.
+
+## Usage
+
+```ruby
+# Verify the provided access token, with no scope requirement
+IndieAuth::TokenVerification.new(access_token).verify
+
+# Verify the provided access token, requiring a particular scope
+IndieAuth::TokenVerification.new(access_token).verify("media")
+```
+
+## Errors
+
+As well as `MissingTokenEndpointError` and `MissingDomainError` mentioned above, there are other errors which will be raised in certain circumstances...
+
+* `IndieAuth::TokenVerification::AccessTokenMissingError` - when the access token is missing
+* `IndieAuth::TokenVerification::ForbiddenUserError` - when the token endpoint reports an error
+* `IndieAuth::TokenVerification::IncorrectMeError` - when the `me` value in the response does not match the `DOMAIN`
+* `IndieAuth::TokenVerification::InsufficentScopeError` - when the scope requested is not granted by the access token
+
+## Development
+
+After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
+
+To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
+
+## Contributing
+
+Bug reports and pull requests are welcome on GitHub at https://github.com/srushe/indieauth-token-verification. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
+
+## License
+
+The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
A => Rakefile +6 -0
@@ 1,6 @@
+require "bundler/gem_tasks"
+require "rspec/core/rake_task"
+
+RSpec::Core::RakeTask.new(:spec)
+
+task :default => :spec
A => bin/console +14 -0
@@ 1,14 @@
+#!/usr/bin/env ruby
+
+require "bundler/setup"
+require "indie_auth/token_verification"
+
+# You can add fixtures and/or initialization code here to make experimenting
+# with your gem easier. You can also use a different console, if you like.
+
+# (If you use this, don't forget to add pry to your Gemfile!)
+# require "pry"
+# Pry.start
+
+require "irb"
+IRB.start(__FILE__)
A => bin/setup +8 -0
@@ 1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+IFS=$'\n\t'
+set -vx
+
+bundle install
+
+# Do any other automated setup that you need to do here
A => indieauth-token-verification.gemspec +38 -0
@@ 1,38 @@
+# coding: utf-8
+lib = File.expand_path('../lib', __FILE__)
+$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
+require 'indie_auth/token_verification/version'
+
+Gem::Specification.new do |spec|
+ spec.name = "indieauth-token-verification"
+ spec.version = IndieAuth::TokenVerification::VERSION
+ spec.authors = ["Stephen Rushe"]
+ spec.email = ["steve+gemspec@deeden.co.uk"]
+
+ spec.summary = %q{Perform the access token verification portion of the IndieAuth process.}
+ spec.description = %q{Perform the access token verification portion of the IndieAuth process by communicationg with a token endpoint.}
+ spec.homepage = "https://github.com/srushe/indieauth-token-verification"
+ spec.license = "MIT"
+
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
+ if spec.respond_to?(:metadata)
+ spec.metadata['allowed_push_host'] = ENV.fetch('GEMSERVER_URL', 'nohost')
+ else
+ raise "RubyGems 2.0 or newer is required to protect against " \
+ "public gem pushes."
+ end
+
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
+ f.match(%r{^(test|spec|features)/})
+ end
+ spec.bindir = "exe"
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
+ spec.require_paths = ["lib"]
+
+ spec.add_development_dependency "bundler", "~> 2.0"
+ spec.add_development_dependency "climate_control"
+ spec.add_development_dependency "pry"
+ spec.add_development_dependency "rake", "~> 12.3"
+ spec.add_development_dependency "rspec", "~> 3.0"
+end
A => lib/indie_auth/token_verification.rb +61 -0
@@ 1,61 @@
+require 'indie_auth/token_verification/version'
+require 'net/http'
+require 'json'
+
+module IndieAuth
+ class TokenVerification
+ class AccessTokenMissingError < StandardError; end
+ class ForbiddenUserError < StandardError; end
+ class IncorrectMeError < StandardError; end
+ class InsufficentScopeError < StandardError; end
+ class MissingDomainError < StandardError; end
+ class MissingTokenEndpointError < StandardError; end
+
+ attr_reader :access_token
+
+ def initialize(access_token)
+ @access_token = access_token.to_s.strip.sub(/\ABearer\s*/, '')
+ end
+
+ def verify(desired_scope=nil)
+ raise AccessTokenMissingError if access_token.nil? or access_token.empty?
+ raise MissingDomainError if ENV.fetch('DOMAIN', nil).nil?
+ raise MissingTokenEndpointError if ENV.fetch('TOKEN_ENDPOINT', nil).nil?
+
+ response = validate_token
+ raise ForbiddenUserError unless response.kind_of? Net::HTTPSuccess
+
+ response_body = JSON.parse(response.body)
+ if response_body.fetch('me', nil) != ENV['DOMAIN']
+ raise IncorrectMeError, "Expected: '#{ENV['DOMAIN']}', Received: '#{response_body.fetch('me', nil)}'"
+ end
+
+ return true if desired_scope.nil?
+ scope_included_in_response?(response_body, desired_scope)
+ end
+
+ private
+
+ def validate_token
+ uri = URI.parse(ENV['TOKEN_ENDPOINT'])
+ http = Net::HTTP.new(uri.host, uri.port)
+ http.use_ssl = true
+ http.get(uri.request_uri, {'Accept' => 'application/json', 'Authorization' => "Bearer #{access_token}"})
+ end
+
+ def scope_included_in_response?(response, desired_scope)
+ scopes = scopes_from(response)
+
+ return true if scopes.include?(desired_scope)
+ return true if desired_scope == 'post' && scopes.include?('create')
+ raise InsufficentScopeError
+ end
+
+ def scopes_from(response_body)
+ return @scopes if defined? @scopes
+ raise InsufficentScopeError unless response_body.key?('scope')
+
+ @scopes ||= response_body['scope'].split
+ end
+ end
+end<
\ No newline at end of file
A => lib/indie_auth/token_verification/version.rb +5 -0
@@ 1,5 @@
+module IndieAuth
+ class TokenVerification
+ VERSION = "0.2.0"
+ end
+end
A => spec/indie_auth/token_verification_spec.rb +155 -0
@@ 1,155 @@
+require 'spec_helper'
+
+def with_modified_environment(options, &block)
+ ClimateControl.modify(options, &block)
+end
+
+RSpec.shared_examples_for 'a successful verification' do
+ context 'the token endpoint is used to verify the token' do
+ let(:expected_headers) do
+ {
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer some-token'
+ }
+ end
+
+ before do
+ verifier.verify
+ end
+
+ it { expect(Net::HTTP).to have_received(:new).with(token_endpoint_uri.host, token_endpoint_uri.port) }
+ it { expect(http_object).to have_received(:use_ssl=).with(true) }
+ it { expect(http_object).to have_received(:get).with(token_endpoint_uri.request_uri, expected_headers) }
+ end
+
+ context 'when a scope is provided' do
+ subject { verifier.verify('create') }
+
+ it { expect(subject).to be true }
+ end
+
+ context 'when no scope is provided' do
+ subject { verifier.verify }
+
+ it { expect(subject).to be true }
+ end
+end
+
+RSpec.describe IndieAuth::TokenVerification do
+ it "has a version number" do
+ expect(IndieAuth::TokenVerification::VERSION).not_to be nil
+ end
+
+ let(:domain) { 'https://example.org/' }
+ let(:token_endpoint_url) { 'https://example.com/token' }
+ let(:token_endpoint_uri) { instance_double(URI::HTTPS, request_uri: '/token', host: 'example.org', port: 443) }
+ let(:http_object) { instance_double(Net::HTTP) }
+ let(:token_response) do
+ double(:http_response, body: response_body)
+ end
+ let(:response_successful) { true }
+ let(:response_body) { "{\"scope\":\"create update delete undelete\",\"me\":\"#{domain}\"}" }
+
+ before do
+ allow(URI).to receive(:parse) { token_endpoint_uri }
+ allow(Net::HTTP).to receive(:new) { http_object }
+ allow(http_object).to receive(:use_ssl=)
+ allow(http_object).to receive(:get) { token_response }
+ allow(token_response).to receive(:kind_of?).with(Net::HTTPSuccess) { response_successful }
+ end
+
+ context 'when no error occurs' do
+ let(:verifier) { described_class.new(access_token) }
+ let(:environment) do
+ { TOKEN_ENDPOINT: token_endpoint_url, DOMAIN: domain }
+ end
+
+ around do |example|
+ with_modified_environment(environment) { example.run }
+ end
+
+ context 'and the access_token starts with "Bearer "' do
+ let(:access_token) { 'Bearer some-token' }
+
+ it_behaves_like 'a successful verification'
+ end
+
+ context 'and the access is plain' do
+ let(:access_token) { 'some-token' }
+
+ it_behaves_like 'a successful verification'
+ end
+ end
+
+ context 'when an error occurs' do
+ let(:verifier) { described_class.new(access_token) }
+ let(:access_token) { 'some.token' }
+
+ context 'due to the access_token being invalid' do
+ subject(:verify) { verifier.verify }
+
+ ['', ' ', nil, 'Bearer', 'Bearer '].each do |invalid_access_token|
+ context "when it is '#{invalid_access_token.nil? ? 'nil' : invalid_access_token}'" do
+ let(:access_token) { invalid_access_token }
+
+ it { expect { verify }.to raise_error(IndieAuth::TokenVerification::AccessTokenMissingError) }
+ end
+ end
+ end
+
+ context 'due to the DOMAIN environment variable not being set' do
+ subject(:verify) { verifier.verify }
+
+ it 'raises the correct error' do
+ with_modified_environment(TOKEN_ENDPOINT: token_endpoint_url) do
+ expect { verify }.to raise_error(IndieAuth::TokenVerification::MissingDomainError)
+ end
+ end
+ end
+
+ context 'due to no TOKEN_ENDPOINT being defined' do
+ subject(:verify) { verifier.verify }
+
+ it 'raises the correct error' do
+ with_modified_environment(DOMAIN: domain) do
+ expect { verify }.to raise_error(IndieAuth::TokenVerification::MissingTokenEndpointError)
+ end
+ end
+ end
+
+ context 'due to the token endpoint not responding with success' do
+ subject(:verify) { verifier.verify }
+
+ let(:response_successful) { false }
+ let(:response_body) { nil }
+
+ it 'raises the correct error' do
+ with_modified_environment(DOMAIN: domain, TOKEN_ENDPOINT: token_endpoint_url) do
+ expect { verify }.to raise_error(IndieAuth::TokenVerification::ForbiddenUserError)
+ end
+ end
+ end
+
+ context 'due to the me value not matching DOMAIN' do
+ subject(:verify) { verifier.verify }
+
+ let(:response_body) { "{\"scope\":\"create update delete undelete\",\"me\":\"https://other.example.com/\"}" }
+
+ it 'raises the correct error' do
+ with_modified_environment(DOMAIN: domain, TOKEN_ENDPOINT: token_endpoint_url) do
+ expect { verify }.to raise_error(IndieAuth::TokenVerification::IncorrectMeError, "Expected: '#{domain}', Received: 'https://other.example.com/'")
+ end
+ end
+ end
+
+ context 'due to the scope requested not being acceptable' do
+ subject(:verify) { verifier.verify('media') }
+
+ it 'raises the correct error' do
+ with_modified_environment(DOMAIN: domain, TOKEN_ENDPOINT: token_endpoint_url) do
+ expect { verify }.to raise_error(IndieAuth::TokenVerification::InsufficentScopeError)
+ end
+ end
+ end
+ end
+end
A => spec/spec_helper.rb +12 -0
@@ 1,12 @@
+require "bundler/setup"
+require "indie_auth/token_verification"
+require "climate_control"
+
+RSpec.configure do |config|
+ # Enable flags like --only-failures and --next-failure
+ config.example_status_persistence_file_path = ".rspec_status"
+
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+end