coalescing_panda 1.1.21.1 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/app/controllers/coalescing_panda/oauth2_controller.rb +11 -7
- data/app/models/coalescing_panda/canvas_api_auth.rb +3 -1
- data/app/models/coalescing_panda/lti_account.rb +0 -1
- data/db/migrate/20151209155923_add_refresh_settings_to_canvas_api_auth.rb +6 -0
- data/lib/coalescing_panda/bearcat_uri.rb +24 -0
- data/lib/coalescing_panda/controller_helpers.rb +85 -83
- data/lib/coalescing_panda/engine.rb +8 -62
- data/lib/coalescing_panda/version.rb +1 -1
- data/spec/controllers/coalescing_panda/lti_controller_spec.rb +2 -2
- data/spec/controllers/coalescing_panda/oauth2_controller_spec.rb +9 -4
- data/spec/dummy/db/test.sqlite3 +0 -0
- data/spec/dummy/log/test.log +15249 -0
- data/spec/factories/accounts.rb +24 -0
- data/spec/factories/canvas_api_auths.rb +9 -0
- data/spec/models/coalescing_panda/canvas_api_auth_spec.rb +11 -1
- data/spec/spec_helper.rb +2 -1
- metadata +84 -88
- data/app/views/coalescing_panda/lti/iframe_cookie_fix.html.erb +0 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 829bbfc42e1c72b89558457db1a751e63cae9ad6
|
4
|
+
data.tar.gz: c045205fcd8fa84ec952e683cf8591f395ae31b8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ae89c71cbbabc5adedc5ef81a630d260eb9df0955f271d07dce2477459a28a28c7385a40899e8362a19652e9fcf333390bb9585414fd628c2e4efad5c2a8707d
|
7
|
+
data.tar.gz: 97b7a7708254b674cf679c33731b524837f1205f359a5ec7ff80033835c69e043c1fd43b339c3e2410b9878c8c1ae7e78033f434f2d7253dbb968f075839aeee
|
@@ -13,13 +13,17 @@ module CoalescingPanda
|
|
13
13
|
client_key = lti_account.oauth2_client_key
|
14
14
|
user_id = params[:user_id]
|
15
15
|
api_domain = params[:api_domain]
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
16
|
+
prefix = [oauth2_protocol, '://', api_domain].join
|
17
|
+
Rails.logger.info "Creating Bearcat client for auth token retrieval pointed to: #{prefix}"
|
18
|
+
client = Bearcat::Client.new(prefix: prefix)
|
19
|
+
token_body = client.retrieve_token(client_id, coalescing_panda.oauth2_redirect_url, client_key, params['code'])
|
20
|
+
auth = CanvasApiAuth.where('user_id = ? and api_domain = ?', user_id, api_domain).first_or_initialize
|
21
|
+
auth.api_token = token_body['access_token']
|
22
|
+
auth.refresh_token = token_body['refresh_token']
|
23
|
+
auth.expires_at = Time.now + token_body['expires_in'] if token_body['expires_in']
|
24
|
+
auth.user_id = user_id
|
25
|
+
auth.api_domain = api_domain
|
26
|
+
auth.save!
|
23
27
|
end
|
24
28
|
end
|
25
29
|
|
@@ -6,7 +6,6 @@ module CoalescingPanda
|
|
6
6
|
:foreign_key => :coalescing_panda_lti_account_id,
|
7
7
|
:class_name => 'CoalescingPanda::LtiNonce'
|
8
8
|
|
9
|
-
attr_accessible :name, :key, :secret, :oauth2_client_id, :oauth2_client_key, :settings
|
10
9
|
serialize :settings
|
11
10
|
|
12
11
|
def validate_nonce(nonce, timestamp)
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class CoalescingPanda::BearcatUri
|
2
|
+
attr_accessor :uri
|
3
|
+
|
4
|
+
def initialize(uri)
|
5
|
+
Rails.logger.info "Parsing Bearcat URI: #{uri}"
|
6
|
+
@uri = URI.parse(uri)
|
7
|
+
end
|
8
|
+
|
9
|
+
def api_domain
|
10
|
+
if Rails.env.test? or Rails.env.development?
|
11
|
+
uri.port.present? ? URI.encode("#{uri.host}:#{uri.port.to_s}") : uri.host
|
12
|
+
else
|
13
|
+
uri.host
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def scheme
|
18
|
+
[uri.scheme, '://'].join
|
19
|
+
end
|
20
|
+
|
21
|
+
def prefix
|
22
|
+
[scheme, api_domain].join
|
23
|
+
end
|
24
|
+
end
|
@@ -1,5 +1,3 @@
|
|
1
|
-
require 'browser'
|
2
|
-
|
3
1
|
module CoalescingPanda
|
4
2
|
module ControllerHelpers
|
5
3
|
require 'useragent'
|
@@ -9,91 +7,111 @@ module CoalescingPanda
|
|
9
7
|
if lti_authorize!(*roles)
|
10
8
|
user_id = params['user_id']
|
11
9
|
launch_presentation_return_url = @lti_account.settings[:launch_presentation_return_url] || params['launch_presentation_return_url']
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
@canvas_url = client.auth_redirect_url(client_id,
|
30
|
-
coalescing_panda.oauth2_redirect_url({key: params['oauth_consumer_key'],
|
31
|
-
user_id: user_id, api_domain: api_domain, state: session['state']}))
|
32
|
-
#delete the added params so the original oauth sig still works
|
33
|
-
@lti_params.delete('action')
|
34
|
-
@lti_params.delete('controller')
|
35
|
-
render 'coalescing_panda/oauth2/oauth2'
|
10
|
+
launch_presentation_return_url = [BearcatUri.new(request.env["HTTP_REFERER"]).prefix, launch_presentation_return_url].join unless launch_presentation_return_url.include?('http')
|
11
|
+
uri = BearcatUri.new(launch_presentation_return_url)
|
12
|
+
set_session(launch_presentation_return_url)
|
13
|
+
|
14
|
+
api_auth = CanvasApiAuth.find_by('user_id = ? and api_domain = ?', user_id, uri.api_domain)
|
15
|
+
if api_auth
|
16
|
+
begin
|
17
|
+
refresh_token(uri, api_auth) if api_auth.expired?
|
18
|
+
@client = Bearcat::Client.new(token: api_auth.api_token, prefix: uri.prefix)
|
19
|
+
@client.user_profile 'self'
|
20
|
+
rescue Footrest::HttpError::BadRequest, Footrest::HttpError::Unauthorized
|
21
|
+
# If we can't retrieve our own user profile, or the token refresh fails, something is awry on the canvas end
|
22
|
+
# and we'll need to go through the oauth flow again
|
23
|
+
render_oauth2_page uri, user_id
|
24
|
+
end
|
25
|
+
else
|
26
|
+
render_oauth2_page uri, user_id
|
36
27
|
end
|
37
28
|
end
|
38
29
|
end
|
39
30
|
|
31
|
+
def render_oauth2_page(uri, user_id)
|
32
|
+
@lti_account = params['oauth_consumer_key'] && LtiAccount.find_by_key(params['oauth_consumer_key'])
|
33
|
+
return unless @lti_account
|
34
|
+
|
35
|
+
client_id = @lti_account.oauth2_client_id
|
36
|
+
client = Bearcat::Client.new(prefix: uri.prefix)
|
37
|
+
session['state'] = SecureRandom.hex(32)
|
38
|
+
redirect_path = coalescing_panda.oauth2_redirect_path({key: params['oauth_consumer_key'], user_id: user_id, api_domain: uri.api_domain, state: session['state']})
|
39
|
+
redirect_url = [coalescing_panda_url, redirect_path.sub(/^\/lti/, '')].join
|
40
|
+
@canvas_url = client.auth_redirect_url(client_id, redirect_url)
|
41
|
+
|
42
|
+
#delete the added params so the original oauth sig still works
|
43
|
+
@lti_params = params.to_hash
|
44
|
+
@lti_params.delete('action')
|
45
|
+
@lti_params.delete('controller')
|
46
|
+
render 'coalescing_panda/oauth2/oauth2', layout: 'coalescing_panda/application'
|
47
|
+
end
|
48
|
+
|
49
|
+
def refresh_token(uri, api_auth)
|
50
|
+
refresh_client = Bearcat::Client.new(prefix: uri.prefix)
|
51
|
+
refresh_body = refresh_client.retrieve_token(@lti_account.oauth2_client_id, coalescing_panda.oauth2_redirect_url,
|
52
|
+
@lti_account.oauth2_client_key, api_auth.refresh_token, 'refresh_token')
|
53
|
+
api_auth.update({ api_token: refresh_body['access_token'], expires_at: (Time.now + refresh_body['expires_in']) })
|
54
|
+
end
|
55
|
+
|
56
|
+
def check_refresh_token
|
57
|
+
uri = BearcatUri.new(session['uri'])
|
58
|
+
api_auth = CanvasApiAuth.find_by(user_id: session['user_id'], api_domain: uri.api_domain)
|
59
|
+
@lti_account = LtiAccount.find_by(key: session['oauth_consumer_key'])
|
60
|
+
return if @lti_account.nil? || api_auth.nil? # Not all tools use oauth
|
61
|
+
|
62
|
+
refresh_token(uri, api_auth) if api_auth.expired?
|
63
|
+
rescue Footrest::HttpError::BadRequest
|
64
|
+
render_oauth2_page uri, session['user_id']
|
65
|
+
end
|
66
|
+
|
67
|
+
def set_session(launch_presentation_return_url)
|
68
|
+
session['user_id'] = params['user_id']
|
69
|
+
session['uri'] = launch_presentation_return_url
|
70
|
+
session['lis_person_sourcedid'] = params['lis_person_sourcedid']
|
71
|
+
session['oauth_consumer_key'] = params['oauth_consumer_key']
|
72
|
+
session['custom_canvas_account_id'] = params['custom_canvas_account_id']
|
73
|
+
end
|
74
|
+
|
40
75
|
def have_session?
|
41
|
-
if params['
|
76
|
+
if params['tool_consumer_instance_guid'] && session['user_id'] != params['user_id']
|
42
77
|
reset_session
|
43
78
|
logger.info("resetting session params")
|
44
79
|
session['user_id'] = params['user_id']
|
45
80
|
end
|
46
81
|
|
47
82
|
if (session['user_id'] && session['uri'])
|
48
|
-
uri =
|
49
|
-
api_domain = uri.
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
83
|
+
uri = BearcatUri.new(session['uri'])
|
84
|
+
api_auth = CanvasApiAuth.find_by('user_id = ? and api_domain = ?', session['user_id'], uri.api_domain)
|
85
|
+
if api_auth && !api_auth.expired?
|
86
|
+
@client = Bearcat::Client.new(token: api_auth.api_token, prefix: uri.prefix)
|
87
|
+
@client.user_profile 'self'
|
88
|
+
end
|
54
89
|
end
|
55
90
|
|
56
91
|
@lti_account = LtiAccount.find_by_key(session['oauth_consumer_key']) if session['oauth_consumer_key']
|
57
92
|
|
58
93
|
!!@client
|
94
|
+
rescue Footrest::HttpError::Unauthorized
|
95
|
+
false
|
59
96
|
end
|
60
97
|
|
61
98
|
def lti_authorize!(*roles)
|
62
99
|
authorized = false
|
63
|
-
use_secure_headers_override(:safari_override) if browser.safari?
|
64
100
|
if @lti_account = params['oauth_consumer_key'] && LtiAccount.find_by_key(params['oauth_consumer_key'])
|
65
|
-
sanitized_params = sanitize_params
|
66
|
-
authenticator = IMS::LTI::Services::MessageAuthenticator.new(request.original_url, sanitized_params, @lti_account.secret)
|
67
|
-
authorized = authenticator.valid_signature?
|
68
101
|
@tp = IMS::LTI::ToolProvider.new(@lti_account.key, @lti_account.secret, params)
|
69
|
-
authorized =
|
102
|
+
authorized = @tp.valid_request?(request)
|
70
103
|
end
|
71
104
|
logger.info 'not authorized on tp valid request' if !authorized
|
72
105
|
authorized = authorized && (roles.count == 0 || (roles & lti_roles).count > 0)
|
73
106
|
logger.info 'not authorized on roles' if !authorized
|
74
107
|
authorized = authorized && @lti_account.validate_nonce(params['oauth_nonce'], DateTime.strptime(params['oauth_timestamp'], '%s'))
|
75
108
|
logger.info 'not authorized on nonce' if !authorized
|
76
|
-
|
77
|
-
render :text => 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized
|
78
|
-
end
|
79
|
-
authorized = authorized && check_for_iframes_problem if authorized
|
109
|
+
render :text => 'Invalid Credentials, please contact your Administrator.', :status => :unauthorized unless authorized
|
80
110
|
authorized
|
81
111
|
end
|
82
112
|
|
83
|
-
# code for method taken from panda_pal v 4.0.8
|
84
|
-
# used for safari workaround
|
85
|
-
def sanitize_params
|
86
|
-
sanitized_params = request.request_parameters
|
87
|
-
# These params come over with a safari-workaround launch. The authenticator doesn't like them, so clean them out.
|
88
|
-
safe_unexpected_params = ["full_win_launch_requested", "platform_redirect_url"]
|
89
|
-
safe_unexpected_params.each do |p|
|
90
|
-
sanitized_params.delete(p)
|
91
|
-
end
|
92
|
-
sanitized_params
|
93
|
-
end
|
94
|
-
|
95
113
|
def lti_editor_button_response(return_type, return_params)
|
96
|
-
valid_return_types = [:image_url, :iframe, :url]
|
114
|
+
valid_return_types = [:image_url, :iframe, :url, :lti_launch_url]
|
97
115
|
raise "invalid editor button return type #{return_type}" unless valid_return_types.include?(return_type)
|
98
116
|
return_params[:return_type] = return_type.to_s
|
99
117
|
return_url = "#{params['launch_presentation_return_url']}?#{return_params.to_query}"
|
@@ -133,35 +151,19 @@ module CoalescingPanda
|
|
133
151
|
end
|
134
152
|
|
135
153
|
def session_check
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
session[:safari_cookie_fixed] = true
|
148
|
-
redirect_to params[:platform_redirect_url]
|
149
|
-
return false
|
150
|
-
end
|
151
|
-
true
|
152
|
-
end
|
153
|
-
def cookies_need_iframe_fix?
|
154
|
-
@browser ||= Browser.new(request.user_agent)
|
155
|
-
@browser.safari? && !request.referrer.include?('sessionless_launch') && !session[:safari_cookie_fixed] && !params[:platform_redirect_url]
|
156
|
-
end
|
157
|
-
def fix_iframe_cookies
|
158
|
-
if params[:safari_cookie_fix].present?
|
159
|
-
session[:safari_cookie_fixed] = true
|
160
|
-
redirect_to params[:return_to]
|
161
|
-
else
|
162
|
-
use_secure_headers_override(:safari_override)
|
163
|
-
render 'coalescing_panda/lti/iframe_cookie_fix', layout: false
|
154
|
+
user_agent = UserAgent.parse(request.user_agent) # Uses useragent gem!
|
155
|
+
if user_agent.browser == 'Safari' && !request.referrer.include?('sessionless_launch') # we apply the fix..
|
156
|
+
return if session[:safari_cookie_fixed] # it is already fixed.. continue
|
157
|
+
if params[:safari_cookie_fix].present? # we should be top window and able to set cookies.. so fix the issue :)
|
158
|
+
session[:safari_cookie_fixed] = true
|
159
|
+
redirect_to params[:return_to]
|
160
|
+
else
|
161
|
+
# Redirect the top frame to your server..
|
162
|
+
query = params.to_query
|
163
|
+
render :text => "<script>var referrer = document.referrer; top.window.location='?safari_cookie_fix=true&return_to='.concat(encodeURI(referrer));</script>"
|
164
|
+
end
|
164
165
|
end
|
165
166
|
end
|
167
|
+
|
166
168
|
end
|
167
169
|
end
|
@@ -1,7 +1,3 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require 'secure_headers'
|
4
|
-
|
5
1
|
module CoalescingPanda
|
6
2
|
class Engine < ::Rails::Engine
|
7
3
|
config.autoload_once_paths += Dir["#{config.root}/lib/**/"]
|
@@ -9,80 +5,30 @@ module CoalescingPanda
|
|
9
5
|
|
10
6
|
initializer :append_migrations do |app|
|
11
7
|
unless app.root.to_s.match root.to_s
|
12
|
-
config.paths[
|
13
|
-
app.config.paths[
|
8
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
9
|
+
app.config.paths["db/migrate"] << expanded_path
|
14
10
|
end
|
15
11
|
end
|
16
12
|
end
|
17
13
|
|
18
|
-
initializer 'coalescing_panda.app_controller' do |
|
14
|
+
initializer 'coalescing_panda.app_controller' do |app|
|
19
15
|
OAUTH_10_SUPPORT = true
|
20
16
|
ActiveSupport.on_load(:action_controller) do
|
21
17
|
include CoalescingPanda::ControllerHelpers
|
22
18
|
end
|
23
19
|
end
|
24
20
|
|
25
|
-
initializer 'cloaescing_panda.route_helper' do |
|
26
|
-
ActionDispatch::Routing::Mapper.include CoalescingPanda::RouteHelpers
|
21
|
+
initializer 'cloaescing_panda.route_helper' do |route|
|
22
|
+
ActionDispatch::Routing::Mapper.send :include, CoalescingPanda::RouteHelpers
|
27
23
|
end
|
28
24
|
|
29
|
-
initializer 'coalescing_panda.route_options', after
|
25
|
+
initializer 'coalescing_panda.route_options', :after => :disable_dependency_loading do |app|
|
30
26
|
ActiveSupport.on_load(:action_controller) do
|
31
|
-
#
|
27
|
+
#force the routes to load
|
32
28
|
Rails.application.reload_routes!
|
33
|
-
CoalescingPanda
|
29
|
+
CoalescingPanda::propagate_lti_navigation
|
34
30
|
end
|
35
31
|
end
|
36
32
|
|
37
|
-
initializer :secure_headers do |_app|
|
38
|
-
connect_src = %w[self]
|
39
|
-
script_src = %w[self]
|
40
|
-
if Rails.env.development?
|
41
|
-
# Allow webpack-dev-server to work
|
42
|
-
connect_src << 'http://localhost:3035'
|
43
|
-
connect_src << 'ws://localhost:3035'
|
44
|
-
# Allow stuff like rack-mini-profiler to work in development:
|
45
|
-
# https://github.com/MiniProfiler/rack-mini-profiler/issues/327
|
46
|
-
# DON'T ENABLE THIS FOR PRODUCTION!
|
47
|
-
script_src << "'unsafe-eval'"
|
48
|
-
end
|
49
|
-
begin
|
50
|
-
SecureHeaders::Configuration.default do |config|
|
51
|
-
# The default cookie headers aren't compatable with PandaPal cookies currenntly
|
52
|
-
config.cookies = { samesite: { none: true } }
|
53
|
-
# Need to allow LTI iframes
|
54
|
-
config.x_frame_options = 'ALLOWALL'
|
55
|
-
config.x_content_type_options = 'nosniff'
|
56
|
-
config.x_xss_protection = '1; mode=block'
|
57
|
-
config.referrer_policy = %w[origin-when-cross-origin strict-origin-when-cross-origin]
|
58
|
-
config.csp = {
|
59
|
-
default_src: %w[self],
|
60
|
-
script_src: script_src,
|
61
|
-
# Certain CSS-in-JS libraries inline the CSS, so we need to use unsafe-inline for them
|
62
|
-
style_src: %w[self unsafe-inline blob: https://fonts.googleapis.com],
|
63
|
-
font_src: %w[self data: https://fonts.gstatic.com],
|
64
|
-
connect_src: connect_src
|
65
|
-
}
|
66
|
-
end
|
67
|
-
rescue AlreadyConfiguredError
|
68
|
-
Rails.logger.warn 'Could not set default secure headers configuration when there is one already, continuing with previously defined configuration'
|
69
|
-
end
|
70
|
-
SecureHeaders::Configuration.override(:safari_override) do |config|
|
71
|
-
config.cookies = SecureHeaders::OPT_OUT
|
72
|
-
# Need to allow LTI iframes
|
73
|
-
config.x_frame_options = 'ALLOWALL'
|
74
|
-
config.x_content_type_options = 'nosniff'
|
75
|
-
config.x_xss_protection = '1; mode=block'
|
76
|
-
config.referrer_policy = %w[origin-when-cross-origin strict-origin-when-cross-origin]
|
77
|
-
config.csp = {
|
78
|
-
default_src: %w[self],
|
79
|
-
script_src: script_src,
|
80
|
-
# Certain CSS-in-JS libraries inline the CSS, so we need to use unsafe-inline for them
|
81
|
-
style_src: %w[self unsafe-inline blob: https://fonts.googleapis.com],
|
82
|
-
font_src: %w[self data: https://fonts.gstatic.com],
|
83
|
-
connect_src: connect_src
|
84
|
-
}
|
85
|
-
end
|
86
|
-
end
|
87
33
|
end
|
88
34
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe CoalescingPanda::LtiController do
|
3
|
+
describe CoalescingPanda::LtiController, type: :controller do
|
4
4
|
routes { CoalescingPanda::Engine.routes }
|
5
5
|
|
6
6
|
describe '#lti_config' do
|
@@ -41,4 +41,4 @@ describe CoalescingPanda::LtiController do
|
|
41
41
|
end
|
42
42
|
|
43
43
|
|
44
|
-
end
|
44
|
+
end
|
@@ -1,15 +1,20 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
describe CoalescingPanda::Oauth2Controller do
|
3
|
+
describe CoalescingPanda::Oauth2Controller, type: :controller do
|
4
4
|
routes { CoalescingPanda::Engine.routes }
|
5
|
+
let(:account) { FactoryGirl.create(:account, settings: {base_url: 'foo.com'}) }
|
5
6
|
|
6
7
|
describe "#redirect" do
|
7
8
|
it 'creates a token in the db' do
|
8
9
|
ENV['OAUTH_PROTOCOL'] = 'http'
|
9
|
-
Bearcat::Client.any_instance.stub(retrieve_token: '
|
10
|
-
|
10
|
+
Bearcat::Client.any_instance.stub(retrieve_token: { 'access_token' => 'token', 'refresh_token' => 'token', 'expires_in' => 3600 })
|
11
|
+
session[:state] = 'test'
|
12
|
+
get :redirect, {user_id: 1, api_domain: 'foo.com', code: 'bar', key: account.key, state: 'test'}
|
11
13
|
auth = CoalescingPanda::CanvasApiAuth.find_by_user_id_and_api_domain(1, 'foo.com')
|
12
14
|
auth.should_not == nil
|
15
|
+
expect(auth.api_token).to eql 'token'
|
16
|
+
expect(auth.refresh_token).to eql 'token'
|
17
|
+
expect(auth.expires_at).to be > 50.minutes.from_now
|
13
18
|
end
|
14
19
|
|
15
20
|
it "doesn't create a token in the db" do
|
@@ -19,4 +24,4 @@ describe CoalescingPanda::Oauth2Controller do
|
|
19
24
|
|
20
25
|
end
|
21
26
|
|
22
|
-
end
|
27
|
+
end
|