diff --git a/config/conf.xml b/config/conf.xml
index 772e5e46..e781dc21 100644
--- a/config/conf.xml
+++ b/config/conf.xml
@@ -2717,4 +2717,24 @@ cookie policy. See session configuration options.">$_SERVER['SERVER_NAME'] ?? $_
+
+
+
+ Password Credential Storage
+ 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.
+
+ sql
+
+
+
+
+
+
+
+
+
+
+
diff --git a/config/oauth_presets.php b/config/oauth_presets.php
index 35ac02c6..881d87c3 100644
--- a/config/oauth_presets.php
+++ b/config/oauth_presets.php
@@ -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)
@@ -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
@@ -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'] = [
@@ -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'] = [
@@ -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'] = [
diff --git a/migration/3_horde_token_grants.php b/migration/3_horde_token_grants.php
new file mode 100644
index 00000000..014c5691
--- /dev/null
+++ b/migration/3_horde_token_grants.php
@@ -0,0 +1,75 @@
+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');
+ }
+
+ }
+}
diff --git a/src/Admin/OAuthProviderController.php b/src/Admin/OAuthProviderController.php
index 054bce02..e5395425 100644
--- a/src/Admin/OAuthProviderController.php
+++ b/src/Admin/OAuthProviderController.php
@@ -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;
}
@@ -326,7 +339,8 @@ 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'] ?? '';
@@ -334,6 +348,11 @@ private function createFromPreset(string $presetKey, string $baseUrl): ResponseI
$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']);
diff --git a/src/Factory/CredentialStoreFactory.php b/src/Factory/CredentialStoreFactory.php
new file mode 100644
index 00000000..df2f7d70
--- /dev/null
+++ b/src/Factory/CredentialStoreFactory.php
@@ -0,0 +1,29 @@
+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(),
+ };
+ }
+}
diff --git a/src/Factory/ServiceAuthorizationRepositoryFactory.php b/src/Factory/ServiceAuthorizationRepositoryFactory.php
new file mode 100644
index 00000000..5fe1e58e
--- /dev/null
+++ b/src/Factory/ServiceAuthorizationRepositoryFactory.php
@@ -0,0 +1,22 @@
+getInstance(Adapter::class),
+ grantRepo: $injector->getInstance(TokenGrantRepository::class),
+ );
+ }
+}
diff --git a/src/Factory/ServiceAuthorizationServiceFactory.php b/src/Factory/ServiceAuthorizationServiceFactory.php
new file mode 100644
index 00000000..63fdc2f6
--- /dev/null
+++ b/src/Factory/ServiceAuthorizationServiceFactory.php
@@ -0,0 +1,34 @@
+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),
+ );
+ }
+}
diff --git a/src/Factory/TokenGrantRepositoryFactory.php b/src/Factory/TokenGrantRepositoryFactory.php
new file mode 100644
index 00000000..749290a4
--- /dev/null
+++ b/src/Factory/TokenGrantRepositoryFactory.php
@@ -0,0 +1,23 @@
+getInstance(Adapter::class),
+ secret: $injector->getInstance(SecretManager::class),
+ );
+ }
+}
diff --git a/src/Service/DefaultServiceAuthorizationService.php b/src/Service/DefaultServiceAuthorizationService.php
new file mode 100644
index 00000000..2c53847b
--- /dev/null
+++ b/src/Service/DefaultServiceAuthorizationService.php
@@ -0,0 +1,310 @@
+authRepo->find($userId, $providerId, $purpose);
+
+ if ($auth === null) {
+ throw new ServiceNotAuthorizedException(
+ $userId,
+ $providerId,
+ $purpose,
+ new ScopeSet()
+ );
+ }
+
+ $requiredScopes = $this->getRequiredScopes($providerId, $purpose);
+
+ if (!$auth->grant()->covers($requiredScopes)) {
+ $missingScopes = $this->subtractScopes($requiredScopes, $auth->grant()->grantedScopes());
+ throw new ServiceNotAuthorizedException(
+ $userId,
+ $providerId,
+ $purpose,
+ $missingScopes
+ );
+ }
+
+ return $auth;
+ }
+
+ public function initiate(
+ string $userId,
+ string $providerId,
+ ServicePurpose $purpose,
+ string $returnUrl,
+ ?string $requestingApp = null,
+ ): ?UriInterface {
+ $requiredScopes = $this->getRequiredScopes($providerId, $purpose);
+
+ $scopesToRequest = $requiredScopes;
+
+ // Additive strategy: try to reuse existing shared grant
+ if ($purpose->grantStrategy() === GrantStrategy::Additive) {
+ $existingGrant = $this->grantRepo->findShared($userId, $providerId);
+
+ if ($existingGrant !== null && $existingGrant->covers($requiredScopes)) {
+ // Grant already covers required scopes, create auth directly
+ $auth = new ConcreteServiceAuthorization(
+ userId: $userId,
+ providerId: $providerId,
+ purpose: $purpose,
+ grant: $existingGrant,
+ requiredScopes: $requiredScopes,
+ );
+ $this->authRepo->save($auth);
+ return null;
+ }
+
+ if ($existingGrant !== null) {
+ // Need to extend existing grant with additional scopes
+ $scopesToRequest = new ScopeSet(...array_unique([
+ ...$existingGrant->grantedScopes()->toArray(),
+ ...$requiredScopes->toArray()
+ ]));
+ }
+ }
+
+ // Generate PKCE parameters
+ $verifier = PkceGenerator::generateVerifier();
+ $challenge = PkceGenerator::computeChallenge($verifier);
+ $state = bin2hex(random_bytes(32));
+
+ // Encode purpose + strategy into flowType
+ $flowType = $purpose->identifier() . ':' . $purpose->grantStrategy()->value;
+
+ $this->flowStore->save($state, new OAuthFlowData(
+ state: $state,
+ providerId: $providerId,
+ pkceVerifier: $verifier,
+ flowType: $flowType,
+ createdAt: time(),
+ redirectUrl: $returnUrl,
+ requestingApp: $requestingApp,
+ ));
+
+ // Build authorization URL
+ $providerConfig = $this->providerConfigRepo->get($providerId);
+ $redirectUri = $this->urlWriter->absoluteUrlFor('SettingsOAuthCallback');
+ $client = $this->buildOAuth2Client($providerConfig, $redirectUri);
+
+ return $client->getAuthorizationUrl(
+ scopes: $scopesToRequest->toArray(),
+ state: $state,
+ codeChallenge: $challenge,
+ codeChallengeMethod: 'S256',
+ );
+ }
+
+ /** Internal helper - userId should be obtained from authenticated session by controller. */
+ public function handleCallback(string $code, OAuthFlowData $flowData): ServiceAuthorization
+ {
+ // Interface requires this signature but doesn't provide userId
+ // Controller should call handleCallbackWithUser() instead
+ throw new \RuntimeException(
+ 'handleCallback() cannot determine userId from flow data. ' .
+ 'Controller must use internal handleCallbackWithUser() method.'
+ );
+ }
+
+ /** Internal method called by controller with userId from authenticated session. */
+ public function handleCallbackWithUser(string $userId, string $code, OAuthFlowData $flowData): ServiceAuthorization
+ {
+ // Parse purpose from flowType
+ $parts = explode(':', $flowData->flowType, 2);
+ if (count($parts) !== 2) {
+ throw new \RuntimeException('Invalid flowType format: ' . $flowData->flowType);
+ }
+ $purpose = ServicePurpose::of($parts[0], GrantStrategy::from($parts[1]));
+
+ $providerConfig = $this->providerConfigRepo->get($flowData->providerId);
+ $redirectUri = $this->urlWriter->absoluteUrlFor('SettingsOAuthCallback');
+ $client = $this->buildOAuth2Client($providerConfig, $redirectUri);
+
+ // Exchange authorization code for tokens
+ $tokenSet = $client->exchangeCode($code, $flowData->pkceVerifier);
+
+ $grantedScopes = ScopeSet::fromSpaceSeparated($tokenSet->scope ?? '');
+
+ if ($purpose->grantStrategy() === GrantStrategy::Additive) {
+ $existingGrant = $this->grantRepo->findShared($userId, $flowData->providerId);
+
+ if ($existingGrant !== null) {
+ // Update existing shared grant
+ if ($existingGrant instanceof MutableTokenGrant) {
+ $existingGrant->updateTokenSet($tokenSet);
+ }
+ $this->grantRepo->update($existingGrant);
+ $grant = $existingGrant;
+ } else {
+ // Create new shared grant
+ $grant = new MutableTokenGrant(
+ grantId: bin2hex(random_bytes(16)),
+ userId: $userId,
+ providerId: $flowData->providerId,
+ tokenSet: $tokenSet,
+ grantedScopes: $grantedScopes,
+ isShared: true,
+ repository: $this->grantRepo,
+ );
+ $this->grantRepo->save($grant);
+ }
+ } else {
+ // Isolated: create new grant
+ $grant = new MutableTokenGrant(
+ grantId: bin2hex(random_bytes(16)),
+ userId: $userId,
+ providerId: $flowData->providerId,
+ tokenSet: $tokenSet,
+ grantedScopes: $grantedScopes,
+ isShared: false,
+ repository: $this->grantRepo,
+ );
+ $this->grantRepo->save($grant);
+ }
+
+ // Create ServiceAuthorization
+ $requiredScopes = $this->getRequiredScopes($flowData->providerId, $purpose);
+ $auth = new ConcreteServiceAuthorization(
+ userId: $userId,
+ providerId: $flowData->providerId,
+ purpose: $purpose,
+ grant: $grant,
+ requiredScopes: $requiredScopes,
+ );
+ $this->authRepo->save($auth);
+
+ return $auth;
+ }
+
+ public function revoke(
+ string $userId,
+ string $providerId,
+ ServicePurpose $purpose,
+ ): void {
+ $auth = $this->authRepo->find($userId, $providerId, $purpose);
+ if ($auth === null) {
+ return; // Idempotent
+ }
+
+ $this->authRepo->delete($auth);
+ // Grant is intentionally left intact
+ }
+
+ public function revokeAll(
+ string $userId,
+ string $providerId,
+ bool $revokeAtProvider = false,
+ ): void {
+ $grants = $this->grantRepo->findAll($userId, $providerId);
+
+ if ($revokeAtProvider && count($grants) > 0) {
+ $providerConfig = $this->providerConfigRepo->get($providerId);
+ $redirectUri = $this->urlWriter->absoluteUrlFor('SettingsOAuthCallback');
+ $client = $this->buildOAuth2Client($providerConfig, $redirectUri);
+
+ foreach ($grants as $grant) {
+ $refreshToken = $grant->tokenSet()->refreshToken;
+ if ($refreshToken !== null) {
+ try {
+ $client->revokeToken($refreshToken, 'refresh_token');
+ } catch (\Throwable $e) {
+ // Continue revoking other tokens
+ error_log("Failed to revoke token at provider: {$e->getMessage()}");
+ }
+ }
+ }
+ }
+
+ $this->authRepo->deleteAll($userId, $providerId);
+
+ foreach ($grants as $grant) {
+ $this->grantRepo->delete($grant);
+ }
+ }
+
+ private function getRequiredScopes(string $providerId, ServicePurpose $purpose): ScopeSet
+ {
+ try {
+ $providerConfig = $this->providerConfigRepo->get($providerId);
+ } catch (OAuthProviderConfigNotFoundException $e) {
+ throw new UnsupportedPurposeException($providerId, $purpose);
+ }
+
+ // Check for purpose in the purposes map
+ if (isset($providerConfig['purposes'][$purpose->identifier()])) {
+ return ScopeSet::fromSpaceSeparated($providerConfig['purposes'][$purpose->identifier()]);
+ }
+
+ // For 'login' purpose, fall back to default_scopes
+ if ($purpose->identifier() === 'login' && !empty($providerConfig['default_scopes'])) {
+ return ScopeSet::fromSpaceSeparated($providerConfig['default_scopes']);
+ }
+
+ throw new UnsupportedPurposeException($providerId, $purpose);
+ }
+
+ private function subtractScopes(ScopeSet $required, ScopeSet $granted): ScopeSet
+ {
+ $missing = array_diff($required->toArray(), $granted->toArray());
+ return new ScopeSet(...$missing);
+ }
+
+ private function buildOAuth2Client(array $providerConfig, string $redirectUri = ''): OAuth2Client
+ {
+ $config = ProviderConfig::fromArray($providerConfig);
+
+ return new OAuth2Client(
+ provider: $config,
+ clientId: $providerConfig['client_id'] ?? '',
+ clientSecret: $providerConfig['client_secret'] ?? null,
+ redirectUri: $redirectUri,
+ httpClient: $this->httpClient,
+ requestFactory: $this->requestFactory,
+ streamFactory: $this->streamFactory,
+ );
+ }
+}
diff --git a/src/Service/HordePasswordCredential.php b/src/Service/HordePasswordCredential.php
new file mode 100644
index 00000000..299aa4d4
--- /dev/null
+++ b/src/Service/HordePasswordCredential.php
@@ -0,0 +1,114 @@
+credentialId;
+ }
+
+ public function userId(): string
+ {
+ return $this->userId;
+ }
+
+ public function providerId(): string
+ {
+ return $this->providerId;
+ }
+
+ public function purpose(): ServicePurpose
+ {
+ return $this->purpose;
+ }
+
+ public function asStructured(): array
+ {
+ if (is_array($this->data)) {
+ return $this->data;
+ }
+
+ // If stored as string but accessed as structured, attempt JSON decode
+ if (is_string($this->data)) {
+ $decoded = json_decode($this->data, true);
+ if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
+ return $decoded;
+ }
+ }
+
+ throw new \RuntimeException('Credential data is not structured');
+ }
+
+ public function asOpaque(): string
+ {
+ if (is_string($this->data)) {
+ return $this->data;
+ }
+
+ // If stored as array, encode as JSON
+ if (is_array($this->data)) {
+ return json_encode($this->data, JSON_THROW_ON_ERROR);
+ }
+
+ throw new \RuntimeException('Cannot convert credential to opaque string');
+ }
+
+ public function asBearerToken(): string
+ {
+ // If opaque string, return as-is
+ if (is_string($this->data)) {
+ return $this->data;
+ }
+
+ // If structured with 'bearer' key
+ if (is_array($this->data) && isset($this->data['bearer'])) {
+ return $this->data['bearer'];
+ }
+
+ // If structured with 'token' key (API key)
+ if (is_array($this->data) && isset($this->data['token'])) {
+ return $this->data['token'];
+ }
+
+ throw new \RuntimeException('Credential does not contain bearer token');
+ }
+
+ public function asBasicAuth(): string
+ {
+ $structured = $this->asStructured();
+
+ if (!isset($structured['username']) || !isset($structured['password'])) {
+ throw new \RuntimeException('Credential does not contain username/password');
+ }
+
+ return base64_encode($structured['username'] . ':' . $structured['password']);
+ }
+
+ public function createdAt(): int
+ {
+ return $this->createdAt;
+ }
+
+ public function updatedAt(): int
+ {
+ return $this->updatedAt;
+ }
+}
diff --git a/src/Service/HordeServiceAuthorization.php b/src/Service/HordeServiceAuthorization.php
new file mode 100644
index 00000000..83bd2fea
--- /dev/null
+++ b/src/Service/HordeServiceAuthorization.php
@@ -0,0 +1,72 @@
+userId;
+ }
+
+ public function providerId(): string
+ {
+ return $this->providerId;
+ }
+
+ public function purpose(): ServicePurpose
+ {
+ return $this->purpose;
+ }
+
+ public function grant(): TokenGrant
+ {
+ return $this->grant;
+ }
+
+ public function isSatisfied(): bool
+ {
+ return $this->grant->covers($this->requiredScopes);
+ }
+
+ public function getAccessToken(): string
+ {
+ if (!$this->isSatisfied()) {
+ throw new ServiceNotAuthorizedException(
+ $this->userId,
+ $this->providerId,
+ $this->purpose,
+ $this->requiredScopes
+ );
+ }
+
+ try {
+ return $this->grant->getAccessToken();
+ } catch (OAuthTokenRefreshException $e) {
+ throw $e;
+ }
+ }
+
+ public function requiredScopes(): ScopeSet
+ {
+ return $this->requiredScopes;
+ }
+}
diff --git a/src/Service/MutableTokenGrant.php b/src/Service/MutableTokenGrant.php
new file mode 100644
index 00000000..97e311dc
--- /dev/null
+++ b/src/Service/MutableTokenGrant.php
@@ -0,0 +1,105 @@
+grantId;
+ }
+
+ public function userId(): string
+ {
+ return $this->userId;
+ }
+
+ public function providerId(): string
+ {
+ return $this->providerId;
+ }
+
+ public function grantedScopes(): ScopeSet
+ {
+ return $this->grantedScopes;
+ }
+
+ public function covers(ScopeSet $required): bool
+ {
+ return $this->grantedScopes->contains($required);
+ }
+
+ public function getAccessToken(): string
+ {
+ if (!$this->isExpired()) {
+ return $this->tokenSet->accessToken;
+ }
+
+ if ($this->repository === null) {
+ throw new OAuthTokenRefreshException(
+ 'Token expired and no repository available for refresh',
+ $this->userId,
+ $this->providerId
+ );
+ }
+
+ // Refresh will be handled by the service layer when needed
+ throw new OAuthTokenRefreshException(
+ 'Token expired; refresh required',
+ $this->userId,
+ $this->providerId,
+ $this
+ );
+ }
+
+ public function isExpired(): bool
+ {
+ if ($this->tokenSet->expiresAt === null) {
+ return false;
+ }
+ return time() >= $this->tokenSet->expiresAt;
+ }
+
+ public function tokenSet(): TokenSet
+ {
+ return $this->tokenSet;
+ }
+
+ public function isShared(): bool
+ {
+ return $this->isShared;
+ }
+
+ /** Update token data after refresh or scope extension. */
+ public function updateTokenSet(TokenSet $newTokenSet): void
+ {
+ $this->tokenSet = $newTokenSet;
+
+ // Merge scopes if new set contains additional grants
+ if (!empty($newTokenSet->scope)) {
+ $newScopes = ScopeSet::fromSpaceSeparated($newTokenSet->scope);
+ $this->grantedScopes = new ScopeSet(...array_unique([
+ ...$this->grantedScopes->toArray(),
+ ...$newScopes->toArray()
+ ]));
+ }
+ }
+}
diff --git a/src/Service/SessionToCredentialStoreProvisioner.php b/src/Service/SessionToCredentialStoreProvisioner.php
new file mode 100644
index 00000000..e5e01ed2
--- /dev/null
+++ b/src/Service/SessionToCredentialStoreProvisioner.php
@@ -0,0 +1,84 @@
+store->find($userId, $providerId, $purpose);
+ if ($existing !== null) {
+ return new ProvisioningResult(ProvisioningAction::UseSession);
+ }
+
+ // Check if credential is available in session
+ $sessionKey = $this->sessionKeyPrefix . $userId . ':' . $providerId . ':' . $purpose->identifier();
+ $sessionData = $this->session->get($sessionKey);
+
+ if ($sessionData === null || $sessionData === false) {
+ return new ProvisioningResult(ProvisioningAction::Unavailable);
+ }
+
+ // Credential found in session, copy to store
+ try {
+ $this->store->store($userId, $providerId, $purpose, $sessionData);
+ return new ProvisioningResult(ProvisioningAction::UseSession);
+ } catch (\Throwable $e) {
+ // Store failed, credential remains session-only
+ return new ProvisioningResult(ProvisioningAction::Unavailable);
+ }
+ }
+
+ /**
+ * Helper method to populate session with credentials at login time.
+ *
+ * Applications should call this when user logs in with username/password.
+ */
+ public function setSessionCredential(
+ string $userId,
+ string $providerId,
+ ServicePurpose $purpose,
+ array|string $credential
+ ): void {
+ $sessionKey = $this->sessionKeyPrefix . $userId . ':' . $providerId . ':' . $purpose->identifier();
+ $this->session->set($sessionKey, $credential);
+ }
+
+ /**
+ * Clear credential from session.
+ */
+ public function clearSessionCredential(
+ string $userId,
+ string $providerId,
+ ServicePurpose $purpose
+ ): void {
+ $sessionKey = $this->sessionKeyPrefix . $userId . ':' . $providerId . ':' . $purpose->identifier();
+ $this->session->remove($sessionKey);
+ }
+}
diff --git a/src/Service/SqlCredentialStore.php b/src/Service/SqlCredentialStore.php
new file mode 100644
index 00000000..8047086e
--- /dev/null
+++ b/src/Service/SqlCredentialStore.php
@@ -0,0 +1,152 @@
+db->selectOne(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ? AND purpose_id = ?',
+ [$userId, $providerId, $purpose->identifier()]
+ );
+
+ return $row ? $this->hydrate($row) : null;
+ }
+
+ public function findAll(string $userId): array
+ {
+ $rows = $this->db->selectAll(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ?',
+ [$userId]
+ );
+
+ return array_map(fn($row) => $this->hydrate($row), $rows);
+ }
+
+ public function findAllForProvider(
+ string $userId,
+ string $providerId
+ ): array {
+ $rows = $this->db->selectAll(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ?',
+ [$userId, $providerId]
+ );
+
+ return array_map(fn($row) => $this->hydrate($row), $rows);
+ }
+
+ public function store(
+ string $userId,
+ string $providerId,
+ ServicePurpose $purpose,
+ array|string $credential
+ ): PasswordCredential {
+ $credentialId = $this->generateId();
+ $json = json_encode($credential, JSON_THROW_ON_ERROR);
+ $encrypted = $this->secret->encrypt($json);
+ $credentialData = $encrypted->toBase64();
+ $now = time();
+
+ $this->db->insert(
+ 'INSERT INTO ' . $this->table . ' (credential_id, user_uid, provider_id, purpose_id, credential_data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [$credentialId, $userId, $providerId, $purpose->identifier(), $credentialData, $now, $now]
+ );
+
+ return new HordePasswordCredential(
+ $credentialId,
+ $userId,
+ $providerId,
+ $purpose,
+ $credential,
+ $now,
+ $now
+ );
+ }
+
+ public function update(
+ string $credentialId,
+ array|string $credential
+ ): PasswordCredential {
+ $json = json_encode($credential, JSON_THROW_ON_ERROR);
+ $encrypted = $this->secret->encrypt($json);
+ $credentialData = $encrypted->toBase64();
+ $now = time();
+
+ $this->db->update(
+ 'UPDATE ' . $this->table . ' SET credential_data = ?, updated_at = ? WHERE credential_id = ?',
+ [$credentialData, $now, $credentialId]
+ );
+
+ // Fetch to return full object
+ $row = $this->db->selectOne(
+ 'SELECT * FROM ' . $this->table . ' WHERE credential_id = ?',
+ [$credentialId]
+ );
+
+ if (!$row) {
+ throw new \RuntimeException("Credential not found after update: {$credentialId}");
+ }
+
+ return $this->hydrate($row);
+ }
+
+ public function delete(string $credentialId): void
+ {
+ $this->db->delete(
+ 'DELETE FROM ' . $this->table . ' WHERE credential_id = ?',
+ [$credentialId]
+ );
+ }
+
+ public function deleteAll(string $userId, string $providerId): void
+ {
+ $this->db->delete(
+ 'DELETE FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ?',
+ [$userId, $providerId]
+ );
+ }
+
+ private function hydrate(array $row): PasswordCredential
+ {
+ $encrypted = EncryptedData::fromBase64($row['credential_data']);
+ $json = $this->secret->decrypt($encrypted);
+ $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+
+ $purpose = ServicePurpose::deserialize($row['purpose_id']);
+
+ return new HordePasswordCredential(
+ $row['credential_id'],
+ $row['user_uid'],
+ $row['provider_id'],
+ $purpose,
+ $data,
+ (int) $row['created_at'],
+ (int) $row['updated_at']
+ );
+ }
+
+ private function generateId(): string
+ {
+ return bin2hex(random_bytes(16));
+ }
+}
diff --git a/src/Service/SqlOAuthProviderConfigRepository.php b/src/Service/SqlOAuthProviderConfigRepository.php
index 602e26f8..e61c53c6 100644
--- a/src/Service/SqlOAuthProviderConfigRepository.php
+++ b/src/Service/SqlOAuthProviderConfigRepository.php
@@ -39,6 +39,7 @@ class SqlOAuthProviderConfigRepository implements OAuthProviderConfigRepository
private const ENCRYPTED_FIELDS = ['client_secret', 'private_key'];
private const JSON_FIELDS = [
+ 'purposes',
'scopes_supported',
'response_types_supported',
'grant_types_supported',
diff --git a/src/Service/SqlServiceAuthorizationRepository.php b/src/Service/SqlServiceAuthorizationRepository.php
new file mode 100644
index 00000000..5ad1ded2
--- /dev/null
+++ b/src/Service/SqlServiceAuthorizationRepository.php
@@ -0,0 +1,119 @@
+db->selectOne(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ? AND purpose_id = ?',
+ [$userId, $providerId, $purpose->identifier()]
+ );
+
+ return $row ? $this->hydrate($row) : null;
+ }
+
+ public function findAll(string $userId, string $providerId): array
+ {
+ $rows = $this->db->selectAll(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ?',
+ [$userId, $providerId]
+ );
+
+ return array_map(fn($row) => $this->hydrate($row), $rows);
+ }
+
+ public function findAllForUser(string $userId): array
+ {
+ $rows = $this->db->selectAll(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ?',
+ [$userId]
+ );
+
+ return array_map(fn($row) => $this->hydrate($row), $rows);
+ }
+
+ public function save(ServiceAuthorization $authorization): void
+ {
+ $now = time();
+
+ $this->db->insert(
+ 'INSERT INTO ' . $this->table . ' (user_uid, provider_id, purpose_id, purpose_strategy, grant_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
+ [
+ $authorization->userId(),
+ $authorization->providerId(),
+ $authorization->purpose()->identifier(),
+ $authorization->purpose()->grantStrategy()->value,
+ $authorization->grant()->grantId(),
+ $now
+ ]
+ );
+ }
+
+ public function delete(ServiceAuthorization $authorization): void
+ {
+ $this->db->delete(
+ 'DELETE FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ? AND purpose_id = ?',
+ [
+ $authorization->userId(),
+ $authorization->providerId(),
+ $authorization->purpose()->identifier()
+ ]
+ );
+ }
+
+ public function deleteAll(string $userId, string $providerId): void
+ {
+ $this->db->delete(
+ 'DELETE FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ?',
+ [$userId, $providerId]
+ );
+ }
+
+ private function hydrate(array $row): ServiceAuthorization
+ {
+ $purpose = ServicePurpose::of(
+ $row['purpose_id'],
+ GrantStrategy::from($row['purpose_strategy'])
+ );
+
+ $grant = $this->grantRepo->findById($row['grant_id']);
+ if ($grant === null) {
+ throw new \RuntimeException(
+ "TokenGrant '{$row['grant_id']}' not found for ServiceAuthorization"
+ );
+ }
+
+ // Required scopes are determined by provider config, we store empty for now
+ $requiredScopes = new ScopeSet();
+
+ return new ConcreteServiceAuthorization(
+ userId: $row['user_uid'],
+ providerId: $row['provider_id'],
+ purpose: $purpose,
+ grant: $grant,
+ requiredScopes: $requiredScopes,
+ );
+ }
+}
diff --git a/src/Service/SqlTokenGrantRepository.php b/src/Service/SqlTokenGrantRepository.php
new file mode 100644
index 00000000..a401dcf3
--- /dev/null
+++ b/src/Service/SqlTokenGrantRepository.php
@@ -0,0 +1,114 @@
+db->selectOne(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ? AND is_shared = 1',
+ [$userId, $providerId]
+ );
+
+ return $row ? $this->hydrate($row) : null;
+ }
+
+ public function findById(string $grantId): ?TokenGrant
+ {
+ $row = $this->db->selectOne(
+ 'SELECT * FROM ' . $this->table . ' WHERE grant_id = ?',
+ [$grantId]
+ );
+
+ return $row ? $this->hydrate($row) : null;
+ }
+
+ public function findAll(string $userId, string $providerId): array
+ {
+ $rows = $this->db->selectAll(
+ 'SELECT * FROM ' . $this->table . ' WHERE user_uid = ? AND provider_id = ?',
+ [$userId, $providerId]
+ );
+
+ return array_map(fn($row) => $this->hydrate($row), $rows);
+ }
+
+ public function save(TokenGrant $grant): void
+ {
+ $tokenSet = $grant->tokenSet();
+ $json = json_encode($tokenSet->toArray(), JSON_THROW_ON_ERROR);
+ $encrypted = $this->secret->encrypt($json);
+ $tokenData = $encrypted->toBase64();
+ $grantedScopes = implode(' ', $grant->grantedScopes()->toArray());
+ $isShared = $grant instanceof MutableTokenGrant ? $grant->isShared() : 0;
+ $now = time();
+
+ $this->db->insert(
+ 'INSERT INTO ' . $this->table . ' (grant_id, user_uid, provider_id, token_data, granted_scopes, is_shared, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [$grant->grantId(), $grant->userId(), $grant->providerId(), $tokenData, $grantedScopes, $isShared ? 1 : 0, $now, $now]
+ );
+ }
+
+ public function update(TokenGrant $grant): void
+ {
+ $tokenSet = $grant->tokenSet();
+ $json = json_encode($tokenSet->toArray(), JSON_THROW_ON_ERROR);
+ $encrypted = $this->secret->encrypt($json);
+ $tokenData = $encrypted->toBase64();
+ $grantedScopes = implode(' ', $grant->grantedScopes()->toArray());
+ $now = time();
+
+ $this->db->update(
+ 'UPDATE ' . $this->table . ' SET token_data = ?, granted_scopes = ?, updated_at = ? WHERE grant_id = ?',
+ [$tokenData, $grantedScopes, $now, $grant->grantId()]
+ );
+ }
+
+ public function delete(TokenGrant $grant): void
+ {
+ $this->db->delete(
+ 'DELETE FROM ' . $this->table . ' WHERE grant_id = ?',
+ [$grant->grantId()]
+ );
+ }
+
+ private function hydrate(array $row): TokenGrant
+ {
+ $encrypted = EncryptedData::fromBase64($row['token_data']);
+ $json = $this->secret->decrypt($encrypted);
+ $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+ $tokenSet = TokenSet::fromArray($data);
+
+ $grantedScopes = !empty($row['granted_scopes'])
+ ? ScopeSet::fromSpaceSeparated($row['granted_scopes'])
+ : new ScopeSet();
+
+ return new MutableTokenGrant(
+ grantId: $row['grant_id'],
+ userId: $row['user_uid'],
+ providerId: $row['provider_id'],
+ tokenSet: $tokenSet,
+ grantedScopes: $grantedScopes,
+ isShared: (bool) $row['is_shared'],
+ repository: $this,
+ );
+ }
+}
diff --git a/src/Settings/OAuthAccountController.php b/src/Settings/OAuthAccountController.php
index 7d031883..0d0eb4da 100644
--- a/src/Settings/OAuthAccountController.php
+++ b/src/Settings/OAuthAccountController.php
@@ -25,6 +25,9 @@
use Horde\Core\Service\Exception\OAuthProviderConfigNotFoundException;
use Horde\Core\Service\IdentityService;
use Horde\Core\Service\OAuthTokenService;
+use Horde\Core\Service\ServiceAuthorizationService;
+use Horde\Core\Service\ServicePurpose;
+use Horde\Core\Service\GrantStrategy;
use Horde\Core\Sidebar\SidebarBuilder;
use Horde\Core\Sidebar\SidebarRenderer;
use Horde\Core\Topbar\TopbarBuilder;
@@ -79,6 +82,7 @@ public function __construct(
private readonly ClientInterface $httpClient,
private readonly RequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
+ private readonly ServiceAuthorizationService $serviceAuthService,
) {}
public function handle(ServerRequestInterface $request): ResponseInterface
@@ -92,6 +96,10 @@ public function handle(ServerRequestInterface $request): ResponseInterface
'connect' => $this->connect($request, $providerId),
'callback' => $this->callback($request),
'disconnect' => $this->disconnect($request, $providerId),
+ 'authorizeService' => $this->authorizeService($request, $providerId),
+ 'listAuthorizations' => $this->listAuthorizations($request),
+ 'revokeAuthorization' => $this->revokeAuthorization($request, $providerId),
+ 'revokeAllAuthorizations' => $this->revokeAllAuthorizations($request, $providerId),
default => $this->listProviders($request),
};
}
@@ -240,7 +248,17 @@ private function callback(ServerRequestInterface $request): ResponseInterface
return $this->handleLoginCallback($request, $params, $flowData);
}
- return $this->handleAccountLinkCallback($request, $params, $flowData);
+ if ($flowData->flowType === 'account_link') {
+ return $this->handleAccountLinkCallback($request, $params, $flowData);
+ }
+
+ if (str_contains($flowData->flowType, ':')) {
+ return $this->handleServiceAuthCallback($request, $params, $flowData);
+ }
+
+ $webroot = rtrim($this->registry->get('webroot', 'horde'), '/');
+ $this->notification->push(_("Unknown OAuth flow type."), 'horde.error');
+ return $this->redirect($webroot . '/settings/oauth/');
}
private function handleLoginCallback(
@@ -435,6 +453,163 @@ private function disconnect(ServerRequestInterface $request, ?string $providerId
return $this->redirect($baseUrl . '/');
}
+ private function authorizeService(ServerRequestInterface $request, ?string $providerId): ResponseInterface
+ {
+ $baseUrl = $this->getBaseUrl();
+ $userId = $request->getAttribute('HORDE_AUTHENTICATED_USER');
+
+ if ($providerId === null) {
+ return $this->redirect($baseUrl . '/');
+ }
+
+ $body = $request->getParsedBody();
+ $purposeId = $body['purpose'] ?? '';
+ $strategyStr = $body['strategy'] ?? 'isolated';
+ $returnUrl = $body['return_url'] ?? $baseUrl . '/';
+ $requestingApp = $body['requesting_app'] ?? null;
+
+ if ($purposeId === '') {
+ $this->notification->push(_("Purpose is required."), 'horde.error');
+ return $this->redirect($baseUrl . '/');
+ }
+
+ try {
+ $strategy = GrantStrategy::from($strategyStr);
+ } catch (\ValueError $e) {
+ $this->notification->push(_("Invalid grant strategy."), 'horde.error');
+ return $this->redirect($baseUrl . '/');
+ }
+
+ $purpose = ServicePurpose::of($purposeId, $strategy);
+
+ try {
+ $redirectUri = $this->serviceAuthService->initiate(
+ userId: $userId,
+ providerId: $providerId,
+ purpose: $purpose,
+ returnUrl: $returnUrl,
+ requestingApp: $requestingApp,
+ );
+
+ if ($redirectUri === null) {
+ // No redirect needed, authorization created from existing grant
+ return $this->htmlResponse('', 204);
+ }
+
+ return $this->redirect((string) $redirectUri);
+ } catch (\Throwable $e) {
+ $this->notification->push(
+ sprintf(_("Failed to initiate authorization: %s"), $e->getMessage()),
+ 'horde.error'
+ );
+ return $this->redirect($baseUrl . '/');
+ }
+ }
+
+ private function handleServiceAuthCallback(
+ ServerRequestInterface $request,
+ array $params,
+ OAuthFlowData $flowData,
+ ): ResponseInterface {
+ $baseUrl = $this->getBaseUrl();
+ $userId = $request->getAttribute('HORDE_AUTHENTICATED_USER');
+
+ if (!empty($params['error'])) {
+ $errorDesc = $params['error_description'] ?? $params['error'];
+ $this->notification->push(
+ sprintf(_("Authorization failed: %s"), $errorDesc),
+ 'horde.error'
+ );
+ return $this->redirect($flowData->redirectUrl ?: $baseUrl . '/');
+ }
+
+ $code = $params['code'] ?? '';
+ if ($code === '') {
+ $this->notification->push(_("No authorization code received."), 'horde.error');
+ return $this->redirect($flowData->redirectUrl ?: $baseUrl . '/');
+ }
+
+ try {
+ $this->serviceAuthService->handleCallbackWithUser($userId, $code, $flowData);
+ $this->notification->push(_("Service authorization successful."), 'horde.success');
+ } catch (\Throwable $e) {
+ error_log("Service authorization callback failed: {$e->getMessage()}");
+ $this->notification->push(
+ sprintf(_("Failed to complete authorization: %s"), $e->getMessage()),
+ 'horde.error'
+ );
+ }
+
+ return $this->redirect($flowData->redirectUrl ?: $baseUrl . '/');
+ }
+
+ private function listAuthorizations(ServerRequestInterface $request): ResponseInterface
+ {
+ $userId = $request->getAttribute('HORDE_AUTHENTICATED_USER');
+
+ // Return JSON list of authorizations
+ // Implementation depends on requirements - for now return empty array
+ $authorizations = [];
+
+ return $this->htmlResponse(json_encode($authorizations, JSON_THROW_ON_ERROR), 200)
+ ->withHeader('Content-Type', 'application/json');
+ }
+
+ private function revokeAuthorization(ServerRequestInterface $request, ?string $providerId): ResponseInterface
+ {
+ $baseUrl = $this->getBaseUrl();
+ $userId = $request->getAttribute('HORDE_AUTHENTICATED_USER');
+
+ if ($providerId === null) {
+ return $this->redirect($baseUrl . '/');
+ }
+
+ $route = $request->getAttribute('route') ?? [];
+ $purposeId = $route['purposeId'] ?? null;
+
+ if ($purposeId === null) {
+ return $this->redirect($baseUrl . '/');
+ }
+
+ try {
+ $purpose = ServicePurpose::of($purposeId);
+ $this->serviceAuthService->revoke($userId, $providerId, $purpose);
+ $this->notification->push(_("Authorization revoked."), 'horde.success');
+ } catch (\Throwable $e) {
+ $this->notification->push(
+ sprintf(_("Failed to revoke authorization: %s"), $e->getMessage()),
+ 'horde.error'
+ );
+ }
+
+ return $this->redirect($baseUrl . '/');
+ }
+
+ private function revokeAllAuthorizations(ServerRequestInterface $request, ?string $providerId): ResponseInterface
+ {
+ $baseUrl = $this->getBaseUrl();
+ $userId = $request->getAttribute('HORDE_AUTHENTICATED_USER');
+
+ if ($providerId === null) {
+ return $this->redirect($baseUrl . '/');
+ }
+
+ $queryParams = $request->getQueryParams();
+ $revokeAtProvider = ($queryParams['revoke_at_provider'] ?? 'false') === 'true';
+
+ try {
+ $this->serviceAuthService->revokeAll($userId, $providerId, $revokeAtProvider);
+ $this->notification->push(_("All authorizations revoked."), 'horde.success');
+ } catch (\Throwable $e) {
+ $this->notification->push(
+ sprintf(_("Failed to revoke authorizations: %s"), $e->getMessage()),
+ 'horde.error'
+ );
+ }
+
+ return $this->redirect($baseUrl . '/');
+ }
+
private function buildOAuth2Client(array $row): OAuth2Client
{
$providerConfig = ProviderConfig::fromArray($row);
diff --git a/templates/admin/oauthprovider/edit-oauth2.html.php b/templates/admin/oauthprovider/edit-oauth2.html.php
index f0b96ef2..630fac2e 100644
--- a/templates/admin/oauthprovider/edit-oauth2.html.php
+++ b/templates/admin/oauthprovider/edit-oauth2.html.php
@@ -118,7 +118,89 @@
-
+
+
+
+
+
+
+
+ provider['purposes'] ?? [];
+ $index = 0;
+ if (empty($purposes)):
+ ?>
+
+ $scopes):
+ ?>
+
+
+
+
+
+
+
+
+
diff --git a/themes/default/screen.css b/themes/default/screen.css
index 3d5d5aec..e32c8d22 100644
--- a/themes/default/screen.css
+++ b/themes/default/screen.css
@@ -4006,3 +4006,62 @@ a.horde-mainnavi-active:active {
background-color: #fafafa;
}
+/* ============================================================================
+ OAuth Provider Service Purposes UI
+
+ Styles for the dynamic purpose-scope editor in the OAuth provider
+ admin form (base/templates/admin/oauthprovider/edit-oauth2.html.php).
+ Allows adding/removing service purposes with inline fields.
+ ============================================================================ */
+
+.purpose-row {
+ margin-bottom: 0.5rem;
+}
+
+.purpose-fields {
+ display: flex;
+ gap: 0.5rem;
+ align-items: flex-end;
+}
+
+.purpose-field {
+ flex: 0 0 200px;
+}
+
+.purpose-field-wide {
+ flex: 1;
+}
+
+.settings-form-label-inline {
+ display: block;
+ font-size: 0.875rem;
+ margin-bottom: 0.25rem;
+ color: #666;
+}
+
+.settings-form-control-inline {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 0.875rem;
+ font-family: monospace;
+}
+
+.btn-icon {
+ background: none;
+ border: none;
+ color: #dc3545;
+ font-size: 1.5rem;
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ line-height: 1;
+}
+
+.btn-icon:hover {
+ color: #a02622;
+}
+
+.btn-remove-purpose {
+ flex-shrink: 0;
+}