├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── .yardopts ├── Appraisals ├── CONTRIBUTING.md ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── app ├── controllers │ ├── .keep │ └── oauth2_controller.rb ├── lib │ └── login_not_found.rb ├── models │ ├── .keep │ └── login.rb └── services │ ├── base_authenticator.rb │ ├── edx_authenticator.rb │ ├── facebook_authenticator.rb │ ├── github_authenticator.rb │ └── google_authenticator.rb ├── bin └── rails ├── config └── routes.rb ├── db └── migrate │ ├── 20150709221755_create_logins.rb │ ├── 20150904110438_add_provider_to_login.rb │ └── rails_api_auth_migration.rb ├── gemfiles ├── rails_3.gemfile ├── rails_4.gemfile └── rails_5.gemfile ├── lib ├── rails_api_auth.rb └── rails_api_auth │ ├── authentication.rb │ ├── engine.rb │ └── version.rb ├── rails_api_auth.gemspec └── spec ├── config └── force_ssl_spec.rb ├── dummy ├── README.rdoc ├── Rakefile ├── app │ ├── assets │ │ ├── images │ │ │ └── .keep │ │ ├── javascripts │ │ │ └── application.js │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── access_once_controller.rb │ │ ├── application_controller.rb │ │ ├── authenticated_controller.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── custom_authenticated_controller.rb │ └── models │ │ ├── .keep │ │ ├── account.rb │ │ └── concerns │ │ └── .keep ├── bin │ ├── bundle │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── assets.rb │ │ ├── backtrace_silencers.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── rails_api_auth.rb │ │ ├── secret_token.rb │ │ ├── session_store.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── routes.rb │ └── secrets.yml ├── db │ ├── migrate │ │ └── 20150803185817_create_accounts.rb │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep └── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ └── favicon.ico ├── factories ├── accounts.rb └── logins.rb ├── models └── login_spec.rb ├── requests ├── access_once_spec.rb ├── authenticated_spec.rb ├── custom_authenticated_spec.rb └── oauth2_spec.rb ├── services ├── edx_authenticator_spec.rb ├── facebook_authenticator_spec.rb ├── github_authenticator_spec.rb └── google_authenticator_spec.rb ├── spec_helper.rb └── support ├── factory_girl.rb ├── shared_contexts ├── force_ssl.rb ├── stubbed_edx_requests.rb ├── stubbed_facebook_requests.rb ├── stubbed_github_requests.rb └── stubbed_google_requests.rb └── shared_examples ├── authenticator_shared_requests.rb ├── oauth2_edx_shared_requests.rb └── oauth2_shared_requests.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbc 2 | capybara-*.html 3 | /log 4 | /tmp 5 | /db/*.sqlite3 6 | /db/*.sqlite3-journal 7 | /public/system 8 | /coverage/ 9 | /spec/tmp 10 | **.orig 11 | rerun.txt 12 | pickle-email-*.html 13 | .DS_Store 14 | tags 15 | Gemfile.lock 16 | gemfiles/*.lock 17 | doc 18 | .yardoc 19 | 20 | config/initializers/secret_token.rb 21 | config/secrets.yml 22 | 23 | ## Environment normalisation: 24 | /.bundle 25 | /vendor/bundle 26 | 27 | # these should all be checked in to normalise the environment: 28 | # Gemfile.lock, .ruby-version, .ruby-gemset 29 | 30 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 31 | .rvmrc 32 | 33 | # dummy app dbs and logs 34 | spec/dummy/db/*.sqlite3 35 | spec/dummy/db/*.sqlite3-journal 36 | spec/dummy/log/* 37 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format documentation 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Include: 3 | - '**/Rakefile' 4 | Exclude: 5 | - '**/bin/**/*' 6 | - 'vendor/**/*' 7 | - 'gemfiles/vendor/**/*' 8 | - 'spec/dummy/db/schema.rb' 9 | - 'Guardfile' 10 | 11 | Rails: 12 | Enabled: true 13 | 14 | Rails/ApplicationRecord: 15 | Enabled: false 16 | 17 | Rails/Blank: 18 | Enabled: false 19 | 20 | Rails/InverseOf: 21 | Enabled: false 22 | 23 | Rails/ActiveRecordAliases: 24 | Enabled: false 25 | 26 | Style/BlockDelimiters: 27 | Exclude: 28 | - 'spec/**/*' 29 | 30 | Style/FileName: 31 | Exclude: ['Gemfile', 'Gemfile.lock', 'Appraisals'] 32 | 33 | Style/RaiseArgs: 34 | EnforcedStyle: compact 35 | 36 | Style/RedundantReturn: 37 | AllowMultipleReturnValues: true 38 | 39 | Style/SignalException: 40 | EnforcedStyle: only_raise 41 | 42 | Style/BracesAroundHashParameters: 43 | Enabled: false 44 | 45 | Style/Documentation: 46 | Enabled: false 47 | 48 | Style/FormatString: 49 | Enabled: false 50 | 51 | Style/FormatStringToken: 52 | Enabled: false 53 | 54 | Style/GuardClause: 55 | Enabled: false 56 | 57 | Style/PercentLiteralDelimiters: 58 | Enabled: false 59 | 60 | Style/NumericLiterals: 61 | Enabled: false 62 | 63 | Layout/AlignHash: 64 | EnforcedHashRocketStyle: table 65 | EnforcedLastArgumentHashStyle: always_ignore 66 | 67 | Layout/DotPosition: 68 | EnforcedStyle: trailing 69 | 70 | Layout/EmptyLinesAroundClassBody: 71 | EnforcedStyle: empty_lines 72 | 73 | Layout/EmptyLinesAroundModuleBody: 74 | EnforcedStyle: empty_lines 75 | 76 | Layout/IndentationConsistency: 77 | EnforcedStyle: rails 78 | 79 | Layout/IndentHash: 80 | EnforcedStyle: consistent 81 | 82 | Layout/AlignParameters: 83 | EnforcedStyle: with_fixed_indentation 84 | 85 | Lint/AmbiguousBlockAssociation: 86 | Enabled: false 87 | 88 | Metrics/AbcSize: 89 | Max: 25 90 | 91 | Metrics/BlockLength: 92 | Enabled: false 93 | 94 | Metrics/LineLength: 95 | Enabled: false 96 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.2.5 5 | - 2.3.0 6 | - jruby-9.1.5.0 7 | 8 | gemfile: 9 | - gemfiles/rails_3.gemfile 10 | - gemfiles/rails_4.gemfile 11 | - gemfiles/rails_5.gemfile 12 | 13 | script: 14 | - RAILS_ENV=test bundle exec rake db:migrate 15 | - bundle exec rspec 16 | - bundle exec rubocop 17 | 18 | notifications: 19 | email: false 20 | slack: 21 | rooms: 22 | secure: OOKD4ZksqzEBW/A3WRuOToODIxnDITqx+Esu7tdmmYPuQlMYgx4SUHv8j9OM9/ScFJiseeVGSkl45vJrHLLIITX9XSjO1RgiGZgw2heVujmGpF6CZNqvT6GsQuKIvMzmwF7IxuHdfV45Csr9Ou/Fg74TszR/4S2h4SOI4zhLg7A= 23 | on_success: never 24 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup=markdown 2 | --no-private 3 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise 'rails-3' do 2 | gem 'rails', '3.2.22' 3 | gem 'bcrypt-ruby', '~> 3.0.0' 4 | end 5 | 6 | appraise 'rails-4' do 7 | gem 'rails', '4.2.3' 8 | end 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | 4 | Contributions will be greatly appreciated. You're welcome to submit [pull requests](https://github.com/simplabs/rails_api_auth/pulls), [propose features and discuss issues](https://github.com/simplabs/rails_api_auth/issues). 5 | 6 | ## Fork the Project 7 | 8 | Fork the [project on Github](https://github.com/simplabs/rails_api_auth) and check out your copy. 9 | 10 | ``` 11 | git clone https://github.com/contributor/rails_api_auth.git 12 | cd rails_api_auth 13 | git remote add upstream https://github.com/simplabs/rails_api_auth.git 14 | ``` 15 | 16 | ## Create a Topic Branch 17 | 18 | Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. 19 | 20 | ``` 21 | git checkout master 22 | git pull upstream master 23 | git checkout -b my-feature-branch 24 | ``` 25 | 26 | ## Write Tests 27 | 28 | Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. Add to [spec/](spec/). 29 | 30 | I also appreciate pull requests that highlight or reproduce a problem, even without a fix. 31 | 32 | ## Write Code 33 | 34 | Implement your feature or bug fix. Please be sure to submit clean, well refactored code. 35 | 36 | ## Commit Changes 37 | 38 | Make sure git knows your name and email address: 39 | 40 | ``` 41 | git config --global user.name "Your Name" 42 | git config --global user.email "contributor@example.com" 43 | ``` 44 | 45 | Writing good commit logs is important. A commit log should describe what changed and why. 46 | 47 | ``` 48 | git add ... 49 | git commit 50 | ``` 51 | 52 | ## Push 53 | 54 | ``` 55 | git push origin my-feature-branch 56 | ``` 57 | 58 | ## Make a Pull Request 59 | 60 | Rebase with upstream/master. 61 | 62 | ``` 63 | git fetch upstream 64 | git rebase upstream/master 65 | git push origin my-feature-branch -f 66 | ``` 67 | 68 | (If you have several commits, please [squash them](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html) into a single commit with `git rebase -i`) 69 | 70 | Go to https://github.com/contributor/rails_api_auth and select your feature branch. Click the 'Pull Request' button and fill out the form. 71 | 72 | If you're new to Pull Requests, check out the [Github docs](https://help.github.com/articles/using-pull-requests) 73 | 74 | ## Thank You 75 | 76 | Any contribution small or big is greatly appreciated. Thank you. 77 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | platforms :ruby, :mswin, :mingw do 6 | gem 'sqlite3' 7 | end 8 | platforms :jruby do 9 | gem 'activerecord-jdbcsqlite3-adapter' 10 | end 11 | 12 | group :development, :test do 13 | gem 'factory_girl_rails' 14 | gem 'faker' 15 | gem 'rspec-rails' 16 | end 17 | 18 | group :development do 19 | gem 'appraisal' 20 | gem 'guard-bundler', require: false 21 | gem 'guard-rspec', require: false 22 | gem 'guard-rubocop' 23 | gem 'rubocop' 24 | end 25 | 26 | group :test do 27 | gem 'json_spec' 28 | gem 'rails-controller-testing' 29 | gem 'shoulda' 30 | gem 'simplecov', require: false 31 | gem 'test-unit' 32 | gem 'timecop' 33 | gem 'webmock', require: 'webmock/rspec' 34 | end 35 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | group :red_green_refactor do 2 | guard :rspec, cmd: 'bundle exec rspec' do 3 | require 'guard/rspec/dsl' 4 | 5 | dsl = Guard::RSpec::Dsl.new(self) 6 | 7 | rspec = dsl.rspec 8 | watch(rspec.spec_helper) { rspec.spec_dir } 9 | watch(rspec.spec_support) { rspec.spec_dir } 10 | watch(rspec.spec_files) 11 | 12 | ruby = dsl.ruby 13 | dsl.watch_spec_files_for(ruby.lib_files) 14 | 15 | rails = dsl.rails 16 | dsl.watch_spec_files_for(rails.app_files) 17 | 18 | watch(rails.controllers) do |m| 19 | [ 20 | rspec.spec.("routing/#{m[1]}_routing"), 21 | rspec.spec.("controllers/#{m[1]}_controller"), 22 | rspec.spec.("requests/#{m[1]}") 23 | ] 24 | end 25 | 26 | watch(rails.spec_helper) { rspec.spec_dir } 27 | watch(rails.routes) { "#{rspec.spec_dir}/routing" } 28 | watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" } 29 | watch(rails.services) { "#{rspec.spec_dir}/services" } 30 | end 31 | 32 | guard :rubocop, all_on_start: true do 33 | watch(%r{.+\.rb$}) 34 | watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) } 35 | end 36 | end 37 | 38 | guard :bundler do 39 | require 'guard/bundler' 40 | require 'guard/bundler/verify' 41 | helper = Guard::Bundler::Verify.new 42 | 43 | files = ['Gemfile'] 44 | files += Dir['*.gemspec'] if files.any? { |f| helper.uses_gemspec?(f) } 45 | 46 | files.each { |file| watch(helper.real_path(file)) } 47 | watch(/^.+\.gemspec/) 48 | end 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2018 simplabs GmbH and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RailsApiAuth 2 | 3 | [![Build Status](https://travis-ci.org/simplabs/rails_api_auth.svg)](https://travis-ci.org/simplabs/rails_api_auth) 4 | 5 | Rails API Auth is a lightweight Rails Engine that __implements the _"Resource 6 | Owner Password Credentials Grant"_ OAuth 2.0 flow 7 | ([RFC 6749](http://tools.ietf.org/html/rfc6749#section-4.3)) as well as 8 | Facebook and Google authentication for API projects__. 9 | 10 | It uses __Bearer tokens__ ([RFC 6750](http://tools.ietf.org/html/rfc6750)) to 11 | authorize requests coming in from clients. 12 | 13 | ## Installation 14 | 15 | To install the engine simply add to the application's `Gemfile` 16 | 17 | ```ruby 18 | gem 'rails_api_auth' 19 | ``` 20 | 21 | and run: 22 | ```bash 23 | bundle install 24 | ``` 25 | 26 | __Rails API Auth also adds a migration__ to the application so run 27 | 28 | ```bash 29 | rake db:migrate 30 | ``` 31 | 32 | as well to migrate the database. 33 | 34 | ## Usage 35 | 36 | __Rails API Auth stores a user's credentials as well as the tokens in a `Login` 37 | model__ so that this data remains separated from the application's `User` model 38 | (or `Account` or whatever the application chose to store profile data in). 39 | 40 | After installing the engine you can add the relation from your user model to 41 | the `Login` model: 42 | 43 | ```ruby 44 | class User < ActiveRecord::Base 45 | 46 | has_one :login # this could be has_many as well of course 47 | 48 | end 49 | ``` 50 | 51 | When creating a new `User` in the host application, make sure to create a 52 | related `Login` as well, e.g.: 53 | 54 | ```ruby 55 | class UsersController < ApplicationController 56 | 57 | def create 58 | user = User.new(user_params) 59 | if user.save && user.create_login(login_params) 60 | head 200 61 | else 62 | head 422 # you'd actually want to return validation errors here 63 | end 64 | end 65 | 66 | private 67 | 68 | def user_params 69 | params.require(:user).permit(:first_name, :last_name) 70 | end 71 | 72 | def login_params 73 | params.require(:user).permit(:identification, :password, :password_confirmation) 74 | end 75 | 76 | end 77 | ``` 78 | 79 | __The engine adds 2 routes to the application__ that implement the endpoints 80 | for acquiring and revoking Bearer tokens: 81 | 82 | ``` 83 | token POST /token(.:format) oauth2#create 84 | revoke POST /revoke(.:format) oauth2#destroy 85 | ``` 86 | 87 | These endpoints are fully implemented in the engine and will issue or revoke 88 | Bearer tokens. 89 | 90 | In order to authorize incoming requests the engine provides the 91 | __`authenticate!` helper that can be used in controllers__ to make sure the 92 | request includes a valid Bearer token in the `Authorization` header (e.g. 93 | `Authorization: Bearer d5086ac8457b9db02a13`): 94 | 95 | ```ruby 96 | class AuthenticatedController < ApplicationController 97 | 98 | include RailsApiAuth::Authentication 99 | 100 | before_action :authenticate! 101 | 102 | def index 103 | render json: { success: true } 104 | end 105 | 106 | end 107 | 108 | ``` 109 | 110 | If no valid Bearer token is provided the client will see a 401 response. 111 | 112 | The engine also provides the `current_login` helper method that will return the 113 | `Login` model authorized with the sent Bearer token. 114 | 115 | You can also invoke `authenticate!` with a block to perform additional checks 116 | on the current login, e.g. making sure the login's associated account has a 117 | certain role: 118 | 119 | ```ruby 120 | class AuthenticatedController < ApplicationController 121 | 122 | include RailsApiAuth::Authentication 123 | 124 | before_action :authenticate_admin! 125 | 126 | def index 127 | render json: { success: true } 128 | end 129 | 130 | private 131 | 132 | def authenticate_admin! 133 | authenticate! do 134 | current_login.account.admin? 135 | end 136 | end 137 | 138 | end 139 | 140 | ``` 141 | 142 | See the [demo project](https://github.com/simplabs/rails_api_auth-demo) for further details. 143 | 144 | ## Configuration 145 | 146 | The Engine can be configured by simply setting some attributes on its main 147 | module: 148 | 149 | ```ruby 150 | RailsApiAuth.tap do |raa| 151 | raa.user_model_relation = :account # this will set up the belongs_to relation from the Login model to the Account model automatically (of course if your application uses a User model this would be :user) 152 | 153 | # Facebook configurations 154 | raa.facebook_app_id = '' 155 | raa.facebook_app_secret = '' 156 | raa.facebook_redirect_uri = '' 157 | 158 | # Google configurations 159 | raa.google_client_id = '' 160 | raa.google_client_secret = '' 161 | raa.google_redirect_uri = '' 162 | 163 | # Edx configurations 164 | raa.edx_client_id = '' 165 | raa.edx_client_secret = '' 166 | raa.edx_domain = '' 167 | raa.edx_redirect_uri = 'your Edx app redirect uri' 168 | 169 | # Force SSL for Oauth2Controller; defaults to `false` for the development environment, otherwise `true` 170 | raa.force_ssl = false 171 | end 172 | 173 | ``` 174 | 175 | ### A note on Edx Oauth2 code flows 176 | 177 | It is nesescary to include the Edx username in the request when making a call 178 | rails_api_auth call /token. When rails_api_auth interfaces with Edx's 179 | user api, the username is need to retrieve user data, not just a valid 180 | oauth2 token. 181 | 182 | E.g. 183 | 184 | ```ruby 185 | headers = { 186 | username: "alice", 187 | auth_code: "alices_authorization_code", 188 | grant_type: "edx_auth_code" 189 | } 190 | ``` 191 | 192 | ## Contribution 193 | 194 | See [CONTRIBUTING](https://github.com/simplabs/rails_api_auth/blob/master/CONTRIBUTING.md). 195 | 196 | ## License 197 | 198 | Rails API Auth is developed by and © 199 | [simplabs GmbH](http://simplabs.com) and contributors. It is released under the 200 | [MIT License](https://github.com/simplabs/ember-simple-auth/blob/master/LICENSE). 201 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__) 8 | load 'rails/tasks/engine.rake' 9 | 10 | load 'rails/tasks/statistics.rake' 11 | 12 | Bundler::GemHelper.install_tasks 13 | 14 | require 'rspec/core' 15 | require 'rspec/core/rake_task' 16 | 17 | desc 'Run all specs in spec directory (excluding plugin specs)' 18 | RSpec::Core::RakeTask.new(spec: 'app:db:test:prepare') 19 | 20 | task default: :spec 21 | -------------------------------------------------------------------------------- /app/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/app/controllers/.keep -------------------------------------------------------------------------------- /app/controllers/oauth2_controller.rb: -------------------------------------------------------------------------------- 1 | require 'login_not_found' 2 | 3 | # The controller that implements the engine's endpoints. 4 | # 5 | # @!visibility private 6 | class Oauth2Controller < ApplicationController 7 | 8 | force_ssl if: -> { RailsApiAuth.force_ssl } 9 | 10 | # rubocop:disable MethodLength 11 | def create 12 | case params[:grant_type] 13 | when 'password' 14 | authenticate_with_credentials(params[:username], params[:password]) 15 | when 'facebook_auth_code' 16 | authenticate_with_facebook(params[:auth_code]) 17 | when 'google_auth_code' 18 | authenticate_with_google(params[:auth_code]) 19 | when 'edx_auth_code' 20 | authenticate_with_edx(params[:username], params[:auth_code]) 21 | when 'github_auth_code' 22 | authenticate_with_github(params[:auth_state], params[:auth_code]) 23 | else 24 | oauth2_error('unsupported_grant_type') 25 | end 26 | end 27 | 28 | # rubocop:enable MethodLength 29 | def destroy 30 | oauth2_error('unsupported_token_type') && return unless params[:token_type_hint] == 'access_token' 31 | 32 | login = Login.where(oauth2_token: params[:token]).first || LoginNotFound.new 33 | login.refresh_oauth2_token! 34 | 35 | head 200 36 | end 37 | 38 | private 39 | 40 | def authenticate_with_credentials(identification, password) 41 | login = Login.where(identification: identification).first || LoginNotFound.new 42 | 43 | if login.authenticate(password) 44 | render json: { access_token: login.oauth2_token } 45 | else 46 | oauth2_error('invalid_grant') 47 | end 48 | end 49 | 50 | def authenticate_with_facebook(auth_code) 51 | oauth2_error('no_authorization_code') && return unless auth_code.present? 52 | 53 | login = FacebookAuthenticator.new(auth_code).authenticate! 54 | 55 | render json: { access_token: login.oauth2_token } 56 | rescue FacebookAuthenticator::ApiError 57 | head 502 58 | end 59 | 60 | def authenticate_with_google(auth_code) 61 | oauth2_error('no_authorization_code') && return unless auth_code.present? 62 | 63 | login = GoogleAuthenticator.new(auth_code).authenticate! 64 | 65 | render json: { access_token: login.oauth2_token } 66 | rescue GoogleAuthenticator::ApiError 67 | head 502 68 | end 69 | 70 | def authenticate_with_edx(username, auth_code) 71 | oauth2_error('no_authorization_code') && return unless auth_code.present? 72 | oauth2_error('no_username') && return unless username.present? 73 | 74 | login = EdxAuthenticator.new(username, auth_code).authenticate! 75 | 76 | render json: { access_token: login.oauth2_token } 77 | rescue EdxAuthenticator::ApiError 78 | head 502 79 | end 80 | 81 | def authenticate_with_github(auth_state, auth_code) 82 | oauth2_error('no_authorization_code') && return unless auth_code.present? 83 | oauth2_error('no_auth_state') && return unless auth_state.present? 84 | 85 | login = GithubAuthenticator.new(auth_state, auth_code).authenticate! 86 | 87 | render json: { access_token: login.oauth2_token } 88 | rescue GithubAuthenticator::ApiError 89 | head 502 90 | end 91 | 92 | def oauth2_error(error) 93 | render json: { error: error }, status: :bad_request 94 | end 95 | 96 | end 97 | -------------------------------------------------------------------------------- /app/lib/login_not_found.rb: -------------------------------------------------------------------------------- 1 | # Null Object that is used when no login is found for a Bearer token. 2 | # 3 | # @!visibility private 4 | class LoginNotFound 5 | 6 | def authenticate(_password) 7 | false 8 | end 9 | 10 | def refresh_oauth2_token!; end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/app/models/.keep -------------------------------------------------------------------------------- /app/models/login.rb: -------------------------------------------------------------------------------- 1 | # The `Login` __model encapsulates login credentials and the associated Bearer 2 | # tokens__. Rails API Auth uses this separate model so that login data and 3 | # user/profile data doesn't get mixed up and the Engine remains clearly 4 | # separeated from the code of the host application. 5 | # 6 | # The __`Login` model has `identification` and `password` attributes__ (in fact 7 | # it uses Rails' 8 | # [`has_secure_password`](http://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html#method-i-has_secure_password)) 9 | # __as well as a `uid`__ (a Facebook uid or Google sub) 10 | # attribute. As opposed to the standard `has_secure_password` behavior it 11 | # doesn't validate that the password must be present but instead validates that 12 | # __either__ the `password` or the `uid` are present as no password is 13 | # required in the case that Facebook or Google is used for authentication. 14 | # 15 | # The `Login` model also stores the Bearer token in the `oauth2_token` 16 | # attribute. The model also stores an additional Bearer token, the 17 | # `single_use_oauth2_token`, that can be used for implementing things like 18 | # password reset where you need to make sure that the provided token can only 19 | # be used once. 20 | class Login < ActiveRecord::Base 21 | 22 | # This is raised when an invalid token or a token that has already been 23 | # consumed is consumed. 24 | class InvalidOAuth2Token < StandardError; end 25 | 26 | if RailsApiAuth.user_model_relation 27 | belongs_to RailsApiAuth.user_model_relation, foreign_key: :user_id 28 | end 29 | 30 | if Rails::VERSION::MAJOR >= 4 31 | has_secure_password validations: false 32 | else 33 | has_secure_password 34 | end 35 | 36 | validates :identification, presence: true, uniqueness: true 37 | validates :oauth2_token, presence: true, uniqueness: true 38 | validates :single_use_oauth2_token, presence: true, uniqueness: true 39 | validates :password, length: { maximum: 72 }, confirmation: true 40 | validate :password_or_uid_present 41 | 42 | before_validation :ensure_oauth2_token 43 | before_validation :assign_single_use_oauth2_token 44 | 45 | # Refreshes the random token. This will effectively log out all clients that 46 | # possess the previous token. 47 | # 48 | # @raise [ActiveRecord::RecordInvalid] if the model is invalid 49 | def refresh_oauth2_token! 50 | ensure_oauth2_token(true) 51 | save! 52 | end 53 | 54 | # Refreshes the single use Oauth 2.0 token. 55 | # 56 | # @raise [ActiveRecord::RecordInvalid] if the model is invalid 57 | def refresh_single_use_oauth2_token! 58 | assign_single_use_oauth2_token 59 | save! 60 | end 61 | 62 | if Rails::VERSION::MAJOR == 3 63 | # @!visibility private 64 | def errors 65 | super.tap do |errors| 66 | errors.delete(:password_digest) 67 | end 68 | end 69 | end 70 | 71 | private 72 | 73 | def password_or_uid_present 74 | if password_digest.blank? && uid.blank? 75 | errors.add :base, 'either password_digest or uid must be present' 76 | end 77 | end 78 | 79 | def ensure_oauth2_token(force = false) 80 | set_token = oauth2_token.blank? || force 81 | self.oauth2_token = generate_token if set_token 82 | end 83 | 84 | def assign_single_use_oauth2_token 85 | self.single_use_oauth2_token = generate_token 86 | end 87 | 88 | def generate_token 89 | SecureRandom.hex(125) 90 | end 91 | 92 | end 93 | -------------------------------------------------------------------------------- /app/services/base_authenticator.rb: -------------------------------------------------------------------------------- 1 | class BaseAuthenticator 2 | 3 | class ApiError < StandardError; end 4 | 5 | def initialize(auth_code) 6 | @auth_code = auth_code 7 | end 8 | 9 | def authenticate! 10 | user = get_user(access_token) 11 | login = find_login(user) 12 | 13 | if login.present? 14 | connect_login_to_account(login, user) 15 | else 16 | login = create_login_from_account(user) 17 | end 18 | 19 | login 20 | end 21 | 22 | private 23 | 24 | def find_login(user) 25 | Login.where(identification: user[:email]).first 26 | end 27 | 28 | def get_request(url) 29 | response = HTTParty.get(url) 30 | unless response.code == 200 31 | Rails.logger.warn "#{self.class::PROVIDER} API request failed with status #{response.code}." 32 | Rails.logger.debug "#{self.class::PROVIDER} API error response was:\n#{response.body}" 33 | raise ApiError.new 34 | end 35 | response 36 | end 37 | 38 | end 39 | -------------------------------------------------------------------------------- /app/services/edx_authenticator.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | 3 | # Handles Edx authentication 4 | # 5 | # @!visibility private 6 | class EdxAuthenticator < BaseAuthenticator 7 | 8 | PROVIDER = 'edx'.freeze 9 | DOMAIN = "http://#{RailsApiAuth.edx_domain}".freeze 10 | TOKEN_URL = "#{DOMAIN}/oauth2/access_token".freeze 11 | PROFILE_URL = "#{DOMAIN}/api/user/v1/accounts/%{username}".freeze 12 | 13 | def initialize(username, auth_code) 14 | @auth_code = auth_code 15 | @username = username 16 | end 17 | 18 | private 19 | 20 | def connect_login_to_account(login, user) 21 | login.update_attributes!(uid: user[:username], provider: PROVIDER) 22 | end 23 | 24 | def create_login_from_account(user) 25 | login_attributes = { 26 | identification: user[:email], 27 | uid: user[:username], 28 | provider: PROVIDER 29 | } 30 | Login.create!(login_attributes) 31 | end 32 | 33 | def access_token 34 | response = HTTParty.post(TOKEN_URL, token_options) 35 | response.parsed_response['access_token'] 36 | end 37 | 38 | # Override base authenticator 39 | def get_request(url, headers) 40 | response = HTTParty.get(url, headers: headers) 41 | unless response.code == 200 42 | Rails.logger.warn "#{self.class::PROVIDER} API request failed with status #{response.code}." 43 | Rails.logger.debug "#{self.class::PROVIDER} API error response was:\n#{response.body}" 44 | raise ApiError.new 45 | end 46 | response 47 | end 48 | 49 | def get_user(access_token) 50 | headers = { 'Authorization' => "Bearer #{access_token}" } 51 | @get_user ||= begin 52 | get_request(user_url, headers).parsed_response.symbolize_keys 53 | end 54 | end 55 | 56 | def user_url 57 | PROFILE_URL % { username: @username } 58 | end 59 | 60 | def token_options 61 | @token_options ||= { 62 | body: { 63 | code: @auth_code, 64 | client_id: RailsApiAuth.edx_client_id, 65 | client_secret: RailsApiAuth.edx_client_secret, 66 | redirect_uri: RailsApiAuth.edx_redirect_uri, 67 | grant_type: 'authorization_code' 68 | } 69 | } 70 | end 71 | 72 | end 73 | -------------------------------------------------------------------------------- /app/services/facebook_authenticator.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | 3 | # Handles Facebook authentication 4 | # 5 | # @!visibility private 6 | class FacebookAuthenticator < BaseAuthenticator 7 | 8 | PROVIDER = 'facebook'.freeze 9 | TOKEN_URL = 'https://graph.facebook.com/v2.4/oauth/access_token?client_id=%{client_id}&client_secret=%{client_secret}&code=%{auth_code}&redirect_uri=%{redirect_uri}'.freeze 10 | PROFILE_URL = 'https://graph.facebook.com/v2.4/me?fields=email,name&access_token=%{access_token}'.freeze 11 | 12 | private 13 | 14 | def connect_login_to_account(login, user) 15 | login.update_attributes!(uid: user[:id], provider: PROVIDER) 16 | end 17 | 18 | def create_login_from_account(user) 19 | login_attributes = { 20 | identification: user[:email], 21 | uid: user[:id], 22 | provider: PROVIDER 23 | } 24 | 25 | Login.create!(login_attributes) 26 | end 27 | 28 | def access_token 29 | response = get_request(token_url) 30 | response.parsed_response.symbolize_keys[:access_token] 31 | end 32 | 33 | def get_user(access_token) 34 | @get_user ||= begin 35 | parsed_response = get_request(user_url(access_token)).parsed_response 36 | parsed_response.symbolize_keys 37 | end 38 | end 39 | 40 | def token_url 41 | url_options = { client_id: RailsApiAuth.facebook_app_id, client_secret: RailsApiAuth.facebook_app_secret, auth_code: @auth_code, redirect_uri: RailsApiAuth.facebook_redirect_uri } 42 | TOKEN_URL % url_options 43 | end 44 | 45 | def user_url(access_token) 46 | PROFILE_URL % { access_token: access_token } 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /app/services/github_authenticator.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | 3 | # Handles Github authentication 4 | # 5 | # @!visibility private 6 | class GithubAuthenticator < BaseAuthenticator 7 | 8 | PROVIDER = 'github'.freeze 9 | TOKEN_URL = 'https://github.com/login/oauth/access_token'.freeze 10 | USER_URL = 'https://api.github.com/user?access_token=%{access_token}'.freeze 11 | 12 | def initialize(auth_state, auth_code) 13 | @auth_code = auth_code 14 | @auth_state = auth_state 15 | end 16 | 17 | private 18 | 19 | def connect_login_to_account(login, user) 20 | login.update_attributes!(uid: user[:id], provider: PROVIDER) 21 | end 22 | 23 | def create_login_from_account(user) 24 | login_attributes = { 25 | identification: user[:email], 26 | uid: user[:id], 27 | provider: PROVIDER 28 | } 29 | 30 | Login.create!(login_attributes) 31 | end 32 | 33 | def access_token 34 | response = HTTParty.post(TOKEN_URL, token_options) 35 | response.parsed_response['access_token'] 36 | end 37 | 38 | def get_user(access_token) 39 | @get_user ||= begin 40 | get_request(user_url(access_token)).parsed_response.symbolize_keys 41 | end 42 | end 43 | 44 | def user_url(access_token) 45 | USER_URL % { access_token: access_token } 46 | end 47 | 48 | def token_options 49 | @token_options ||= { 50 | headers: { 'Accept' => 'application/json' }, 51 | body: { 52 | code: @auth_code, 53 | client_id: RailsApiAuth.github_client_id, 54 | client_secret: RailsApiAuth.github_client_secret, 55 | redirect_uri: RailsApiAuth.github_redirect_uri, 56 | state: @auth_state 57 | } 58 | } 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /app/services/google_authenticator.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | 3 | # Handles Google authentication 4 | # 5 | # @!visibility private 6 | class GoogleAuthenticator < BaseAuthenticator 7 | 8 | PROVIDER = 'google'.freeze 9 | TOKEN_URL = 'https://www.googleapis.com/oauth2/v3/token'.freeze 10 | PROFILE_URL = 'https://www.googleapis.com/plus/v1/people/me/openIdConnect?access_token=%{access_token}'.freeze 11 | 12 | private 13 | 14 | def connect_login_to_account(login, user) 15 | login.update_attributes!(uid: user[:sub], provider: PROVIDER) 16 | end 17 | 18 | def create_login_from_account(user) 19 | login_attributes = { 20 | identification: user[:email], 21 | uid: user[:sub], 22 | provider: PROVIDER 23 | } 24 | 25 | Login.create!(login_attributes) 26 | end 27 | 28 | def access_token 29 | response = HTTParty.post(TOKEN_URL, token_options) 30 | response.parsed_response['access_token'] 31 | end 32 | 33 | def get_user(access_token) 34 | @get_user ||= begin 35 | get_request(user_url(access_token)).parsed_response.symbolize_keys 36 | end 37 | end 38 | 39 | def user_url(access_token) 40 | PROFILE_URL % { access_token: access_token } 41 | end 42 | 43 | def token_options 44 | @token_options ||= { 45 | body: { 46 | code: @auth_code, 47 | client_id: RailsApiAuth.google_client_id, 48 | client_secret: RailsApiAuth.google_client_secret, 49 | redirect_uri: RailsApiAuth.google_redirect_uri, 50 | grant_type: 'authorization_code' 51 | } 52 | } 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails 4 gems installed from the root of your application. 3 | 4 | ENGINE_ROOT = File.expand_path('../..', __FILE__) 5 | ENGINE_PATH = File.expand_path('../../lib/rails_api_auth/engine', __FILE__) 6 | 7 | # Set up gems listed in the Gemfile. 8 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 9 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 10 | 11 | require 'rails/all' 12 | require 'rails/engine/commands' 13 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | post 'token', to: 'oauth2#create' 3 | post 'revoke', to: 'oauth2#destroy' 4 | end 5 | -------------------------------------------------------------------------------- /db/migrate/20150709221755_create_logins.rb: -------------------------------------------------------------------------------- 1 | require_relative 'rails_api_auth_migration' 2 | 3 | class CreateLogins < RailsAPIAuthMigration 4 | 5 | def change 6 | create_table :logins, primary_key_options(:id) do |t| 7 | t.string :identification, null: false 8 | t.string :password_digest, null: true 9 | t.string :oauth2_token, null: false 10 | t.string :facebook_uid 11 | t.string :single_use_oauth2_token 12 | 13 | t.references :user, primary_key_options(:type) 14 | 15 | t.timestamps 16 | end 17 | end 18 | 19 | private 20 | 21 | def primary_key_options(option_name) 22 | RailsApiAuth.primary_key_type ? { option_name => RailsApiAuth.primary_key_type } : {} 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /db/migrate/20150904110438_add_provider_to_login.rb: -------------------------------------------------------------------------------- 1 | require_relative 'rails_api_auth_migration' 2 | 3 | class AddProviderToLogin < RailsAPIAuthMigration 4 | 5 | def change 6 | add_column :logins, :provider, :string 7 | rename_column :logins, :facebook_uid, :uid 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/rails_api_auth_migration.rb: -------------------------------------------------------------------------------- 1 | private def migration_parent_class 2 | if Rails.version >= '5.0.0' 3 | ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"] 4 | else 5 | ActiveRecord::Migration 6 | end 7 | end 8 | 9 | class RailsAPIAuthMigration < migration_parent_class 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/rails_3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'bcrypt-ruby', '~> 3.0.0' 6 | gem 'rails', '3.2.22.2' 7 | 8 | group :development, :test do 9 | gem 'factory_girl_rails' 10 | gem 'faker' 11 | gem 'rspec-rails' 12 | end 13 | 14 | group :development do 15 | gem 'appraisal' 16 | gem 'guard-bundler', require: false 17 | gem 'guard-rspec', require: false 18 | gem 'guard-rubocop' 19 | end 20 | 21 | group :test do 22 | gem 'json_spec' 23 | gem 'shoulda' 24 | gem 'simplecov', require: false 25 | gem 'test-unit' 26 | gem 'timecop' 27 | gem 'webmock', require: 'webmock/rspec' 28 | end 29 | 30 | platforms :ruby, :mswin, :mingw do 31 | gem 'sqlite3' 32 | end 33 | 34 | platforms :jruby do 35 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.3.24' 36 | end 37 | 38 | gemspec path: '../' 39 | -------------------------------------------------------------------------------- /gemfiles/rails_4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rails', '4.2.6' 6 | 7 | group :development, :test do 8 | gem 'factory_girl_rails' 9 | gem 'faker' 10 | gem 'pry-rails' 11 | gem 'rspec-rails' 12 | end 13 | 14 | group :development do 15 | gem 'appraisal' 16 | gem 'guard-bundler', require: false 17 | gem 'guard-rspec', require: false 18 | gem 'guard-rubocop' 19 | end 20 | 21 | group :test do 22 | gem 'json_spec' 23 | gem 'shoulda' 24 | gem 'simplecov', require: false 25 | gem 'test-unit' 26 | gem 'timecop' 27 | gem 'webmock', require: 'webmock/rspec' 28 | end 29 | 30 | platforms :ruby, :mswin, :mingw do 31 | gem 'sqlite3' 32 | end 33 | 34 | platforms :jruby do 35 | gem 'activerecord-jdbcsqlite3-adapter', '~> 1.3.24' 36 | end 37 | 38 | gemspec path: '../' 39 | -------------------------------------------------------------------------------- /gemfiles/rails_5.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'rails', '5.0.1' 6 | 7 | group :development, :test do 8 | gem 'factory_girl_rails' 9 | gem 'faker' 10 | gem 'pry-rails' 11 | gem 'rspec-rails' 12 | end 13 | 14 | group :development do 15 | gem 'appraisal' 16 | gem 'guard-bundler', require: false 17 | gem 'guard-rspec', require: false 18 | gem 'guard-rubocop' 19 | end 20 | 21 | group :test do 22 | gem 'json_spec' 23 | gem 'rails-controller-testing' 24 | gem 'shoulda' 25 | gem 'simplecov', require: false 26 | gem 'test-unit' 27 | gem 'timecop' 28 | gem 'webmock', require: 'webmock/rspec' 29 | end 30 | 31 | platforms :ruby, :mswin, :mingw do 32 | gem 'sqlite3' 33 | end 34 | 35 | platforms :jruby do 36 | gem 'activerecord-jdbcsqlite3-adapter' 37 | end 38 | 39 | gemspec path: '../' 40 | -------------------------------------------------------------------------------- /lib/rails_api_auth.rb: -------------------------------------------------------------------------------- 1 | require 'rails_api_auth/engine' 2 | 3 | # The engine's main module. 4 | module RailsApiAuth 5 | 6 | # @!attribute [rw] user_model_relation 7 | # Defines the `Login` model's `belongs_to` relation to the host application's 8 | # `User` model (or `Account` or whatever the application stores user data 9 | # in). 10 | # 11 | # E.g. is this is set to `:profile`, the `Login` model will have a 12 | # `belongs_to :profile` relation. 13 | mattr_accessor :user_model_relation 14 | 15 | # @!attribute [rw] facebook_app_id 16 | # The Facebook App ID. 17 | mattr_accessor :facebook_app_id 18 | 19 | # @!attribute [rw] facebook_app_secret 20 | # The Facebook App secret. 21 | mattr_accessor :facebook_app_secret 22 | 23 | # @!attribute [rw] facebook_redirect_uri 24 | # The Facebook App's redirect URI. 25 | mattr_accessor :facebook_redirect_uri 26 | 27 | # @!attribute [rw] google_client_id 28 | # The Google client ID. 29 | mattr_accessor :google_client_id 30 | 31 | # @!attribute [rw] google_client_secret 32 | # The Google client secret. 33 | mattr_accessor :google_client_secret 34 | 35 | # @!attribute [rw] google_redirect_uri 36 | # The Google App's redirect URI. 37 | mattr_accessor :google_redirect_uri 38 | 39 | # @!attribute [rw] github_client_id 40 | # The Github client ID. 41 | mattr_accessor :github_client_id 42 | 43 | # @!attribute [rw] github_client_secret 44 | # The Github client secret. 45 | mattr_accessor :github_client_secret 46 | 47 | # @!attribute [rw] github_redirect_uri 48 | # The Github App's redirect URI. 49 | mattr_accessor :github_redirect_uri 50 | 51 | # @!attribute [rw] edx_client_id 52 | # The Edx client ID. 53 | mattr_accessor :edx_client_id 54 | 55 | # @!attribute [rw] edx_client_secret 56 | # The Edx client secret. 57 | mattr_accessor :edx_client_secret 58 | 59 | # @!attribute [rw] edx_redirect_uri 60 | # The Edx App's redirect URI. 61 | mattr_accessor :edx_redirect_uri 62 | 63 | # @!attribute [rw] edx_domain 64 | # The domain used for the Edx oauth2 provider 65 | mattr_accessor :edx_domain 66 | 67 | # @!attribute [rw] primary_key_type 68 | # Configures database column type used for primary keys, 69 | # currently only accepts :uuid 70 | mattr_accessor :primary_key_type 71 | 72 | # @!attribute [rw] force_ssl 73 | # Force SSL for Oauth2Controller; defaults to `false` for the development environment, otherwise `true` 74 | mattr_accessor :force_ssl 75 | self.force_ssl = !Rails.env.development? 76 | 77 | end 78 | -------------------------------------------------------------------------------- /lib/rails_api_auth/authentication.rb: -------------------------------------------------------------------------------- 1 | module RailsApiAuth 2 | 3 | # Module that defines attributes and method for use in controllers. This 4 | # module would typically be included in the `ApplicationController`. 5 | module Authentication 6 | 7 | extend ActiveSupport::Concern 8 | 9 | # @!attribute [r] current_login 10 | # The login that was authenticated from the Bearer token in the 11 | # `Authorization` header (if one was successfully authenticated). 12 | 13 | # @!method authenticate!(&block) 14 | # Ensures that the `Authorization` header is present with a valid Bearer 15 | # token. 16 | # 17 | # If a valid bearer token is present this method will retrieve the 18 | # respective `Login` model and store it in `current_login`. If no or an 19 | # invalid Bearer token is present this will result in a 401 response. 20 | # 21 | # This method will typically be called as a `before_action`: 22 | # 23 | # ```ruby 24 | # class AuthenticatedController < ApplicationController 25 | # 26 | # include RailsApiAuth::Authentication 27 | # 28 | # before_filter :authenticate! 29 | # 30 | # def index 31 | # render text: 'zuper content', status: 200 32 | # end 33 | # 34 | # end 35 | # ``` 36 | # 37 | # You can also call this method with a block to perform additional checks 38 | # on the login retrieved for the Bearer token. When the block returns a 39 | # truthy value authentication is successful, when the block returns a falsy 40 | # value the client will see a 401 response: 41 | # 42 | # ```ruby 43 | # class AuthenticatedController < ApplicationController 44 | # 45 | # include RailsApiAuth::Authentication 46 | # 47 | # before_filter :authenticate_admin! 48 | # 49 | # def index 50 | # render text: 'zuper content', status: 200 51 | # end 52 | # 53 | # private 54 | # 55 | # def authenticate_with_account! 56 | # authenticate! do 57 | # current_login.account.first_name == 'user x' 58 | # end 59 | # end 60 | # 61 | # end 62 | # ``` 63 | # 64 | # @see #current_login 65 | 66 | # @!method consume_single_use_oauth2_token! 67 | # Ensures that the `Authorization` header is present with a valid 68 | # single-use Bearer token. 69 | # 70 | # If a valid bearer token is present this method will retrieve the 71 | # respective `Login` model and store it in `current_login`. If no or an 72 | # invalid Bearer token is present this will result in a 401 response. This 73 | # will also changes the `Login`'s single use OAuth 2.0 token so that the 74 | # same one cannot be used again. This authentication mechanism is generally 75 | # useful for implementing e.g. password change functionality. 76 | # 77 | # This method will typically be called as a `before_action`: 78 | # 79 | # ```ruby 80 | # class AuthenticatedController < ApplicationController 81 | # 82 | # include RailsApiAuth::Authentication 83 | # 84 | # before_filter :consume_single_use_oauth2_token! 85 | # 86 | # def change_password 87 | # ... 88 | # end 89 | # 90 | # end 91 | # ``` 92 | 93 | included do 94 | attr_reader :current_login 95 | 96 | private 97 | 98 | def authenticate!(*) 99 | @current_login = Login.where(oauth2_token: bearer_token).first! 100 | 101 | if block_given? 102 | head 401 unless yield 103 | else 104 | @current_login 105 | end 106 | rescue ActiveRecord::RecordNotFound 107 | head 401 108 | end 109 | 110 | def consume_single_use_oauth2_token! 111 | @current_login = Login.where(single_use_oauth2_token: bearer_token).first! 112 | @current_login.refresh_single_use_oauth2_token! 113 | rescue ActiveRecord::RecordNotFound 114 | head 401 115 | end 116 | 117 | def bearer_token 118 | auth_header = request.headers['Authorization'] 119 | auth_header ? auth_header.split(' ').last : nil 120 | end 121 | end 122 | 123 | end 124 | 125 | end 126 | -------------------------------------------------------------------------------- /lib/rails_api_auth/engine.rb: -------------------------------------------------------------------------------- 1 | require 'rails_api_auth/authentication' 2 | 3 | module RailsApiAuth 4 | 5 | # @!visibility private 6 | class Engine < ::Rails::Engine 7 | 8 | engine_name 'rails_api_auth' 9 | 10 | initializer :append_migrations do |app| 11 | unless app.root.to_s.match root.to_s 12 | config.paths['db/migrate'].expanded.each do |expanded_path| 13 | app.config.paths['db/migrate'] << expanded_path 14 | end 15 | end 16 | end 17 | 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /lib/rails_api_auth/version.rb: -------------------------------------------------------------------------------- 1 | module RailsApiAuth 2 | 3 | VERSION = '0.1.0'.freeze 4 | 5 | end 6 | -------------------------------------------------------------------------------- /rails_api_auth.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path('lib', __dir__) 2 | 3 | require 'rails_api_auth/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'rails_api_auth' 7 | s.version = RailsApiAuth::VERSION 8 | s.authors = ['Marco Otte-Witte', 'Oliver Barnes'] 9 | s.email = ['marco.otte-witte@simplabs.com'] 10 | s.homepage = 'https://github.com/simplabs/rails-api-auth' 11 | s.summary = 'Engine that implements OAuth 2.0 and Facebook authentication for API projects' 12 | s.description = 'Rails API Auth is a Rails Engine that implements the "Resource Owner Password Credentials Grant" OAuth 2.0 flow as well as Facebook authentication for API projects.' 13 | s.license = 'MIT' 14 | 15 | s.files = Dir['{app,config,db,lib}/**/*', 'LICENSE', 'Rakefile', 'README.md'] 16 | s.test_files = Dir['spec/**/*'] 17 | 18 | s.add_dependency('bcrypt', '~> 3.1.7') 19 | s.add_dependency('httparty', '~> 0.13.3') 20 | s.add_dependency('rails', '>= 3.2.6', '< 6') 21 | end 22 | -------------------------------------------------------------------------------- /spec/config/force_ssl_spec.rb: -------------------------------------------------------------------------------- 1 | describe RailsApiAuth do 2 | it 'has force_ssl with default value of true' do 3 | expect(subject.force_ssl).to eq true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/README.rdoc: -------------------------------------------------------------------------------- 1 | == README 2 | 3 | This README would normally document whatever steps are necessary to get the 4 | application up and running. 5 | 6 | Things you may want to cover: 7 | 8 | * Ruby version 9 | 10 | * System dependencies 11 | 12 | * Configuration 13 | 14 | * Database creation 15 | 16 | * Database initialization 17 | 18 | * How to run the test suite 19 | 20 | * Services (job queues, cache servers, search engines, etc.) 21 | 22 | * Deployment instructions 23 | 24 | * ... 25 | 26 | 27 | Please feel free to use a different markup language if you do not plan to run 28 | rake doc:app. 29 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('config/application', __dir__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/spec/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /spec/dummy/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require_tree . 14 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/access_once_controller.rb: -------------------------------------------------------------------------------- 1 | class AccessOnceController < ApplicationController 2 | 3 | include RailsApiAuth::Authentication 4 | 5 | if Rails::VERSION::MAJOR < 4 6 | before_filter :consume_single_use_oauth2_token! 7 | else 8 | before_action :consume_single_use_oauth2_token! 9 | end 10 | 11 | def index 12 | if Rails::VERSION::MAJOR < 4 13 | render text: 'zuper content', status: :ok 14 | else 15 | render plain: 'zuper content', status: :ok 16 | end 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | 3 | # Prevent CSRF attacks by raising an exception. 4 | # For APIs, you may want to use :null_session instead. 5 | protect_from_forgery with: :exception 6 | 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/authenticated_controller.rb: -------------------------------------------------------------------------------- 1 | class AuthenticatedController < ApplicationController 2 | 3 | include RailsApiAuth::Authentication 4 | 5 | if Rails::VERSION::MAJOR < 4 6 | before_filter :authenticate! 7 | else 8 | before_action :authenticate! 9 | end 10 | 11 | def index 12 | if Rails::VERSION::MAJOR < 4 13 | render text: 'zuper content', status: :ok 14 | else 15 | render plain: 'zuper content', status: :ok 16 | end 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/spec/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/controllers/custom_authenticated_controller.rb: -------------------------------------------------------------------------------- 1 | class CustomAuthenticatedController < ApplicationController 2 | 3 | include RailsApiAuth::Authentication 4 | 5 | if Rails::VERSION::MAJOR < 4 6 | before_filter :authenticate_with_account! 7 | else 8 | before_action :authenticate_with_account! 9 | end 10 | 11 | def index 12 | if Rails::VERSION::MAJOR < 4 13 | render text: 'zuper content', status: :ok 14 | else 15 | render plain: 'zuper content', status: :ok 16 | end 17 | end 18 | 19 | private 20 | 21 | def authenticate_with_account! 22 | authenticate! do 23 | current_login.account.first_name == 'user x' 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /spec/dummy/app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/spec/dummy/app/models/.keep -------------------------------------------------------------------------------- /spec/dummy/app/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account < ActiveRecord::Base 2 | 3 | has_one :login 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/spec/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts "== Installing dependencies ==" 12 | system "gem install bundler --conservative" 13 | system "bundle check || bundle install" 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system "bin/rake db:setup" 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system "rm -f log/*" 25 | system "rm -rf tmp/cache" 26 | 27 | puts "\n== Restarting application server ==" 28 | system "touch tmp/restart.txt" 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('boot', __dir__) 2 | 3 | require 'active_record/railtie' 4 | require 'action_controller/railtie' 5 | require 'sprockets/railtie' 6 | 7 | Bundler.require(*Rails.groups) 8 | 9 | require 'rails_api_auth' 10 | 11 | module Dummy 12 | 13 | class Application < Rails::Application 14 | 15 | end 16 | 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: 5 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('application', __dir__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Print deprecation notices to the Rails logger. 17 | config.active_support.deprecation = :log 18 | 19 | # Raise an error on page load if there are pending migrations. 20 | # config.active_record.migration_error = :page_load 21 | 22 | # Debug mode disables concatenation and preprocessing of assets. 23 | # This option may cause significant delays in view rendering with a large 24 | # number of complex assets. 25 | config.assets.debug = true 26 | 27 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 28 | # yet still be able to expire them through the digest params. 29 | config.assets.digest = true 30 | 31 | # Adds additional error checking when serving assets at runtime. 32 | # Checks for improperly declared sprockets dependencies. 33 | # Raises helpful error messages. 34 | config.assets.raise_runtime_errors = true 35 | 36 | # Raises error for missing translations 37 | # config.action_view.raise_on_missing_translations = true 38 | end 39 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like 20 | # NGINX, varnish or squid. 21 | # config.action_dispatch.rack_cache = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | if Rails::VERSION::MAJOR < 5 26 | config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present? 27 | else 28 | config.public_file_server.enabled = false 29 | end 30 | 31 | # Compress JavaScripts and CSS. 32 | config.assets.js_compressor = :uglifier 33 | # config.assets.css_compressor = :sass 34 | 35 | # Do not fallback to assets pipeline if a precompiled asset is missed. 36 | config.assets.compile = false 37 | 38 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 39 | # yet still be able to expire them through the digest params. 40 | config.assets.digest = true 41 | 42 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 43 | 44 | # Specifies the header that your server uses for sending files. 45 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 46 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Use the lowest log level to ensure availability of diagnostic information 52 | # when problems arise. 53 | config.log_level = :debug 54 | 55 | # Prepend all log lines with the following tags. 56 | # config.log_tags = [ :subdomain, :uuid ] 57 | 58 | # Use a different logger for distributed setups. 59 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 60 | 61 | # Use a different cache store in production. 62 | # config.cache_store = :mem_cache_store 63 | 64 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 65 | # config.action_controller.asset_host = 'http://assets.example.com' 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Send deprecation notices to registered listeners. 76 | config.active_support.deprecation = :notify 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Do not dump schema after migrations. 82 | config.active_record.dump_schema_after_migration = false 83 | end 84 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static file server for tests with Cache-Control for performance. 16 | if Rails::VERSION::MAJOR < 5 17 | config.serve_static_files = true 18 | config.static_cache_control = 'public, max-age=3600' 19 | else 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' } 22 | end 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | 28 | # Raise exceptions instead of rendering exception templates. 29 | config.action_dispatch.show_exceptions = false 30 | 31 | # Disable request forgery protection in test environment. 32 | config.action_controller.allow_forgery_protection = false 33 | 34 | # Randomize the order test cases are executed. 35 | config.active_support.test_order = :random 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Dummy::Application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | # Rails.application.config.assets.precompile += %w( search.js ) 12 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Dummy::Application.config.action_dispatch.cookies_serializer = :json 4 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Dummy::Application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/rails_api_auth.rb: -------------------------------------------------------------------------------- 1 | RailsApiAuth.tap do |raa| 2 | raa.user_model_relation = :account 3 | 4 | raa.facebook_app_id = 'app_id' 5 | raa.facebook_app_secret = 'app_secret' 6 | raa.facebook_redirect_uri = 'redirect_uri' 7 | 8 | raa.google_client_id = 'google_client_id' 9 | raa.google_client_secret = 'google_client_secret' 10 | raa.google_redirect_uri = 'google_redirect_uri' 11 | 12 | raa.github_client_id = 'github_client_id' 13 | raa.github_client_secret = 'github_client_secret' 14 | raa.github_redirect_uri = 'github_redirect_uri' 15 | 16 | raa.edx_client_id = 'edx_client_id' 17 | raa.edx_client_secret = 'edx_client_secret' 18 | raa.edx_domain = 'edxdomain.org' 19 | raa.edx_redirect_uri = 'edx_redirect_uri' 20 | end 21 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | Dummy::Application.config.secret_token = 'd793b643dbf138320b2e2edef7d6ba8e' 2 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Dummy::Application.config.session_store :cookie_store, key: '_dummy_session' 4 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | get '/authenticated' => 'authenticated#index' 3 | get '/custom-authenticated' => 'custom_authenticated#index' 4 | get '/access-once' => 'access_once#index' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: aae832d08f1d89a0ae423b6ba2f5f1a37b151766495d845e566dc43a91025ca9952ecb2b833c20b5d589cfeaf9af1541f7ebee0fabc41e0a0f1812395483ecb7 15 | 16 | test: 17 | secret_key_base: 077891dd99945627a5f5b4d27fae06486863c495f1a283758dd67802b742a16223aeea403ae98dc8bfd8c18ab9b716fd7fb1f2e9c16eee6efbdb38319db91093 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV['SECRET_KEY_BASE'] %> 23 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20150803185817_create_accounts.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../../db/migrate/rails_api_auth_migration' 2 | 3 | class CreateAccounts < RailsAPIAuthMigration 4 | 5 | def change 6 | create_table :accounts do |t| 7 | t.string :first_name, null: false 8 | t.string :last_name, null: true 9 | 10 | t.timestamps 11 | end 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended to check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(:version => 20150904110438) do 15 | 16 | create_table "accounts", :force => true do |t| 17 | t.string "first_name", :limit => nil, :null => false 18 | t.string "last_name", :limit => nil 19 | t.datetime "created_at", :null => false 20 | t.datetime "updated_at", :null => false 21 | end 22 | 23 | create_table "ar_internal_metadata", :primary_key => "key", :force => true do |t| 24 | t.string "value", :limit => nil 25 | t.datetime "created_at", :null => false 26 | t.datetime "updated_at", :null => false 27 | end 28 | 29 | add_index "ar_internal_metadata", ["key"], :name => "sqlite_autoindex_ar_internal_metadata_1", :unique => true 30 | 31 | create_table "logins", :force => true do |t| 32 | t.string "identification", :limit => nil, :null => false 33 | t.string "password_digest", :limit => nil 34 | t.string "oauth2_token", :limit => nil, :null => false 35 | t.string "uid", :limit => nil 36 | t.string "single_use_oauth2_token", :limit => nil 37 | t.integer "user_id" 38 | t.datetime "created_at", :null => false 39 | t.datetime "updated_at", :null => false 40 | t.string "provider", :limit => nil 41 | end 42 | 43 | add_index "logins", ["user_id"], :name => "index_logins_on_user_id" 44 | 45 | end 46 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/spec/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mainmatter/rails_api_auth/c949cef9f207b3ac60c400eb0d5191bf110ebab8/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/factories/accounts.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :account do 3 | first_name { Faker::Name.first_name } 4 | last_name { Faker::Name.last_name } 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/logins.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :login do 3 | identification { Faker::Internet.email } 4 | password { Faker::Lorem.word } 5 | 6 | trait :facebook do 7 | uid { Faker::Number.number } 8 | password nil 9 | provider 'facebook' 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/models/login_spec.rb: -------------------------------------------------------------------------------- 1 | describe Login do 2 | it 'belongs to the configured user model' do 3 | expect(subject).to belong_to(:account).with_foreign_key(:user_id) 4 | end 5 | 6 | subject { Login.new(identification: 'test@example.com', oauth2_token: 'token', single_use_oauth2_token: 'oldtoken') } 7 | 8 | describe 'validations' do 9 | before do 10 | allow_any_instance_of(Login).to receive(:ensure_oauth2_token).and_return(true) 11 | allow_any_instance_of(Login).to receive(:assign_single_use_oauth2_token).and_return(true) 12 | end 13 | 14 | it { is_expected.to validate_presence_of(:identification) } 15 | it { is_expected.to validate_uniqueness_of(:identification) } 16 | it { is_expected.to validate_uniqueness_of(:oauth2_token) } 17 | it { is_expected.to validate_uniqueness_of(:single_use_oauth2_token) } 18 | end 19 | 20 | it 'validates presence of either password or Facebook UID' do 21 | login = described_class.new(identification: 'test@example.com', oauth2_token: 'token') 22 | 23 | expect(login).to_not be_valid 24 | end 25 | 26 | it "doesn't validate presence of password when Facebook UID is present" do 27 | login = described_class.new(identification: 'test@example.com', oauth2_token: 'token', uid: '123', provider: 'facebook') 28 | 29 | expect(login).to be_valid 30 | end 31 | 32 | it "doesn't validate presence of Facebook UID when password is present" do 33 | login = described_class.new(identification: 'test@example.com', oauth2_token: 'token', password: '123') 34 | 35 | expect(login).to be_valid 36 | end 37 | 38 | describe '#refresh_oauth2_token!' do 39 | subject { described_class.new(oauth2_token: 'oldtoken') } 40 | 41 | before do 42 | allow(subject).to receive(:save!) 43 | end 44 | 45 | it 'force-resets the oauth2 token' do 46 | expect { subject.refresh_oauth2_token! }.to change(subject, :oauth2_token) 47 | end 48 | 49 | it 'saves the model' do 50 | expect(subject).to receive(:save!) 51 | 52 | subject.refresh_oauth2_token! 53 | end 54 | end 55 | 56 | describe '#refresh_single_use_oauth2_token!' do 57 | subject { described_class.new(single_use_oauth2_token: 'oldtoken') } 58 | 59 | before do 60 | allow(subject).to receive(:save!) 61 | end 62 | 63 | it 'force-resets the single oauth2 token' do 64 | expect { subject.refresh_single_use_oauth2_token! }.to change(subject, :single_use_oauth2_token) 65 | end 66 | 67 | it 'saves the model' do 68 | expect(subject).to receive(:save!) 69 | 70 | subject.refresh_single_use_oauth2_token! 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/requests/access_once_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'an access-once route' do 2 | if Rails::VERSION::MAJOR < 5 3 | # rubocop:disable Rails/HttpPositionalArguments 4 | subject { get '/access-once', {}, headers } 5 | # rubocop:enable Rails/HttpPositionalArguments 6 | else 7 | subject { get '/access-once', params: {}, headers: headers } 8 | end 9 | 10 | let(:login) { create(:login) } 11 | let(:headers) do 12 | { 'Authorization' => "Bearer #{login.single_use_oauth2_token}" } 13 | end 14 | 15 | context 'when a valid Bearer token is present' do 16 | it 'assigns the authenticated login to @current_login' do 17 | subject 18 | 19 | expect(assigns[:current_login]).to eq(login) 20 | end 21 | 22 | it "responds with the actual action's status" do 23 | subject 24 | 25 | expect(response).to have_http_status(200) 26 | end 27 | 28 | it "responds with the actual action's body" do 29 | subject 30 | 31 | expect(response.body).to eql('zuper content') 32 | end 33 | 34 | it "changes the login's single_use_oauth2_token" do 35 | expect { subject }.to change { login.reload.single_use_oauth2_token } 36 | end 37 | end 38 | 39 | shared_examples 'when access is not allowed' do 40 | it 'does not assign the authenticated login to @current_login' do 41 | subject 42 | 43 | expect(assigns[:current_login]).to be_nil 44 | end 45 | 46 | it 'responds with status 401' do 47 | subject 48 | 49 | expect(response).to have_http_status(401) 50 | end 51 | 52 | it 'responds with an empty body' do 53 | subject 54 | 55 | expect(response.body.strip).to be_empty 56 | end 57 | end 58 | 59 | context 'when accessed a second time with the same token' do 60 | before do 61 | if Rails::VERSION::MAJOR < 5 62 | # rubocop:disable Rails/HttpPositionalArguments 63 | get '/access-once', {}, headers 64 | # rubocop:enable Rails/HttpPositionalArguments 65 | else 66 | get '/access-once', params: {}, headers: headers 67 | end 68 | end 69 | 70 | it_behaves_like 'when access is not allowed' 71 | end 72 | 73 | context 'when no valid Bearer token is present' do 74 | let(:headers) { {} } 75 | 76 | it_behaves_like 'when access is not allowed' 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/requests/authenticated_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'an authenticated route' do 2 | if Rails::VERSION::MAJOR < 5 3 | # rubocop:disable Rails/HttpPositionalArguments 4 | subject { get '/authenticated', {}, headers } 5 | # rubocop:enable Rails/HttpPositionalArguments 6 | else 7 | subject { get '/authenticated', params: {}, headers: headers } 8 | end 9 | 10 | let(:headers) { {} } 11 | 12 | context 'when a valid Bearer token is present' do 13 | let(:login) { create(:login) } 14 | let(:headers) do 15 | { 'Authorization' => "Bearer #{login.oauth2_token}" } 16 | end 17 | 18 | it 'assigns the authenticated login to @current_login' do 19 | subject 20 | 21 | expect(assigns[:current_login]).to eq(login) 22 | end 23 | 24 | it "responds with the actual action's status" do 25 | subject 26 | 27 | expect(response).to have_http_status(200) 28 | end 29 | 30 | it "responds with the actual action's body" do 31 | subject 32 | 33 | expect(response.body).to eql('zuper content') 34 | end 35 | end 36 | 37 | context 'when no valid Bearer token is present' do 38 | it 'does not assign the authenticated login to @current_login' do 39 | subject 40 | 41 | expect(assigns[:current_login]).to be_nil 42 | end 43 | 44 | it 'responds with status 401' do 45 | subject 46 | 47 | expect(response).to have_http_status(401) 48 | end 49 | 50 | it 'responds with an empty body' do 51 | subject 52 | 53 | expect(response.body.strip).to be_empty 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/requests/custom_authenticated_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'a custom authenticated route' do 2 | if Rails::VERSION::MAJOR < 5 3 | # rubocop:disable Rails/HttpPositionalArguments 4 | subject { get '/custom-authenticated', {}, headers } 5 | # rubocop:enable Rails/HttpPositionalArguments 6 | let(:headers) do 7 | { 'Authorization' => "Bearer #{login.oauth2_token}" } 8 | end 9 | else 10 | subject { get '/custom-authenticated', params: {}, headers: headers } 11 | let(:headers) do 12 | { HTTP_AUTHORIZATION: "Bearer #{login.oauth2_token}" } 13 | end 14 | end 15 | 16 | let(:account) { create(:account) } 17 | let(:login) { create(:login, account: account) } 18 | 19 | context 'when the block returns true' do 20 | let(:account) { create(:account, first_name: 'user x') } 21 | 22 | it 'assigns the authenticated login to @current_login' do 23 | subject 24 | 25 | expect(assigns[:current_login]).to eq(login) 26 | end 27 | 28 | it "responds with the actual action's status" do 29 | subject 30 | 31 | expect(response).to have_http_status(200) 32 | end 33 | 34 | it "responds with the actual action's body" do 35 | subject 36 | 37 | expect(response.body).to eql('zuper content') 38 | end 39 | end 40 | 41 | context 'when the block returns false' do 42 | it 'responds with status 401' do 43 | subject 44 | 45 | expect(response).to have_http_status(401) 46 | end 47 | 48 | it 'responds with an empty body' do 49 | subject 50 | 51 | expect(response.body.strip).to be_empty 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/requests/oauth2_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Oauth2 API' do 2 | let!(:login) { create(:login) } 3 | 4 | describe 'POST /token' do 5 | let(:params) { { grant_type: 'password', username: login.identification, password: login.password } } 6 | if Rails::VERSION::MAJOR < 5 7 | # rubocop:disable Rails/HttpPositionalArguments 8 | subject { post '/token', params, 'HTTPS' => ssl } 9 | # rubocop:enable Rails/HttpPositionalArguments 10 | else 11 | subject { post '/token', params: params, headers: { 'HTTPS' => ssl } } 12 | end 13 | 14 | shared_examples 'when the request gets through' do 15 | context 'for grant_type "password"' do 16 | context 'with valid login credentials' do 17 | it 'responds with status 200' do 18 | subject 19 | 20 | expect(response).to have_http_status(200) 21 | end 22 | 23 | it 'responds with an access token' do 24 | subject 25 | 26 | expect(response.body).to be_json_eql({ access_token: login.oauth2_token }.to_json) 27 | end 28 | end 29 | 30 | context 'with invalid login credentials' do 31 | let(:params) { { grant_type: 'password', username: login.identification, password: 'badpassword' } } 32 | 33 | it 'responds with status 400' do 34 | subject 35 | 36 | expect(response).to have_http_status(400) 37 | end 38 | 39 | it 'responds with an invalid grant error' do 40 | subject 41 | 42 | expect(response.body).to be_json_eql({ error: 'invalid_grant' }.to_json) 43 | end 44 | end 45 | end 46 | 47 | context 'for grant_type "facebook_auth_code"' do 48 | let(:authenticated_user_data) do 49 | { 50 | id: '1238190321', 51 | email: email 52 | } 53 | end 54 | let(:uid_mapped_field) { 'id' } 55 | let(:grant_type) { 'facebook_auth_code' } 56 | let(:profile_url) { FacebookAuthenticator::PROFILE_URL } 57 | 58 | include_context 'stubbed facebook requests' 59 | include_examples 'oauth2 shared contexts' 60 | end 61 | 62 | context 'for grant_type "google_auth_code"' do 63 | let(:authenticated_user_data) do 64 | { 65 | sub: '1238190321', 66 | email: email 67 | } 68 | end 69 | let(:uid_mapped_field) { 'sub' } 70 | let(:grant_type) { 'google_auth_code' } 71 | let(:profile_url) { GoogleAuthenticator::PROFILE_URL } 72 | 73 | include_context 'stubbed google requests' 74 | include_examples 'oauth2 shared contexts' 75 | end 76 | 77 | context 'for grant_type "edx_auth_code"' do 78 | let(:authenticated_user_data) do 79 | { 80 | username: 'user', 81 | email: email 82 | } 83 | end 84 | let(:uid_mapped_field) { 'username' } 85 | let(:grant_type) { 'edx_auth_code' } 86 | let(:profile_url) { EdxAuthenticator::PROFILE_URL } 87 | 88 | include_context 'stubbed edx requests' 89 | include_examples 'oauth2 edx shared contexts' 90 | end 91 | 92 | context 'for an unknown grant type' do 93 | let(:params) { { grant_type: 'UNKNOWN' } } 94 | 95 | it 'responds with status 400' do 96 | subject 97 | 98 | expect(response).to have_http_status(400) 99 | end 100 | 101 | it 'responds with an "unsupported_grant_type" error' do 102 | subject 103 | 104 | expect(response.body).to be_json_eql({ error: 'unsupported_grant_type' }.to_json) 105 | end 106 | end 107 | end 108 | 109 | context 'when SSL is forced' do 110 | include_context 'with force_ssl configured' 111 | let(:force_ssl) { true } 112 | 113 | context 'and the request uses SSL' do 114 | let(:ssl) { 'on' } 115 | 116 | include_examples 'when the request gets through' 117 | end 118 | 119 | context 'and the request does not use SSL' do 120 | let(:ssl) { false } 121 | 122 | it 'responds with status 301' do 123 | subject 124 | 125 | expect(response).to have_http_status(301) 126 | end 127 | end 128 | end 129 | 130 | context 'when SSL is not forced' do 131 | include_context 'with force_ssl configured' 132 | let(:force_ssl) { false } 133 | 134 | context 'and the request uses SSL' do 135 | let(:ssl) { 'on' } 136 | 137 | include_examples 'when the request gets through' 138 | end 139 | 140 | context 'and the request does not use SSL' do 141 | let(:ssl) { false } 142 | 143 | include_examples 'when the request gets through' 144 | end 145 | end 146 | end 147 | 148 | describe 'POST #destroy' do 149 | let(:params) { { token_type_hint: 'access_token', token: login.oauth2_token } } 150 | 151 | if Rails::VERSION::MAJOR < 5 152 | # rubocop:disable Rails/HttpPositionalArguments 153 | subject { get '/access-once', {}, headers } 154 | subject { post '/revoke', params, 'HTTPS' => ssl } 155 | # rubocop:enable Rails/HttpPositionalArguments 156 | else 157 | subject { get '/access-once', params: {}, headers: headers } 158 | subject { post '/revoke', params: params, headers: { 'HTTPS' => ssl } } 159 | end 160 | 161 | shared_examples 'when the request gets through' do 162 | it 'responds with status 200' do 163 | subject 164 | 165 | expect(response).to have_http_status(200) 166 | end 167 | 168 | it "resets the login's OAuth 2.0 token" do 169 | expect { subject }.to change { login.reload.oauth2_token } 170 | 171 | subject 172 | end 173 | 174 | context 'for an invalid token' do 175 | let(:params) { { token_type_hint: 'access_token', token: 'badtoken' } } 176 | 177 | it 'responds with status 200' do 178 | subject 179 | 180 | expect(response).to have_http_status(200) 181 | end 182 | 183 | it "doesn't reset any logins' token" do 184 | expect_any_instance_of(LoginNotFound).to receive(:refresh_oauth2_token!) 185 | 186 | subject 187 | end 188 | end 189 | end 190 | 191 | context 'when SSL is forced' do 192 | include_context 'with force_ssl configured' 193 | let(:force_ssl) { true } 194 | 195 | context 'and the request uses SSL' do 196 | let(:ssl) { 'on' } 197 | 198 | include_examples 'when the request gets through' 199 | end 200 | 201 | context 'and the request does not use SSL' do 202 | let(:ssl) { false } 203 | 204 | it 'responds with status 301' do 205 | subject 206 | 207 | expect(response).to have_http_status(301) 208 | end 209 | end 210 | end 211 | 212 | context 'when SSL is not forced' do 213 | include_context 'with force_ssl configured' 214 | let(:force_ssl) { false } 215 | 216 | context 'and the request uses SSL' do 217 | let(:ssl) { 'on' } 218 | 219 | include_examples 'when the request gets through' 220 | end 221 | 222 | context 'and the request does not use SSL' do 223 | let(:ssl) { false } 224 | 225 | include_examples 'when the request gets through' 226 | end 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /spec/services/edx_authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | describe EdxAuthenticator do 2 | let(:uid_mapped_field) { 'username' } 3 | 4 | let(:authenticated_user_data) do 5 | { 6 | email: 'user@edxdomain.org', 7 | username: 'user' 8 | } 9 | end 10 | include_context 'stubbed edx requests' 11 | it_behaves_like 'a authenticator' 12 | end 13 | -------------------------------------------------------------------------------- /spec/services/facebook_authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | describe FacebookAuthenticator do 2 | let(:uid_mapped_field) { 'id' } 3 | 4 | let(:authenticated_user_data) do 5 | { 6 | email: 'user@facebook.com', 7 | id: '123123123123' 8 | } 9 | end 10 | include_context 'stubbed facebook requests' 11 | it_behaves_like 'a authenticator' 12 | end 13 | -------------------------------------------------------------------------------- /spec/services/github_authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | describe GithubAuthenticator do 2 | let(:uid_mapped_field) { 'id' } 3 | 4 | let(:authenticated_user_data) do 5 | { 6 | email: 'user@gmail.com', 7 | id: '789789789789' 8 | } 9 | end 10 | include_context 'stubbed github requests' 11 | it_behaves_like 'a authenticator' 12 | end 13 | -------------------------------------------------------------------------------- /spec/services/google_authenticator_spec.rb: -------------------------------------------------------------------------------- 1 | describe GoogleAuthenticator do 2 | let(:uid_mapped_field) { 'sub' } 3 | 4 | let(:authenticated_user_data) do 5 | { 6 | email: 'user@gmail.com', 7 | sub: '789789789789' 8 | } 9 | end 10 | include_context 'stubbed google requests' 11 | it_behaves_like 'a authenticator' 12 | end 13 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | 3 | require 'simplecov' 4 | SimpleCov.start do 5 | unless defined?(JRUBY_VERSION) || defined?(JRuby) 6 | minimum_coverage 95 7 | refuse_coverage_drop 8 | end 9 | end 10 | 11 | require File.expand_path('dummy/config/environment.rb', __dir__) 12 | require 'rspec/rails' 13 | require 'factory_girl_rails' 14 | require 'faker' 15 | 16 | Rails.backtrace_cleaner.remove_silencers! 17 | 18 | %w(factories support).each do |path| 19 | Dir["#{File.dirname(__FILE__)}/#{path}/**/*.rb"].each { |f| require f } 20 | end 21 | 22 | RSpec.configure do |config| 23 | config.mock_with :rspec 24 | config.use_transactional_fixtures = true 25 | config.infer_base_class_for_anonymous_controllers = false 26 | config.infer_spec_type_from_file_location! 27 | config.order = 'random' 28 | end 29 | -------------------------------------------------------------------------------- /spec/support/factory_girl.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.include FactoryGirl::Syntax::Methods 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/force_ssl.rb: -------------------------------------------------------------------------------- 1 | shared_context 'with force_ssl configured' do 2 | around do |example| 3 | default_force_ssl = RailsApiAuth.force_ssl 4 | RailsApiAuth.force_ssl = force_ssl 5 | example.run 6 | RailsApiAuth.force_ssl = default_force_ssl 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/stubbed_edx_requests.rb: -------------------------------------------------------------------------------- 1 | shared_context 'stubbed edx requests' do 2 | let(:auth_code) { 'authcode' } 3 | let(:username) { 'user' } 4 | 5 | let(:response_with_token) { { body: '{ "access_token": "access_token" }, "token_type": "Bearer", "expires_in": 3600' } } 6 | let(:response_with_user) { { body: JSON.generate(authenticated_user_data), headers: { 'Content-Type' => 'application/json' } } } 7 | 8 | before do 9 | stub_request(:post, EdxAuthenticator::TOKEN_URL). 10 | with(body: hash_including(grant_type: 'authorization_code')).to_return(response_with_token) 11 | 12 | stub_request(:get, EdxAuthenticator::PROFILE_URL % { username: username }). 13 | with(headers: { 'Authorization'=>'Bearer access_token' }). 14 | to_return(response_with_user) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/stubbed_facebook_requests.rb: -------------------------------------------------------------------------------- 1 | shared_context 'stubbed facebook requests' do 2 | let(:auth_code) { 'authcode' } 3 | let(:access_token) { 'CAAMvEGOZAxB8BAODGpIWO9meEXEpvigfIRs5j7LIi1Uef8xvTz4vpayfP6rxn0Om3jZAmvEojZB9HNWD44PgSSwFyD7bKsJ3EaNMKwYpZBRqjm25HfwUzF3pOVRXp9cdquT1afm7bj4mnb4WFFo7TxLcgO848FaAKZBdxwefJlPneVUSpquEh2TZAVWghndnPO9ON7QTqXhAZDZD' } 4 | let(:response_with_fb_token) { { body: JSON.generate({ access_token: access_token, token_type: 'bearer', expires_in: 5169402 }), headers: { 'Content-Type' => 'application/json' } } } 5 | let(:response_with_fb_user) { { body: JSON.generate(authenticated_user_data), headers: { 'Content-Type' => 'application/json' } } } 6 | let(:token_parameters) { { client_id: 'app_id', client_secret: 'app_secret', auth_code: auth_code, redirect_uri: 'redirect_uri' } } 7 | 8 | before do 9 | stub_request(:get, FacebookAuthenticator::TOKEN_URL % token_parameters).to_return(response_with_fb_token) 10 | stub_request(:get, FacebookAuthenticator::PROFILE_URL % { access_token: access_token }).to_return(response_with_fb_user) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/stubbed_github_requests.rb: -------------------------------------------------------------------------------- 1 | shared_context 'stubbed github requests' do 2 | let(:auth_code) { 'authcode' } 3 | let(:auth_state) { 'abc123' } 4 | let(:access_token) { 'UsQfLVVKUJmSjD6gtRk9UsrZqfpL9ajB' } 5 | let(:response_with_gh_token) { { body: JSON.generate({ access_token: access_token, token_type: 'bearer', scope: 'user' }), headers: { 'Content-Type' => 'application/json' } } } 6 | let(:response_with_gh_user) { { body: JSON.generate(authenticated_user_data), headers: { 'Content-Type' => 'application/json' } } } 7 | let(:token_parameters) { { code: auth_code, client_id: 'app_id', client_secret: 'app_secret', redirect_uri: 'redirect_uri', state: auth_state } } 8 | 9 | before do 10 | stub_request(:post, GithubAuthenticator::TOKEN_URL % token_parameters).to_return(response_with_gh_token) 11 | stub_request(:get, GithubAuthenticator::USER_URL % { access_token: access_token }).to_return(response_with_gh_user) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/stubbed_google_requests.rb: -------------------------------------------------------------------------------- 1 | shared_context 'stubbed google requests' do 2 | let(:auth_code) { 'authcode' } 3 | let(:response_with_token) { { body: '{ "access_token": "access_token" }, "token_type": "Bearer", "expires_in": 3600' } } 4 | let(:response_with_user) { { body: JSON.generate(authenticated_user_data), headers: { 'Content-Type' => 'application/json' } } } 5 | 6 | before do 7 | stub_request(:post, GoogleAuthenticator::TOKEN_URL). 8 | with(body: hash_including(grant_type: 'authorization_code')).to_return(response_with_token) 9 | stub_request(:get, GoogleAuthenticator::PROFILE_URL % { access_token: 'access_token' }).to_return(response_with_user) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/shared_examples/authenticator_shared_requests.rb: -------------------------------------------------------------------------------- 1 | shared_examples 'a authenticator' do 2 | describe '#authenticate!' do 3 | let(:login) { double('login') } 4 | 5 | if described_class::PROVIDER.eql? 'edx' 6 | subject { described_class.new(username, auth_code).authenticate! } 7 | elsif described_class::PROVIDER.eql? 'github' 8 | subject { described_class.new(auth_state, auth_code).authenticate! } 9 | else 10 | subject { described_class.new(auth_code).authenticate! } 11 | end 12 | 13 | context "when no login for the #{described_class::PROVIDER} account exists" do 14 | let(:login_attributes) do 15 | { 16 | identification: authenticated_user_data[:email], 17 | uid: authenticated_user_data[uid_mapped_field.to_sym], 18 | provider: described_class::PROVIDER 19 | } 20 | end 21 | 22 | before do 23 | allow(Login).to receive(:create!).with(login_attributes).and_return(login) 24 | end 25 | 26 | it "returns a login created from the #{described_class::PROVIDER} account" do 27 | expect(subject).to eql(login) 28 | end 29 | end 30 | 31 | context "when a login for the #{described_class::PROVIDER} account exists already" do 32 | before do 33 | expect(Login).to receive(:where).with(identification: authenticated_user_data[:email]).and_return([login]) 34 | allow(login).to receive(:update_attributes!).with(uid: authenticated_user_data[uid_mapped_field.to_sym], provider: described_class::PROVIDER) 35 | end 36 | 37 | it "connects the login to the #{described_class::PROVIDER} account" do 38 | expect(login).to receive(:update_attributes!).with(uid: authenticated_user_data[uid_mapped_field.to_sym], provider: described_class::PROVIDER) 39 | 40 | subject 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/support/shared_examples/oauth2_edx_shared_requests.rb: -------------------------------------------------------------------------------- 1 | shared_context 'oauth2 edx shared contexts' do 2 | let(:params) { { username: username, grant_type: grant_type, auth_code: 'authcode' } } 3 | let(:access_token) { 'access_token' } 4 | let(:email) { login.identification } 5 | 6 | context 'when a login with for the service account exists' do 7 | it 'connects the login to the service account' do 8 | subject 9 | expect(login.reload.uid).to eq(authenticated_user_data[uid_mapped_field.to_sym]) 10 | end 11 | 12 | it 'responds with status 200' do 13 | subject 14 | 15 | expect(response).to have_http_status(200) 16 | end 17 | 18 | it "responds with the login's OAuth 2.0 token" do 19 | subject 20 | expect(response.body).to be_json_eql({ access_token: login.oauth2_token }.to_json) 21 | end 22 | end 23 | 24 | context 'when no login for the service account exists' do 25 | let(:email) { Faker::Internet.email } 26 | 27 | it 'responds with status 200' do 28 | subject 29 | expect(response).to have_http_status(200) 30 | end 31 | 32 | it 'creates a login for the service account' do 33 | expect { subject }.to change { Login.where(identification: email).count }.by(1) 34 | end 35 | 36 | it "responds with the login's OAuth 2.0 token" do 37 | subject 38 | login = Login.where(identification: email).first 39 | 40 | expect(response.body).to be_json_eql({ access_token: login.oauth2_token }.to_json) 41 | end 42 | end 43 | 44 | context 'when no service auth code is sent' do 45 | let(:params) { { grant_type: grant_type } } 46 | 47 | it 'responds with status 400' do 48 | subject 49 | 50 | expect(response).to have_http_status(400) 51 | end 52 | 53 | it 'responds with a "no_authorization_code" error' do 54 | subject 55 | 56 | expect(response.body).to be_json_eql({ error: 'no_authorization_code' }.to_json) 57 | end 58 | end 59 | 60 | context 'when no username is sent' do 61 | let(:params) { { auth_code: 'auth_code', grant_type: grant_type } } 62 | 63 | it 'responds with status 400' do 64 | subject 65 | 66 | expect(response).to have_http_status(400) 67 | end 68 | 69 | it 'responds with a "no_username" error' do 70 | subject 71 | 72 | expect(response.body).to be_json_eql({ error: 'no_username' }.to_json) 73 | end 74 | end 75 | 76 | context 'when service responds with an error' do 77 | before do 78 | stub_request(:get, profile_url % { username: 'user', access_token: access_token }).to_return(status: 422) 79 | end 80 | 81 | it 'responds with status 502' do 82 | subject 83 | 84 | expect(response).to have_http_status(502) 85 | end 86 | 87 | it 'responds with an empty response body' do 88 | subject 89 | 90 | expect(response.body.strip).to eql('') 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/support/shared_examples/oauth2_shared_requests.rb: -------------------------------------------------------------------------------- 1 | shared_context 'oauth2 shared contexts' do 2 | let(:params) { { grant_type: grant_type, auth_code: 'authcode' } } 3 | let(:access_token) { 'access_token' } 4 | let(:email) { login.identification } 5 | 6 | context 'when a login with for the service account exists' do 7 | it 'connects the login to the service account' do 8 | subject 9 | expect(login.reload.uid).to eq(authenticated_user_data[uid_mapped_field.to_sym]) 10 | end 11 | 12 | it 'responds with status 200' do 13 | subject 14 | 15 | expect(response).to have_http_status(200) 16 | end 17 | 18 | it "responds with the login's OAuth 2.0 token" do 19 | subject 20 | expect(response.body).to be_json_eql({ access_token: login.oauth2_token }.to_json) 21 | end 22 | end 23 | 24 | context 'when no login for the service account exists' do 25 | let(:email) { Faker::Internet.email } 26 | 27 | it 'responds with status 200' do 28 | subject 29 | 30 | expect(response).to have_http_status(200) 31 | end 32 | 33 | it 'creates a login for the service account' do 34 | expect { subject }.to change { Login.where(identification: email).count }.by(1) 35 | end 36 | 37 | it "responds with the login's OAuth 2.0 token" do 38 | subject 39 | login = Login.where(identification: email).first 40 | 41 | expect(response.body).to be_json_eql({ access_token: login.oauth2_token }.to_json) 42 | end 43 | end 44 | 45 | context 'when no service auth code is sent' do 46 | let(:params) { { grant_type: grant_type } } 47 | 48 | it 'responds with status 400' do 49 | subject 50 | 51 | expect(response).to have_http_status(400) 52 | end 53 | 54 | it 'responds with a "no_authorization_code" error' do 55 | subject 56 | 57 | expect(response.body).to be_json_eql({ error: 'no_authorization_code' }.to_json) 58 | end 59 | end 60 | 61 | context 'when service responds with an error' do 62 | before do 63 | stub_request(:get, profile_url % { access_token: access_token }).to_return(status: 422) 64 | end 65 | 66 | it 'responds with status 502' do 67 | subject 68 | 69 | expect(response).to have_http_status(502) 70 | end 71 | 72 | it 'responds with an empty response body' do 73 | subject 74 | 75 | expect(response.body.strip).to eql('') 76 | end 77 | end 78 | end 79 | --------------------------------------------------------------------------------