add PKCE support to OAuth2 endpoints

closes CNVS-63544
flag=pkce

Test Plan:

* Regression Testing *
Validate each of the following authorization grant
types continue to work as expected:
- Authorization Code
- Client Credentials (service user)
- Refresh Token

Validate the above grant types require a client secret during
the token exchange.

Validate the access tokens returned by each do not have
a permanent expiration set.

* PKCE Testing *
Setup
- Create a DeveloperKey in your root account and activate it
- Add scopes the the developer DeveloperKey
- Via Rails console, change the `client_type` of the key to `Public`

Authorization Request
- Make an authorization request as defined in rfc7636
  (include a code challenge)
- Validate Canvas prompts the user for consent
- Validate the authorization code returned to the redirect URI

Token Request
- Make a token request as defined in rfc7636 (include a code verifier)
- Validate Canvas returns an access token
- Validate the access token has a permanent expiration set to 2
  hours from the time of issuance
- Validate the token cannot be used to exceed the scopes it was granted

Refresh Token Request
- Make a refresh token request _without_ a client_secret
- Validate Canvas returns a new access token
- Validate Canvas returns a new _refresh_ token
- Validate Canvas advances the permanent expiration of the access
  token by two hours from the time of the refresh token request

* Error Testing *
- Validate requests to the token endpoint using an authorization
  code grant _must_ include a client secret if the DeveloperKey
  is not "public"
- Validate the PKCE-style token request fails when the
  code_verifier does not produce the code_challenge sent in the
  authorization request
- Validate tokens cannot be refreshed after their
  `permanent_expires_at` has passed

Change-Id: Ifedc3795cc55f0d32ce6e8c5bd67a4d5b23f608e
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/358608
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Attila Sedon <attila.sedon@instructure.com>
Reviewed-by: Jackson Howe <jackson.howe@instructure.com>
QA-Review: Jackson Howe <jackson.howe@instructure.com>
Product-Review: Weston Dransfield <wdransfield@instructure.com>
This commit is contained in:
Weston Dransfield 2024-09-26 14:36:08 -06:00
parent 32a40b0031
commit 07125fa845
19 changed files with 849 additions and 13 deletions

View File

@ -8,4 +8,7 @@
"editor.insertSpaces": true,
"editor.semanticHighlighting.enabled": true
},
"cSpell.words": [
"urlsafe"
],
}

View File

@ -36,7 +36,16 @@ class OAuth2ProviderController < ApplicationController
scopes = (params[:scope] || params[:scopes] || "").split
provider = Canvas::OAuth::Provider.new(params[:client_id], params[:redirect_uri], scopes, params[:purpose])
provider = Canvas::OAuth::Provider.new(
params[:client_id],
params[:redirect_uri],
scopes,
params[:purpose],
pkce: {
code_challenge: params[:code_challenge],
code_challenge_method: params[:code_challenge_method]
}
)
raise Canvas::OAuth::RequestError, :invalid_client_id unless provider.has_valid_key?
raise Canvas::OAuth::RequestError, :invalid_redirect unless provider.has_valid_redirect?
@ -139,7 +148,11 @@ class OAuth2ProviderController < ApplicationController
granter = case grant_type
when "authorization_code"
Canvas::OAuth::GrantTypes::AuthorizationCode.new(client_id, secret, params)
if Canvas::OAuth::PKCE.use_pkce_in_token?(params)
Canvas::OAuth::GrantTypes::AuthorizationCodeWithPKCE.new(client_id, secret, params)
else
Canvas::OAuth::GrantTypes::AuthorizationCode.new(client_id, secret, params)
end
when "refresh_token"
Canvas::OAuth::GrantTypes::RefreshToken.new(client_id, secret, params)
when "client_credentials"

View File

@ -168,6 +168,11 @@ class AccessToken < ActiveRecord::Base
end
end
def set_permanent_expiration
expires_in = developer_key.tokens_expire_in
self.permanent_expires_at = Time.now.utc + expires_in if expires_in
end
def usable?(token_key = :crypted_token)
return false if expired? || pending?
@ -265,8 +270,10 @@ class AccessToken < ActiveRecord::Base
@plaintext_refresh_token = new_token
end
def generate_refresh_token
self.refresh_token = CanvasSlug.generate(nil, TOKEN_SIZE) unless crypted_refresh_token
def generate_refresh_token(overwrite: false)
if !crypted_refresh_token || overwrite
self.refresh_token = CanvasSlug.generate(nil, TOKEN_SIZE)
end
end
def clear_plaintext_refresh_token!

View File

@ -396,3 +396,8 @@ site_admin_service_auth:
description: When enabled, site admin account UI will allow associating
a service User with a DeveloperKey and using that key/user with the
client_credentials grant type
pkce:
state: hidden
applies_to: SiteAdmin
display_name: PKCE in OAuth2 Authorization Code Flow
description: Enable PKCE in OAuth2 Authorization Code Flow

View File

@ -29,5 +29,6 @@ ActiveSupport::Inflector.inflections do |inflect|
inflect.acronym "OAuth"
inflect.acronym "OAuth2"
inflect.acronym "LLM"
inflect.acronym "PKCE"
inflect.irregular "feedback", "feedback"
end

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
module Canvas::OAuth
module GrantTypes
class AuthorizationCodeWithPKCE < AuthorizationCode
# PKCE can be used by public or confidential clients as defined in RFC 6749.
def allow_public_client?
true
end
private
def validate_type
unless Canvas::OAuth::PKCE.valid_code_verifier?(code: opts[:code], code_verifier: opts[:code_verifier])
raise Canvas::OAuth::RequestError, :invalid_grant
end
super
end
end
end
end

View File

@ -3,6 +3,8 @@
module Canvas::OAuth
module GrantTypes
class BaseType
attr_reader :opts, :provider
def initialize(client_id, secret, opts)
@secret = secret
@provider = Canvas::OAuth::Provider.new(client_id)
@ -15,6 +17,12 @@ module Canvas::OAuth
generate_token
end
# Unless otherwise specified by a sub-class, don't
# allow public clients as defined in RFC 6749.
def allow_public_client?
false
end
def supported_type?
false
end
@ -23,6 +31,11 @@ module Canvas::OAuth
def validate_client_id_and_secret
raise Canvas::OAuth::RequestError, :invalid_client_id unless @provider.has_valid_key?
# Issue an access token if the grant type supports public client and the
# DeveloperKey identifies a public client. Otherwise, the client must must
# provide a client secret.
return if allow_public_client? && @provider.key&.public_client? && @secret.blank?
raise Canvas::OAuth::RequestError, :invalid_client_secret unless @provider.is_authorized_by?(@secret)
end

View File

@ -7,6 +7,12 @@ module Canvas::OAuth
true
end
# Access tokens obtained by public clients through PKCE should
# be refreshed using this grant type
def allow_public_client?
true
end
private
def validate_type
@ -19,6 +25,19 @@ module Canvas::OAuth
def generate_token
@_token.access_token.regenerate_access_token
if provider.key.public_client? && Account.site_admin.feature_enabled?(:pkce)
# Access tokens for public clients have a (default) two-hour rolling window
# in which tokens are eligible for refresh. When a refresh action is take for
# a public client, extend that window by another two hours.
@_token.access_token.set_permanent_expiration
# For better token security, force public clients to rotate refresh tokens
# after each use. This helps mitigate the risk of a leaked refresh token.
@_token.access_token.generate_refresh_token(overwrite: true)
@_token.access_token.save
end
@_token
end
end

96
lib/canvas/oauth/pkce.rb Normal file
View File

@ -0,0 +1,96 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
module Canvas::OAuth
class PKCE
KEY_PREFIX = "oauth2/pkce:"
KEY_TTL = 10.minutes.to_i
REQUIRED_PARAMS = %i[code_challenge code_challenge_method].freeze
SUPPORTED_METHODS = %w[S256].freeze
class << self
# Determines whether PKCE (Proof Key for Code Exchange) should be used in the authorization request.
#
# @param options [Hash] The options hash that may contain the PKCE params.
#
# @return [Boolean] Returns true if PKCE params present and supported, false otherwise.
def use_pkce_in_authorization?(options)
return false if options.blank?
return false unless Account.site_admin.feature_enabled? :pkce
params_present = options.keys & REQUIRED_PARAMS == REQUIRED_PARAMS
params_present && valid_code_verifier_method?(options[:code_challenge_method])
end
# Determines whether PKCE (Proof Key for Code Exchange) should be used in the token request.
#
# @param options [Hash] The options hash that may contain the :code_verifier key.
#
# @return [Boolean] Returns true if PKCE should be used, false otherwise.
def use_pkce_in_token?(options)
return false if options.blank?
return false unless Account.site_admin.feature_enabled? :pkce
options.include? :code_verifier
end
# Stores a code challenge in Redis with a specified time-to-live (TTL).
# The key includes the authorization code so that the authorization code
# may be validated against the code challenge during the token exchange.
#
# @param challenge [String] The code challenge to be stored.
# @param code [String] The code associated with the challenge.
#
# @return [String] "OK" if the operation was successful.
def store_code_challenge(challenge, code)
Canvas.redis.setex("#{KEY_PREFIX}#{code}", KEY_TTL, challenge)
end
# Checks if the provided code verifier is valid by comparing it with the stored code challenge.
#
# See https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
#
# @param code [String] the code associated with the code challenge
# @param code_verifier [String] the code verifier to be validated
#
# @return [Boolean] true if the code verifier is valid, false otherwise
def valid_code_verifier?(code:, code_verifier:)
code_challenge = fetch_code_challenge_for(code)
return false if code_challenge.blank?
sha256_hash = Digest::SHA256.digest(code_verifier)
Base64.urlsafe_encode64(sha256_hash, padding: false) == code_challenge
end
private
def valid_code_verifier_method?(method)
SUPPORTED_METHODS.include? method.to_s
end
def fetch_code_challenge_for(code)
challenge = Canvas.redis.get("#{KEY_PREFIX}#{code}")
Canvas.redis.del("#{KEY_PREFIX}#{code}")
challenge
end
end
end
end

View File

@ -23,11 +23,12 @@ module Canvas::OAuth
attr_reader :client_id, :scopes, :purpose
def initialize(client_id, redirect_uri = "", scopes = [], purpose = nil, key: nil)
def initialize(client_id, redirect_uri = "", scopes = [], purpose = nil, key: nil, pkce: {})
@client_id = client_id
@redirect_uri = redirect_uri
@scopes = scopes
@purpose = purpose
@pkce = pkce
# Some grant types have already loaded the developer key. If that's the case allow
# passing the key into this provider rather than re-querying for it.
@ -99,8 +100,16 @@ module Canvas::OAuth
@redirect_uri.presence || ""
end
def code_challenge
@pkce&.dig(:code_challenge)
end
def code_challenge_method
@pkce&.dig(:code_challenge_method)
end
def session_hash
{ client_id: key.id, redirect_uri:, scopes:, purpose: }
{ client_id: key.id, redirect_uri:, scopes:, purpose:, code_challenge:, code_challenge_method: }
end
def valid_scopes?
@ -125,7 +134,14 @@ module Canvas::OAuth
end
def self.final_redirect_params(oauth_session, current_user, real_user = nil, options = {})
options = { scopes: oauth_session&.dig(:scopes), remember_access: options&.dig(:remember_access), purpose: oauth_session&.dig(:purpose) }
options = {
scopes: oauth_session&.dig(:scopes),
remember_access: options&.dig(:remember_access),
purpose: oauth_session&.dig(:purpose),
code_challenge: oauth_session&.dig(:code_challenge),
code_challenge_method: oauth_session&.dig(:code_challenge_method)
}
code = Canvas::OAuth::Token.generate_code_for(current_user.global_id, real_user&.global_id, oauth_session[:client_id], options)
redirect_params = { code: }
redirect_params[:state] = oauth_session[:state] if oauth_session[:state]

View File

@ -65,6 +65,11 @@ module Canvas::OAuth
unsupported_grant_type: {
error: :unsupported_grant_type,
error_description: "The grant_type you requested is not currently supported"
}.freeze,
invalid_grant: {
error: :invalid_grant,
error_description: "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client."
}.freeze
}.freeze

View File

@ -94,8 +94,7 @@ module Canvas::OAuth
})
@access_token.real_user = real_user if real_user && real_user != user
expires_in = key.tokens_expire_in
@access_token.permanent_expires_at = Time.now.utc + expires_in if expires_in
@access_token.set_permanent_expiration
@access_token.save!
@ -173,6 +172,11 @@ module Canvas::OAuth
REMEMBER_ACCESS => options[:remember_access]
}
Canvas.redis.setex("#{REDIS_PREFIX}#{code}", 10.minutes.to_i, code_data.to_json)
if Canvas::OAuth::PKCE.use_pkce_in_authorization?(options)
Canvas::OAuth::PKCE.store_code_challenge(options[:code_challenge], code)
end
code
end

View File

@ -552,6 +552,111 @@ describe OAuth2ProviderController do
end
end
context "authorization code with verifier" do
subject(:token_request) { post :token, params: }
let(:grant_type) { "authorization_code" }
let(:valid_code) { "thecode" }
let(:code_verifier) { SecureRandom.uuid }
let(:code_challenge) { Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) }
let(:valid_code_redis_key) { "#{Canvas::OAuth::Token::REDIS_PREFIX}#{valid_code}" }
let(:code_challenge_key) { "#{Canvas::OAuth::PKCE::KEY_PREFIX}#{valid_code}" }
let(:redis) do
redis = double("Redis")
allow(redis).to receive(:get)
allow(redis).to receive(:get).with(valid_code_redis_key).and_return(%({"client_id": #{key.id}, "user": #{user.id}}))
allow(redis).to receive(:del).with(valid_code_redis_key).and_return(%({"client_id": #{key.id}, "user": #{user.id}}))
allow(redis).to receive(:get).with(code_challenge_key).and_return(code_challenge)
allow(redis).to receive(:del).with(code_challenge_key)
redis
end
let(:params) do
{
client_id:,
code: valid_code,
code_verifier:,
redirect_uri: key.redirect_uri,
grant_type: "authorization_code"
}
end
before do
allow(Account.site_admin).to receive(:feature_enabled?).and_call_original
allow(Account.site_admin).to receive(:feature_enabled?).with(:pkce).and_return(true)
allow(Canvas).to receive_messages(redis:)
key.update!(redirect_uri: "https://example.com", client_type: DeveloperKey::PUBLIC_CLIENT_TYPE)
end
context "when the request is valid" do
it { is_expected.to be_successful }
it "returns a token" do
token_request
expect(json_parse.keys).to match_array %w[
access_token
refresh_token
user
expires_in
token_type
canvas_region
]
end
it "deletes the code challenge from Redis" do
expect(redis).to receive(:del).with(code_challenge_key)
token_request
end
it "sets an permanent expiration on the token" do
token_request
token = AccessToken.authenticate(json_parse["access_token"])
expect(token.permanent_expires_at).to be_within(2.minutes).of(2.hours.from_now)
end
end
context "when the included code verifier does not verify the code" do
let(:params) { super().merge(code_verifier: "invalid") }
it { is_expected.to be_bad_request }
it "includes the proper error in the response" do
token_request
expect(json_parse["error"]).to eq "invalid_grant"
end
end
context "when the client is confidential" do
before { key.update!(client_type: DeveloperKey::CONFIDENTIAL_CLIENT_TYPE) }
it { is_expected.to be_unauthorized }
it "includes the proper error in the response" do
token_request
expect(json_parse["error"]).to eq "invalid_client"
end
end
context "when no code challenge is found in Redis" do
before { allow(redis).to receive(:get).with(code_challenge_key).and_return(nil) }
it { is_expected.to be_bad_request }
it "includes the proper error in the response" do
token_request
expect(json_parse["error"]).to eq "invalid_grant"
end
end
end
context "grant_type refresh_token" do
let(:grant_type) { "refresh_token" }
let(:refresh_token) { old_token.plaintext_refresh_token }
@ -572,6 +677,11 @@ describe OAuth2ProviderController do
expect(json["access_token"]).to_not eq old_token.full_token
end
it "does not rotate the refresh token" do
post :token, params: base_params.merge(refresh_token:)
expect(json_parse["refresh_token"]).to be_blank
end
it "errors with a mismatched client id and refresh_token" do
post :token, params: base_params.merge(client_id: other_key.id, client_secret: other_key.api_key, refresh_token:)
assert_status(400)
@ -590,6 +700,71 @@ describe OAuth2ProviderController do
json = response.parsed_body
expect(json["access_token"]).to_not eq access_token
end
context "with public clients" do
subject(:refresh_token_request) { post :token, params: }
let(:refresh_token) { old_token.plaintext_refresh_token }
let(:params) do
{
grant_type: "refresh_token",
client_id:,
refresh_token:
}
end
before do
allow(Account.site_admin).to receive(:feature_enabled?).and_call_original
allow(Account.site_admin).to receive(:feature_enabled?).with(:pkce).and_return(true)
key.update!(client_type: DeveloperKey::PUBLIC_CLIENT_TYPE)
end
context "with a valid parameters" do
it { is_expected.to be_successful }
it "returns a token" do
refresh_token_request
expect(json_parse.keys).to match_array %w[
access_token
user
expires_in
token_type
canvas_region
refresh_token
]
end
it "rotates the refresh token" do
refresh_token_request
expect(json_parse["refresh_token"]).to_not eq old_token.plaintext_refresh_token
end
it "extends the permanent expiration on the token" do
old_token.set_permanent_expiration
old_token.save!
old_perm_expires_at = old_token.permanent_expires_at
refresh_token_request
token = AccessToken.authenticate(json_parse["access_token"])
expect(token.permanent_expires_at).to be_within(2.minutes).of(2.hours.from_now)
expect(token.permanent_expires_at).to be > old_perm_expires_at
end
end
context "when the client is confidential" do
before { key.update!(client_type: DeveloperKey::CONFIDENTIAL_CLIENT_TYPE) }
it { is_expected.to be_unauthorized }
it "includes the proper error in the response" do
refresh_token_request
expect(json_parse["error"]).to eq "invalid_client"
end
end
end
end
context "with client_credentials grant type and service key" do
@ -848,20 +1023,37 @@ describe OAuth2ProviderController do
before { user_session user }
it "uses the global id of the user for generating the code" do
expect(Canvas::OAuth::Token).to receive(:generate_code_for).with(user.global_id, user.global_id, key.id, { scopes: nil, remember_access: nil, purpose: nil }).and_return("code")
expect(Canvas::OAuth::Token).to receive(:generate_code_for).with(
user.global_id,
user.global_id,
key.id,
{ code_challenge: nil, code_challenge_method: nil, purpose: nil, remember_access: nil, scopes: nil }
).and_return("code")
oauth_accept
expect(response).to redirect_to(oauth2_auth_url(code: "code"))
end
it "saves the requested scopes with the code" do
scopes = "userinfo"
session_hash[:oauth2][:scopes] = scopes
expect(Canvas::OAuth::Token).to receive(:generate_code_for).with(user.global_id, user.global_id, key.id, { scopes:, remember_access: nil, purpose: nil }).and_return("code")
expect(Canvas::OAuth::Token).to receive(:generate_code_for).with(
user.global_id,
user.global_id,
key.id,
{ scopes:, remember_access: nil, purpose: nil, code_challenge: nil, code_challenge_method: nil }
).and_return("code")
oauth_accept
end
it "remembers the users access preference with the code" do
expect(Canvas::OAuth::Token).to receive(:generate_code_for).with(user.global_id, user.global_id, key.id, { scopes: nil, remember_access: "1", purpose: nil }).and_return("code")
expect(Canvas::OAuth::Token).to receive(:generate_code_for).with(
user.global_id,
user.global_id,
key.id,
{ scopes: nil, remember_access: "1", purpose: nil, code_challenge: nil, code_challenge_method: nil }
).and_return("code")
post :accept, params: { remember_access: "1" }, session: session_hash
end
@ -882,6 +1074,40 @@ describe OAuth2ProviderController do
post :accept, session: {}
expect(response.code.to_i).to eq(400)
end
context "when PKCE options are present" do
let(:code_verifier) { SecureRandom.uuid }
let(:code_challenge) { Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) }
let(:code_challenge_method) { "S256" }
let(:session_hash) do
{
oauth2: {
client_id: key.id,
redirect_uri: Canvas::OAuth::Provider::OAUTH2_OOB_URI,
code_challenge:,
code_challenge_method:
}
}
end
it "saves the requested scopes with the code" do
expect(Canvas::OAuth::Token).to receive(:generate_code_for).with(
user.global_id,
user.global_id,
key.id,
{
scopes: nil,
remember_access: nil,
purpose: nil,
code_challenge:,
code_challenge_method:
}
).and_return("code")
oauth_accept
end
end
end
describe "GET deny" do

View File

@ -0,0 +1,59 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
RSpec.describe Canvas::OAuth::GrantTypes::AuthorizationCodeWithPKCE do # rubocop:disable RSpec/SpecFilePathFormat
let(:key) { DeveloperKey.create! }
let(:client_id) { key.global_id }
let(:secret) { key.api_key }
let(:opts) { { code: "test_code", code_verifier: "test_code_verifier" } }
let(:provider) { instance_double("Canvas::OAuth::Provider") }
let(:token) { instance_double("Canvas::OAuth::Token") }
let(:authorization_code_with_pkce) { described_class.new(client_id, secret, opts) }
before do
allow(Canvas::OAuth::Provider).to receive(:new).with(client_id).and_return(provider)
allow(provider).to receive(:is_authorized_by?).with(secret).and_return(true)
allow(provider).to receive_messages(has_valid_key?: true, token_for: token)
allow(token).to receive_messages(is_for_valid_code?: true, key:, client_id:)
end
describe "#allow_public_client?" do
it "returns true" do
expect(authorization_code_with_pkce.allow_public_client?).to be true
end
end
describe "#validate_type" do
context "when PKCE code verifier is valid" do
it "calls super method" do
allow(Canvas::OAuth::PKCE).to receive(:valid_code_verifier?).with(code: opts[:code], code_verifier: opts[:code_verifier]).and_return(true)
expect { authorization_code_with_pkce.send(:validate_type) }.not_to raise_error
end
end
context "when PKCE code verifier is invalid" do
it "raises Canvas::OAuth::RequestError with :invalid_grant" do
allow(Canvas::OAuth::PKCE).to receive(:valid_code_verifier?).with(code: opts[:code], code_verifier: opts[:code_verifier]).and_return(false)
expect { authorization_code_with_pkce.send(:validate_type) }.to raise_error(Canvas::OAuth::RequestError)
end
end
end
end

View File

@ -0,0 +1,114 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
RSpec.describe Canvas::OAuth::GrantTypes::RefreshToken do # rubocop:disable RSpec/SpecFilePathFormat
let(:key) { DeveloperKey.create! }
let(:client_id) { key.global_id }
let(:secret) { key.api_key }
let(:opts) { { refresh_token: "test_refresh_token" } }
let(:provider) { instance_double("Canvas::OAuth::Provider") }
let(:token) { instance_double("Canvas::OAuth::Token") }
let(:access_token) { key.access_tokens.create! }
let(:refresh_token_instance) { described_class.new(client_id, secret, opts) }
before do
allow(Canvas::OAuth::Provider).to receive(:new).with(client_id).and_return(provider)
allow(provider).to receive(:is_authorized_by?).with(secret).and_return(true)
allow(provider).to receive(:token_for_refresh_token).with(opts[:refresh_token]).and_return(token)
allow(provider).to receive_messages(has_valid_key?: true, key:)
allow(token).to receive_messages(access_token:, key:)
end
describe "#supported_type?" do
it "returns true" do
expect(refresh_token_instance.supported_type?).to be true
end
end
describe "#allow_public_client?" do
it "returns true" do
expect(refresh_token_instance.allow_public_client?).to be true
end
end
describe "#validate_type" do
context "when refresh_token is not supplied" do
let(:opts) { {} }
it "raises Canvas::OAuth::RequestError with :refresh_token_not_supplied" do
expect { refresh_token_instance.send(:validate_type) }.to raise_error(Canvas::OAuth::RequestError)
end
end
context "when refresh_token is invalid" do
before do
allow(provider).to receive(:token_for_refresh_token).with(opts[:refresh_token]).and_return(nil)
end
it "raises Canvas::OAuth::RequestError with :invalid_refresh_token" do
expect { refresh_token_instance.send(:validate_type) }.to raise_error(Canvas::OAuth::RequestError)
end
end
context "when client_id does not match" do
before do
allow(access_token).to receive(:developer_key_id).and_return(nil)
end
it "raises Canvas::OAuth::RequestError with :incorrect_client" do
expect { refresh_token_instance.send(:validate_type) }.to raise_error(Canvas::OAuth::RequestError)
end
end
end
describe "#generate_token" do
context "when the client is public" do
let(:key) { DeveloperKey.create!(client_type: DeveloperKey::PUBLIC_CLIENT_TYPE) }
before do
allow(Account.site_admin).to receive(:feature_enabled?).with(:pkce).and_return(true)
end
it "regenerates access token and sets permanent expiration" do
expect(access_token).to receive(:regenerate_access_token)
expect(access_token).to receive(:set_permanent_expiration)
expect(access_token).to receive(:generate_refresh_token).with(overwrite: true)
expect(access_token).to receive(:save)
refresh_token_instance.token
end
end
context "when provider.key.public_client? is false" do
before do
allow(Account.site_admin).to receive(:feature_enabled?).with(:pkce).and_return(true)
end
it "regenerates access token without setting permanent expiration" do
expect(access_token).to receive(:regenerate_access_token)
expect(access_token).not_to receive(:set_permanent_expiration)
expect(access_token).not_to receive(:generate_refresh_token)
expect(access_token).not_to receive(:save)
refresh_token_instance.token
end
end
end
end

View File

@ -0,0 +1,124 @@
# frozen_string_literal: true
#
# Copyright (C) 2024 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
RSpec.describe Canvas::OAuth::PKCE do # rubocop:disable RSpec/SpecFilePathFormat
before do
allow(Account.site_admin).to receive(:feature_enabled?).with(:pkce).and_return(true)
end
describe ".use_pkce_in_authorization?" do
let(:options) { { code_challenge: "challenge", code_challenge_method: "S256" } }
context "when options are blank" do
it "returns false" do
expect(described_class.use_pkce_in_authorization?(nil)).to be_falsey
end
end
context "when PKCE feature is disabled" do
it "returns false" do
allow(Account.site_admin).to receive(:feature_enabled?).with(:pkce).and_return(false)
expect(described_class.use_pkce_in_authorization?(options)).to be_falsey
end
end
context "when required params are missing" do
it "returns false" do
expect(described_class.use_pkce_in_authorization?(code_challenge: "challenge")).to be_falsey
end
end
context "when method is unsupported" do
it "returns false" do
expect(described_class.use_pkce_in_authorization?(code_challenge: "challenge", code_challenge_method: "unsupported")).to be_falsey
end
end
context "when required params are present and method is supported" do
it "returns true" do
expect(described_class.use_pkce_in_authorization?(options)).to be_truthy
end
end
end
describe ".use_pkce_in_token?" do
let(:options) { { code_verifier: "verifier" } }
context "when options are blank" do
it "returns false" do
expect(described_class.use_pkce_in_token?(nil)).to be_falsey
end
end
context "when PKCE feature is disabled" do
it "returns false" do
allow(Account.site_admin).to receive(:feature_enabled?).with(:pkce).and_return(false)
expect(described_class.use_pkce_in_token?(options)).to be_falsey
end
end
context "when :code_verifier is not included" do
it "returns false" do
expect(described_class.use_pkce_in_token?(code_challenge: "challenge")).to be_falsey
end
end
context "when :code_verifier is included" do
it "returns true" do
expect(described_class.use_pkce_in_token?(options)).to be_truthy
end
end
end
describe ".store_code_challenge" do
it "stores a code challenge in redis" do
expect(Canvas.redis).to receive(:setex).with("oauth2/pkce:code", 600, "challenge").and_return("OK")
expect(described_class.store_code_challenge("challenge", "code")).to eq("OK")
end
end
describe ".valid_code_verifier?" do
let(:code) { "code" }
let(:code_verifier) { SecureRandom.uuid }
let(:code_challenge) { Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) }
context "when code challenge is blank" do
it "returns false" do
allow(Canvas.redis).to receive(:get).with("oauth2/pkce:code").and_return(nil)
expect(described_class.valid_code_verifier?(code:, code_verifier:)).to be_falsey
end
end
context "when code verifier is valid" do
it "returns true" do
allow(Canvas.redis).to receive(:get).with("oauth2/pkce:code").and_return(code_challenge)
allow(Canvas.redis).to receive(:del).with("oauth2/pkce:code")
expect(described_class.valid_code_verifier?(code:, code_verifier:)).to be_truthy
end
end
context "when code verifier is invalid" do
it "returns false" do
allow(Canvas.redis).to receive(:get).with("oauth2/pkce:code").and_return("invalid_challenge")
allow(Canvas.redis).to receive(:del).with("oauth2/pkce:code")
expect(described_class.valid_code_verifier?(code:, code_verifier:)).to be_falsey
end
end
end
end

View File

@ -39,6 +39,30 @@ module Canvas::OAuth
end
end
describe "#code_challenge" do
it "returns the code_challenge when pkce is set" do
provider = Provider.new("123", "", [], nil, pkce: { code_challenge: "challenge" })
expect(provider.code_challenge).to eq "challenge"
end
it "returns nil when pkce is not set" do
provider = Provider.new("123")
expect(provider.code_challenge).to be_nil
end
end
describe "#code_challenge_method" do
it "returns the code_challenge_method when pkce is set" do
provider = Provider.new("123", "", [], nil, pkce: { code_challenge_method: "S256" })
expect(provider.code_challenge_method).to eq "S256"
end
it "returns nil when pkce is not set" do
provider = Provider.new("123")
expect(provider.code_challenge_method).to be_nil
end
end
describe "#has_valid_key?" do
it "is true when there is a key and the key is active" do
stub_dev_key(double(active?: true))

View File

@ -267,6 +267,7 @@ module Canvas::OAuth
describe ".generate_code_for" do
let(:code) { "brand_new_code" }
let(:redis) { double(setex: true) }
before { allow(SecureRandom).to receive_messages(hex: code) }
@ -276,13 +277,26 @@ module Canvas::OAuth
end
it "sets the new data hash into redis with 10 min ttl" do
redis = Object.new
code_data = { user: 1, real_user: 2, client_id: 3, scopes: nil, purpose: nil, remember_access: nil }
# should have 10 min (in seconds) ttl passed as second param
expect(redis).to receive(:setex).with("oauth2:brand_new_code", 600, code_data.to_json)
allow(Canvas).to receive_messages(redis:)
Token.generate_code_for(1, 2, 3)
end
context "when PKCE is used in the authorization request" do
before do
allow(Canvas::OAuth::PKCE).to receive(:use_pkce_in_authorization?).and_return(true)
allow(Canvas::OAuth::PKCE).to receive(:store_code_challenge)
end
it "stores the code challenge" do
code_challenge = "code_challenge"
expect(Canvas::OAuth::PKCE).to receive(:store_code_challenge).with(code_challenge, code)
Token.generate_code_for(1, 2, 3, { code_challenge: })
end
end
end
context "token expiration" do

View File

@ -153,6 +153,60 @@ describe AccessToken do
end
end
describe "#generate_refresh_token" do
let(:developer_key) { DeveloperKey.create! }
let(:access_token) { AccessToken.create!(user: user_model, developer_key:) }
context "when no refresh token exists" do
before { access_token.update!(crypted_refresh_token: nil) }
it "generates a new refresh token" do
expect(access_token.crypted_refresh_token).to be_nil
access_token.generate_refresh_token
expect(access_token.crypted_refresh_token).to be_present
end
end
context "when a refresh token exists" do
it "does not overwrite the existing refresh token by default" do
access_token.generate_refresh_token
initial_refresh_token = access_token.crypted_refresh_token
access_token.generate_refresh_token
expect(access_token.crypted_refresh_token).to eq(initial_refresh_token)
end
it "overwrites the existing refresh token if overwrite is true" do
access_token.generate_refresh_token
initial_refresh_token = access_token.crypted_refresh_token
access_token.generate_refresh_token(overwrite: true)
expect(access_token.crypted_refresh_token).not_to eq(initial_refresh_token)
end
end
end
describe "#set_permanent_expiration" do
let(:developer_key) { DeveloperKey.create!(client_type: DeveloperKey::PUBLIC_CLIENT_TYPE) }
let(:access_token) { AccessToken.create!(user: user_model, developer_key:) }
context "when the developer key has a token expiration" do
it "sets the permanent_expires_at to the correct time" do
access_token.set_permanent_expiration
expect(access_token.permanent_expires_at).to be_within(1.second).of(2.hours.from_now)
end
end
context "when the developer key does not have a token expiration" do
let(:developer_key) { DeveloperKey.create! }
it "does not set the permanent_expires_at" do
access_token.set_permanent_expiration
expect(access_token.permanent_expires_at).to be_nil
end
end
end
describe "usable?" do
before :once do
@at = AccessToken.create!(user: user_model, developer_key: DeveloperKey.default)