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:
parent
e3ae0e7d26
commit
7511243ef7
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue