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:
parent
32a40b0031
commit
07125fa845
|
@ -8,4 +8,7 @@
|
|||
"editor.insertSpaces": true,
|
||||
"editor.semanticHighlighting.enabled": true
|
||||
},
|
||||
"cSpell.words": [
|
||||
"urlsafe"
|
||||
],
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue