Update Simple OAuth module to v6 #743

This commit is contained in:
Michael Stenta 2023-11-01 14:41:47 -04:00
commit 69b36c065f
25 changed files with 456 additions and 547 deletions

View File

@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [Issue #3394069: Update quantities to use bundle permission granularity](https://www.drupal.org/node/3394069)
- [Issue #3357679: Allow material quantities to reference multiple material types](https://www.drupal.org/project/farm/issues/3357679)
- [Issue #3330490: Update Drupal core to 10.x in farmOS](https://www.drupal.org/project/farm/issues/3330490)
- [Issue #3256745: Move default farm OAuth2 client to a separate module](https://www.drupal.org/project/farm/issues/3256745)
- [Update Simple OAuth module to v6 #743](https://github.com/farmOS/farmOS/pull/743)
### Fixed

View File

@ -42,7 +42,8 @@
"drupal/migrate_source_ui": "^1.0",
"drupal/migrate_tools": "^6.0.2",
"drupal/role_delegation": "^1.2",
"drupal/simple_oauth": "5.2.4",
"drupal/simple_oauth": "6.0.0-beta5",
"drupal/simple_oauth_password_grant": "^1.0@RC",
"drupal/state_machine": "1.8",
"drupal/subrequests": "^3.0.6",
"drupal/token": "^1.11",
@ -79,7 +80,8 @@
"Issue #3322227: Document schema title wrong for multiple resource types": "https://www.drupal.org/files/issues/2022-11-17/3322227-0.patch"
},
"drupal/simple_oauth": {
"Issue #3322325: Cannot authorize clients with empty string set as secret": "https://www.drupal.org/files/issues/2022-11-17/3322325-1.patch"
"Issue #3322325: Cannot authorize clients with empty string set as secret": "https://www.drupal.org/files/issues/2023-10-31/3322325-8.patch",
"Issue #3397590: Add method to check if scope has permission": "https://www.drupal.org/files/issues/2023-10-30/3397590-5_0.patch"
},
"drupal/state_machine": {
"Issue #3396186: State constraint is not validated on new entities": "https://www.drupal.org/files/issues/2023-10-23/3396186-2.patch"

View File

@ -31,44 +31,60 @@ server.
### Scopes
OAuth Scopes define different levels of permission. The farmOS server
implements scopes as roles associated with OAuth clients. This means that users
will authorize clients with roles that determine how much access they have
to data on the server.
OAuth Scopes define different levels of access. The farmOS server
implements scopes that represent individual roles or permissions. Users will
authorize clients with one or more scopes that determine how much access they
have to data on the server.
The farmOS Default Roles module provides an OAuth scope for each of the default
roles: `farm_manager`, `farm_worker`, and `farm_viewer`.
If you are creating an integration with farmOS, see the
[OAuth](/development/module/oauth) page of the farmOS module development docs
for steps to create additional OAuth Scopes.
### Clients
An OAuth Client represents a 1st or 3rd party integration with the farmOS
server. Clients are uniquely identified by a `client_id` and are
configured to use different `scopes`.
server. Clients are uniquely identified by a `client_id` and can have an
optional `client_secret` for private integrations. Clients are configured to
allow only specific OAuth grants and can specify default `scopes` that are
granted when none are requested.
The core `farm_api` module provides a default client with
`client_id = farm`. If you are writing a script that communicates with *your*
farmOS server via the API, you should use this client to authorize access and
generate an `access_token` for authentication.
The core `farm_api_default_consumer` module provides a default client with
`client_id = farm` that can use the `password` and `refresh_token` grant. You
can use this client for general usage of the API, like writing a script that
communicates with *your* farmOS server, but it comes with limitations.
If you are creating a third party integration with farmOS, see the
If you are creating an integration with farmOS, see the
[OAuth](/development/module/oauth) page of the farmOS module development docs
for steps to create an OAuth Client.
### Authorization Flows
The [OAuth 2.0 standards](https://oauth.net/2/) outline 5
[Oauth2 Grant Types](https://oauth.net/2/grant-types/) to be used in an OAuth2
Authorization Flow - They are the *Authorization Code, Implicit, Password
Credentials, Client Credentials* and *Refresh Token* Grants. The
The [OAuth 2.0 standards](https://oauth.net/2/) outline 3
[Oauth2 Grant Types](https://oauth.net/2/grant-types/) to be used in an OAuth2 Authorization Flow - They are
the *Authorization Code, Client Credentials* and *Refresh Token* Grants. The
[Authorization Code](#authorization-code-grant) and
[Refresh Token](#refreshing-tokens) grants are the only Authorization Flows
recommended by farmOS for use with 3rd party clients.
[Refresh Token](#refreshing-tokens) grants are the only Authorization Flows recommended by
farmOS for use with 3rd party clients.
**NOTE:** Only use the **Password Grant** if the client can be trusted with a
farmOS username and password (this is considered *1st party*). The
**Client Credentials Grant** is often used for machine authentication not
The **Client Credentials Grant** is often used for machine authentication not
associated with a user account. The client credentials grant should only be
used if a `client_secret` can be kept secret. If connecting to multiple
farmOS servers, each server should use a different secret. This is
challenging due to the nature of farmOS being a self-hosted application.
The [Password Credentials Grant](#password-credentials-grant) is a legacy
grant type that is
[no longer recommended](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.4).
Only use the Password Credentials Grant if the client can be trusted with a
farmOS username and password (this is considered *1st party*). Even if the
client is trusted, this grant type exposes the username and password and
results in an increased attack surface. In most cases the **Client Credentials
Grant** can be used with an OAuth client that is configured for each separate
integration.
#### Authorization Code Grant
The Authorization Code Grant is most popular for 3rd party client
@ -106,8 +122,13 @@ resources. The header is an Authorization header with a Bearer token:
#### Password Credentials Grant
**NOTE:** Only use the **Password Grant** if the client can be trusted with a
farmOS username and password (this is considered *1st party*).
**NOTE:** The **Password Credentials Grant** is a legacy grant type that is
[no longer recommended](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.4).
Only use the **Password Grant** if the client can be trusted with a farmOS
username and password (this is considered *1st party*).
**NOTE:** The [Simple OAuth Password Grant](https://www.drupal.org/project/simple_oauth_password_grant)
module must be enabled to use the password grant.
The Password Credentials Grant uses a farmOS `username` and `password` to
retrieve an `access_token` and `refresh_token` in one step. For the user, this

View File

@ -2,6 +2,29 @@
## 3.x vs 2.x
The [Simple OAuth](https://www.drupal.org/project/simple_oauth) module has been
updated to version 6. This includes a few breaking changes which may affect API
integrations. farmOS includes code to handle the transition of its own OAuth
clients and scopes, but if you have made any additional clients that used
special roles they will also need to be updated.
The biggest changes are that the "Implicit" grant type has been
removed, and the "Password Credentials" grant type has been moved to an optional
"Simple OAuth Password Grant" module, which must be enabled in order to use that
grant type.
There have also been changes to how scopes are provided. User roles no longer
act as scopes by default. Instead, scopes must be created separately to
reference each role they represent. Scopes can also be associated with
individual permissions and can reference parent scopes to create
hierarchical scope trees. farmOS provides `static` scopes for each of the
default roles: `farm_manager`, `farm_worker` and `farm_viewer`.
The default farmOS client that is included with farmOS has also been
moved to a separate module that is not enabled by default. After the update to
farmOS 3.x, all access tokens will be invalidated, but refresh tokens will still
work to get a new access token.
- [Material quantities can reference multiple material types](https://www.drupal.org/node/3395697)
## 2.x vs 1.x

View File

@ -5,22 +5,101 @@ to provide an [OAuth2 standard](https://oauth.net/2/) authorization server.
For documentation on using and authenticating with the farmOS API see [API](/api).
## Providing OAuth Scopes
OAuth Scopes define different levels of access. The farmOS server
implements scopes that represent individual roles or permissions. Users will
authorize clients with one or more scopes that determine how much access they
have to data on the server.
OAuth scopes are provided to the server using scope provider plugins. Each
scope provider determines how scopes are implemented and created. The OAuth
server must choose a single scope provider to provide all the scopes
necessary for the server's authorization needs. All scopes use the same
configuration and provide the same features regardless of the scope provider.
The [Simple OAuth](https://www.drupal.org/project/simple_oauth) module
provides two scope providers: `static` and `dynamic`. The `static` scope
provider implements scopes as a `yaml` plugin that must be provided by modules
and prevents scopes from being modified. The `dynamic` scope provider
implements scopes as a config entity and allows scopes to be created and
modified via the UI. Modules can provide `dynamic` scopes as well but there are
no guarantees that these scopes will remain unmodified.
farmOS defaults to using the `static` scope provider. This allows modules
providing OAuth scopes to guarantee that their scopes exist unmodified within
the server. The farmOS administrator can change to using the `dynamic` scope
provider if necessary, but may need to re-create any `static` scopes that
are needed for integrations provided by other modules.
The farmOS Default Roles module provides a `static` OAuth scope for each of the
default roles: `farm_manager`, `farm_worker`, and `farm_viewer`.
### Scope Configuration
All scopes use the same configuration and provide the same features
regardless of the scope provider:
- Scopes must provide a `name` to uniquely identify the scope
- Scopes must provide a `description`
- Scopes must specify if they are an `umbrella` scope. Umbrella scopes are
only used as parent for child scopes to reference and do not specify a
`granularity`.
- Scopes must configure which `grant_types` they allow. Each grant type can
include an optional `description` to describe how the scope is used in the
context of each grant type.
- Scopes can optionally specify a `parent` scope that the scope is a part of.
When the parent scope is requested, all of its child scopes are granted as
well.
- Scopes must specify a `granularity` if they are not an `umbrella` scope.
This value must be equal to `permission` or `role`. The scope must
provide a single value for the `permission` or `role` it is associated with.
This configuration is most easily demonstrated with
`static` scopes that are provided in a `module.oauth2_scopes.yml` plugin file.
```yaml
"scope:name":
description: string (required)
umbrella: boolean (required)
grant_types: (required)
GRANT_TYPE_PLUGIN_ID: (required: only known grant types)
status: boolean (required)
description: string
parent: string
granularity: string (required: if umbrella is FALSE, values: permission or role)
permission: string (required: if umbrella is FALSE and granularity set to permission)
role: string (required: if umbrella is FALSE and granularity set to role)
```
An example of the static `farm_manager` scope provided by the farmOS Role
Roles mdoule:
```yaml
farm_manager:
description: 'Grants access to the Farm Manager role.'
umbrella: false
grant_types:
authorization_code:
status: true
refresh_token:
status: true
password:
status: true
granularity: 'role'
role: 'farm_manager'
```
## Providing OAuth Clients
OAuth clients are modeled as "Consumer" entities (provided by the
[Consumers](https://www.drupal.org/project/consumers) module. The `farm_api`
module provides a default client with `client_id = farm`. This can be used for
general usage of the API, but comes with limitations. To create a third party
integration with farmOS a `consumer` entity must be created that identifies
the integration and configures the OAuth Client authorization behavior.
[Consumers](https://www.drupal.org/project/consumers) module. To create
integrations with farmOS a `consumer` entity must be created that
identifies the integration and configures the OAuth Client for the desired
authorization behavior.
## Scopes
OAuth scopes define different levels of permission. OAuth clients are
configured with the scopes needed for the purposes of a specific integration.
With consumers, these scopes are implemented as Drupal Roles. This means that
OAuth clients interacting with farmOS over the API use the same permission
system as Users normally using the site.
The core `farm_api_default_consumer` module provides a default client with
`client_id = farm` that can use the `password` and `refresh_token` grant. You
can use this client for general usage of the API, like writing a script that
communicates with *your* farmOS server, but it comes with limitations.
## Client Configuration
@ -40,6 +119,13 @@ Standard Consumer configuration:
- `consumer.user_id` - When no specific user is authenticated Drupal will use
this user as the author of all the actions made by this consumer.
- This is only the case during the `Client Credentials` authorization flow.
- `consumer.grant_types` - A list of the grant types that the client allows.
- `consumer.scopes` - A list of default scopes that will be granted for this
client if no scopes are requested during the authorization flow. No scopes
will be granted that the user does not have access to.
- `consumer.access_token_expiration` - The lifetime of access tokens in seconds.
- `consumer.refresh_token_expiration` - The lifetime of refresh tokens in
seconds.
- `consumer.redirect_uri` - The URI this client will redirect to when needed.
- This is used with the Authorization Code authorization flow.
- `consumer.allowed_origins` - Define any allowed origins the farmOS server
@ -48,28 +134,3 @@ Standard Consumer configuration:
- `consumer.third_party` - Enable if the Consumer represents a third party.
- Users will skip the "grant" step of the authorization flow for first
party consumers only.
farmOS extends the `consumers` and `simple_oauth` modules to provide additional
authorization options on consumer entities. These additional options make it
possible to support different third party integration use cases via the same
OAuth Authorization server. They can be configured via the UI or when creating
a consumer entity programmatically.
Authorization options (all are disabled by default):
- `consumer.grant_user_access` - Always grant the authorizing user's access
to this consumer.
- This is how the farmOS Field Kit consumer is configured. If this is the
only option enabled, then the consumer will only be granted the roles
the user has access to.
- `consumer.limit_requested_access` - Only grant this consumer the scopes
requested during authorization.
- By default, all scopes configured with the consumer will be granted
during authorization. This allows users to select which scopes they want
to grant the third party during authorization.
- `consumer.limit_user_access` - Never grant the consumer more access than
the authorizing user.
- It is possible that clients will be configured with different roles
than the user that authorizes access to a third party. There are times
that this may be intentional, but this setting ensures that consumers
will not be granted more access than the authorizing user.

View File

@ -58,6 +58,7 @@ function farm_modules() {
'farm_import_csv' => t('CSV importer'),
'farm_kml' => t('KML export features'),
'farm_import_kml' => t('KML asset importer'),
'farm_api_default_consumer' => t('Default API Consumer'),
'farm_fieldkit' => t('Field Kit integration'),
'farm_l10n' => t('Translation/localization features'),
'farm_role_account_admin' => t('Account Admin role'),

View File

@ -9,4 +9,5 @@ dependencies:
- jsonapi_extras:jsonapi_extras
- jsonapi_schema:jsonapi_schema
- simple_oauth:simple_oauth
- simple_oauth:simple_oauth_static_scope
- subrequests:subrequests

View File

@ -5,7 +5,6 @@
* Install, update and uninstall functions for the farm_api module.
*/
use Drupal\consumers\Entity\Consumer;
use Drupal\Core\Utility\Error;
/**
@ -22,13 +21,13 @@ function farm_api_install() {
// Load the simple_oauth module settings.
$simple_oauth_settings = \Drupal::configFactory()->getEditable('simple_oauth.settings');
// Increase access token expiration time to 1 hour.
$simple_oauth_settings->set('access_token_expiration', 3600);
// Explicitly set the public/private key path.
$simple_oauth_settings->set('public_key', '../keys/public.key');
$simple_oauth_settings->set('private_key', '../keys/private.key');
// Use static scopes by default.
$simple_oauth_settings->set('scope_provider', 'static');
// Save simple_oauth settings.
$simple_oauth_settings->save();
@ -73,39 +72,6 @@ function farm_api_install() {
$default_consumer->delete();
}
// Create a "Farm default" consumer.
$base_url = \Drupal::service('router.request_context')->getCompleteBaseUrl();
$farm_consumer = Consumer::create([
'label' => 'Farm default',
'client_id' => 'farm',
'redirect' => $base_url,
'is_default' => TRUE,
'owner_id' => '',
'secret' => NULL,
'confidential' => FALSE,
'third_party' => FALSE,
'grant_user_access' => TRUE,
'limit_user_access' => TRUE,
'limit_requested_access' => FALSE,
]);
$farm_consumer->save();
}
/**
* Implements hook_uninstall().
*/
function farm_api_uninstall() {
// Load the default farm consumer.
$consumers = \Drupal::entityTypeManager()->getStorage('consumer')
->loadByProperties(['client_id' => 'farm']);
// If found, delete the consumer.
if (!empty($consumers)) {
$farm_consumer = reset($consumers);
$farm_consumer->delete();
}
}
/**

View File

@ -1,3 +1,4 @@
farm_api:
default_permissions:
- grant simple_oauth codes
- issue subrequests

View File

@ -34,47 +34,8 @@ function farm_api_consumers_list_alter(&$data, $context) {
function farm_api_entity_base_field_info(EntityTypeInterface $entity_type) {
$fields = [];
// Add bundle fields to the consumer entity.
// Add allowed_origins field to the consumer entity.
if ($entity_type->id() == 'consumer') {
$fields['grant_user_access'] = BundleFieldDefinition::create('boolean')
->setLabel(t('Grant user access'))
->setDescription(t("Always grant the authorizing user's access to this consumer."))
->setSetting('on_label', t('Yes'))
->setSetting('off_label', t('No'))
->setDisplayOptions('form', [
'type' => 'boolean_checkbox',
'settings' => [
'display_label' => TRUE,
],
'weight' => 4,
]);
$fields['limit_requested_access'] = BundleFieldDefinition::create('boolean')
->setLabel(t('Limit to requested access'))
->setDescription(t('Only grant this consumer the scopes requested during authorization.'))
->setSetting('on_label', t('Yes'))
->setSetting('off_label', t('No'))
->setDisplayOptions('form', [
'type' => 'boolean_checkbox',
'settings' => [
'display_label' => TRUE,
],
'weight' => 4,
]);
$fields['limit_user_access'] = BundleFieldDefinition::create('boolean')
->setLabel(t('Limit to user access'))
->setDescription(t('Never grant this consumer more access than the authorizing user.'))
->setSetting('on_label', t('Yes'))
->setSetting('off_label', t('No'))
->setDisplayOptions('form', [
'type' => 'boolean_checkbox',
'settings' => [
'display_label' => TRUE,
],
'weight' => 4,
]);
$fields['allowed_origins'] = BundleFieldDefinition::create('string')
->setLabel(t('Allowed origins'))
->setDescription(t('Configure CORS origins for this consumer.'))

View File

@ -0,0 +1,65 @@
<?php
/**
* @file
* Post update functions for farm_settings module.
*/
/**
* Remove farm_api consumer bundle fields.
*/
function farm_api_post_update_remove_consumer_fields(&$sandbox = NULL) {
// Remove old consumer fields.
$fields = [
'grant_user_access',
'limit_user_access',
'limit_requested_access',
];
foreach ($fields as $field) {
$entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager();
$roles_field_definition = $entity_definition_update_manager->getFieldStorageDefinition($field, 'consumer');
$entity_definition_update_manager->uninstallFieldStorageDefinition($roles_field_definition);
}
}
/**
* Enable static oauth2 scopes.
*/
function farm_api_post_update_enable_static_oauth2_scopes(&$sandbox = NULL) {
// Enable static scope module.
if (!\Drupal::service('module_handler')->moduleExists('simple_oauth_static_scope')) {
\Drupal::service('module_installer')->install(['simple_oauth_static_scope']);
}
// Use static scope provider.
$simple_oauth_settings = \Drupal::configFactory()->getEditable('simple_oauth.settings');
$simple_oauth_settings->set('scope_provider', 'static');
$simple_oauth_settings->save();
}
/**
* Enable default consumer module.
*/
function farm_api_post_update_enable_default_consumer_module(&$sandbox = NULL) {
// Check for an existing farm default consumer.
$consumers = \Drupal::entityTypeManager()->getStorage('consumer')
->loadByProperties(['client_id' => 'farm']);
if (!empty($consumers)) {
// Enable default consumer module.
if (!\Drupal::service('module_handler')->moduleExists('farm_api_default_consumer')) {
\Drupal::service('module_installer')->install(['farm_api_default_consumer']);
}
// Update values on the consumer.
/** @var \Drupal\consumers\Entity\ConsumerInterface $farm_default */
$farm_default = reset($consumers);
$farm_default->set('user_id', NULL);
$farm_default->set('grant_types', ['authorization_code', 'refresh_token', 'password']);
$farm_default->save();
}
}

View File

@ -10,7 +10,3 @@ services:
class: Drupal\farm_api\Routing\RouteSubscriber
tags:
- { name: event_subscriber }
farm_api.repositories.scope:
class: Drupal\farm_api\Repositories\FarmScopeRepository
decorates: simple_oauth.repositories.scope
arguments: [ '@entity_type.manager' ]

View File

@ -0,0 +1,8 @@
name: farmOS Default API Consumer
description: Provides a default consumer for using the farmOS API.
type: module
package: farmOS
core_version_requirement: ^10
dependencies:
- farm:farm_api
- simple_oauth_password_grant:simple_oauth_password_grant

View File

@ -0,0 +1,57 @@
<?php
/**
* @file
* Install and uninstall functions for the farm_api_default_consumer module.
*/
use Drupal\consumers\Entity\Consumer;
/**
* Implements hook_install().
*/
function farm_api_default_consumer_install() {
// Check for an existing farm default consumer.
$consumers = \Drupal::entityTypeManager()->getStorage('consumer')
->loadByProperties(['client_id' => 'farm']);
// If not found, create the farm default consumer.
if (empty($consumers)) {
$base_url = \Drupal::service('router.request_context')->getCompleteBaseUrl();
$farm_consumer = Consumer::create([
'label' => 'Farm default',
'client_id' => 'farm',
'access_token_expiration' => 3600,
'grant_types' => [
'authorization_code',
'refresh_token',
'password',
],
'redirect' => $base_url,
'is_default' => TRUE,
'owner_id' => NULL,
'secret' => NULL,
'confidential' => FALSE,
'third_party' => FALSE,
]);
$farm_consumer->save();
}
}
/**
* Implements hook_uninstall().
*/
function farm_api_default_consumer_uninstall() {
// Load the default farm consumer.
$consumers = \Drupal::entityTypeManager()->getStorage('consumer')
->loadByProperties(['client_id' => 'farm']);
// If found, delete the consumer.
if (!empty($consumers)) {
$farm_consumer = reset($consumers);
$farm_consumer->delete();
}
}

View File

@ -1,118 +0,0 @@
<?php
namespace Drupal\farm_api\Repositories;
use Drupal\simple_oauth\Repositories\ScopeRepository;
use Drupal\user\RoleInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
/**
* Decorates the simple_oauth ScopeRepository.
*
* Alter the default behavior to account for additional consumer config options:
* - consumer.grant_user_access: Always grant the user's roles.
* - consumer.limit_requested_access: Always limit to the requested scopes.
* - consumer.limit_user_access: Always limit access to what the user has.
*
* @ingroup farm
*/
class FarmScopeRepository extends ScopeRepository {
/**
* {@inheritdoc}
*/
public function finalizeScopes(array $scopes, $grant_type, ClientEntityInterface $client_entity, $user_identifier = NULL) {
// Start a list of allowed roles.
$allowed_roles = [];
// Load the consumer entity.
/** @var \Drupal\consumers\Entity\Consumer $client_drupal_entity */
$consumer_entity = $client_entity->getDrupalEntity();
// Load role ids of roles the consumer has.
$consumer_roles = array_map(function ($role) {
return $role['target_id'];
}, $consumer_entity->get('roles')->getValue());
// Include consumer roles.
// By default all consumer roles are available to authorization.
$allowed_roles = array_merge($allowed_roles, $consumer_roles);
// Load the default user associated with the consumer.
// This is an optional setting, so it may not exist.
$default_user = NULL;
try {
$default_user = $client_entity->getDrupalEntity()->get('user_id')->entity;
}
catch (\InvalidArgumentException $e) {
// Do nothing.
}
// Load the user associated with the token.
// If there is no user, use the default user.
/** @var \Drupal\user\UserInterface $user */
$user = $user_identifier
? $this->entityTypeManager->getStorage('user')->load($user_identifier)
: $default_user;
if (!$user) {
return [];
}
// Load the user's roles.
// Load all roles for user 1 so they can be granted all possible scopes.
if ((int) $user->id() === 1) {
$user_roles = array_map(function (RoleInterface $role) {
return $role->id();
}, $this->entityTypeManager->getStorage('user_role')->loadMultiple());
}
// Else load the normal user's roles.
else {
$user_roles = $user->getRoles();
}
// Include the user's roles if enabled.
if ($consumer_entity->get('grant_user_access')->value) {
$allowed_roles = array_merge($allowed_roles, $user_roles);
}
/* Limit the roles granted to the token. */
// Limit to requested roles if enabled.
if ($consumer_entity->get('limit_requested_access')->value) {
// Save the requested scopes (roles) that were passed to this
// finalizeScopes() method.
$requested_roles = array_map(function (ScopeEntityInterface $scope) {
return $scope->getIdentifier();
}, $scopes);
// Reduce the requested roles to only those in allowed roles.
// This prevents additional roles being granted than the user
// and consumer have available.
$allowed_requested_roles = array_filter($requested_roles, function ($role_id) use ($allowed_roles) {
return in_array($role_id, $allowed_roles);
});
// Filter the allowed roles to only those requested.
$allowed_roles = array_intersect($allowed_roles, $allowed_requested_roles);
}
// Limit to roles the user already has, if enabled.
if ($consumer_entity->get('limit_user_access')->value) {
$allowed_roles = array_intersect($allowed_roles, $user_roles);
}
// Always include the authenticated role.
$allowed_roles[] = RoleInterface::AUTHENTICATED_ID;
// Build a new list of ScopeEntityInterface to return.
$scopes = [];
foreach ($allowed_roles as $role_id) {
$scopes = $this->addRoleToScopes($scopes, $role_id);
}
return $scopes;
}
}

View File

@ -6,3 +6,4 @@ core_version_requirement: ^10
dependencies:
- farm:asset
- log:log
- simple_oauth_password_grant:simple_oauth_password_grant

View File

@ -0,0 +1,12 @@
test:password:
description: 'Static scope for password grant.'
umbrella: false
grant_types:
authorization_code:
status: true
description: 'Authorization code grant.'
password:
status: true
description: 'Password grant'
granularity: 'permission'
permission: 'access content'

View File

@ -1,282 +0,0 @@
<?php
namespace Drupal\Tests\farm_api\Functional;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Url;
use Drupal\user\Entity\Role;
/**
* Tests using the consumer.client_id field.
*
* @group farm
*/
class ConsumerConfigTest extends OauthTestBase {
/**
* The URL for debugging tokens.
*
* @var \Drupal\Core\Url
*/
protected $tokenDebugUrl;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->tokenDebugUrl = Url::fromRoute('oauth2_token.user_debug');
// Override the additional roles created by parent.
$this->additionalRoles = [];
for ($i = 0; $i < 4; $i++) {
$role = Role::create([
'id' => 'scope_' . $i,
'label' => 'Scope: ' . $i,
'is_admin' => FALSE,
]);
$role->save();
$this->additionalRoles[] = $role;
}
}
/**
* Test consumer.grant_user_access config.
*/
public function testGrantUserAccess() {
// Set up the client.
$this->client->set('grant_user_access', FALSE);
$this->client->set('limit_requested_access', FALSE);
$this->client->set('limit_user_access', FALSE);
$this->client->save();
// Grant the user more roles than the consumer.
$this->user->addRole('scope_1');
$this->user->addRole('scope_2');
$this->user->save();
// 1. Test that only the consumers roles are granted.
// Prepare expected roles. Include all roles the consumer has.
$expected_roles = array_merge($this->getClientRoleIds(), ['authenticated']);
// Check the token.
$access_token = $this->getAccessToken();
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
// 2. Test that the user's roles are granted as well.
// Update the client.
$this->client->set('grant_user_access', TRUE);
$this->client->save();
// Include the consumer + user roles.
$expected_roles = array_merge($expected_roles, ['scope_1', 'scope_2']);
// Check the token.
$access_token = $this->getAccessToken();
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
// 3. Test that additional roles are not granted.
// Request "scope_3" even though it is not given to the user or consumer.
// Check the token.
$access_token = $this->getAccessToken(['scope_3']);
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
}
/**
* Test consumer.limit_requested_access.
*/
public function testLimitRequestedAccess() {
// Set up the client.
$this->client->set('grant_user_access', FALSE);
$this->client->set('limit_requested_access', FALSE);
$this->client->set('limit_user_access', FALSE);
$this->client->save();
// Grant the user additional roles.
$this->user->addRole('scope_1');
$this->user->addRole('scope_2');
$this->user->save();
// Grant the client additional roles.
$client_roles = array_merge(
$this->getClientRoleIds(),
['scope_3']
);
$this->grantClientRoles($client_roles);
// Array of expected roles. Includes all roles the consumer has.
$expected_roles = array_merge($client_roles, ['authenticated']);
// 1. Test that all roles on the consumer are granted.
$access_token = $this->getAccessToken();
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
// 2. Test that only the requested scopes (roles) are granted.
// Update the client.
$this->client->set('limit_requested_access', TRUE);
$this->client->save();
$requested_roles = ['scope_3'];
$expected_roles = array_merge($requested_roles, ['authenticated']);
// Check the token.
$access_token = $this->getAccessToken($requested_roles);
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
// 3. Test only the requested roles are granted,
// even if user roles are granted.
$this->client->set('limit_requested_access', TRUE);
$this->client->set('grant_user_access', TRUE);
$this->client->save();
$requested_roles = ['scope_1', 'scope_3'];
$expected_roles = array_merge($requested_roles, ['authenticated']);
// Check the token.
$access_token = $this->getAccessToken($requested_roles);
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
}
/**
* Test consumer.limit_user_access.
*/
public function testLimitUserAccess() {
// Set up the client.
$this->client->set('grant_user_access', FALSE);
$this->client->set('limit_requested_access', FALSE);
$this->client->set('limit_user_access', FALSE);
$this->client->save();
// Grant the user one additional role.
$this->user->addRole('scope_1');
$this->user->save();
// Grant the client all roles.
$client_roles = array_merge(
$this->getClientRoleIds(),
['scope_1', 'scope_2', 'scope_3']
);
$this->grantClientRoles($client_roles);
// Array of expected roles. Includes all roles the consumer has.
$expected_roles = array_merge($client_roles, ['authenticated']);
// 1. Test that all roles on the consumer are granted.
$access_token = $this->getAccessToken();
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
// 2. Test that only the roles the user has are granted.
// Update the client.
$this->client->set('limit_user_access', TRUE);
$this->client->save();
$requested_roles = ['scope_1', 'scope_3'];
$expected_roles = ['scope_1', 'authenticated'];
// Check the token.
$access_token = $this->getAccessToken($requested_roles);
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
// 3. Test that limit_user_access and grant_user_access work together.
$this->client->set('grant_user_access', TRUE);
$this->client->set('limit_user_access', TRUE);
$this->client->save();
$requested_roles = [];
$expected_roles = ['scope_1', 'authenticated'];
// Check the token.
$access_token = $this->getAccessToken($requested_roles);
$token_info = $this->getTokenInfo($access_token);
$this->assertEquals($this->user->id(), $token_info['id']);
$this->assertEqualsCanonicalizing($expected_roles, $token_info['roles']);
}
/**
* Return the response from oauth/debug.
*
* @param string $access_token
* The access_token to use for authentication.
*
* @return mixed
* The JSON parsed response.
*/
protected function getTokenInfo($access_token) {
$response = $this->get(
$this->tokenDebugUrl,
[
'query' => ['_format' => 'json'],
'headers' => [
'Authorization' => 'Bearer ' . $access_token,
],
]
);
return Json::decode((string) $response->getBody());
}
/**
* Helper function to get role IDs the client has.
*
* @return array
* Array of role IDs.
*/
protected function getClientRoleIds() {
return array_map(function ($role) {
return $role['target_id'];
}, $this->client->get('roles')->getValue());
}
/**
* Helper function to grant roles to the client.
*
* @param array $role_ids
* Role IDs to add.
*
* @throws \Drupal\Core\Entity\EntityStorageException
*/
protected function grantClientRoles(array $role_ids) {
$roles = [];
foreach ($role_ids as $id) {
$roles[] = ['target_id' => $id];
}
$this->client->set('roles', $roles);
$this->client->save();
}
/**
* Return an access token.
*
* @param array $scopes
* The scopes.
*
* @return string
* The access token.
*/
protected function getAccessToken(array $scopes = []) {
$valid_payload = [
'grant_type' => 'password',
'client_id' => $this->client->get('client_id')->value,
'client_secret' => $this->clientSecret,
'username' => $this->user->getAccountName(),
'password' => $this->user->pass_raw,
];
if (!empty($scopes)) {
$valid_payload['scope'] = implode(' ', $scopes);
}
$response = $this->post($this->url, $valid_payload);
$parsed_response = Json::decode((string) $response->getBody());
return $parsed_response['access_token'] ?? NULL;
}
}

View File

@ -42,6 +42,7 @@ class FarmApiTest extends KernelTestBase {
'jsonapi',
'jsonapi_extras',
'log',
'options',
'serialization',
'simple_oauth',
'state_machine',

View File

@ -6,3 +6,4 @@ core_version_requirement: ^10
dependencies:
- entity:entity
- farm:farm_api
- simple_oauth_password_grant:simple_oauth_password_grant

View File

@ -6,25 +6,37 @@
*/
use Drupal\consumers\Entity\Consumer;
use Drupal\simple_oauth\Oauth2ScopeInterface;
/**
* Implements hook_install().
*/
function farm_fieldkit_install() {
// Check for default role scopes.
/** @var \Drupal\simple_oauth\Oauth2ScopeProviderInterface $scope_provider */
$scope_provider = \Drupal::service('simple_oauth.oauth2_scope.provider');
$scopes = $scope_provider->loadMultiple(['farm_manager', 'farm_worker']);
$scope_ids = array_map(function (Oauth2ScopeInterface $scope) {
return $scope->id();
}, $scopes);
// Create a consumer for the farmOS Field Kit client.
$fk_consumer = Consumer::create([
'label' => 'Field Kit',
'client_id' => 'fieldkit',
'access_token_expiration' => 3600,
'grant_types' => [
'refresh_token',
'password',
],
'scopes' => array_values($scope_ids),
'redirect' => 'https://farmOS.app',
'allowed_origins' => 'https://farmos.app',
'owner_id' => '',
'owner_id' => NULL,
'secret' => NULL,
'confidential' => FALSE,
'third_party' => FALSE,
'grant_user_access' => TRUE,
'limit_user_access' => TRUE,
'limit_requested_access' => FALSE,
]);
$fk_consumer->save();
}

View File

@ -0,0 +1,39 @@
<?php
/**
* @file
* Post update functions for farm_fieldkit module.
*/
use Drupal\simple_oauth\Oauth2ScopeInterface;
/**
* Enable simple oauth password grant.
*/
function farm_fieldkit_post_update_enable_password_grant(&$sandbox = NULL) {
// Enable password grant module.
if (!\Drupal::service('module_handler')->moduleExists('simple_oauth_password_grant')) {
\Drupal::service('module_installer')->install(['simple_oauth_password_grant']);
}
// Check for default role scopes.
/** @var \Drupal\simple_oauth\Oauth2ScopeProviderInterface $scope_provider */
$scope_provider = \Drupal::service('simple_oauth.oauth2_scope.provider');
$scopes = $scope_provider->loadMultiple(['farm_manager', 'farm_worker']);
$scope_ids = array_map(function (Oauth2ScopeInterface $scope) {
return $scope->id();
}, $scopes);
// Update existing fieldkit consumer.
$consumers = \Drupal::entityTypeManager()->getStorage('consumer')
->loadByProperties(['client_id' => 'fieldkit']);
if (!empty($consumers)) {
/** @var \Drupal\consumers\Entity\ConsumerInterface $fieldkit */
$fieldkit = reset($consumers);
$fieldkit->set('user_id', NULL);
$fieldkit->set('grant_types', ['refresh_token', 'password']);
$fieldkit->set('scopes', array_values($scope_ids));
$fieldkit->save();
}
}

View File

@ -26,10 +26,24 @@ class OauthPasswordTest extends OauthTestBase {
'simple_oauth',
'text',
'user',
'farm_api',
'farm_api_default_consumer',
'farm_api_test',
'farm_login',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Add support for password grant and password scope consumer.
$this->client->get('grant_types')->appendItem('password');
$this->client->set('scopes', ['test:password']);
$this->client->save();
$this->scope = 'test:password';
}
/**
* Test a valid Password grant using username and email.
*/

View File

@ -0,0 +1,28 @@
<?php
/**
* @file
* Hooks implemented by the Farm Role Roles module.
*/
/**
* Implements hook_oauth2_scope_info_alter().
*/
function farm_role_roles_oauth2_scope_info_alter(array &$scopes) {
// Enable the password grant for static role scopes.
if (\Drupal::moduleHandler()->moduleExists('simple_oauth_password_grant')) {
$target_scopes = [
'farm_manager',
'farm_worker',
'farm_viewer',
];
foreach ($target_scopes as $scope_id) {
if (isset($target_scopes[$scope_id])) {
$scopes[$scope_id]['grant_types']['password'] = [
'status' => TRUE,
];
}
}
}
}

View File

@ -0,0 +1,36 @@
farm_manager:
description: 'Grants access to the Farm Manager role.'
umbrella: false
grant_types:
authorization_code:
status: true
client_credentials:
status: true
refresh_token:
status: true
granularity: 'role'
role: 'farm_manager'
farm_worker:
description: 'Grants access to the Farm Worker role.'
umbrella: false
grant_types:
authorization_code:
status: true
client_credentials:
status: true
refresh_token:
status: true
granularity: 'role'
role: 'farm_worker'
farm_viewer:
description: 'Grants access to the Farm Viewer role.'
umbrella: false
grant_types:
authorization_code:
status: true
client_credentials:
status: true
refresh_token:
status: true
granularity: 'role'
role: 'farm_viewer'