Skip to content

Commit ad8f17d

Browse files
committed
oauth2 confirmation screen, closes #7762
This explicit confirmation step is an improvement on our login-and-implicitly-accept workflow from before. And it allows us to do the oauth workflow without forcing a logout, which is much more ideal especially for embedded LTI tools that want to use oauth. Eventually this dialog will contain more information on the app and the permissions requested. test plan: As a client application, kick off the oauth workflow for a logged-in user, verify the user goes straight to the confirmation screen. Verify you only get a code back if they accept, and an error if they deny. Do the same without a web session, verify you go to the confirmation screen straight after logging in. Change-Id: Idf9905b795979339aec0cb5e4e058f4507a81bac Reviewed-on: https://gerrit.instructure.com/9804 Tested-by: Hudson <hudson@instructure.com> Reviewed-by: Brian Whitmer <brian@instructure.com>
1 parent 73666d9 commit ad8f17d

7 files changed

Lines changed: 195 additions & 37 deletions

File tree

app/controllers/pseudonym_sessions_controller.rb

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -362,21 +362,7 @@ def successful_login(user, pseudonym)
362362
respond_to do |format|
363363
flash[:notice] = t 'notices.login_success', "Login successful." unless flash[:error]
364364
if session[:oauth2]
365-
# this is where we will verify client authorization and scopes, once implemented
366-
# .....
367-
# now generate the temporary code, and respond/redirect
368-
code = ActiveSupport::SecureRandom.hex(64)
369-
code_data = { 'user' => user.id, 'client_id' => session[:oauth2][:client_id] }
370-
Canvas.redis.setex("oauth2:#{code}", 1.day, code_data.to_json)
371-
redirect_uri = session[:oauth2][:redirect_uri]
372-
if redirect_uri == OAUTH2_OOB_URI
373-
# destroy this user session, it's only for generating the token
374-
@pseudonym_session.try(:destroy)
375-
reset_session
376-
format.html { redirect_to oauth2_auth_url(:code => code) }
377-
else
378-
format.html { redirect_to "#{redirect_uri}?code=#{code}" }
379-
end
365+
return redirect_to(oauth2_auth_confirm_url)
380366
elsif session[:course_uuid] && user && (course = Course.find_by_uuid_and_workflow_state(session[:course_uuid], "created"))
381367
claim_session_course(course, user)
382368
format.html { redirect_to(course_url(course, :login_success => '1')) }
@@ -396,26 +382,48 @@ def successful_login(user, pseudonym)
396382
OAUTH2_OOB_URI = 'urn:ietf:wg:oauth:2.0:oob'
397383

398384
def oauth2_auth
399-
if params[:code]
385+
if params[:code] || params[:error]
400386
# hopefully the user never sees this, since it's an oob response and the
401387
# browser should be closed automatically. but we'll at least display
402388
# something basic.
403389
return render()
404390
end
405391

406-
key = DeveloperKey.find_by_id(params[:client_id]) if params[:client_id].present?
407-
unless key
392+
@key = DeveloperKey.find_by_id(params[:client_id]) if params[:client_id].present?
393+
unless @key
408394
return render(:status => 400, :json => { :message => "invalid client_id" })
409395
end
410396

411397
redirect_uri = params[:redirect_uri].presence || ""
412-
unless redirect_uri == OAUTH2_OOB_URI || key.redirect_domain_matches?(redirect_uri)
398+
unless redirect_uri == OAUTH2_OOB_URI || @key.redirect_domain_matches?(redirect_uri)
413399
return render(:status => 400, :json => { :message => "invalid redirect_uri" })
414400
end
415401

416-
session[:oauth2] = { :client_id => key.id, :redirect_uri => redirect_uri }
417-
# force the user to re-authenticate
418-
redirect_to login_url(:re_login => true)
402+
session[:oauth2] = { :client_id => @key.id, :redirect_uri => redirect_uri }
403+
if @current_pseudonym
404+
redirect_to oauth2_auth_confirm_url
405+
else
406+
redirect_to login_url
407+
end
408+
end
409+
410+
def oauth2_confirm
411+
@key = DeveloperKey.find(session[:oauth2][:client_id])
412+
@app_name = @key.name.presence || @key.user_name.presence || @key.email.presence || t(:default_app_name, "Third-Party Application")
413+
end
414+
415+
def oauth2_accept
416+
# now generate the temporary code, and respond/redirect
417+
code = ActiveSupport::SecureRandom.hex(64)
418+
code_data = { 'user' => @current_user.id, 'client_id' => session[:oauth2][:client_id] }
419+
Canvas.redis.setex("oauth2:#{code}", 1.day, code_data.to_json)
420+
final_oauth2_redirect(session[:oauth2][:redirect_uri], :code => code)
421+
session.delete(:oauth2)
422+
end
423+
424+
def oauth2_deny
425+
final_oauth2_redirect(session[:oauth2][:redirect_uri], :error => "access_denied")
426+
session.delete(:oauth2)
419427
end
420428

421429
def oauth2_token
@@ -445,4 +453,13 @@ def oauth2_token
445453
'user' => user.as_json(:only => [:id, :name], :include_root => false),
446454
}
447455
end
456+
457+
def final_oauth2_redirect(redirect_uri, opts = {})
458+
if redirect_uri == OAUTH2_OOB_URI
459+
redirect_to oauth2_auth_url(opts)
460+
else
461+
has_params = redirect_uri =~ %r{\?}
462+
redirect_to(redirect_uri + (has_params ? "&" : "?") + opts.to_query)
463+
end
464+
end
448465
end
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
<%= t 'oauth2_authorized', 'Your application has been authorized for access to your Canvas data. Your session should begin automatically.' %>
1+
<%# normally the native application will intercept the redirect and this will never be seen, this is just a failsafe %>
2+
<%= t 'oauth2_complete', 'The application has finished the login workflow, and should reactivate shortly.' %>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<%
2+
jammit_css :login
3+
@headers = false
4+
@body_classes << "modal"
5+
content_for :page_title, t(:page_title, "App Login")
6+
%>
7+
<div id="modal-box-top"></div>
8+
<div id="modal-box-arbitrary-size" class="modal-box-more-padding">
9+
<h2><%= @app_name %></h2>
10+
<p class="modal-box-content">
11+
<%= mt 'details.allow_application', "%{app_name} is requesting access to your account.", :app_name => @app_name %>
12+
<br/><br/>
13+
<%= mt 'details.login_name', "You are logging into this app as %{user_name}.", :user_name => link_to(@current_user.short_name, profile_url, :popup => true) %>
14+
<% if @current_user.email.present? && @current_user.email != @current_user.short_name %>
15+
<br/>
16+
<%= t 'details.email', "Your email address is %{email}.", :email => @current_user.email %>
17+
<% end %>
18+
</p>
19+
<div class="button_box">
20+
<%= link_to(t(:cancel, "Cancel"), oauth2_auth_deny_path, :class => "button") %>
21+
<%= link_to(t(:log_in, "Login"), oauth2_auth_accept_path, :method => :post, :class => "button button-default") %>
22+
</div>
23+
</div>
24+
<div id="modal-box-bottom"></div>

config/routes.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,10 @@ def et_routes(route_object, context)
831831
map.api_v1_files_create 'files_api', :controller => 'files', :action => 'api_create', :conditions => { :method => :post }
832832

833833
map.oauth2_auth 'login/oauth2/auth', :controller => 'pseudonym_sessions', :action => 'oauth2_auth', :conditions => { :method => :get }
834-
map.oauth2_token 'login/oauth2/token',:controller => 'pseudonym_sessions', :action => 'oauth2_token', :conditions => { :method => :post }
834+
map.oauth2_token 'login/oauth2/token', :controller => 'pseudonym_sessions', :action => 'oauth2_token', :conditions => { :method => :post }
835+
map.oauth2_auth_confirm 'login/oauth2/confirm', :controller => 'pseudonym_sessions', :action => 'oauth2_confirm', :conditions => { :method => :get }
836+
map.oauth2_auth_accept 'login/oauth2/accept', :controller => 'pseudonym_sessions', :action => 'oauth2_accept', :conditions => { :method => :post }
837+
map.oauth2_auth_deny 'login/oauth2/deny', :controller => 'pseudonym_sessions', :action => 'oauth2_deny', :conditions => { :method => :get }
835838

836839
ApiRouteSet.route(map, "/api/lti/v1") do |lti|
837840
lti.post "tools/:tool_id/grade_passback", :controller => :lti_api, :action => :grade_passback, :path_name => "lti_grade_passback_api"

doc/api/oauth.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,16 @@ response:
111111
The app can then extract the code, and use it along with the
112112
client_id and client_secret to obtain the final access_key.
113113

114+
If the user doesn't accept the request for access, or if another error
115+
occurs, Canvas redirects back to your request\_uri with an `error`
116+
parameter, rather than a `code` parameter, in the query string.
117+
118+
<div class="method_details">
119+
<h3>http://www.example.com/oauth2response?error=access_denied</h3>
120+
</div>
121+
122+
`access_denied` is the only currently implemented error code.
123+
114124
### Step 3: Exchange the code for the final access token
115125

116126
<div class="method_details">
@@ -226,6 +236,16 @@ changed to contain <code>code=&lt;code&gt;</code> somewhere in the query
226236
string. The app can then extract the code, and use it along with the
227237
client_id and client_secret to obtain the final access_key.
228238

239+
If the user doesn't accept the request for access, or if another error
240+
occurs, Canvas will add an `error`
241+
parameter, rather than a `code` parameter, to the query string.
242+
243+
<div class="method_details">
244+
<h3>/login/oauth2/auth?error=access_denied</h3>
245+
</div>
246+
247+
`access_denied` is the only currently implemented error code.
248+
229249
### Step 3: Exchange the code for the final access token
230250

231251
<div class="method_details">

spec/apis/auth_spec.rb

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,21 @@ def flow(opts = {})
137137

138138
# step 1
139139
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
140-
response.should redirect_to(login_url(:re_login => true))
140+
response.should redirect_to(login_url)
141141

142142
yield
143143

144-
# step 2
144+
# step 3
145145
response.should be_redirect
146-
response['Location'].should match(%r{/login/oauth2/auth?})
147-
code = response['Location'].match(/code=([^\?&]+)/)[1]
148-
code.should be_present
146+
response['Location'].should match(%r{/login/oauth2/confirm$})
147+
get response['Location']
148+
response.should render_template("pseudonym_sessions/oauth2_confirm")
149+
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
149150

150-
# make sure the user is now logged out, or the app also has full access to their session
151-
get '/'
152151
response.should be_redirect
153-
response['Location'].should == 'http://www.example.com/login'
152+
response['Location'].should match(%r{/login/oauth2/auth\?})
153+
code = response['Location'].match(/code=([^\?&]+)/)[1]
154+
code.should be_present
154155

155156
# we have the code, we can close the browser session
156157
if opts[:basic_auth]
@@ -241,11 +242,45 @@ def cas.validate_service_ticket(st)
241242

242243
get '/login', :ticket => 'ST-abcd'
243244
response.should be_redirect
244-
response['Location'].should match(%r{/login/oauth2/auth\?code=})
245-
session.should be_blank
246245
end
247246
end
248247

248+
it "should not require logging in again, or log out afterwards" do
249+
course_with_student_logged_in(:active_all => true, :user => user_with_pseudonym)
250+
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
251+
response.should be_redirect
252+
response['Location'].should match(%r{/login/oauth2/confirm$})
253+
get response['Location']
254+
response.should render_template("pseudonym_sessions/oauth2_confirm")
255+
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
256+
response.should be_redirect
257+
response['Location'].should match(%r{/login/oauth2/auth\?})
258+
code = response['Location'].match(/code=([^\?&]+)/)[1]
259+
code.should be_present
260+
get response['Location']
261+
response.should be_success
262+
# verify we're still logged in
263+
get "/courses/#{@course.id}"
264+
response.should be_success
265+
end
266+
267+
it "should redirect with access_denied if the user doesn't accept" do
268+
course_with_student_logged_in(:active_all => true, :user => user_with_pseudonym)
269+
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
270+
response.should be_redirect
271+
response['Location'].should match(%r{/login/oauth2/confirm$})
272+
get response['Location']
273+
response.should render_template("pseudonym_sessions/oauth2_confirm")
274+
get "/login/oauth2/deny"
275+
response.should be_redirect
276+
response['Location'].should match(%r{/login/oauth2/auth\?})
277+
error = response['Location'].match(%r{error=([^\?&]+)})[1]
278+
error.should == "access_denied"
279+
response['Location'].should_not match(%r{code=})
280+
get response['Location']
281+
response.should be_success
282+
end
283+
249284
it "should allow http basic auth for the app auth" do
250285
flow(:basic_auth => true) do
251286
get response['Location']
@@ -257,7 +292,7 @@ def cas.validate_service_ticket(st)
257292
it "should require the correct client secret" do
258293
# step 1
259294
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => 'urn:ietf:wg:oauth:2.0:oob'
260-
response.should redirect_to(login_url(:re_login => true))
295+
response.should redirect_to(login_url)
261296

262297
get response['Location']
263298
response.should be_success
@@ -268,7 +303,9 @@ def cas.validate_service_ticket(st)
268303

269304
# step 2
270305
response.should be_redirect
271-
response['Location'].should match(%r{/login/oauth2/auth?})
306+
response['Location'].should match(%r{/login/oauth2/confirm$})
307+
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
308+
272309
code = response['Location'].match(/code=([^\?&]+)/)[1]
273310
code.should be_present
274311

@@ -304,12 +341,17 @@ def cas.validate_service_ticket(st)
304341
@key.update_attribute :redirect_uri, 'http://www.example.com/oauth2response'
305342

306343
get "/login/oauth2/auth", :response_type => 'code', :client_id => @client_id, :redirect_uri => "http://www.example.com/my_uri"
307-
response.should redirect_to(login_url(:re_login => true))
344+
response.should redirect_to(login_url)
308345

309346
get response['Location']
310347
response.should be_success
311348
post "/login", :pseudonym_session => { :unique_id => 'test1@example.com', :password => 'test123' }
312349

350+
response.should be_redirect
351+
response['Location'].should match(%r{/login/oauth2/confirm$})
352+
get response['Location']
353+
post "/login/oauth2/accept", { :authenticity_token => session[:_csrf_token] }
354+
313355
response.should be_redirect
314356
response['Location'].should match(%r{http://www.example.com/my_uri?})
315357
code = response['Location'].match(/code=([^\?&]+)/)[1]

spec/selenium/oauth_spec.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
require File.expand_path(File.dirname(__FILE__) + '/common')
2+
3+
if Canvas.redis_enabled?
4+
5+
describe "oauth2 flow" do
6+
it_should_behave_like "in-process server selenium tests"
7+
8+
before do
9+
@key = DeveloperKey.create!(:name => 'Specs')
10+
@client_id = @key.id
11+
@client_secret = @key.api_key
12+
end
13+
14+
describe "a logged-in user" do
15+
before do
16+
course_with_student_logged_in(:active_all => true)
17+
end
18+
19+
it "should show the confirmation dialog without requiring login" do
20+
get "/login/oauth2/auth?response_type=code&client_id=#{@client_id}&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
21+
f('#modal-box-arbitrary-size').text.should match(%r{Specs is requesting access to your account})
22+
expect_new_page_load { f('#modal-box-arbitrary-size a.button-default').click() }
23+
driver.current_url.should match(%r{/login/oauth2/auth\?})
24+
code = driver.current_url.match(%r{code=([^\?&]+)})[1]
25+
code.should be_present
26+
end
27+
end
28+
29+
describe "a non-logged-in user" do
30+
before do
31+
course_with_student(:active_all => true, :user => user_with_pseudonym)
32+
end
33+
34+
it "should show the confirmation dialog after logging in" do
35+
get "/login/oauth2/auth?response_type=code&client_id=#{@client_id}&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
36+
driver.current_url.should match(%r{/login$})
37+
user_element = driver.find_element(:css, '#pseudonym_session_unique_id')
38+
user_element.send_keys("nobody@example.com")
39+
password_element = driver.find_element(:css, '#pseudonym_session_password')
40+
password_element.send_keys("asdfasdf")
41+
password_element.submit
42+
f('#modal-box-arbitrary-size').text.should match(%r{Specs is requesting access to your account})
43+
expect_new_page_load { f('#modal-box-arbitrary-size a.button-default').click() }
44+
driver.current_url.should match(%r{/login/oauth2/auth\?})
45+
code = driver.current_url.match(%r{code=([^\?&]+)})[1]
46+
code.should be_present
47+
end
48+
end
49+
end
50+
51+
end

0 commit comments

Comments
 (0)