Claude
Skills
Sign in
Back

drupal-simple-oauth

Included with Lifetime
$97 forever

OAuth2 authentication patterns for Drupal using simple_oauth module. Covers TokenAuthUser permission logic, scope/role matching, mobile app token flows, field_permissions integration, CSRF bypass, and debugging token issues.

General

What this skill does


# Drupal Simple OAuth Patterns

Comprehensive patterns for working with the simple_oauth module for OAuth2 authentication in Drupal. Use when working with API authentication, mobile app tokens, or OAuth2 implementation.

## Version Information

- simple_oauth: 6.0.9
- Scope provider: dynamic (role-based granularity)
- Current Drupal: 10.x/11.x compatible

## Critical OAuth Token Concepts

### TokenAuthUser: The Core Authentication Wrapper

When a request is authenticated with an OAuth token, Drupal wraps the user in a `TokenAuthUser` decorator that enforces BOTH token AND user permissions.

**Location:** `/docroot/modules/contrib/simple_oauth/src/Authentication/TokenAuthUser.php`

#### Permission Check Logic (Line 95)

```php
public function hasPermission($permission) {
  // User #1 has all permissions.
  if ((int) $this->id() === 1) {
    return TRUE;
  }

  return $this->token->hasPermission($permission) && $this->subject->hasPermission($permission);
}
```

**Critical Rule:** BOTH the token AND the user must have the permission (AND condition).

#### Role Intersection Logic (Line 109)

```php
public function getRoles($exclude_locked_roles = FALSE) {
  $default_roles = [];
  if (!$exclude_locked_roles) {
    $default_roles[] = $this->isAuthenticated() ? self::AUTHENTICATED_ROLE : self::ANONYMOUS_ROLE;
  }

  $token_roles = array_unique(array_merge($this->token->getRoles($exclude_locked_roles), $default_roles));
  $user_roles = $this->subject->getRoles($exclude_locked_roles);
  return array_intersect($token_roles, $user_roles);
}
```

**Critical Rule:** Only roles that exist in BOTH the token AND the user are granted (`array_intersect`).

### The Scope/Role Matching Requirement

For an OAuth token to grant a permission:

1. The user MUST have a role with that permission
2. The token request MUST include a scope matching that role
3. An OAuth2 scope entity MUST exist with that name

**If any condition fails, permission is DENIED.**

## Common Pitfalls

### Pitfall 1: Scope/Role Mismatch

**Problem:**
```php
// User has: administrator role
// Token requested with: scope=api_consumer
// Result: Only 'authenticated' role granted (intersection)
// Permissions from administrator: DENIED
```

**Why it fails:**
```php
$token_roles = ['authenticated', 'api_consumer'];  // From scope
$user_roles = ['authenticated', 'administrator']; // From user
$granted = array_intersect($token_roles, $user_roles);   // ['authenticated']
```

**Solution:** Request token with correct scope:
```javascript
formData.append('scope', 'administrator');
```

### Pitfall 2: Non-existent Scope Entity

**Problem:**
```javascript
// Mobile app requests: scope=subscriber
// But no "subscriber" OAuth2 scope entity exists
// Result: Token has NO scopes, NO roles, NO permissions
```

**Solution:** Create the OAuth2 scope entity or use existing scope name.

**Check existing scopes:**
```bash
ddev drush config:get simple_oauth.settings
# Or query scope entities
ddev drush sqlq "SELECT id FROM consumer_scopes"
```

### Pitfall 3: Authenticated Role Permissions

**Problem:** Assuming authenticated role permissions are always granted.

**Reality:** Only if the token includes the authenticated role in its scope intersection.

**From Role.php (line 94):**
```php
// Scopes automatically grant authenticated role
return $exclude_locked_roles ? [$role] : [AccountInterface::AUTHENTICATED_ROLE, $role];
```

This was fixed in issue #3451692 (included in 6.0.x).

## Debugging OAuth Permission Issues

### Step 1: Verify Scope Entity Exists

```bash
# List all OAuth2 scopes
ddev drush sqlq "SELECT id, description FROM consumer_scopes"

# Example scopes you might have:
# - authenticated
# - api_consumer
# - premium_user
# - administrator
```

### Step 2: Check User Roles

```bash
ddev drush user:role:list [email protected]
```

### Step 3: Verify Role Permissions

```bash
# Check if role has the permission
ddev drush role:perm:list api_consumer | grep "view field_premium_content"
```

### Step 4: Test Token Creation

```php
// Create test script: test_oauth_token.php
use Drupal\simple_oauth\Entity\Oauth2Token;

$username = 'test_user';
$scope = 'premium_user'; // Match user's role!

// Get user
$user = user_load_by_name($username);
$consumer = \Drupal::entityTypeManager()
  ->getStorage('consumer')
  ->loadByProperties(['label' => 'Mobile App']);
$consumer = reset($consumer);

// Create token
$token = Oauth2Token::create([
  'auth_user_id' => $user->id(),
  'client' => $consumer->id(),
  'bundle' => 'access_token',
  'scopes' => $scope,
  'value' => bin2hex(random_bytes(32)),
  'expire' => time() + 3600,
  'status' => TRUE,
]);
$token->save();

// Wrap user with token context
$token_user = new \Drupal\simple_oauth\Authentication\TokenAuthUser($token);

// Test permissions
$permission = 'view field_premium_content';
$token_has = $token->hasPermission($permission);
$user_has = $user->hasPermission($permission);
$token_user_has = $token_user->hasPermission($permission);

print "Token roles: " . implode(', ', $token->getRoles()) . "\n";
print "User roles: " . implode(', ', $user->getRoles()) . "\n";
print "Intersected roles: " . implode(', ', $token_user->getRoles()) . "\n";
print "Token has permission: " . ($token_has ? 'YES' : 'NO') . "\n";
print "User has permission: " . ($user_has ? 'YES' : 'NO') . "\n";
print "TokenAuthUser has permission: " . ($token_user_has ? 'YES' : 'NO') . "\n";
```

Run with: `ddev drush php:script test_oauth_token.php`

### Step 5: Test API Request

```bash
#!/bin/bash
# Get OAuth token
TOKEN_RESPONSE=$(curl -s -X POST "https://yoursite.ddev.site/oauth/token" \
  -d "grant_type=password" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "[email protected]" \
  -d "password=password123" \
  -d "scope=premium_user")

ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', ''))")

# Test API request
curl -s -X GET "https://yoursite.ddev.site/jsonapi/node/article/2" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/vnd.api+json" | python3 -m json.tool
```

## Integration with Other Modules

### field_permissions Module

**How it works:**
```php
// field_permissions.module (line 34)
function field_permissions_entity_field_access($operation, FieldDefinitionInterface $field_definition, $account, FieldItemListInterface $items = NULL) {
  // ...
  $access_field = \Drupal::service('field_permissions.permissions_service')
    ->getFieldAccess($operation, $items, $account, $field_definition);
  // ...
}

// CustomAccess.php (line 36)
public function hasFieldAccess($operation, EntityInterface $entity, AccountInterface $account) {
  // Calls $account->hasPermission()
  // If $account is TokenAuthUser, uses the AND logic!
  return $account->hasPermission($operation . ' ' . $field_name);
}
```

**Result:** field_permissions works correctly with simple_oauth when scopes match roles.

### JSON:API Module

JSON:API respects all field-level access checks, including field_permissions. When using OAuth tokens:

1. JSON:API calls `entity_field_access` hooks
2. field_permissions checks `$account->hasPermission()`
3. If `$account` is TokenAuthUser, both token AND user must have permission
4. If either fails, field is excluded from JSON:API response

**No special configuration needed** - it works automatically when scopes are correct.

## OAuth Client Configuration

### Mobile App Client Configuration

When configuring a mobile or decoupled app as an OAuth client, the token request follows this pattern:

```javascript
const formData = new FormData();
formData.append('client_id', clientId);
formData.append('client_secret', clientSecret);
formData.append('scope', scope); // MUST match user's role!
formData.append('grant_type', 'password');
formData.append('username', username);
formData.append('password', password);
```

Store `client_id` and `client_secret` securely in your ap

Related in General