Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions config/conf.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2717,4 +2717,24 @@ cookie policy. See session configuration options.">$_SERVER['SERVER_NAME'] ?? $_

</configsection>
</configtab>

<configtab name="password_credentials" desc="Password Credentials">
<configsection name="password_credentials">
<configheader>Password Credential Storage</configheader>
<configdescription>Configure storage for password-based service credentials
(IMAP/SMTP passwords, API keys, bearer tokens). These are stored encrypted
using libsodium. This is separate from OAuth token storage.</configdescription>

<configswitch name="storage_driver" desc="Where to store password credentials?">sql
<case name="null" desc="Disabled (no password credential storage)">
</case>
<case name="sql" desc="SQL Database (encrypted with libsodium)">
<configsection name="storage_params">
<configsql switchname="driverconfig" />
</configsection>
</case>
</configswitch>

</configsection>
</configtab>
</configuration>
34 changes: 33 additions & 1 deletion config/oauth_presets.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
*
* Keys are provider_id slugs (lowercase, [a-z0-9_-]).
*
* Fields:
* == Provider Configuration Fields ==
*
* name – Human-readable provider name
* type – 'oauth2' | 'oidc'
* issuer – Issuer URL (OIDC providers use this for auto-discovery)
Expand All @@ -29,6 +30,27 @@
* display – Persisted on creation as display_label, display_icon, display_color
* notes – Shown in admin UI; not persisted
*
* == Service Authorization (Purpose-Specific Scopes) ==
*
* Service definitions map Horde service purposes to provider-specific scopes.
* These enable granular, purpose-specific authorization beyond basic login.
*
* Format: 'purposes' => ['purposeId' => 'space-separated-scopes', ...]
*
* Examples:
* 'purposes' => [
* 'github_repo' => 'repo repo:status',
* 'github_org' => 'read:org write:org',
* ]
*
* Grant strategies (specified at request time, not in preset):
* - Isolated (default): Purpose gets its own token grant
* - Additive: Extends the shared grant with new scopes
*
* These are not independent providers but Horde service definitions on the
* parent provider entry. The purposeId becomes part of the authorization
* flow and is stored in horde_service_authorizations.
*
* Copyright 2026 The Horde Project (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
Expand All @@ -54,6 +76,10 @@
'color' => '#24292e',
],
'notes' => 'Register an OAuth App at https://github.com/settings/developers. Set the callback URL to your Horde\'s /settings/oauth/callback.',
'purposes' => [
'github_repo' => 'repo repo:status repo_deployment public_repo repo:invite delete_repo',
'github_org' => 'read:org write:org',
],
];

$backends['google'] = [
Expand All @@ -72,6 +98,9 @@
'color' => '#4285f4',
],
'notes' => 'Create OAuth credentials at https://console.cloud.google.com/apis/credentials. Enable the "Google Identity" API.',
'purposes' => [
'rest_mail' => 'https://www.googleapis.com/auth/gmail.read https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send',
],
];

$backends['microsoft'] = [
Expand Down Expand Up @@ -140,6 +169,9 @@
'color' => '#6364ff',
],
'notes' => 'Register an application at https://phpc.social/settings/applications. Replace the default Redirect URI (urn:ietf:wg:oauth:2.0:oob) with the callback URL shown below. Any Mastodon instance uses the same endpoint pattern.',
'purposes' => [
'post' => 'read write',
],
];

$backends['x'] = [
Expand Down
75 changes: 75 additions & 0 deletions migration/3_horde_token_grants.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php
/**
* Enhance OAUTH related schema to support purpose-specific authorization and token grants.
*/
class HordeTokenGrants extends Horde_Db_Migration_Base
{
public function up()
{
if (!in_array('horde_token_grants', $this->tables())) {
$t = $this->createTable('horde_token_grants', ['autoincrementKey' => false, 'primaryKey' => 'grant_id']);
$t->column('grant_id', 'string', ['limit' => 36, 'null' => false]);
$t->column('user_uid', 'string', ['limit' => 255, 'null' => false]);
$t->column('provider_id', 'string', ['limit' => 255, 'null' => false]);
$t->column('token_data', 'text', ['null' => false]);
$t->column('granted_scopes', 'text', ['null' => false]);
$t->column('is_shared', 'integer', ['limit' => 1, 'null' => false, 'default' => 0]);
$t->column('created_at', 'integer', ['null' => false]);
$t->column('updated_at', 'integer', ['null' => false]);
$t->end();

$this->addIndex('horde_token_grants', ['user_uid', 'provider_id']);
$this->addIndex('horde_token_grants', ['user_uid', 'provider_id', 'is_shared']);
}
if (!in_array('horde_service_authorizations', $this->tables())) {
$t = $this->createTable('horde_service_authorizations', ['autoincrementKey' => false, 'primaryKey' => ['user_uid', 'provider_id', 'purpose_id']]);
$t->column('user_uid', 'string', ['limit' => 255, 'null' => false]);
$t->column('provider_id', 'string', ['limit' => 255, 'null' => false]);
$t->column('purpose_id', 'string', ['limit' => 100, 'null' => false]);
$t->column('purpose_strategy', 'string', ['limit' => 20, 'null' => false]);
$t->column('grant_id', 'string', ['limit' => 36, 'null' => false]);
$t->column('created_at', 'integer', ['null' => false]);
$t->end();

$this->addIndex('horde_service_authorizations', ['grant_id']);
}
if (!in_array('horde_password_credentials', $this->tables())) {
$t = $this->createTable('horde_password_credentials', ['autoincrementKey' => false, 'primaryKey' => 'credential_id']);
$t->column('credential_id', 'string', ['limit' => 32, 'null' => false]);
$t->column('user_uid', 'string', ['limit' => 255, 'null' => false]);
$t->column('provider_id', 'string', ['limit' => 255, 'null' => false]);
$t->column('purpose_id', 'string', ['limit' => 255, 'null' => false]);
$t->column('credential_data', 'text', ['null' => false]);
$t->column('created_at', 'integer', ['null' => false]);
$t->column('updated_at', 'integer', ['null' => false]);
$t->end();

$this->addIndex('horde_password_credentials', ['user_uid', 'provider_id']);
$this->addIndex('horde_password_credentials', ['user_uid', 'provider_id', 'purpose_id'], ['unique' => true]);
}

if (in_array('horde_oauth_flows', $this->tables())) {
$this->changeColumn('horde_oauth_flows', 'flow_type', 'string', ['limit' => 255, 'null' => false]);
}
if (in_array('horde_oauth_providers', $this->tables())) {
$this->addColumn('horde_oauth_providers', 'purposes', 'text');
}

}

public function down()
{
if (in_array('horde_oauth_flows', $this->tables())) {
$this->changeColumn('horde_oauth_flows', 'flow_type', 'string', ['limit' => 50, 'null' => false]);
}
if (in_array('horde_oauth_providers', $this->tables())) {
$this->removeColumn('horde_oauth_providers', 'purposes');
}
$this->dropTable('horde_token_grants');
$this->dropTable('horde_service_authorizations');
if (in_array('horde_password_credentials', $this->tables())) {
$this->dropTable('horde_password_credentials');
}

}
}
21 changes: 20 additions & 1 deletion src/Admin/OAuthProviderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,19 @@ private function extractUpdateData(array $existing, array $body): array
}
}

// Extract purposes (service-specific scopes)
if (isset($body['purposes'])) {
$purposes = [];
foreach ($body['purposes'] as $purposeData) {
$purposeId = trim($purposeData['id'] ?? '');
$scopes = trim($purposeData['scopes'] ?? '');
if ($purposeId !== '' && $scopes !== '') {
$purposes[$purposeId] = $scopes;
}
}
$data['purposes'] = $purposes;
}

return $data;
}

Expand All @@ -326,14 +339,20 @@ private function createFromPreset(string $presetKey, string $baseUrl): ResponseI

$preset = $presets[$presetKey];
$display = $preset['display'] ?? [];
unset($preset['display'], $preset['notes']);
$purposes = $preset['purposes'] ?? [];
unset($preset['display'], $preset['notes'], $preset['purposes']);

$data = $preset;
$data['display_label'] = $display['label'] ?? $preset['name'] ?? '';
$data['display_icon'] = $display['icon'] ?? '';
$data['display_color'] = $display['color'] ?? '';
$data['enabled'] = 0;

// Preserve purposes from preset
if (!empty($purposes)) {
$data['purposes'] = $purposes;
}

if (($data['type'] ?? '') === 'oidc' && ($data['issuer'] ?? '') !== '' && $this->discovery !== null) {
try {
$discovered = $this->discovery->discover($data['issuer']);
Expand Down
29 changes: 29 additions & 0 deletions src/Factory/CredentialStoreFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Horde\Horde\Factory;

use Horde\Core\Service\CredentialStore;
use Horde\Core\Service\NullCredentialStore;
use Horde\Db\Adapter;
use Horde\Horde\Service\SqlCredentialStore;
use Horde\Injector\Injector;
use Horde\Secret\SecretManager;

class CredentialStoreFactory
{
public function create(Injector $injector): CredentialStore
{
$config = $injector->getInstance('Horde_Registry')->config();
$driver = $config['password_credentials']['storage_driver'] ?? 'null';

return match ($driver) {
'sql' => new SqlCredentialStore(
db: $injector->getInstance(Adapter::class),
secret: $injector->getInstance(SecretManager::class),
),
default => new NullCredentialStore(),
};
}
}
22 changes: 22 additions & 0 deletions src/Factory/ServiceAuthorizationRepositoryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Horde\Horde\Factory;

use Horde\Core\Service\ServiceAuthorizationRepository;
use Horde\Core\Service\TokenGrantRepository;
use Horde\Db\Adapter;
use Horde\Horde\Service\SqlServiceAuthorizationRepository;
use Horde\Injector\Injector;

class ServiceAuthorizationRepositoryFactory
{
public function create(Injector $injector): ServiceAuthorizationRepository
{
return new SqlServiceAuthorizationRepository(
db: $injector->getInstance(Adapter::class),
grantRepo: $injector->getInstance(TokenGrantRepository::class),
);
}
}
34 changes: 34 additions & 0 deletions src/Factory/ServiceAuthorizationServiceFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Horde\Horde\Factory;

use Horde\Core\Service\OAuthProviderConfigRepository;
use Horde\Core\Service\ServiceAuthorizationRepository;
use Horde\Core\Service\ServiceAuthorizationService;
use Horde\Core\Service\TokenGrantRepository;
use Horde\Core\Uri\RouteUrlWriter;
use Horde\Horde\Service\DefaultServiceAuthorizationService;
use Horde\Injector\Injector;
use Horde\OAuth\Client\OAuthFlowStore;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;

class ServiceAuthorizationServiceFactory
{
public function create(Injector $injector): ServiceAuthorizationService
{
return new DefaultServiceAuthorizationService(
authRepo: $injector->getInstance(ServiceAuthorizationRepository::class),
grantRepo: $injector->getInstance(TokenGrantRepository::class),
providerConfigRepo: $injector->getInstance(OAuthProviderConfigRepository::class),
flowStore: $injector->getInstance(OAuthFlowStore::class),
httpClient: $injector->getInstance(ClientInterface::class),
requestFactory: $injector->getInstance(RequestFactoryInterface::class),
streamFactory: $injector->getInstance(StreamFactoryInterface::class),
urlWriter: $injector->getInstance(RouteUrlWriter::class),
);
}
}
23 changes: 23 additions & 0 deletions src/Factory/TokenGrantRepositoryFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Horde\Horde\Factory;

use Horde\Core\Service\TokenGrantRepository;
use Horde\Db\Adapter;
use Horde\Horde\Service\SqlTokenGrantRepository;
use Horde\Injector\Binder;
use Horde\Injector\Injector;
use Horde\Secret\SecretManager;

class TokenGrantRepositoryFactory
{
public function create(Injector $injector): TokenGrantRepository
{
return new SqlTokenGrantRepository(
db: $injector->getInstance(Adapter::class),
secret: $injector->getInstance(SecretManager::class),
);
}
}
Loading