Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
AccessTokenRepository
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
4 / 4
15
100.00% covered (success)
100.00%
1 / 1
 getNewToken
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 persistNewAccessToken
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 revokeAccessToken
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 isAccessTokenRevoked
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * OAuth2 access token repository.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth\OAuth2\Repository;
21
22use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
23use League\OAuth2\Server\Entities\ClientEntityInterface;
24use League\OAuth2\Server\Entities\ScopeEntityInterface;
25use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException;
26use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
27use phpMyFAQ\Auth\OAuth2\Entity\AccessTokenEntity;
28
29final class AccessTokenRepository extends AbstractRepository implements AccessTokenRepositoryInterface
30{
31    public function getNewToken(
32        ClientEntityInterface $clientEntity,
33        array $scopes,
34        ?string $userIdentifier = null,
35    ): AccessTokenEntityInterface {
36        $token = new AccessTokenEntity();
37        $token->setClient($clientEntity);
38
39        foreach ($scopes as $scope) {
40            /* @mago-expect analysis:impossible-condition - callers may pass untyped arrays; skipping garbage is test-pinned */
41            if (!$scope instanceof ScopeEntityInterface) {
42                continue;
43            }
44
45            $token->addScope($scope);
46        }
47
48        if ($userIdentifier !== null && $userIdentifier !== '') {
49            $token->setUserIdentifier($userIdentifier);
50        }
51
52        return $token;
53    }
54
55    public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void
56    {
57        $scopes = array_map(
58            static fn(ScopeEntityInterface $scope): string => $scope->getIdentifier(),
59            $accessTokenEntity->getScopes(),
60        );
61
62        $tokenUserIdentifier = $accessTokenEntity->getUserIdentifier();
63
64        $tokenUserIdentifier = $tokenUserIdentifier === null ? null : (string) $tokenUserIdentifier;
65
66        $insert = sprintf(
67            "INSERT INTO %s (identifier, client_id, user_id, scopes, revoked, expires_at, created)
68             VALUES ('%s', '%s', %s, '%s', 0, '%s', %s)",
69            $this->table('faqoauth_access_tokens'),
70            $this->db()->escape($accessTokenEntity->getIdentifier()),
71            $this->db()->escape($accessTokenEntity->getClient()->getIdentifier()),
72            $tokenUserIdentifier === null ? 'NULL' : "'" . $this->db()->escape($tokenUserIdentifier) . "'",
73            $this->db()->escape((string) json_encode($scopes)),
74            $this->db()->escape($accessTokenEntity->getExpiryDateTime()->format('Y-m-d H:i:s')),
75            $this->db()->now(),
76        );
77
78        if ($this->db()->query($insert) === false) {
79            $dbError = strtolower($this->db()->error());
80            if (str_contains($dbError, 'duplicate') || str_contains($dbError, 'unique')) {
81                throw UniqueTokenIdentifierConstraintViolationException::create();
82            }
83
84            throw new \RuntimeException('Failed to persist access token: ' . $this->db()->error());
85        }
86    }
87
88    public function revokeAccessToken(string $tokenId): void
89    {
90        $this->db()->query(sprintf(
91            "UPDATE %s SET revoked = 1 WHERE identifier = '%s'",
92            $this->table('faqoauth_access_tokens'),
93            $this->db()->escape($tokenId),
94        ));
95    }
96
97    public function isAccessTokenRevoked(string $tokenId): bool
98    {
99        $result = $this->db()->query(sprintf(
100            "SELECT revoked FROM %s WHERE identifier = '%s'",
101            $this->table('faqoauth_access_tokens'),
102            $this->db()->escape($tokenId),
103        ));
104
105        if ($result === false) {
106            return true;
107        }
108
109        $row = $this->db()->fetchObject($result);
110        if (!is_object($row)) {
111            return true;
112        }
113
114        return (int) ($row->revoked ?? 1) === 1;
115    }
116}