Compare commits

...

16 Commits

Author SHA1 Message Date
Emelia Smith
e54e3ba8ca
Merge a2a34fbadd into 3b52dca405 2025-07-11 17:04:07 +00:00
Claire
3b52dca405
Fix quote attributes missing from Mastodon's context (#35354)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
Chromatic / Run Chromatic (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
2025-07-11 16:35:06 +00:00
Echo
853a0c466e
Make bio hashtags open the local page instead of the remote instance (#35349) 2025-07-11 15:18:34 +00:00
Emelia Smith
a2a34fbadd
Migrate Web::PushSubscription and SessionActivation records to the new access token after refresh 2025-04-23 20:58:17 +02:00
Emelia Smith
677e0b8a37
Add 'none' to token_endpoint_auth_methods_supported to indicate public client support 2025-04-23 20:58:17 +02:00
Emelia Smith
81b79fefb7
WIP: refresh tokens 2025-04-23 20:58:17 +02:00
Emelia Smith
9e0eb99747
Change /oauth/token request specs to use client_secret_basic authentication 2025-04-23 20:58:17 +02:00
Emelia Smith
fcd238cb4b
Fix issues with null appearing for user owned access tokens 2025-04-23 20:58:16 +02:00
Emelia Smith
463d5dd4d5
Try to fix the usage of doorkeeper configuration 2025-04-23 20:57:58 +02:00
Emelia Smith
1af6ae19b9
Add offline_access scope 2025-04-23 20:56:51 +02:00
Emelia Smith
7898619d74
Prevent only using offline_access scope 2025-04-23 20:56:51 +02:00
Emelia Smith
2250aead46
WIP 2025-04-23 20:56:51 +02:00
Emelia Smith
47e4f8478f
Enable expiry of OAuth Access Tokens granted to public clients 2025-04-23 20:56:50 +02:00
Emelia Smith
fad8f7b148
Only return client_secret for confidential clients 2025-04-23 20:56:50 +02:00
Emelia Smith
b21e7d8fdb
Add support for public clients to OAuth Application creation - parameter name TBD 2025-04-23 20:56:50 +02:00
Emelia Smith
5c6ad1a0e5
Ensure only confidential clients can use Client Credentials grant flow 2025-04-23 20:56:50 +02:00
21 changed files with 305 additions and 51 deletions

View File

@ -2,6 +2,9 @@
class Api::V1::AppsController < Api::BaseController class Api::V1::AppsController < Api::BaseController
skip_before_action :require_authenticated_user! skip_before_action :require_authenticated_user!
before_action :validate_token_endpoint_auth_method!
TOKEN_ENDPOINT_AUTH_METHODS = %w(none client_secret_basic client_secret_post).freeze
def create def create
@app = Doorkeeper::Application.create!(application_options) @app = Doorkeeper::Application.create!(application_options)
@ -16,14 +19,25 @@ class Api::V1::AppsController < Api::BaseController
redirect_uri: app_params[:redirect_uris], redirect_uri: app_params[:redirect_uris],
scopes: app_scopes_or_default, scopes: app_scopes_or_default,
website: app_params[:website], website: app_params[:website],
confidential: !app_public?,
} }
end end
def validate_token_endpoint_auth_method!
return unless app_params.include? :token_endpoint_auth_method
bad_request unless TOKEN_ENDPOINT_AUTH_METHODS.include? app_params[:token_endpoint_auth_method]
end
def app_public?
app_params[:token_endpoint_auth_method] == 'none'
end
def app_scopes_or_default def app_scopes_or_default
app_params[:scopes] || Doorkeeper.configuration.default_scopes app_params[:scopes] || Doorkeeper.configuration.default_scopes
end end
def app_params def app_params
params.permit(:client_name, :scopes, :website, :redirect_uris, redirect_uris: []) params.permit(:client_name, :scopes, :website, :token_endpoint_auth_method, :redirect_uris, redirect_uris: [])
end end
end end

View File

@ -21,7 +21,13 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
end end
def render_success def render_success
if skip_authorization? || (matching_token? && !truthy_param?('force_login')) # FIXME: Find a better way to apply this validation: if the scopes only
# includes offline_access, then it's not valid, since offline_access doesn't
# actually give access to resources:
if pre_auth.scopes.all?('offline_access')
error = Doorkeeper::OAuth::InvalidRequestResponse.new(reason: :offline_access_only, missing_param: nil)
render :error, locals: { error_response: error }, status: 400
elsif skip_authorization? || (matching_token? && !truthy_param?('force_login'))
redirect_or_render authorize_response redirect_or_render authorize_response
elsif Doorkeeper.configuration.api_only elsif Doorkeeper.configuration.api_only
render json: pre_auth render json: pre_auth

View File

@ -26,6 +26,12 @@ module ContextHelper
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } }, attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' }, quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
quotes: {
'quote' => 'https://w3id.org/fep/044f#quote',
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
'quoteAuthorization' => 'https://w3id.org/fep/044f#quoteAuthorization',
},
interaction_policies: { interaction_policies: {
'gts' => 'https://gotosocial.org/ns#', 'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' }, 'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },

View File

@ -1,12 +1,30 @@
import { useCallback } from 'react';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{ interface AccountBioProps {
note: string; note: string;
className: string; className: string;
}> = ({ note, className }) => { dropdownAccountId?: string;
const handleClick = useLinks(); }
if (note.length === 0 || note === '<p></p>') { export const AccountBio: React.FC<AccountBioProps> = ({
note,
className,
dropdownAccountId,
}) => {
const handleClick = useLinks(!!dropdownAccountId);
const handleNodeChange = useCallback(
(node: HTMLDivElement | null) => {
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
return;
}
addDropdownToHashtags(node, dropdownAccountId);
},
[dropdownAccountId],
);
if (note.length === 0) {
return null; return null;
} }
@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
className={`${className} translate`} className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }} dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick} onClickCapture={handleClick}
ref={handleNodeChange}
/> />
); );
}; };
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
if (!node) {
return;
}
for (const childNode of node.childNodes) {
if (!(childNode instanceof HTMLElement)) {
continue;
}
if (
childNode instanceof HTMLAnchorElement &&
(childNode.classList.contains('hashtag') ||
childNode.innerText.startsWith('#')) &&
!childNode.dataset.menuHashtag
) {
childNode.dataset.menuHashtag = accountId;
} else if (childNode.childNodes.length > 0) {
addDropdownToHashtags(childNode, accountId);
}
}
}

View File

@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -773,7 +774,6 @@ export const AccountHeader: React.FC<{
); );
} }
const content = { __html: account.note_emojified };
const displayNameHtml = { __html: account.display_name_html }; const displayNameHtml = { __html: account.display_name_html };
const fields = account.fields; const fields = account.fields;
const isLocal = !account.acct.includes('@'); const isLocal = !account.acct.includes('@');
@ -897,12 +897,11 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} /> <AccountNote accountId={accountId} />
)} )}
{account.note.length > 0 && account.note !== '<p></p>' && ( <AccountBio
<div note={account.note_emojified}
className='account__header__content translate' dropdownAccountId={accountId}
dangerouslySetInnerHTML={content} className='account__header__content'
/> />
)}
<div className='account__header__fields'> <div className='account__header__fields'>
<dl> <dl>

View File

@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store'; import { useAppDispatch } from 'mastodon/store';
const isMentionClick = (element: HTMLAnchorElement) => const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention'); element.classList.contains('mention') &&
!element.classList.contains('hashtag');
const isHashtagClick = (element: HTMLAnchorElement) => const isHashtagClick = (element: HTMLAnchorElement) =>
element.textContent?.[0] === '#' || element.textContent?.[0] === '#' ||
element.previousSibling?.textContent?.endsWith('#'); element.previousSibling?.textContent?.endsWith('#');
export const useLinks = () => { export const useLinks = (skipHashtags?: boolean) => {
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -61,12 +62,12 @@ export const useLinks = () => {
if (isMentionClick(target)) { if (isMentionClick(target)) {
e.preventDefault(); e.preventDefault();
void handleMentionClick(target); void handleMentionClick(target);
} else if (isHashtagClick(target)) { } else if (isHashtagClick(target) && !skipHashtags) {
e.preventDefault(); e.preventDefault();
handleHashtagClick(target); handleHashtagClick(target);
} }
}, },
[handleMentionClick, handleHashtagClick], [skipHashtags, handleMentionClick, handleHashtagClick],
); );
return handleClick; return handleClick;

View File

@ -126,6 +126,9 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
? accountJSON.username ? accountJSON.username
: accountJSON.display_name; : accountJSON.display_name;
const accountNote =
accountJSON.note && accountJSON.note !== '<p></p>' ? accountJSON.note : '';
return AccountFactory({ return AccountFactory({
...accountJSON, ...accountJSON,
moved: moved?.id, moved: moved?.id,
@ -142,8 +145,8 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
escapeTextContentForBrowser(displayName), escapeTextContentForBrowser(displayName),
emojiMap, emojiMap,
), ),
note_emojified: emojify(accountJSON.note, emojiMap), note_emojified: emojify(accountNote, emojiMap),
note_plain: unescapeHTML(accountJSON.note), note_plain: unescapeHTML(accountNote),
url: url:
accountJSON.url.startsWith('http://') || accountJSON.url.startsWith('http://') ||
accountJSON.url.startsWith('https://') accountJSON.url.startsWith('https://')

View File

@ -14,6 +14,8 @@ class ScopeTransformer < Parslet::Transform
# # override for profile scope which is read only # # override for profile scope which is read only
@access = %w(read) if @term == 'profile' @access = %w(read) if @term == 'profile'
# Override offline_access since it doesn't imply read or write access:
@access = %w(offline) if @term == 'offline_access'
end end
def key def key

View File

@ -65,12 +65,22 @@ class SessionActivation < ApplicationRecord
end end
def access_token_attributes def access_token_attributes
app = Doorkeeper::Application.find_by(superapp: true)
scopes = Doorkeeper::OAuth::Scopes.from_array(DEFAULT_SCOPES)
context = Doorkeeper::OAuth::Authorization::Token.build_context(
app,
Doorkeeper::OAuth::AUTHORIZATION_CODE,
scopes,
user_id
)
{ {
application_id: Doorkeeper::Application.find_by(superapp: true)&.id, application_id: context.client.id,
resource_owner_id: user_id, resource_owner_id: context.resource_owner,
scopes: DEFAULT_SCOPES.join(' '), scopes: context.scopes,
expires_in: Doorkeeper.configuration.access_token_expires_in, expires_in: Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context),
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?, use_refresh_token: Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context),
} }
end end
end end

View File

@ -314,10 +314,12 @@ class User < ApplicationRecord
def token_for_app(app) def token_for_app(app)
return nil if app.nil? || app.owner != self return nil if app.nil? || app.owner != self
Doorkeeper::AccessToken.find_or_create_by(application_id: app.id, resource_owner_id: id) do |t| context = Doorkeeper::OAuth::Authorization::Token.build_context(app, Doorkeeper::OAuth::AUTHORIZATION_CODE, app.scopes, app.owner.id)
t.scopes = app.scopes
t.expires_in = Doorkeeper.configuration.access_token_expires_in Doorkeeper::AccessToken.find_or_create_by(application_id: context.client.id, resource_owner_id: context.resource_owner) do |t|
t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled? t.scopes = context.scopes
t.expires_in = Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context)
t.use_refresh_token = Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context)
end end
end end

View File

@ -61,7 +61,7 @@ class OAuthMetadataPresenter < ActiveModelSerializers::Model
end end
def token_endpoint_auth_methods_supported def token_endpoint_auth_methods_supported
%w(client_secret_basic client_secret_post) %w(none client_secret_basic client_secret_post)
end end
def code_challenge_methods_supported def code_challenge_methods_supported

View File

@ -8,7 +8,7 @@ class REST::CredentialApplicationSerializer < REST::ApplicationSerializer
end end
def client_secret def client_secret
object.secret object.secret if object.confidential?
end end
# Added for future forwards compatibility when we may decide to expire OAuth # Added for future forwards compatibility when we may decide to expire OAuth

View File

@ -27,12 +27,14 @@ class AppSignUpService < BaseService
end end
def create_access_token! def create_access_token!
context = Doorkeeper::OAuth::Authorization::Token.build_context(@app, Doorkeeper::OAuth::AUTHORIZATION_CODE, @app.scopes, @user.id)
@access_token = Doorkeeper::AccessToken.create!( @access_token = Doorkeeper::AccessToken.create!(
application: @app, application: context.client,
resource_owner_id: @user.id, resource_owner_id: context.resource_owner,
scopes: @app.scopes, scopes: context.scopes,
expires_in: Doorkeeper.configuration.access_token_expires_in, expires_in: Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context),
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled? use_refresh_token: Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context)
) )
end end

View File

@ -31,10 +31,30 @@ Doorkeeper.configure do
# If you want to disable expiration, set this to nil. # If you want to disable expiration, set this to nil.
access_token_expires_in nil access_token_expires_in nil
# Assign a custom TTL for implicit grants. # context.grant_type to compare with Doorkeeper::OAUTH grant type constants
# custom_access_token_expires_in do |oauth_client| # context.client for client (Doorkeeper::Application)
# oauth_client.application.additional_settings.implicit_oauth_expiration # context.scopes for scopes
# end custom_access_token_expires_in do |context|
# If the client is confidential (all clients pre 4.3) and it hasn't
# requested offline_access, then we don't want to expire access tokens.
# Applications created by users are also considered confidential.
if context.client.confidential? && !context.scopes.exists?('offline_access')
nil
else
15.minutes.to_i
end
end
use_refresh_token do |context|
context.scopes.exists?('offline_access')
end
after_successful_strategy_response do |request, _response|
if request.is_a? Doorkeeper::OAuth::RefreshTokenRequest
Web::PushSubscription.where(access_token_id: request.refresh_token.id).update!(access_token_id: request.access_token.id)
SessionActivation.where(access_token_id: request.refresh_token.id).update!(access_token_id: request.access_token.id)
end
end
# Use a custom class for generating the access token. # Use a custom class for generating the access token.
# https://github.com/doorkeeper-gem/doorkeeper#custom-access-token-generator # https://github.com/doorkeeper-gem/doorkeeper#custom-access-token-generator
@ -71,6 +91,7 @@ Doorkeeper.configure do
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
default_scopes :read default_scopes :read
optional_scopes :profile, optional_scopes :profile,
:offline_access,
:write, :write,
:'write:accounts', :'write:accounts',
:'write:blocks', :'write:blocks',
@ -120,7 +141,9 @@ Doorkeeper.configure do
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
# falls back to the `:client_id` and `:client_secret` params from the `params` object. # falls back to the `:client_id` and `:client_secret` params from the `params` object.
# Check out the wiki for more information on customization # Check out the wiki for more information on customization
# client_credentials :from_basic, :from_params #
# This is the default value:
client_credentials :from_basic, :from_params
# Change the way access token is authenticated from the request object. # Change the way access token is authenticated from the request object.
# By default it retrieves first from the `HTTP_AUTHORIZATION` header, then # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then
@ -165,7 +188,17 @@ Doorkeeper.configure do
# http://tools.ietf.org/html/rfc6819#section-4.4.3 # http://tools.ietf.org/html/rfc6819#section-4.4.3
# #
grant_flows %w(authorization_code client_credentials) grant_flows %w(authorization_code client_credentials refresh_token)
# If the client is not a confidential client, it should not be able to use the
# client_credentials grant flow, since it cannot keep a secret.
allow_grant_flow_for_client do |grant_flow, client|
if grant_flow == Doorkeeper::OAuth::CLIENT_CREDENTIALS
client.confidential?
else
true
end
end
# Under some circumstances you might want to have applications auto-approved, # Under some circumstances you might want to have applications auto-approved,
# so that the user skips the authorization step. # so that the user skips the authorization step.

View File

@ -89,6 +89,7 @@ en:
invalid_request: invalid_request:
missing_param: 'Missing required parameter: %{value}.' missing_param: 'Missing required parameter: %{value}.'
request_not_authorized: Request need to be authorized. Required parameter for authorizing request is missing or invalid. request_not_authorized: Request need to be authorized. Required parameter for authorizing request is missing or invalid.
offline_access_only: The offline_access scope can only be used with other scopes.
unknown: The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed. unknown: The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.
invalid_resource_owner: The provided resource owner credentials are not valid, or resource owner cannot be found invalid_resource_owner: The provided resource owner credentials are not valid, or resource owner cannot be found
invalid_scope: The requested scope is invalid, unknown, or malformed. invalid_scope: The requested scope is invalid, unknown, or malformed.
@ -118,6 +119,7 @@ en:
read: Read-only access read: Read-only access
read/write: Read and write access read/write: Read and write access
write: Write-only access write: Write-only access
offline: Access for an extended period of time
title: title:
accounts: Accounts accounts: Accounts
admin/accounts: Administration of accounts admin/accounts: Administration of accounts
@ -138,6 +140,7 @@ en:
notifications: Notifications notifications: Notifications
profile: Your Mastodon profile profile: Your Mastodon profile
push: Push notifications push: Push notifications
offline_access: Offline access
reports: Reports reports: Reports
search: Search search: Search
statuses: Posts statuses: Posts

View File

@ -34,12 +34,19 @@ RSpec.describe OAuth::AuthorizationsController do
context 'when app is already authorized' do context 'when app is already authorized' do
before do before do
context = Doorkeeper::OAuth::Authorization::Token.build_context(
app,
Doorkeeper::OAuth::AUTHORIZATION_CODE,
app.scopes,
user.id
)
Doorkeeper::AccessToken.find_or_create_for( Doorkeeper::AccessToken.find_or_create_for(
application: app, application: context.client,
resource_owner: user.id, resource_owner: context.resource_owner,
scopes: app.scopes, scopes: context.scopes,
expires_in: Doorkeeper.configuration.access_token_expires_in, expires_in: Doorkeeper::OAuth::Authorization::Token.access_token_expires_in(Doorkeeper.config, context),
use_refresh_token: Doorkeeper.configuration.refresh_token_enabled? use_refresh_token: Doorkeeper::OAuth::Authorization::Token.refresh_token_enabled?(Doorkeeper.config, context)
) )
end end

View File

@ -3,5 +3,5 @@
Fabricator(:application, from: Doorkeeper::Application) do Fabricator(:application, from: Doorkeeper::Application) do
name 'Example' name 'Example'
website 'http://example.com' website 'http://example.com'
redirect_uri 'http://example.com/callback' redirect_uri 'urn:ietf:wg:oauth:2.0:oob'
end end

View File

@ -23,6 +23,12 @@ RSpec.describe ScopeTransformer do
it_behaves_like 'a scope', nil, 'profile', 'read' it_behaves_like 'a scope', nil, 'profile', 'read'
end end
context 'with scope "offline_access"' do
let(:input) { 'offline_access' }
it_behaves_like 'a scope', nil, 'offline_access', 'offline'
end
context 'with scope "read"' do context 'with scope "read"' do
let(:input) { 'read' } let(:input) { 'read' }

View File

@ -36,6 +36,7 @@ RSpec.describe 'Apps' do
expect(app).to be_present expect(app).to be_present
expect(app.scopes.to_s).to eq scopes expect(app.scopes.to_s).to eq scopes
expect(app.redirect_uris).to eq redirect_uris expect(app.redirect_uris).to eq redirect_uris
expect(app.confidential).to be true
expect(response.parsed_body).to match( expect(response.parsed_body).to match(
a_hash_including( a_hash_including(
@ -55,6 +56,76 @@ RSpec.describe 'Apps' do
end end
end end
context 'without being a confidential application' do
let(:client_name) { 'Test confidential app' }
let(:params) do
{
client_name: client_name,
redirect_uris: redirect_uris,
scopes: scopes,
website: website,
token_endpoint_auth_method: 'none',
}
end
it 'creates an public OAuth app', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(response.content_type)
.to start_with('application/json')
app = Doorkeeper::Application.find_by(name: client_name)
expect(app).to be_present
expect(app.scopes.to_s).to eq scopes
expect(app.redirect_uris).to eq redirect_uris
expect(app.confidential).to be false
expect(response.parsed_body).to match(
a_hash_including(
id: app.id.to_s,
client_id: app.uid,
client_secret: nil,
client_secret_expires_at: 0,
name: client_name,
website: website,
scopes: ['read', 'write'],
redirect_uris: redirect_uris,
# Deprecated properties as of 4.3:
redirect_uri: redirect_uri,
vapid_key: Rails.configuration.x.vapid_public_key
)
)
end
end
context 'when token_endpoint_auth_method is unknown' do
let(:client_name) { 'Test unknown auth app' }
let(:params) do
{
client_name: client_name,
redirect_uris: redirect_uris,
scopes: scopes,
website: website,
# Not yet supported:
token_endpoint_auth_method: 'private_key_jwt',
}
end
it 'does not create an OAuth app', :aggregate_failures do
subject
expect(response).to have_http_status(400)
expect(response.content_type)
.to start_with('application/json')
app = Doorkeeper::Application.find_by(name: client_name)
expect(app).to_not be_present
end
end
context 'without scopes being supplied' do context 'without scopes being supplied' do
let(:scopes) { nil } let(:scopes) { nil }

View File

@ -5,17 +5,19 @@ require 'rails_helper'
RSpec.describe 'Managing OAuth Tokens' do RSpec.describe 'Managing OAuth Tokens' do
describe 'POST /oauth/token' do describe 'POST /oauth/token' do
subject do subject do
post '/oauth/token', params: params post '/oauth/token', params: params, headers: {
# This is using the OAuth client_secret_basic client authentication method
Authorization: ActionController::HttpAuthentication::Basic.encode_credentials(application.uid, application.secret),
}
end end
let(:application) do let(:application) do
Fabricate(:application, scopes: 'read write follow', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob') Fabricate(:application, scopes: 'read write follow', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob')
end end
let(:params) do let(:params) do
{ {
grant_type: grant_type, grant_type: grant_type,
client_id: application.uid,
client_secret: application.secret,
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
code: code, code: code,
scope: scope, scope: scope,
@ -103,6 +105,53 @@ RSpec.describe 'Managing OAuth Tokens' do
end end
end end
end end
context "with grant_type 'refresh_token'" do
let(:grant_type) { 'refresh_token' }
let!(:user) { Fabricate(:user) }
let!(:application) { Fabricate(:application, scopes: 'read offline_access') }
let!(:access_token) do
Fabricate(
:accessible_access_token,
resource_owner_id: user.id,
application: application,
# Even though the `application` uses the `offline_access` scope, the
# generation of a refresh token only happens when the model is created
# with `use_refresh_token: true`.
#
# This is normally determined from the request to create the access
# token, but here we are just creating the access token model, so we
# need to force the `access_token` to have `use_refresh_token: true`
use_refresh_token: true
)
end
let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) }
let(:params) do
{
grant_type: grant_type,
refresh_token: access_token.refresh_token,
}
end
it 'updates the Web::PushSubscription when refreshed' do
expect { subject }
.to change { access_token.reload.revoked_at }.from(nil).to(be_present)
expect(response).to have_http_status(200)
new_token = Doorkeeper::AccessToken.by_token(response.parsed_body[:access_token])
expect(web_push_subscription.reload.access_token_id).to eq(new_token.id)
# Assert that there are definitely no subscriptions left for the
# previous access token:
expect(Web::PushSubscription.where(access_token: access_token.id).count)
.to eq(0)
end
end
end end
describe 'POST /oauth/revoke' do describe 'POST /oauth/revoke' do

View File

@ -25,7 +25,7 @@ RSpec.describe 'The /.well-known/oauth-authorization-server request' do
scopes_supported: Doorkeeper.configuration.scopes.map(&:to_s), scopes_supported: Doorkeeper.configuration.scopes.map(&:to_s),
response_types_supported: Doorkeeper.configuration.authorization_response_types, response_types_supported: Doorkeeper.configuration.authorization_response_types,
response_modes_supported: Doorkeeper.configuration.authorization_response_flows.flat_map(&:response_mode_matches).uniq, response_modes_supported: Doorkeeper.configuration.authorization_response_flows.flat_map(&:response_mode_matches).uniq,
token_endpoint_auth_methods_supported: %w(client_secret_basic client_secret_post), token_endpoint_auth_methods_supported: %w(none client_secret_basic client_secret_post),
grant_types_supported: grant_types_supported, grant_types_supported: grant_types_supported,
code_challenge_methods_supported: Doorkeeper.configuration.pkce_code_challenge_methods_supported, code_challenge_methods_supported: Doorkeeper.configuration.pkce_code_challenge_methods_supported,
# non-standard extension: # non-standard extension: