add endpoint to refresh jwt with with workflows

now that services jwts have a concept of workflows and extra data
encoded in the token based on workflow requirements, the existing jwts
endpoint is insufficient for refreshing expired jwts. to some extent
it was already broken since it lost the context when refreshed with
that endpoint

a new refresh endpoint has been addded that takes an exisintg jwt as
a param. this makes it possible to get a new token with the same
workflows, state, and context as an existing expired token as long as
the token matches your user and is requested with a valid session or
oauth token.

tokens may only be used for refresh up to six hours past expiration.

refs CNVS-35199

test plan:
- go to "Pages" in a couse with RCS enabled
- open the console, and get the jwt from ENV.JWT
- wait at least an hour
- make a POST to [same-domain]/api/v1/jwts/refresh with the token as
  the `jwt` param
- it should return a json response with a token property
- copy the token
- open up your rails console
- run Canvas::Security.ServicesJwt.new("[copied token]").original_token
- should return hash with the following proerties
  - should have :sub with your users global id
  - should have :domain that matches your canvas domain
  - should have :context_type of Course
  - should have :context_id of the course you generated the original
    token from
  - should have :workflows with rich_content and ui
- repeat process masquerading as another user
  - when making a the post to the refresh endpoint use your user and
    set a param `as_user_id` to the id of the user you are
    masquerading as
- the hash in the console should have
  - :sub with the global id of the user you are masquerading as
  - :masq_sub with your user id

Change-Id: I399569ed8f2d3d0646728f72910456b77b3ed46a
Reviewed-on: https://gerrit.instructure.com/102909
Reviewed-by: Tucker McKnight <tmcknight@instructure.com>
QA-Review: Tucker McKnight <tmcknight@instructure.com>
Tested-by: Jenkins
Product-Review: Brent Burgoyne <bburgoyne@instructure.com>
This commit is contained in:
Brent Burgoyne 2017-02-22 09:16:49 -07:00
parent e3ae0e7d26
commit 7511243ef7
6 changed files with 284 additions and 27 deletions

View File

@ -18,7 +18,7 @@
class JwtsController < ApplicationController
before_action :require_user
before_action :require_user, :require_non_jwt_auth
# @API Create JWT
#
@ -32,17 +32,63 @@ class JwtsController < ApplicationController
# -X POST \
# -H "Accept: application/json" \
# -H 'Authorization: Bearer <token>'
#
# @returns JWT
def create
if @authenticated_with_jwt
render(
json: {error: "cannot generate a JWT when authorized by a JWT"},
status: 403
)
return false
end
services_jwt = Canvas::Security::ServicesJwt.
for_user(request.env['HTTP_HOST'], @current_user, real_user: @real_current_user)
render json: { token: services_jwt }
end
# @API Refresh JWT
#
# Refresh a JWT for use with other canvas services
#
# Generates a different JWT each time it's called, each one expires
# after a short window (1 hour).
#
# @argument jwt [Required, String]
# An existing JWT token to be refreshed. The new token will have
# the same context and workflows as the existing token.
#
# @example_request
# curl 'https://<canvas>/api/v1/jwts/refresh' \
# -X POST \
# -H "Accept: application/json" \
# -H 'Authorization: Bearer <token>'
# -d 'jwt=<jwt>'
#
# @returns JWT
def refresh
if params[:jwt].nil?
return render(
json: {errors: {jwt: "required"}},
status: 400
)
end
services_jwt = Canvas::Security::ServicesJwt.refresh_for_user(
params[:jwt],
request.env['HTTP_HOST'],
@current_user,
real_user: @real_current_user
)
render json: { token: services_jwt }
rescue Canvas::Security::ServicesJwt::InvalidRefresh
render(
json: {errors: {jwt: "invalid refresh"}},
status: 400
)
end
private
def require_non_jwt_auth
if @authenticated_with_jwt
render(
json: {error: "cannot generate a JWT when authorized by a JWT"},
status: 403
)
end
end
end

View File

@ -1881,6 +1881,7 @@ CanvasRails::Application.routes.draw do
scope(controller: :jwts) do
post 'jwts', action: :create
post 'jwts/refresh', action: :refresh
end
scope(controller: :gradebook_settings) do

View File

@ -167,13 +167,13 @@ module Canvas::Security
raise Canvas::Security::InvalidToken
end
def self.decrypt_services_jwt(token, signing_secret=nil, encryption_secret=nil)
def self.decrypt_services_jwt(token, signing_secret=nil, encryption_secret=nil, ignore_expiration: false)
signing_secret ||= services_signing_secret
encryption_secret ||= services_encryption_secret
begin
signed_coded_jwt = JSON::JWT.decode(token, encryption_secret)
raw_jwt = JSON::JWT.decode(signed_coded_jwt.plain_text, signing_secret)
verify_jwt(raw_jwt)
verify_jwt(raw_jwt, ignore_expiration: ignore_expiration)
raw_jwt.with_indifferent_access
rescue JSON::JWS::VerificationFailed
raise Canvas::Security::InvalidToken
@ -317,8 +317,8 @@ module Canvas::Security
class << self
private
def verify_jwt(body)
if body[:exp].present?
def verify_jwt(body, ignore_expiration: false)
if body[:exp].present? && !ignore_expiration
if timestamp_is_expired?(body[:exp])
raise Canvas::Security::TokenExpired
end

View File

@ -1,4 +1,8 @@
class Canvas::Security::ServicesJwt
class InvalidRefresh < RuntimeError; end
REFRESH_WINDOW = 6.hours
attr_reader :token_string, :is_wrapped
def initialize(raw_token_string, wrapped=true)
@ -15,13 +19,18 @@ class Canvas::Security::ServicesJwt
Canvas::Security.decode_jwt(raw_wrapper_token, [signing_secret])
end
def original_token
def original_token(ignore_expiration: false)
original_crypted_token = if is_wrapped
wrapper_token[:user_token]
else
Canvas::Security.base64_decode(token_string)
end
Canvas::Security.decrypt_services_jwt(original_crypted_token, signing_secret, encryption_secret)
Canvas::Security.decrypt_services_jwt(
original_crypted_token,
signing_secret,
encryption_secret,
ignore_expiration: ignore_expiration
)
end
def id
@ -69,6 +78,31 @@ class Canvas::Security::ServicesJwt
generate(payload)
end
def self.refresh_for_user(jwt, domain, user, real_user: nil)
begin
payload = new(jwt, false).original_token(ignore_expiration: true)
rescue JSON::JWT::InvalidFormat
raise InvalidRefresh, "invalid token"
end
if refresh_invalid_for_user?(payload, domain, user, real_user)
raise InvalidRefresh, "token does not match user and domain"
end
if past_refresh_window?(payload[:exp])
raise InvalidRefresh, "refresh window exceeded"
end
if payload[:context_type].present?
context = payload[:context_type].constantize.find(payload[:context_id])
end
for_user(domain, user,
real_user: real_user,
workflows: payload[:workflows],
context: context)
end
private
def self.create_payload(payload_data)
@ -101,4 +135,30 @@ class Canvas::Security::ServicesJwt
def self.signing_secret
Canvas::DynamicSettings.from_cache("canvas")["signing-secret"]
end
class << self
private
def refresh_invalid_for_user?(payload, domain, user, real_user)
invalid_user = payload[:sub] != user.global_id
invalid_domain = payload[:domain] != domain
if payload[:masq_sub].present?
invalid_real = real_user.nil? || payload[:masq_sub] != real_user.global_id
else
invalid_real = real_user.present?
end
invalid_user || invalid_domain || invalid_real
end
def past_refresh_window?(exp)
if exp.is_a?(Time)
refresh_exp = exp + REFRESH_WINDOW
now = Time.zone.now
else
refresh_exp = exp + REFRESH_WINDOW.to_i
now = Time.zone.now.to_i
end
refresh_exp <= now
end
end
end

View File

@ -1,12 +1,19 @@
require_relative '../spec_helper'
describe JwtsController do
include_context "JWT setup"
let(:token_user){ user_factory(active_user: true) }
let(:other_user){ user_factory(active_user: true) }
let(:translate_token) do
->(resp){
un_csrfd_body = resp.body.gsub("while(1);", "")
utf8_token_string = JSON.parse(un_csrfd_body)['token']
decoded_crypted_token = Canvas::Security.base64_decode(utf8_token_string)
return Canvas::Security.decrypt_services_jwt(decoded_crypted_token)
}
end
describe "#generate" do
include_context "JWT setup"
let(:token_user){ user_factory(active_user: true) }
let(:other_user){ user_factory(active_user: true) }
it "requires being logged in" do
post 'create'
expect(response).to be_redirect
@ -14,7 +21,6 @@ describe JwtsController do
end
context "with valid user session" do
before(:each){ user_session(token_user) }
let(:translate_token) do
->(resp){
@ -36,7 +42,6 @@ describe JwtsController do
decrypted_token_body = translate_token.call(response)
expect(decrypted_token_body[:domain]).to eq("test.host")
end
end
it "doesn't allow using a token to gen a token" do
@ -44,6 +49,64 @@ describe JwtsController do
get 'create', {format: 'json'}, {'Authorization' => "Bearer #{token}"}
expect(response.status).to_not eq(200)
end
end
describe "#refresh" do
it "requires being logged in" do
post 'refresh'
expect(response).to be_redirect
expect(response.status).to eq(302)
end
it "doesn't allow using a token to gen a token" do
token = Canvas::Security::ServicesJwt.generate({ sub: token_user.global_id })
get 'refresh', {format: 'json'}, {'Authorization' => "Bearer #{token}"}
expect(response.status).to_not eq(200)
end
context "with valid user session" do
before(:each) do
user_session(token_user)
request.env['HTTP_HOST'] = 'testhost'
end
it "requires a jwt param" do
post 'refresh'
expect(response.status).to_not eq(200)
end
it "returns a refreshed token for user" do
real_user = site_admin_user(active_user: true)
user_with_pseudonym(:user => other_user, :username => "other@example.com")
user_session(real_user)
services_jwt = class_double(Canvas::Security::ServicesJwt).as_stubbed_const
expect(services_jwt).to receive(:refresh_for_user)
.with('testjwt', 'testhost', other_user, real_user: real_user)
.and_return('refreshedjwt')
post 'refresh', format: 'json', jwt: 'testjwt', as_user_id: other_user.id
token = JSON.parse(response.body)['token']
expect(token).to eq('refreshedjwt')
end
it "returns a different jwt when refresh is called" do
course = course_factory
original_jwt = Canvas::Security::ServicesJwt.for_user(
request.env['HTTP_HOST'],
token_user
)
post 'refresh', jwt: original_jwt
refreshed_jwt = JSON.parse(response.body)['token']
expect(refreshed_jwt).to_not eq(original_jwt)
end
it "returns an error if jwt is invalid for refresh" do
services_jwt = class_double(Canvas::Security::ServicesJwt)
.as_stubbed_const(transfer_nested_constants: true)
expect(services_jwt).to receive(:refresh_for_user)
.and_raise(Canvas::Security::ServicesJwt::InvalidRefresh)
post 'refresh', format: 'json', jwt: 'testjwt'
expect(response.status).to eq(400)
end
end
end
end

View File

@ -18,6 +18,13 @@ module Canvas::Security
Canvas::Security.base64_encode(wrapper_token)
end
let(:translate_token) do
->(jwt){
decoded_crypted_token = Canvas::Security.base64_decode(jwt)
return Canvas::Security.decrypt_services_jwt(decoded_crypted_token)
}
end
it "has secrets accessors" do
expect(ServicesJwt.encryption_secret).to eq(fake_encryption_secret)
expect(ServicesJwt.signing_secret).to eq(fake_signing_secret)
@ -114,12 +121,6 @@ module Canvas::Security
let(:ctx){ stub(id: 47) }
let(:host){ "example.instructure.com" }
let(:masq_user){ stub(global_id: 24) }
let(:translate_token) do
->(jwt){
decoded_crypted_token = Canvas::Security.base64_decode(jwt)
return Canvas::Security.decrypt_services_jwt(decoded_crypted_token)
}
end
it "can build from a user and host" do
jwt = ServicesJwt.for_user(host, user)
@ -197,6 +198,92 @@ module Canvas::Security
to raise_error(ArgumentError)
end
end
describe "refresh_for_user" do
let(:user1){ stub(global_id: 42) }
let(:user2){ stub(global_id: 43) }
let(:host) { 'testhost' }
it 'is invalid if jwt cannot be decoded' do
expect{ ServicesJwt.refresh_for_user('invalidjwt', host, user1) }
.to raise_error(ServicesJwt::InvalidRefresh)
end
it 'is invlaid if user id is different' do
jwt = ServicesJwt.for_user(host, user1)
expect{ ServicesJwt.refresh_for_user(jwt, host, user2) }
.to raise_error(ServicesJwt::InvalidRefresh)
end
it 'is invlaid if host is different' do
jwt = ServicesJwt.for_user('differenthost', user1)
expect{ ServicesJwt.refresh_for_user(jwt, host, user1) }
.to raise_error(ServicesJwt::InvalidRefresh)
end
it 'is invlaid masquerading user is different' do
masq_user = stub(global_id: 44)
jwt = ServicesJwt.for_user(host, user1, real_user: masq_user)
expect{ ServicesJwt.refresh_for_user(jwt, host, user1, real_user: user2) }
.to raise_error(ServicesJwt::InvalidRefresh)
end
it 'is invalid if masquerading and token does not have masq_sub' do
jwt = ServicesJwt.for_user(host, user1)
expect{ ServicesJwt.refresh_for_user(jwt, host, user1, real_user: user2) }
.to raise_error(ServicesJwt::InvalidRefresh)
end
it 'is invalid if more than 6 hours past token expiration' do
jwt = ServicesJwt.for_user(host, user1)
Timecop.freeze(7.hours.from_now) do
expect{ ServicesJwt.refresh_for_user(jwt, host, user1) }
.to raise_error(ServicesJwt::InvalidRefresh)
end
end
it 'generates a token with the same user id and host' do
jwt = ServicesJwt.for_user(host, user1)
refreshed = ServicesJwt.refresh_for_user(jwt, host, user1)
payload = translate_token.call(refreshed)
expect(payload[:sub]).to eq(user1.global_id)
expect(payload[:domain]).to eq(host)
expect(payload[:masq_sub]).to be_nil
end
it 'generates a token with masq_sub for masquerading users' do
jwt = ServicesJwt.for_user(host, user1, real_user: user2)
refreshed = ServicesJwt.refresh_for_user(jwt, host, user1, real_user: user2)
payload = translate_token.call(refreshed)
expect(payload[:masq_sub]).to eq(user2.global_id)
end
it 'generates a token with same workflows as original' do
workflows = ['rich_content', 'ui']
jwt = ServicesJwt.for_user(host, user1, workflows: workflows)
refreshed = ServicesJwt.refresh_for_user(jwt, host, user1)
payload = translate_token.call(refreshed)
expect(payload[:workflows]).to eq(workflows)
end
it 'generates a token with same context as original' do
context = course_factory
jwt = ServicesJwt.for_user(host, user1, context: context)
refreshed = ServicesJwt.refresh_for_user(jwt, host, user1)
payload = translate_token.call(refreshed)
expect(payload[:context_type]).to eq(context.class.name)
expect(payload[:context_id]).to eq(context.id.to_s)
end
it 'generates a new token even if the original token has expired' do
jwt = ServicesJwt.for_user(host, user1)
Timecop.freeze(61.minutes.from_now) do
refreshed = ServicesJwt.refresh_for_user(jwt, host, user1)
payload = translate_token.call(refreshed)
expect(payload[:sub]).to eq(user1.global_id)
end
end
end
end
end
end