canvas_oauth_engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. data/MIT-LICENSE +20 -0
  2. data/README.md +102 -0
  3. data/Rakefile +28 -0
  4. data/app/controllers/canvas_oauth/application_controller.rb +5 -0
  5. data/app/controllers/canvas_oauth/canvas_controller.rb +25 -0
  6. data/app/models/canvas_oauth/authorization.rb +26 -0
  7. data/config/canvas.yml.example +12 -0
  8. data/config/routes.rb +3 -0
  9. data/db/migrate/20121121005358_create_canvas_oauth_authorizations.rb +15 -0
  10. data/lib/canvas_oauth.rb +25 -0
  11. data/lib/canvas_oauth/canvas_api.rb +249 -0
  12. data/lib/canvas_oauth/canvas_api_extensions.rb +8 -0
  13. data/lib/canvas_oauth/canvas_application.rb +66 -0
  14. data/lib/canvas_oauth/canvas_config.rb +28 -0
  15. data/lib/canvas_oauth/config.rb +1 -0
  16. data/lib/canvas_oauth/engine.rb +15 -0
  17. data/lib/canvas_oauth/version.rb +3 -0
  18. data/lib/tasks/canvas_oauth_tasks.rake +1 -0
  19. data/spec/controllers/canvas_oauth/canvas_controller_spec.rb +80 -0
  20. data/spec/dummy/README.rdoc +261 -0
  21. data/spec/dummy/Rakefile +7 -0
  22. data/spec/dummy/app/controllers/application_controller.rb +5 -0
  23. data/spec/dummy/app/controllers/welcome_controller.rb +5 -0
  24. data/spec/dummy/config.ru +4 -0
  25. data/spec/dummy/config/application.rb +55 -0
  26. data/spec/dummy/config/boot.rb +10 -0
  27. data/spec/dummy/config/canvas.yml +12 -0
  28. data/spec/dummy/config/database.yml +25 -0
  29. data/spec/dummy/config/environment.rb +5 -0
  30. data/spec/dummy/config/environments/development.rb +26 -0
  31. data/spec/dummy/config/environments/production.rb +69 -0
  32. data/spec/dummy/config/environments/test.rb +33 -0
  33. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  34. data/spec/dummy/config/initializers/inflections.rb +15 -0
  35. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  36. data/spec/dummy/config/initializers/secret_token.rb +8 -0
  37. data/spec/dummy/config/initializers/session_store.rb +8 -0
  38. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  39. data/spec/dummy/config/locales/en.yml +5 -0
  40. data/spec/dummy/config/routes.rb +4 -0
  41. data/spec/dummy/db/migrate/20130326194409_create_canvas_oauth_authorizations.canvas_oauth.rb +13 -0
  42. data/spec/dummy/db/schema.rb +25 -0
  43. data/spec/dummy/db/test.sqlite3 +0 -0
  44. data/spec/dummy/log/test.log +8127 -0
  45. data/spec/dummy/public/404.html +26 -0
  46. data/spec/dummy/public/422.html +26 -0
  47. data/spec/dummy/public/500.html +25 -0
  48. data/spec/dummy/public/favicon.ico +0 -0
  49. data/spec/dummy/public/robots.txt +5 -0
  50. data/spec/dummy/script/rails +6 -0
  51. data/spec/lib/canvas_oauth/canvas_api_extensions_spec.rb +13 -0
  52. data/spec/lib/canvas_oauth/canvas_api_spec.rb +228 -0
  53. data/spec/models/canvas_oauth/authorization_spec.rb +47 -0
  54. data/spec/spec_helper.rb +57 -0
  55. metadata +353 -0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2012 - 2015 Instructure, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # CanvasOauth
2
+
3
+ CanvasOauth is a mountable engine for handling the oauth workflow with canvas
4
+ and making api calls from your rails app. This is tested with Rails 3.2, we'll
5
+ be looking at verifying with Rails 4 soon.
6
+
7
+ ## Installation
8
+
9
+ Add the gem to your `Gemfile` with the following line, and then `bundle install`
10
+
11
+ ```
12
+ gem 'canvas_oauth_engine', :require => 'canvas_oauth'
13
+ ```
14
+
15
+ Then, mount the engine to your app by adding this line to your `routes.rb` file
16
+
17
+ ```
18
+ mount CanvasOauth::Engine => "/canvas_oauth"
19
+ ```
20
+
21
+ Next, include the engine in your `ApplicationController`
22
+
23
+ ```
24
+ class ApplicationController < ActionController::Base
25
+ include CanvasOauth::CanvasApplication
26
+
27
+ ...
28
+ end
29
+ ```
30
+
31
+ After that, create an `canvas.yml` file in your `config/` folder that looks something
32
+ like this (or see `config/canvas.yml.example` for a template):
33
+
34
+ ```
35
+ default: &default
36
+ key: your_key
37
+ secret: your_secret
38
+
39
+ development:
40
+ <<: *default
41
+
42
+ test:
43
+ <<: *default
44
+
45
+ production:
46
+ <<: *default
47
+ ```
48
+
49
+ The values of key and secret should be set from the developer key that you
50
+ generate in the canvas application.
51
+
52
+ Finally, run migrations:
53
+
54
+ ```
55
+ bundle exec install
56
+ bundle exec rake railties:install:migrations
57
+ bundle exec rake db:migrate
58
+ ```
59
+
60
+ This will create the `canvas_oauth_authorizations` table which stores
61
+ successful oauth tokens for later use, so the user does not have to accept the
62
+ oauth login each time they use the app.
63
+
64
+ ## Usage
65
+
66
+ The engine only uses one url, whatever it is mounted to, to serve as the
67
+ `redirect_url` in the oauth workflow.
68
+
69
+ The engine sets up a global `before_filter`, which checks for a valid oauth
70
+ token, and if one does not exist, starts the oauth login flow. It handles
71
+ posting the oauth request, verifying the result and redirecting to the
72
+ application root. It exposes the following methods to your controllers:
73
+
74
+ * `canvas`
75
+ * `canvas_token`
76
+
77
+ The first is an instance of HTTParty ready to make api requests to your canvas
78
+ application. The second is if you need access to the oauth token directly.
79
+
80
+ ## Configuring the Tool Consumer
81
+
82
+ You will a developer key and secret from canvas, which should be entered into
83
+ you `canvas.yml` file.
84
+
85
+ ## Example
86
+
87
+ You can see and interact with an example of an app using this engine by looking
88
+ at `spec/dummy`. This is a full rails app which integrates the gem and has
89
+ a simple index page that says 'Hello Oauth' if the app is launched and the
90
+ oauth flow is successful.
91
+
92
+ ## About Oauth
93
+
94
+ TODO...
95
+
96
+ ## Contributing
97
+
98
+ 1. Fork it
99
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
100
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
101
+ 4. Push to the branch (`git push origin my-new-feature`)
102
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env rake
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+ begin
8
+ require 'rdoc/task'
9
+ rescue LoadError
10
+ require 'rdoc/rdoc'
11
+ require 'rake/rdoctask'
12
+ RDoc::Task = Rake::RDocTask
13
+ end
14
+
15
+ RDoc::Task.new(:rdoc) do |rdoc|
16
+ rdoc.rdoc_dir = 'rdoc'
17
+ rdoc.title = 'CanvasOauth'
18
+ rdoc.options << '--line-numbers'
19
+ rdoc.rdoc_files.include('README.rdoc')
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+ APP_RAKEFILE = File.expand_path("../spec/dummy/Rakefile", __FILE__)
24
+ load 'rails/tasks/engine.rake'
25
+
26
+ Bundler::GemHelper.install_tasks
27
+
28
+ task :default => :spec
@@ -0,0 +1,5 @@
1
+ module CanvasOauth
2
+ class ApplicationController < ActionController::Base
3
+ include CanvasOauth::CanvasApplication
4
+ end
5
+ end
@@ -0,0 +1,25 @@
1
+ module CanvasOauth
2
+ class CanvasController < CanvasOauth::ApplicationController
3
+ skip_before_filter :request_canvas_authentication
4
+
5
+ def oauth
6
+ if verify_oauth2_state(params[:state]) && params[:code]
7
+ if token = canvas.get_access_token(params[:code])
8
+ if CanvasOauth::Authorization.cache_token(token, user_id, tool_consumer_instance_guid)
9
+ redirect_to main_app.root_path
10
+ else
11
+ render text: "Error: unable to save token"
12
+ end
13
+ else
14
+ render text: "Error: invalid code - #{params[:code]}"
15
+ end
16
+ else
17
+ render text: "#{CanvasOauth::Config.tool_name} needs access to your account in order to function properly. Please try again and click log in to approve the integration."
18
+ end
19
+ end
20
+
21
+ def verify_oauth2_state(callback_state)
22
+ callback_state.present? && callback_state == session.delete(:oauth2_state)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ module CanvasOauth
2
+ class Authorization < ActiveRecord::Base
3
+ validates :canvas_user_id, :token, :last_used_at, presence: true
4
+
5
+ def self.cache_token(token, user_id, tool_consumer_instance_guid)
6
+ create(
7
+ token: token,
8
+ canvas_user_id: user_id,
9
+ last_used_at: Time.now,
10
+ tool_consumer_instance_guid: tool_consumer_instance_guid
11
+ )
12
+ end
13
+
14
+ def self.fetch_token(user_id, tool_consumer_instance_guid)
15
+ user_tokens = where(canvas_user_id: user_id, tool_consumer_instance_guid: tool_consumer_instance_guid).order("created_at DESC")
16
+ if canvas_auth = user_tokens.first
17
+ canvas_auth.update_attribute(:last_used_at, Time.now)
18
+ return canvas_auth.token
19
+ end
20
+ end
21
+
22
+ def self.clear_tokens(user_id, tool_consumer_instance_guid)
23
+ where(canvas_user_id: user_id, tool_consumer_instance_guid: tool_consumer_instance_guid).destroy_all
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,12 @@
1
+ default: &default
2
+ key: your_key
3
+ secret: your_secret
4
+
5
+ development:
6
+ <<: *default
7
+
8
+ test:
9
+ <<: *default
10
+
11
+ cucumber:
12
+ <<: *default
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ CanvasOauth::Engine.routes.draw do
2
+ match "/", to: "canvas#oauth", via: :get, as: :canvas_oauth
3
+ end
@@ -0,0 +1,15 @@
1
+ class CreateCanvasOauthAuthorizations < ActiveRecord::Migration
2
+ def change
3
+ create_table "canvas_oauth_authorizations", :force => true do |t|
4
+ t.integer "canvas_user_id", :limit => 8
5
+ t.string "tool_consumer_instance_guid", :null => false
6
+ t.string "token"
7
+ t.datetime "last_used_at"
8
+ t.datetime "created_at", :null => false
9
+ t.datetime "updated_at", :null => false
10
+ end
11
+
12
+ add_index :canvas_oauth_authorizations, [:canvas_user_id, :tool_consumer_instance_guid],
13
+ name: 'index_canvas_oauth_auths_on_user_id_and_tciguid'
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ require 'ostruct'
2
+
3
+ require 'httparty'
4
+ require 'link_header'
5
+
6
+ require "canvas_oauth/config"
7
+ require "canvas_oauth/canvas_application"
8
+ require 'canvas_oauth/canvas_api'
9
+ require 'canvas_oauth/canvas_api_extensions'
10
+ require 'canvas_oauth/canvas_config'
11
+
12
+ module CanvasOauth
13
+ mattr_accessor :app_root
14
+
15
+ def self.setup
16
+ yield self
17
+ end
18
+
19
+ def self.config
20
+ yield(CanvasOauth::Config)
21
+ end
22
+ end
23
+
24
+ require "canvas_oauth/engine"
25
+
@@ -0,0 +1,249 @@
1
+ module CanvasOauth
2
+ class CanvasApi
3
+ include HTTParty
4
+ PER_PAGE = 50
5
+
6
+ attr_accessor :token, :key, :secret
7
+ attr_reader :canvas_url
8
+
9
+ def initialize(canvas_url, token, key, secret)
10
+ self.canvas_url = canvas_url
11
+ self.token = token
12
+ self.key = key
13
+ self.secret = secret
14
+ end
15
+
16
+ def authenticated_request(method, *params)
17
+ params << {} if params.size == 1
18
+
19
+ params.last[:headers] ||= {}
20
+ params.last[:headers]['Authorization'] = "Bearer #{token}"
21
+
22
+ start = Time.now
23
+
24
+ response = self.class.send(method, *params)
25
+
26
+ Rails.logger.info {
27
+ stop = Time.now
28
+ elapsed = ((stop - start) * 1000).round(2)
29
+
30
+ params.last[:headers].reject! { |k| k == 'Authorization' }
31
+ "API call (#{elapsed}ms): #{method} #{params.inspect}"
32
+ }
33
+
34
+ if response && response.unauthorized?
35
+ if response.headers['WWW-Authenticate'].present?
36
+ raise CanvasApi::Authenticate
37
+ else
38
+ raise CanvasApi::Unauthorized
39
+ end
40
+ else
41
+ return response
42
+ end
43
+ end
44
+
45
+ def paginated_get(*params)
46
+ params[1] ||= {}
47
+ params[1][:query] ||= {}
48
+ params[1][:query][:per_page] = PER_PAGE
49
+
50
+ all_pages = []
51
+
52
+ while params[0] do
53
+ if current_page = authenticated_get(*params)
54
+ all_pages += current_page if valid_page?(current_page)
55
+
56
+ links = LinkHeader.parse(current_page.headers['link'])
57
+ params[0] = links.find_link(["rel", "next"]).try(:href)
58
+ else
59
+ params[0] = nil
60
+ end
61
+ end
62
+
63
+ all_pages
64
+ end
65
+
66
+ def get_report(account_id, report_type, params)
67
+ report = authenticated_post("/api/v1/accounts/#{account_id}/reports/#{report_type}", { body: params })
68
+ report = authenticated_get "/api/v1/accounts/#{account_id}/reports/#{report_type}/#{report['id']}"
69
+ while report['status'] == 'running'
70
+ sleep(4)
71
+ report = authenticated_get "/api/v1/accounts/#{account_id}/reports/#{report_type}/#{report['id']}"
72
+ end
73
+
74
+ if report['status'] == 'complete'
75
+ file_id = report['file_url'].match(/files\/([0-9]+)\/download/)[1]
76
+ file = get_file(file_id)
77
+ return hash_csv(self.class.get(file['url'], limit: 15).parsed_response)
78
+ else
79
+ return report
80
+ end
81
+ end
82
+
83
+ def valid_page?(page)
84
+ page && page.size > 0
85
+ end
86
+
87
+ def get_file(file_id)
88
+ authenticated_get "/api/v1/files/#{file_id}"
89
+ end
90
+
91
+ def get_accounts_provisioning_report(account_id)
92
+ get_report(account_id, :provisioning_csv, 'parameters[accounts]' => true)
93
+ end
94
+
95
+ #Needs to be refactored to somewhere more generic
96
+ def hash_csv(csv_string)
97
+ require 'csv'
98
+
99
+ csv = CSV.parse(csv_string)
100
+ headers = csv.shift
101
+ output = []
102
+
103
+ csv.each do |row|
104
+ hash = {}
105
+ headers.each do |header|
106
+ hash[header] = row.shift.to_s
107
+ end
108
+ output << hash
109
+ end
110
+
111
+ return output
112
+ end
113
+
114
+ def authenticated_get(*params)
115
+ authenticated_request(:get, *params)
116
+ end
117
+
118
+ def authenticated_post(*params)
119
+ authenticated_request(:post, *params)
120
+ end
121
+
122
+ def authenticated_put(*params)
123
+ authenticated_request(:put, *params)
124
+ end
125
+
126
+ def get_courses
127
+ paginated_get "/api/v1/courses"
128
+ end
129
+
130
+ def get_account(account_id)
131
+ authenticated_get "/api/v1/accounts/#{account_id}"
132
+ end
133
+
134
+ def get_account_sub_accounts(account_id)
135
+ paginated_get "/api/v1/accounts/#{account_id}/sub_accounts", { query: { :recursive => true } }
136
+ end
137
+
138
+ def get_account_courses(account_id)
139
+ paginated_get "/api/v1/accounts/#{account_id}/courses"
140
+ end
141
+
142
+ def get_account_users(account_id)
143
+ paginated_get "/api/v1/accounts/#{account_id}/users"
144
+ end
145
+
146
+ def get_course(course_id)
147
+ authenticated_get "/api/v1/courses/#{course_id}"
148
+ end
149
+
150
+ def get_section_enrollments(section_id)
151
+ paginated_get "/api/v1/sections/#{section_id}/enrollments"
152
+ end
153
+
154
+ def get_user_enrollments(user_id)
155
+ paginated_get "/api/v1/users/#{user_id}/enrollments"
156
+ end
157
+
158
+ def get_course_users(course_id)
159
+ paginated_get "/api/v1/courses/#{course_id}/users"
160
+ end
161
+
162
+ def get_all_course_users(course_id)
163
+ paginated_get "/api/v1/courses/#{course_id}/users", { query: {enrollment_state: ["active","invited","rejected","completed","inactive"] } }
164
+ end
165
+
166
+ def get_course_teachers_and_tas(course_id)
167
+ paginated_get "/api/v1/courses/#{course_id}/users", { query: { enrollment_type: ['teacher', 'ta'] } }
168
+ end
169
+
170
+ def get_course_students(course_id)
171
+ paginated_get "/api/v1/courses/#{course_id}/students"
172
+ end
173
+
174
+ def get_section(section_id)
175
+ authenticated_get "/api/v1/sections/#{section_id}"
176
+ end
177
+
178
+ def get_sections(course_id)
179
+ paginated_get "/api/v1/courses/#{course_id}/sections", { query: { :include => ['students', 'avatar_url', 'enrollments'] } }
180
+ end
181
+
182
+ def get_assignments(course_id)
183
+ paginated_get "/api/v1/courses/#{course_id}/assignments"
184
+ end
185
+
186
+ def get_assignment(course_id, assignment_id)
187
+ authenticated_get "/api/v1/courses/#{course_id}/assignments/#{assignment_id}"
188
+ end
189
+
190
+ def get_user_profile(user_id)
191
+ authenticated_get "/api/v1/users/#{user_id}/profile"
192
+ end
193
+
194
+ def create_assignment(course_id, params)
195
+ authenticated_post "/api/v1/courses/#{course_id}/assignments", { body: { assignment: params } }
196
+ end
197
+
198
+ def grade_assignment(course_id, assignment_id, user_id, params)
199
+ authenticated_put "/api/v1/courses/#{course_id}/assignments/#{assignment_id}/submissions/#{user_id}", { body: params }
200
+ end
201
+
202
+ def course_account_id(course_id)
203
+ course = get_course(course_id)
204
+ course['account_id'] if course
205
+ end
206
+
207
+ def root_account_id(account_id)
208
+ if account_id && account = get_account(account_id)
209
+ root_id = account['root_account_id']
210
+ end
211
+
212
+ root_id || account_id
213
+ end
214
+
215
+ def course_root_account_id(course_id)
216
+ root_account_id(course_account_id(course_id))
217
+ end
218
+
219
+ def auth_url(redirect_uri, oauth2_state)
220
+ "#{canvas_url}/login/oauth2/auth?client_id=#{key}&response_type=code&state=#{oauth2_state}&redirect_uri=#{redirect_uri}"
221
+ end
222
+
223
+ def get_access_token(code)
224
+ params = {
225
+ body: {
226
+ client_id: key,
227
+ client_secret: secret,
228
+ code: code
229
+ }
230
+ }
231
+
232
+ response = self.class.post '/login/oauth2/token', params
233
+ self.token = response['access_token']
234
+ end
235
+
236
+ def hex_sis_id(name, value)
237
+ hex = value.unpack("H*")[0]
238
+ return "hex:#{name}:#{hex}"
239
+ end
240
+
241
+ def canvas_url=(value)
242
+ @canvas_url = value
243
+ self.class.base_uri(value)
244
+ end
245
+ end
246
+
247
+ class CanvasApi::Unauthorized < StandardError ; end
248
+ class CanvasApi::Authenticate < StandardError ; end
249
+ end