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; +}