diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 701fff0..2a61f75 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -12,7 +12,7 @@ # Include: **/*.gemspec, **/Gemfile, **/gems.rb Gemspec/DevelopmentDependencies: Exclude: - - 'rpi_auth.gemspec' + - "rpi_auth.gemspec" # Offense count: 1 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. @@ -43,4 +43,5 @@ RSpec/NestedGroups: # Include: **/*_spec.rb RSpec/SpecFilePathFormat: Exclude: - - 'spec/rpi_auth/models/authenticatable_spec.rb' + - "spec/rpi_auth/models/authenticatable_spec.rb" + - "spec/rpi_auth/models/with_tokens_spec.rb" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e2e9fd..495acf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Add access token-related functionality including auto-refresh (#83) ### Fixed - Fix use of `User#expires_at` in `SpecHelpers#stub_auth_for` (#82) diff --git a/README.md b/README.md index f94804b..c75a833 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,29 @@ class in `config/application.rb`. config.railties_order = [RpiAuth::Engine, :main_app, :all] ``` +### Obtaining an access token for user + +This optional behaviour is useful if your Rails app (which is using this gem) +needs to use a RPF API which required authentication via an OAuth2 access +token. + +Include the `RpiAuth::Models::WithTokens` concern (which depends on the +`RpiAuth::Models::Authenticatable` concern) into your user model in order to +add `access_token`, `refresh_token` & `expires_at` attributes. These methods +are automatically populated by `RpiAuth::AuthController#callback` via the +`RpiAuth::Models::WithTokens.from_omniauth` method. + +This also relies on the following: +- `RpiAuth.configuration.scope` including the "offline" scope in the Rails app + which is using the `rpi_auth` gem. +- In the `profile` app `hydra_client` config for the Rails app, `grant_types` + must include "refresh_token" and `scope` must include "offline". + +Include the `RpiAuth::Controllers::AutoRefreshingToken` concern (which depends +on the `RpiAuth::Controllers::CurrentUser` concern) into your controller so +that when the user's access token expires, a new one is obtained using the +user's refresh token. + ## Test helpers and routes There are some standardised test helpers in `RpiAuth::SpecHelpers` that can be used when testing. diff --git a/gemfiles/rails_6.1.gemfile.lock b/gemfiles/rails_6.1.gemfile.lock index 154bffa..9489b46 100644 --- a/gemfiles/rails_6.1.gemfile.lock +++ b/gemfiles/rails_6.1.gemfile.lock @@ -2,6 +2,7 @@ PATH remote: .. specs: rpi_auth (4.0.0) + oauth2 omniauth-rails_csrf_protection (~> 1.0.0) omniauth_openid_connect (~> 0.7.1) rails (>= 6.1.4) @@ -74,6 +75,7 @@ GEM ast (2.4.3) attr_required (1.0.2) base64 (0.2.0) + bigdecimal (3.1.9) bindata (2.5.1) builder (3.3.0) byebug (12.0.0) @@ -88,6 +90,9 @@ GEM xpath (~> 3.2) coderay (1.1.3) concurrent-ruby (1.3.5) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) date (3.4.1) diff-lcs (1.6.1) @@ -106,6 +111,7 @@ GEM ffi (1.17.1) globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.1.2) hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -117,6 +123,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jwt (2.10.1) + base64 language_server-protocol (3.17.0.4) lint_roller (1.1.0) listen (3.9.0) @@ -137,6 +145,8 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.8) minitest (5.25.5) + multi_xml (0.7.1) + bigdecimal (~> 3.1) net-http (0.6.0) uri net-imap (0.5.6) @@ -152,6 +162,13 @@ GEM nokogiri (1.18.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -237,6 +254,7 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) regexp_parser (2.10.0) + rexml (3.4.1) rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) @@ -290,6 +308,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -313,10 +334,15 @@ GEM validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix + version_gem (1.1.7) webfinger (2.1.3) activesupport faraday (~> 2.0) faraday-follow_redirects + webmock (3.25.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) @@ -342,6 +368,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec simplecov + webmock BUNDLED WITH 2.3.27 diff --git a/gemfiles/rails_7.0.gemfile.lock b/gemfiles/rails_7.0.gemfile.lock index 265ae65..a9a6668 100644 --- a/gemfiles/rails_7.0.gemfile.lock +++ b/gemfiles/rails_7.0.gemfile.lock @@ -2,6 +2,7 @@ PATH remote: .. specs: rpi_auth (4.0.0) + oauth2 omniauth-rails_csrf_protection (~> 1.0.0) omniauth_openid_connect (~> 0.7.1) rails (>= 6.1.4) @@ -80,6 +81,7 @@ GEM ast (2.4.3) attr_required (1.0.2) base64 (0.2.0) + bigdecimal (3.1.9) bindata (2.5.1) builder (3.3.0) byebug (12.0.0) @@ -94,6 +96,9 @@ GEM xpath (~> 3.2) coderay (1.1.3) concurrent-ruby (1.3.5) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) date (3.4.1) diff-lcs (1.6.1) @@ -112,6 +117,7 @@ GEM ffi (1.17.1) globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.1.2) hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -123,6 +129,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jwt (2.10.1) + base64 language_server-protocol (3.17.0.4) lint_roller (1.1.0) listen (3.9.0) @@ -143,6 +151,8 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.8) minitest (5.25.5) + multi_xml (0.7.1) + bigdecimal (~> 3.1) net-http (0.6.0) uri net-imap (0.5.6) @@ -158,6 +168,13 @@ GEM nokogiri (1.18.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -243,6 +260,7 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) regexp_parser (2.10.0) + rexml (3.4.1) rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) @@ -296,6 +314,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) @@ -312,10 +333,15 @@ GEM validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix + version_gem (1.1.7) webfinger (2.1.3) activesupport faraday (~> 2.0) faraday-follow_redirects + webmock (3.25.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) @@ -341,6 +367,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec simplecov + webmock BUNDLED WITH 2.3.27 diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index 63604d4..e1659e7 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -2,6 +2,7 @@ PATH remote: .. specs: rpi_auth (4.0.0) + oauth2 omniauth-rails_csrf_protection (~> 1.0.0) omniauth_openid_connect (~> 0.7.1) rails (>= 6.1.4) @@ -109,6 +110,9 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.0) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) date (3.4.1) diff-lcs (1.6.1) @@ -128,6 +132,7 @@ GEM ffi (1.17.1) globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.1.2) hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -144,6 +149,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jwt (2.10.1) + base64 language_server-protocol (3.17.0.4) lint_roller (1.1.0) listen (3.9.0) @@ -164,6 +171,8 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.8) minitest (5.25.5) + multi_xml (0.7.1) + bigdecimal (~> 3.1) mutex_m (0.3.0) net-http (0.6.0) uri @@ -180,6 +189,13 @@ GEM nokogiri (1.18.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -282,6 +298,7 @@ GEM regexp_parser (2.10.0) reline (0.6.1) io-console (~> 0.5) + rexml (3.4.1) rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) @@ -336,6 +353,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) stringio (3.1.6) swd (2.0.3) activesupport (>= 3) @@ -353,10 +373,15 @@ GEM validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix + version_gem (1.1.7) webfinger (2.1.3) activesupport faraday (~> 2.0) faraday-follow_redirects + webmock (3.25.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) @@ -382,6 +407,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec simplecov + webmock BUNDLED WITH 2.3.27 diff --git a/gemfiles/rails_7.2.gemfile.lock b/gemfiles/rails_7.2.gemfile.lock index 9622982..58affb8 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -2,6 +2,7 @@ PATH remote: .. specs: rpi_auth (4.0.0) + oauth2 omniauth-rails_csrf_protection (~> 1.0.0) omniauth_openid_connect (~> 0.7.1) rails (>= 6.1.4) @@ -103,6 +104,9 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.0) + crack (1.0.0) + bigdecimal + rexml crass (1.0.6) date (3.4.1) diff-lcs (1.6.1) @@ -129,6 +133,7 @@ GEM ffi (1.17.1-x86_64-linux-musl) globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.1.2) hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -145,6 +150,8 @@ GEM bindata faraday (~> 2.0) faraday-follow_redirects + jwt (2.10.1) + base64 language_server-protocol (3.17.0.4) lint_roller (1.1.0) listen (3.9.0) @@ -164,6 +171,8 @@ GEM method_source (1.1.0) mini_mime (1.1.5) minitest (5.25.5) + multi_xml (0.7.1) + bigdecimal (~> 3.1) net-http (0.6.0) uri net-imap (0.5.6) @@ -192,6 +201,13 @@ GEM racc (~> 1.4) nokogiri (1.18.7-x86_64-linux-musl) racc (~> 1.4) + oauth2 (2.0.9) + faraday (>= 0.17.3, < 3.0) + jwt (>= 1.0, < 3.0) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0) + version_gem (~> 1.1) omniauth (2.1.3) hashie (>= 3.4.6) rack (>= 2.2.3) @@ -294,6 +310,7 @@ GEM regexp_parser (2.10.0) reline (0.6.1) io-console (~> 0.5) + rexml (3.4.1) rspec-core (3.13.3) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) @@ -348,6 +365,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.1) + hashie + version_gem (~> 1.1, >= 1.1.1) stringio (3.1.6) swd (2.0.3) activesupport (>= 3) @@ -366,10 +386,15 @@ GEM validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix + version_gem (1.1.7) webfinger (2.1.3) activesupport faraday (~> 2.0) faraday-follow_redirects + webmock (3.25.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) websocket-driver (0.7.7) base64 websocket-extensions (>= 0.1.0) @@ -402,6 +427,7 @@ DEPENDENCIES rubocop-rails rubocop-rspec simplecov + webmock BUNDLED WITH 2.3.27 diff --git a/lib/rpi_auth.rb b/lib/rpi_auth.rb index a9a5707..f5a55cd 100644 --- a/lib/rpi_auth.rb +++ b/lib/rpi_auth.rb @@ -4,6 +4,7 @@ require 'rpi_auth/engine' require 'rpi_auth/configuration' require 'rpi_auth/models/authenticatable' +require 'rpi_auth/models/with_tokens' require 'omniauth/rails_csrf_protection' module RpiAuth diff --git a/lib/rpi_auth/controllers/auto_refreshing_token.rb b/lib/rpi_auth/controllers/auto_refreshing_token.rb new file mode 100644 index 0000000..aee9a3c --- /dev/null +++ b/lib/rpi_auth/controllers/auto_refreshing_token.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'oauth2/error' + +module RpiAuth + module Controllers + module AutoRefreshingToken + extend ActiveSupport::Concern + + include CurrentUser + + included do + before_action :refresh_credentials_if_needed if respond_to?(:before_action) + end + + private + + def refresh_credentials_if_needed + return unless current_user + + return if Time.now.to_i < current_user.expires_at + + current_user.refresh_credentials! + self.current_user = current_user + rescue OAuth2::Error, ArgumentError + reset_session + end + end + end +end diff --git a/lib/rpi_auth/controllers/current_user.rb b/lib/rpi_auth/controllers/current_user.rb index fc75779..21129c9 100644 --- a/lib/rpi_auth/controllers/current_user.rb +++ b/lib/rpi_auth/controllers/current_user.rb @@ -6,7 +6,7 @@ module CurrentUser extend ActiveSupport::Concern included do - helper_method :current_user + helper_method :current_user if respond_to?(:helper_method) end def current_user diff --git a/lib/rpi_auth/models/authenticatable.rb b/lib/rpi_auth/models/authenticatable.rb index 75fbf6e..d45c964 100644 --- a/lib/rpi_auth/models/authenticatable.rb +++ b/lib/rpi_auth/models/authenticatable.rb @@ -23,11 +23,15 @@ module Authenticatable include ActiveModel::Serialization attr_accessor :user_id, *PROFILE_KEYS + end - # Allow serialization - def attributes - (['user_id'] + PROFILE_KEYS).index_with { |_k| nil } - end + def attribute_keys + %w[user_id] + PROFILE_KEYS + end + + # Allow serialization + def attributes + attribute_keys.map(&:to_s).index_with { |_k| nil } end class_methods do diff --git a/lib/rpi_auth/models/with_tokens.rb b/lib/rpi_auth/models/with_tokens.rb new file mode 100644 index 0000000..9a86cf0 --- /dev/null +++ b/lib/rpi_auth/models/with_tokens.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rpi_auth/oauth_client' + +module RpiAuth + module Models + module WithTokens + extend ActiveSupport::Concern + + include Authenticatable + + included do + attr_accessor :access_token, :expires_at, :refresh_token + end + + def attribute_keys + super + %w[access_token expires_at refresh_token user_id] + end + + def refresh_credentials! + oauth_client = OauthClient.new + credentials = oauth_client.refresh_credentials(access_token:, refresh_token:) + + assign_attributes(**credentials) + end + + class_methods do + def from_omniauth(auth) + user = super + return unless user + + if auth.credentials + user.access_token = auth.credentials.token + user.refresh_token = auth.credentials.refresh_token + user.expires_at = Time.now.to_i + auth.credentials.expires_in + end + + user + end + end + end + end +end diff --git a/lib/rpi_auth/oauth_client.rb b/lib/rpi_auth/oauth_client.rb new file mode 100644 index 0000000..135b830 --- /dev/null +++ b/lib/rpi_auth/oauth_client.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'oauth2/client' +require 'oauth2/access_token' + +module RpiAuth + class OauthClient + attr_reader :client + + def initialize + @client = OAuth2::Client.new( + RpiAuth.configuration.auth_client_id, + RpiAuth.configuration.auth_client_secret, + site: RpiAuth.configuration.auth_token_url, + token_url: RpiAuth.configuration.token_endpoint.path, + authorize_url: RpiAuth.configuration.authorization_endpoint.path + ) + end + + def refresh_credentials(**credentials) + new_credentials = if RpiAuth.configuration.bypass_auth + credentials.merge(expires_at: 1.hour.from_now) + else + OAuth2::AccessToken.from_hash(client, credentials).refresh.to_hash + end + + { + access_token: new_credentials[:access_token], + refresh_token: new_credentials[:refresh_token], + expires_at: new_credentials[:expires_at].to_i + } + end + end +end diff --git a/rpi_auth.gemspec b/rpi_auth.gemspec index f0f60ad..c57562e 100644 --- a/rpi_auth.gemspec +++ b/rpi_auth.gemspec @@ -21,6 +21,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 3.1.0' + spec.add_dependency 'oauth2' spec.add_dependency 'omniauth_openid_connect', '~> 0.7.1' spec.add_dependency 'omniauth-rails_csrf_protection', '~> 1.0.0' spec.add_dependency 'rails', '>= 6.1.4' @@ -36,4 +37,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'rubocop-rails' spec.add_development_dependency 'rubocop-rspec' spec.add_development_dependency 'simplecov' + spec.add_development_dependency 'webmock' end diff --git a/spec/dummy/app/controllers/application_controller.rb b/spec/dummy/app/controllers/application_controller.rb index c967eb1..9740b68 100644 --- a/spec/dummy/app/controllers/application_controller.rb +++ b/spec/dummy/app/controllers/application_controller.rb @@ -1,3 +1,7 @@ +require 'rpi_auth/controllers/current_user' +require 'rpi_auth/controllers/auto_refreshing_token' + class ApplicationController < ActionController::Base include RpiAuth::Controllers::CurrentUser + include RpiAuth::Controllers::AutoRefreshingToken end diff --git a/spec/dummy/app/models/user.rb b/spec/dummy/app/models/user.rb index 5f45bef..e10adfb 100644 --- a/spec/dummy/app/models/user.rb +++ b/spec/dummy/app/models/user.rb @@ -1,3 +1,4 @@ class User include RpiAuth::Models::Authenticatable + include RpiAuth::Models::WithTokens end diff --git a/spec/dummy/spec/requests/refresh_credentials_spec.rb b/spec/dummy/spec/requests/refresh_credentials_spec.rb new file mode 100644 index 0000000..f01a4d5 --- /dev/null +++ b/spec/dummy/spec/requests/refresh_credentials_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'oauth2/error' + +RSpec.describe 'Refreshing the auth token', type: :request do + include ActiveSupport::Testing::TimeHelpers + + subject(:request) { get root_path } + + let(:logged_in_text) { 'Log out' } + let(:stub_oauth_client) { instance_double(RpiAuth::OauthClient) } + + before do + freeze_time + allow(RpiAuth::OauthClient).to receive(:new).and_return(stub_oauth_client) + allow(stub_oauth_client).to receive(:refresh_credentials).with(any_args) + RpiAuth.configuration.user_model = 'User' + end + + after do + unfreeze_time + end + + shared_examples 'there is no attempt to renew the token' do + it 'calls refresh_credentials on the oauth client' do + request + expect(stub_oauth_client).not_to have_received(:refresh_credentials) + end + end + + shared_examples 'there is an attempt to renew the token' do + it 'does not call refresh_credentials on the oauth client' do + request + expect(stub_oauth_client).to have_received(:refresh_credentials) + end + end + + shared_examples 'the user is logged in' do + it do + request + expect(response.body).to include(logged_in_text) + end + end + + shared_examples 'the user is logged out' do + it do + request + expect(response.body).not_to include(logged_in_text) + end + end + + context 'when not logged in' do + it_behaves_like 'the user is logged out' + it_behaves_like 'there is no attempt to renew the token' + end + + context 'when logged in' do + let(:user) { User.new(expires_at:) } + + before do + log_in(user:) + end + + context 'when the access token has not expired' do + let(:expires_at) { 10.seconds.from_now } + + it_behaves_like 'the user is logged in' + it_behaves_like 'there is no attempt to renew the token' + end + + context 'when the access token has expired' do + let(:expires_at) { 10.seconds.ago } + + before do + allow(stub_oauth_client).to receive(:refresh_credentials).with(any_args).and_return({ access_token: 'foo', + refresh_token: 'bar', + expires_at: 10.minutes.from_now }) + end + + it_behaves_like 'the user is logged in' + it_behaves_like 'there is an attempt to renew the token' + + context 'when an OAuth error is raised' do + before do + allow(stub_oauth_client).to receive(:refresh_credentials).with(any_args).and_raise(OAuth2::Error.new('blargh')) + end + + it_behaves_like 'the user is logged out' + it_behaves_like 'there is an attempt to renew the token' + end + end + end +end diff --git a/spec/rpi_auth/models/with_tokens_spec.rb b/spec/rpi_auth/models/with_tokens_spec.rb new file mode 100644 index 0000000..b5cf04b --- /dev/null +++ b/spec/rpi_auth/models/with_tokens_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +class DummyUser + include RpiAuth::Models::Authenticatable + include RpiAuth::Models::WithTokens +end + +RSpec.describe DummyUser, type: :model do + include ActiveSupport::Testing::TimeHelpers + + subject(:user) { described_class.new } + + it { is_expected.to respond_to(:access_token) } + it { is_expected.to respond_to(:refresh_token) } + it { is_expected.to respond_to(:expires_at) } + + describe '#refresh_credentials!' do + subject(:refresh_credentials) { user.refresh_credentials! } + + let(:stub_oauth_client) { instance_double(RpiAuth::OauthClient) } + let(:new_tokens) { { access_token: 'foo', refresh_token: 'bar', expires_at: 1.hour.from_now.utc } } + + before do + allow(RpiAuth::OauthClient).to receive(:new).and_return(stub_oauth_client) + allow(stub_oauth_client).to receive(:refresh_credentials).with(access_token: user.access_token, refresh_token: user.refresh_token).and_return(new_tokens) + end + + it { expect { refresh_credentials }.to change(user, :access_token).from(user.access_token).to(new_tokens[:access_token]) } + it { expect { refresh_credentials }.to change(user, :refresh_token).from(user.refresh_token).to(new_tokens[:refresh_token]) } + it { expect { refresh_credentials }.to change(user, :expires_at).from(user.expires_at).to(new_tokens[:expires_at]) } + end + + describe '#from_omniauth' do + subject(:user) { described_class.from_omniauth(auth) } + + let(:omniauth_user) { described_class.new } + let(:info) { omniauth_user.serializable_hash } + let(:credentials) { { token: SecureRandom.base64(12), refresh_token: SecureRandom.base64(12), expires_in: rand(60..240) } } + + let(:auth) do + OmniAuth::AuthHash.new( + { + provider: 'rpi', + uid: omniauth_user.user_id, + credentials:, + extra: { + raw_info: info + } + } + ) + end + + it { is_expected.to be_a described_class } + + it 'sets the access_token' do + expect(user.access_token).to eq credentials[:token] + end + + it 'sets the refresh_token' do + expect(user.refresh_token).to eq credentials[:refresh_token] + end + + context 'when no credentials are returned' do + let(:credentials) { nil } + + it 'sets the access_token to be nil' do + expect(user.access_token).to be_nil + end + end + + it 'sets the expires_at time correctly' do + freeze_time do + expect(user.expires_at).to eq credentials[:expires_in].seconds.from_now.to_i + end + end + + context 'with unusual keys in info' do + let(:info) { { foo: :bar, flibble: :woo } } + + it { is_expected.to be_a described_class } + end + + context 'with no info' do + let(:info) { nil } + + it { is_expected.to be_a described_class } + end + + context 'with no auth set' do + let(:auth) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/rpi_auth/oauth_client_spec.rb b/spec/rpi_auth/oauth_client_spec.rb new file mode 100644 index 0000000..d5c42c7 --- /dev/null +++ b/spec/rpi_auth/oauth_client_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'oauth2' + +RSpec.describe RpiAuth::OauthClient do + include ActiveSupport::Testing::TimeHelpers + + describe '#refresh_credentials' do + subject(:refresh_credentials) { oauth_client.refresh_credentials(**credentials) } + + let(:oauth_client) { described_class.new } + let(:credentials) { { access_token: 'foo', refresh_token: 'bar' } } + let(:refresh_body) { { token: 'baz', refresh_token: 'quux', expires_in: rand(300..600) } } + + before do + RpiAuth.configure do |config| + config.bypass_auth = bypass_auth + config.auth_url = 'https://auth.com:123/' + end + + freeze_time + end + + after do + unfreeze_time + end + + context 'when RpiAuth.configuration.bypass is not set' do + let(:bypass_auth) { false } + + before do + stub_request(:post, RpiAuth.configuration.token_endpoint) + .with(body: { grant_type: 'refresh_token', refresh_token: credentials[:refresh_token] }) + .to_return(status: 200, body: refresh_body.to_json, headers: { content_type: 'application/json' }) + end + + it do + expect(refresh_credentials).to eq({ access_token: refresh_body[:token], + refresh_token: refresh_body[:refresh_token], + expires_at: Time.now.to_i + refresh_body[:expires_in] }) + end + end + + context 'when RpiAuth.configuration.bypass is set' do + let(:bypass_auth) { true } + + it { expect(refresh_credentials).to eq credentials.merge(expires_at: 1.hour.from_now.to_i) } + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index df435e4..cea613d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,6 +10,7 @@ require_relative 'support/omniauth' require_relative 'support/request_helpers' +require_relative 'support/webmock' require 'rpi_auth/spec_helpers' ENV['RAILS_ENV'] = 'test' diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb new file mode 100644 index 0000000..ebb3576 --- /dev/null +++ b/spec/support/webmock.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require 'webmock/rspec'