mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-01 06:01:42 +00:00
Use bundle entities
This commit is contained in:
parent
c2948f7d84
commit
01a747da1d
30 changed files with 665 additions and 1954 deletions
|
|
@ -1,12 +1,13 @@
|
|||
## OAuth2 authorization server used for API/MCP app auto-provisioning (see docs/api/authentication.md).
|
||||
#
|
||||
# The bundle's own persistence layer (its Client/AccessToken/RefreshToken/AuthCode Doctrine entities) is
|
||||
# NOT used - "persistence: in_memory" here just satisfies the bundle's config schema (it registers a set
|
||||
# of trivial in-memory managers that some of the bundle's console commands/services still need to exist,
|
||||
# even though we never use them). The actual League\OAuth2\Server\Repositories\* interfaces are overridden
|
||||
# in config/services.yaml to point at our own Doctrine-backed repositories (App\Security\OAuth\Repository),
|
||||
# which make an OAuth2 access token just be a regular App\Entity\UserSystem\ApiToken row - see
|
||||
# App\Security\OAuth\Repository\AccessTokenRepository for why.
|
||||
# Uses the bundle's own Doctrine persistence (its Client/AccessToken/RefreshToken/AuthorizationCode
|
||||
# entities, tables prefixed "oauth2_") rather than custom entities/repositories, so upstream bundle
|
||||
# upgrades keep this working without us having to touch repository code every time (as happened going
|
||||
# from league/oauth2-server 8.5 to 9.2). OAuth-issued tokens are therefore a distinct credential type
|
||||
# from App\Entity\UserSystem\ApiToken (Personal Access Tokens) - see App\Security\OAuth\OAuthBearerAuthenticator
|
||||
# for how incoming requests are routed to the right validator, and role_prefix below for how OAuth scopes
|
||||
# end up granting the exact same ROLE_API_* roles the existing permission system (config/permissions.yaml)
|
||||
# already understands.
|
||||
league_oauth2_server:
|
||||
authorization_server:
|
||||
private_key: '%kernel.project_dir%/var/oauth2/private.key'
|
||||
|
|
@ -26,18 +27,24 @@ league_oauth2_server:
|
|||
enable_refresh_token_grant: true
|
||||
require_code_challenge_for_public_clients: true
|
||||
revoke_refresh_tokens: true
|
||||
response_type_class: App\Security\OAuth\OpaqueBearerTokenResponse
|
||||
# No response_type_class override: access tokens are real JWTs (the bundle's default). Clients
|
||||
# only ever treat access_token as an opaque string per the OAuth2 spec, so this is not a
|
||||
# compatibility concern - and it means the RSA keypair below is actually used, unlike the earlier
|
||||
# custom-repository design.
|
||||
resource_server:
|
||||
# Unused: we protect /api and /mcp with our own App\Security\ApiTokenAuthenticator, not the
|
||||
# bundle's resource-server/security integration. Still required by the bundle's config schema.
|
||||
public_key: '%kernel.project_dir%/var/oauth2/public.key'
|
||||
scopes:
|
||||
# Scopes are just the existing App\Entity\UserSystem\ApiTokenLevel enum - see App\Security\OAuth\OAuthScope.
|
||||
# Scopes map 1:1 onto the existing App\Entity\UserSystem\ApiTokenLevel enum names. Combined with
|
||||
# role_prefix below, an OAuth token scoped "edit" ends up with role ROLE_API_EDIT - the exact same
|
||||
# role App\Entity\UserSystem\ApiTokenLevel::EDIT grants a Personal Access Token, so
|
||||
# config/permissions.yaml's apiTokenRole mappings apply unchanged to both credential types (see
|
||||
# App\Services\UserSystem\VoterHelper for the permission-ceiling check both types go through).
|
||||
available: ['read_only', 'edit', 'admin', 'full']
|
||||
default: ['read_only']
|
||||
persistence:
|
||||
in_memory: ~
|
||||
doctrine:
|
||||
table_prefix: 'oauth2_'
|
||||
client:
|
||||
# Irrelevant to us (we bypass the bundle's own client model entirely - see the top comment),
|
||||
# set to false just to silence the bundle's deprecation notice for the true default.
|
||||
allow_plaintext_secrets: false
|
||||
# Roles end up as ROLE_API_<SCOPE>, e.g. ROLE_API_READ_ONLY - see the "scopes" comment above.
|
||||
role_prefix: 'ROLE_API_'
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ security:
|
|||
|
||||
custom_authenticators:
|
||||
- App\Security\ApiTokenAuthenticator
|
||||
- App\Security\OAuth\OAuthBearerAuthenticator
|
||||
|
||||
two_factor:
|
||||
auth_form_path: 2fa_login
|
||||
|
|
|
|||
|
|
@ -140,22 +140,6 @@ services:
|
|||
$update_group_on_login: '%env(bool:SAML_UPDATE_GROUP_ON_LOGIN)%'
|
||||
|
||||
|
||||
####################################################################################################################
|
||||
# OAuth2 authorization server (auto-provisioning of API/MCP tokens, see docs/api/authentication.md)
|
||||
#
|
||||
# league/oauth2-server-bundle provides the /authorize and /token routes/controllers and builds the
|
||||
# League\OAuth2\Server\AuthorizationServer service from config/packages/league_oauth2_server.yaml, but
|
||||
# its own persistence layer is not used (see that file's top comment) - these overrides point the
|
||||
# core league/oauth2-server repository interfaces at our own Doctrine-backed implementations instead
|
||||
# of the bundle's, so an OAuth2 access token stays a plain App\Entity\UserSystem\ApiToken row.
|
||||
####################################################################################################################
|
||||
|
||||
League\OAuth2\Server\Repositories\ClientRepositoryInterface: '@App\Security\OAuth\Repository\ClientRepository'
|
||||
League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface: '@App\Security\OAuth\Repository\AccessTokenRepository'
|
||||
League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface: '@App\Security\OAuth\Repository\RefreshTokenRepository'
|
||||
League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface: '@App\Security\OAuth\Repository\AuthCodeRepository'
|
||||
League\OAuth2\Server\Repositories\ScopeRepositoryInterface: '@App\Security\OAuth\Repository\ScopeRepository'
|
||||
|
||||
security.access_token_extractor.header.token:
|
||||
class: Symfony\Component\Security\Http\AccessToken\HeaderAccessTokenExtractor
|
||||
arguments:
|
||||
|
|
|
|||
|
|
@ -1,279 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260726170000 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add OAuth2 authorization server tables (oauth_clients, oauth_auth_codes, oauth_refresh_tokens) and link api_tokens to the OAuth client that issued them';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_clients (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
redirect_uris JSON NOT NULL,
|
||||
registration_access_token VARCHAR(255) NOT NULL,
|
||||
dynamically_registered TINYINT(1) NOT NULL,
|
||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
UNIQUE INDEX UNIQ_13CE8101772E836A (identifier),
|
||||
UNIQUE INDEX UNIQ_13CE8101105C05AC (registration_access_token),
|
||||
PRIMARY KEY (id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_auth_codes (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
scope_identifiers JSON NOT NULL,
|
||||
expiry_date_time DATETIME NOT NULL,
|
||||
redirect_uri VARCHAR(255) DEFAULT NULL,
|
||||
revoked TINYINT(1) NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
UNIQUE INDEX UNIQ_BB493F83772E836A (identifier),
|
||||
INDEX IDX_BB493F8319EB6921 (client_id),
|
||||
INDEX IDX_BB493F83A76ED395 (user_id),
|
||||
PRIMARY KEY (id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_refresh_tokens (
|
||||
id INT AUTO_INCREMENT NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
expiry_date_time DATETIME NOT NULL,
|
||||
revoked TINYINT(1) NOT NULL,
|
||||
family_id VARCHAR(255) NOT NULL,
|
||||
api_token_id INT NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
UNIQUE INDEX UNIQ_5AB687772E836A (identifier),
|
||||
INDEX IDX_5AB68792E52D36 (api_token_id),
|
||||
INDEX IDX_5AB68719EB6921 (client_id),
|
||||
PRIMARY KEY (id)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||
SQL);
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES `users` (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE api_tokens ADD oauth_client_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE');
|
||||
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP FOREIGN KEY FK_BB493F8319EB6921');
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP FOREIGN KEY FK_BB493F83A76ED395');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP FOREIGN KEY FK_5AB68792E52D36');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP FOREIGN KEY FK_5AB68719EB6921');
|
||||
$this->addSql('ALTER TABLE api_tokens DROP FOREIGN KEY FK_2CAD560EDCA49ED');
|
||||
$this->addSql('DROP INDEX IDX_2CAD560EDCA49ED ON api_tokens');
|
||||
$this->addSql('ALTER TABLE api_tokens DROP oauth_client_id');
|
||||
$this->addSql('DROP TABLE oauth_auth_codes');
|
||||
$this->addSql('DROP TABLE oauth_refresh_tokens');
|
||||
$this->addSql('DROP TABLE oauth_clients');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_clients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
redirect_uris CLOB NOT NULL,
|
||||
registration_access_token VARCHAR(255) NOT NULL,
|
||||
dynamically_registered BOOLEAN NOT NULL,
|
||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101772E836A ON oauth_clients (identifier)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101105C05AC ON oauth_clients (registration_access_token)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_auth_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
scope_identifiers CLOB NOT NULL,
|
||||
expiry_date_time DATETIME NOT NULL,
|
||||
redirect_uri VARCHAR(255) DEFAULT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
client_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE,
|
||||
CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_BB493F83772E836A ON oauth_auth_codes (identifier)');
|
||||
$this->addSql('CREATE INDEX IDX_BB493F8319EB6921 ON oauth_auth_codes (client_id)');
|
||||
$this->addSql('CREATE INDEX IDX_BB493F83A76ED395 ON oauth_auth_codes (user_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
expiry_date_time DATETIME NOT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
family_id VARCHAR(255) NOT NULL,
|
||||
api_token_id INTEGER NOT NULL,
|
||||
client_id INTEGER NOT NULL,
|
||||
CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE,
|
||||
CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_5AB687772E836A ON oauth_refresh_tokens (identifier)');
|
||||
$this->addSql('CREATE INDEX IDX_5AB68792E52D36 ON oauth_refresh_tokens (api_token_id)');
|
||||
$this->addSql('CREATE INDEX IDX_5AB68719EB6921 ON oauth_refresh_tokens (client_id)');
|
||||
|
||||
//SQLite can't ALTER TABLE ADD COLUMN with a foreign key constraint, so the table is rebuilt
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TEMPORARY TABLE __temp__api_tokens AS
|
||||
SELECT id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added
|
||||
FROM api_tokens
|
||||
SQL);
|
||||
$this->addSql('DROP TABLE api_tokens');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id INTEGER DEFAULT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
valid_until DATETIME DEFAULT NULL,
|
||||
token VARCHAR(68) NOT NULL,
|
||||
level SMALLINT NOT NULL,
|
||||
last_time_used DATETIME DEFAULT NULL,
|
||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
oauth_client_id INTEGER DEFAULT NULL,
|
||||
CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE,
|
||||
CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO api_tokens (id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added)
|
||||
SELECT id, user_id, name, valid_until, token, level, last_time_used, last_modified, datetime_added
|
||||
FROM __temp__api_tokens
|
||||
SQL);
|
||||
$this->addSql('DROP TABLE __temp__api_tokens');
|
||||
$this->addSql('CREATE INDEX IDX_2CAD560EA76ED395 ON api_tokens (user_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CAD560E5F37A13B ON api_tokens (token)');
|
||||
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE oauth_auth_codes');
|
||||
$this->addSql('DROP TABLE oauth_refresh_tokens');
|
||||
$this->addSql('DROP TABLE oauth_clients');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TEMPORARY TABLE __temp__api_tokens AS
|
||||
SELECT id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id
|
||||
FROM api_tokens
|
||||
SQL);
|
||||
$this->addSql('DROP TABLE api_tokens');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
valid_until DATETIME DEFAULT NULL,
|
||||
token VARCHAR(68) NOT NULL,
|
||||
level SMALLINT NOT NULL,
|
||||
last_time_used DATETIME DEFAULT NULL,
|
||||
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
user_id INTEGER DEFAULT NULL,
|
||||
CONSTRAINT FK_2CAD560EA76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO api_tokens (id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id)
|
||||
SELECT id, name, valid_until, token, level, last_time_used, last_modified, datetime_added, user_id
|
||||
FROM __temp__api_tokens
|
||||
SQL);
|
||||
$this->addSql('DROP TABLE __temp__api_tokens');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_2CAD560E5F37A13B ON api_tokens (token)');
|
||||
$this->addSql('CREATE INDEX IDX_2CAD560EA76ED395 ON api_tokens (user_id)');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_clients (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
redirect_uris JSON NOT NULL,
|
||||
registration_access_token VARCHAR(255) NOT NULL,
|
||||
dynamically_registered BOOLEAN NOT NULL,
|
||||
last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101772E836A ON oauth_clients (identifier)');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_13CE8101105C05AC ON oauth_clients (registration_access_token)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_auth_codes (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
scope_identifiers JSON NOT NULL,
|
||||
expiry_date_time TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
redirect_uri VARCHAR(255) DEFAULT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_BB493F83772E836A ON oauth_auth_codes (identifier)');
|
||||
$this->addSql('CREATE INDEX IDX_BB493F8319EB6921 ON oauth_auth_codes (client_id)');
|
||||
$this->addSql('CREATE INDEX IDX_BB493F83A76ED395 ON oauth_auth_codes (user_id)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth_refresh_tokens (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
identifier VARCHAR(255) NOT NULL,
|
||||
expiry_date_time TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
family_id VARCHAR(255) NOT NULL,
|
||||
api_token_id INT NOT NULL,
|
||||
client_id INT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_5AB687772E836A ON oauth_refresh_tokens (identifier)');
|
||||
$this->addSql('CREATE INDEX IDX_5AB68792E52D36 ON oauth_refresh_tokens (api_token_id)');
|
||||
$this->addSql('CREATE INDEX IDX_5AB68719EB6921 ON oauth_refresh_tokens (client_id)');
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F8319EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes ADD CONSTRAINT FK_BB493F83A76ED395 FOREIGN KEY (user_id) REFERENCES "users" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68792E52D36 FOREIGN KEY (api_token_id) REFERENCES api_tokens (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens ADD CONSTRAINT FK_5AB68719EB6921 FOREIGN KEY (client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE api_tokens ADD oauth_client_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE api_tokens ADD CONSTRAINT FK_2CAD560EDCA49ED FOREIGN KEY (oauth_client_id) REFERENCES oauth_clients (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_2CAD560EDCA49ED ON api_tokens (oauth_client_id)');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP CONSTRAINT FK_BB493F8319EB6921');
|
||||
$this->addSql('ALTER TABLE oauth_auth_codes DROP CONSTRAINT FK_BB493F83A76ED395');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP CONSTRAINT FK_5AB68792E52D36');
|
||||
$this->addSql('ALTER TABLE oauth_refresh_tokens DROP CONSTRAINT FK_5AB68719EB6921');
|
||||
$this->addSql('ALTER TABLE api_tokens DROP CONSTRAINT FK_2CAD560EDCA49ED');
|
||||
$this->addSql('DROP INDEX IDX_2CAD560EDCA49ED');
|
||||
$this->addSql('ALTER TABLE api_tokens DROP oauth_client_id');
|
||||
$this->addSql('DROP TABLE oauth_auth_codes');
|
||||
$this->addSql('DROP TABLE oauth_refresh_tokens');
|
||||
$this->addSql('DROP TABLE oauth_clients');
|
||||
}
|
||||
}
|
||||
209
migrations/Version20260726180454.php
Normal file
209
migrations/Version20260726180454.php
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260726180454 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add league/oauth2-server-bundle\'s own Doctrine tables (oauth2_client, oauth2_access_token, oauth2_refresh_token, oauth2_authorization_code) for OAuth2 authorization server support (API/MCP app auto-provisioning)';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_client (
|
||||
name VARCHAR(128) NOT NULL,
|
||||
secret VARCHAR(128) DEFAULT NULL,
|
||||
redirect_uris TEXT DEFAULT NULL,
|
||||
grants TEXT DEFAULT NULL,
|
||||
scopes TEXT DEFAULT NULL,
|
||||
active TINYINT NOT NULL,
|
||||
allow_plain_text_pkce TINYINT DEFAULT 0 NOT NULL,
|
||||
identifier VARCHAR(32) NOT NULL,
|
||||
PRIMARY KEY (identifier)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_access_token (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry DATETIME NOT NULL,
|
||||
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||
scopes TEXT DEFAULT NULL,
|
||||
revoked TINYINT NOT NULL,
|
||||
client VARCHAR(32) NOT NULL,
|
||||
INDEX IDX_454D9673C7440455 (client),
|
||||
PRIMARY KEY (identifier)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_authorization_code (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry DATETIME NOT NULL,
|
||||
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||
scopes TEXT DEFAULT NULL,
|
||||
revoked TINYINT NOT NULL,
|
||||
client VARCHAR(32) NOT NULL,
|
||||
INDEX IDX_509FEF5FC7440455 (client),
|
||||
PRIMARY KEY (identifier)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_refresh_token (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry DATETIME NOT NULL,
|
||||
revoked TINYINT NOT NULL,
|
||||
access_token CHAR(80) DEFAULT NULL,
|
||||
INDEX IDX_4DD90732B6A2DD68 (access_token),
|
||||
PRIMARY KEY (identifier)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
|
||||
SQL);
|
||||
$this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE oauth2_access_token DROP FOREIGN KEY FK_454D9673C7440455');
|
||||
$this->addSql('ALTER TABLE oauth2_authorization_code DROP FOREIGN KEY FK_509FEF5FC7440455');
|
||||
$this->addSql('ALTER TABLE oauth2_refresh_token DROP FOREIGN KEY FK_4DD90732B6A2DD68');
|
||||
$this->addSql('DROP TABLE oauth2_access_token');
|
||||
$this->addSql('DROP TABLE oauth2_authorization_code');
|
||||
$this->addSql('DROP TABLE oauth2_refresh_token');
|
||||
$this->addSql('DROP TABLE oauth2_client');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_client (
|
||||
name VARCHAR(128) NOT NULL,
|
||||
secret VARCHAR(128) DEFAULT NULL,
|
||||
redirect_uris CLOB DEFAULT NULL,
|
||||
grants CLOB DEFAULT NULL,
|
||||
scopes CLOB DEFAULT NULL,
|
||||
active BOOLEAN NOT NULL,
|
||||
allow_plain_text_pkce BOOLEAN DEFAULT 0 NOT NULL,
|
||||
identifier VARCHAR(32) NOT NULL,
|
||||
PRIMARY KEY (identifier)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_access_token (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry DATETIME NOT NULL,
|
||||
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||
scopes CLOB DEFAULT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
client VARCHAR(32) NOT NULL,
|
||||
PRIMARY KEY (identifier),
|
||||
CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_authorization_code (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry DATETIME NOT NULL,
|
||||
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||
scopes CLOB DEFAULT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
client VARCHAR(32) NOT NULL,
|
||||
PRIMARY KEY (identifier),
|
||||
CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_refresh_token (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry DATETIME NOT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
access_token CHAR(80) DEFAULT NULL,
|
||||
PRIMARY KEY (identifier),
|
||||
CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE oauth2_access_token');
|
||||
$this->addSql('DROP TABLE oauth2_authorization_code');
|
||||
$this->addSql('DROP TABLE oauth2_refresh_token');
|
||||
$this->addSql('DROP TABLE oauth2_client');
|
||||
}
|
||||
|
||||
public function postgreSQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_client (
|
||||
name VARCHAR(128) NOT NULL,
|
||||
secret VARCHAR(128) DEFAULT NULL,
|
||||
redirect_uris TEXT DEFAULT NULL,
|
||||
grants TEXT DEFAULT NULL,
|
||||
scopes TEXT DEFAULT NULL,
|
||||
active BOOLEAN NOT NULL,
|
||||
allow_plain_text_pkce BOOLEAN DEFAULT false NOT NULL,
|
||||
identifier VARCHAR(32) NOT NULL,
|
||||
PRIMARY KEY (identifier)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_access_token (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||
scopes TEXT DEFAULT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
client VARCHAR(32) NOT NULL,
|
||||
PRIMARY KEY (identifier)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_454D9673C7440455 ON oauth2_access_token (client)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_authorization_code (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
user_identifier VARCHAR(128) DEFAULT NULL,
|
||||
scopes TEXT DEFAULT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
client VARCHAR(32) NOT NULL,
|
||||
PRIMARY KEY (identifier)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_509FEF5FC7440455 ON oauth2_authorization_code (client)');
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE oauth2_refresh_token (
|
||||
identifier CHAR(80) NOT NULL,
|
||||
expiry TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
revoked BOOLEAN NOT NULL,
|
||||
access_token CHAR(80) DEFAULT NULL,
|
||||
PRIMARY KEY (identifier)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX IDX_4DD90732B6A2DD68 ON oauth2_refresh_token (access_token)');
|
||||
$this->addSql('ALTER TABLE oauth2_access_token ADD CONSTRAINT FK_454D9673C7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth2_authorization_code ADD CONSTRAINT FK_509FEF5FC7440455 FOREIGN KEY (client) REFERENCES oauth2_client (identifier) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE oauth2_refresh_token ADD CONSTRAINT FK_4DD90732B6A2DD68 FOREIGN KEY (access_token) REFERENCES oauth2_access_token (identifier) ON DELETE SET NULL');
|
||||
}
|
||||
|
||||
public function postgreSQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE oauth2_access_token DROP CONSTRAINT FK_454D9673C7440455');
|
||||
$this->addSql('ALTER TABLE oauth2_authorization_code DROP CONSTRAINT FK_509FEF5FC7440455');
|
||||
$this->addSql('ALTER TABLE oauth2_refresh_token DROP CONSTRAINT FK_4DD90732B6A2DD68');
|
||||
$this->addSql('DROP TABLE oauth2_access_token');
|
||||
$this->addSql('DROP TABLE oauth2_authorization_code');
|
||||
$this->addSql('DROP TABLE oauth2_refresh_token');
|
||||
$this->addSql('DROP TABLE oauth2_client');
|
||||
}
|
||||
}
|
||||
|
|
@ -34,10 +34,10 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|||
|
||||
/**
|
||||
* Generates the RSA keypair required by league/oauth2-server-bundle's AuthorizationServer (for API/MCP
|
||||
* app auto-provisioning, see config/packages/league_oauth2_server.yaml). This keypair is only used by
|
||||
* the underlying library's (unused, in our setup) JWT-signing code path - see
|
||||
* App\Security\OAuth\OpaqueBearerTokenResponse - so its content is not otherwise security-critical, but
|
||||
* the library still requires a syntactically valid keypair to exist at the configured paths.
|
||||
* app auto-provisioning, see config/packages/league_oauth2_server.yaml). Unlike an inert placeholder,
|
||||
* this keypair actually signs the JWT access tokens handed to OAuth clients - keep the private key
|
||||
* secret and do not regenerate it while tokens signed with the old one are still outstanding (they
|
||||
* would fail validation against the new public key).
|
||||
*/
|
||||
#[AsCommand('partdb:oauth:generate-keys', 'Generates the RSA keypair used by the OAuth2 authorization server (for API/MCP app auto-provisioning)')]
|
||||
class OAuthGenerateKeysCommand extends Command
|
||||
|
|
@ -61,7 +61,7 @@ class OAuthGenerateKeysCommand extends Command
|
|||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if (!$input->getOption('force') && (is_file($this->privateKeyPath) || is_file($this->publicKeyPath))) {
|
||||
$io->error('A keypair already exists. Use --force to overwrite it (this invalidates nothing, since the keypair is not used to sign anything a client relies on - see OpaqueBearerTokenResponse).');
|
||||
$io->error('A keypair already exists. Use --force to overwrite it - note this invalidates every outstanding OAuth2 access token, since they are JWTs signed with the old private key.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ use ApiPlatform\OpenApi\Model\Operation;
|
|||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\Entity\Base\TimestampTrait;
|
||||
use App\Entity\Contracts\TimeStampableInterface;
|
||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
||||
use App\Repository\UserSystem\ApiTokenRepository;
|
||||
use App\State\CurrentApiTokenProvider;
|
||||
use App\Validator\Constraints\Year2038BugWorkaround;
|
||||
|
|
@ -92,15 +91,6 @@ class ApiToken implements TimeStampableInterface
|
|||
#[Groups('token:read')]
|
||||
private ?\DateTimeImmutable $last_time_used = null;
|
||||
|
||||
/**
|
||||
* The OAuth2 client this token was issued to, if it originates from the OAuth authorization code
|
||||
* flow (see src/Security/OAuth) rather than being manually created by the user.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
||||
#[Groups('token:read')]
|
||||
private ?OAuthClient $oauthClient = null;
|
||||
|
||||
public function __construct(ApiTokenType $tokenType = ApiTokenType::PERSONAL_ACCESS_TOKEN)
|
||||
{
|
||||
// Generate a rondom token on creation. The tokenType is 3 characters long (plus underscore), so the token is 68 characters long.
|
||||
|
|
@ -205,15 +195,4 @@ class ApiToken implements TimeStampableInterface
|
|||
return substr($this->token, -4);
|
||||
}
|
||||
|
||||
public function getOauthClient(): ?OAuthClient
|
||||
{
|
||||
return $this->oauthClient;
|
||||
}
|
||||
|
||||
public function setOauthClient(?OAuthClient $oauthClient): self
|
||||
{
|
||||
$this->oauthClient = $oauthClient;
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -31,13 +31,6 @@ enum ApiTokenType: string
|
|||
{
|
||||
case PERSONAL_ACCESS_TOKEN = 'tcp';
|
||||
|
||||
/**
|
||||
* A token issued through the OAuth2 authorization code flow (see src/Security/OAuth), rather than
|
||||
* created manually by a user. Functionally just an ApiToken like any other; the prefix only exists
|
||||
* so the UI can tell them apart.
|
||||
*/
|
||||
case OAUTH_ACCESS_TOKEN = 'oat';
|
||||
|
||||
/**
|
||||
* Get the prefix of the token including the underscore
|
||||
* @return string
|
||||
|
|
@ -60,4 +53,20 @@ enum ApiTokenType: string
|
|||
}
|
||||
return self::from($parts[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given string looks like one of our own tokens (i.e. has a recognized prefix),
|
||||
* without throwing. Used by App\Security\ApiTokenAuthenticator and
|
||||
* App\Security\OAuth\OAuthBearerAuthenticator to decide, up front, which of the two authenticators
|
||||
* should even attempt to handle a given bearer credential.
|
||||
*/
|
||||
public static function isRecognizedToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
self::getTypeFromToken($token);
|
||||
return true;
|
||||
} catch (\InvalidArgumentException|\ValueError) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\UserSystem\OAuth;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Repository\UserSystem\OAuth\OAuthAuthCodeRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A revocation/single-use record for a short-lived OAuth2 authorization code (RFC 6749 section 4.1).
|
||||
*
|
||||
* league/oauth2-server hands the client an encrypted, self-contained code and never asks for this
|
||||
* data again - it only calls App\Security\OAuth\Repository\AuthCodeRepository::isAuthCodeRevoked()/
|
||||
* revokeAuthCode() by identifier. So unlike OAuthClient, this entity does not need to implement
|
||||
* AuthCodeEntityInterface; App\Security\OAuth\Entity\TransientAuthCode fills that role during issuance.
|
||||
* The client/user/scopes/redirect_uri columns exist purely for admin auditing.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: OAuthAuthCodeRepository::class)]
|
||||
#[ORM\Table(name: 'oauth_auth_codes')]
|
||||
class OAuthAuthCode
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
#[ORM\GeneratedValue]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
||||
private string $identifier;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private OAuthClient $client;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $scopeIdentifiers = [];
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $expiryDateTime;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
private ?string $redirectUri = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $revoked = false;
|
||||
|
||||
/**
|
||||
* @param string[] $scopeIdentifiers
|
||||
*/
|
||||
public function __construct(
|
||||
string $identifier,
|
||||
OAuthClient $client,
|
||||
User $user,
|
||||
array $scopeIdentifiers,
|
||||
\DateTimeImmutable $expiryDateTime,
|
||||
?string $redirectUri,
|
||||
) {
|
||||
$this->identifier = $identifier;
|
||||
$this->client = $client;
|
||||
$this->user = $user;
|
||||
$this->scopeIdentifiers = $scopeIdentifiers;
|
||||
$this->expiryDateTime = $expiryDateTime;
|
||||
$this->redirectUri = $redirectUri;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
public function getClient(): OAuthClient
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getScopeIdentifiers(): array
|
||||
{
|
||||
return $this->scopeIdentifiers;
|
||||
}
|
||||
|
||||
public function getExpiryDateTime(): \DateTimeImmutable
|
||||
{
|
||||
return $this->expiryDateTime;
|
||||
}
|
||||
|
||||
public function getRedirectUri(): ?string
|
||||
{
|
||||
return $this->redirectUri;
|
||||
}
|
||||
|
||||
public function isRevoked(): bool
|
||||
{
|
||||
return $this->revoked;
|
||||
}
|
||||
|
||||
public function setRevoked(bool $revoked): self
|
||||
{
|
||||
$this->revoked = $revoked;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\UserSystem\OAuth;
|
||||
|
||||
use App\Entity\Base\TimestampTrait;
|
||||
use App\Entity\Contracts\TimeStampableInterface;
|
||||
use App\Repository\UserSystem\OAuth\OAuthClientRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
||||
|
||||
/**
|
||||
* An OAuth2 client application that has been registered against this Part-DB instance, either via
|
||||
* Dynamic Client Registration (RFC 7591) or (in the future) manually by an admin.
|
||||
*
|
||||
* Only public clients (Authorization Code + PKCE, no client secret) are supported.
|
||||
*
|
||||
* Note: this class deliberately does NOT use league/oauth2-server's ClientTrait/EntityTrait. Those
|
||||
* traits declare their properties (e.g. $identifier, $name) without a type, and Doctrine attribute
|
||||
* mapping requires typed properties; PHP considers a typed redeclaration of an untyped trait property
|
||||
* "incompatible" (fatal error), so the interface is implemented manually instead.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: OAuthClientRepository::class)]
|
||||
#[ORM\Table(name: 'oauth_clients')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class OAuthClient implements ClientEntityInterface, TimeStampableInterface
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
#[ORM\GeneratedValue]
|
||||
protected int $id;
|
||||
|
||||
/**
|
||||
* The public client_id handed out to the client. This is what league/oauth2-server calls the
|
||||
* "identifier", it is NOT the same as the Doctrine primary key.
|
||||
*/
|
||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
||||
private string $identifier;
|
||||
|
||||
#[ORM\Column(type: Types::STRING)]
|
||||
private string $name;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
#[ORM\Column(type: Types::JSON)]
|
||||
private array $redirectUris = [];
|
||||
|
||||
/**
|
||||
* The opaque bearer token used to authenticate against the RFC 7591 client-configuration endpoint
|
||||
* (GET/PUT/DELETE /oauth/register/{client_id}). Not related to the OAuth access tokens issued to users.
|
||||
*/
|
||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
||||
private string $registrationAccessToken;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $dynamicallyRegistered = true;
|
||||
|
||||
/**
|
||||
* @param string[] $redirectUris
|
||||
*/
|
||||
public function __construct(string $clientId, string $name, array $redirectUris, string $registrationAccessToken)
|
||||
{
|
||||
$this->identifier = $clientId;
|
||||
$this->name = $name;
|
||||
$this->redirectUris = $redirectUris;
|
||||
$this->registrationAccessToken = $registrationAccessToken;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getRedirectUri(): array
|
||||
{
|
||||
return $this->redirectUris;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getRedirectUris(): array
|
||||
{
|
||||
return $this->redirectUris;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $redirectUris
|
||||
*/
|
||||
public function setRedirectUris(array $redirectUris): self
|
||||
{
|
||||
$this->redirectUris = $redirectUris;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only public clients (Authorization Code + PKCE, no client secret) are supported.
|
||||
*/
|
||||
public function isConfidential(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getRegistrationAccessToken(): string
|
||||
{
|
||||
return $this->registrationAccessToken;
|
||||
}
|
||||
|
||||
public function isDynamicallyRegistered(): bool
|
||||
{
|
||||
return $this->dynamicallyRegistered;
|
||||
}
|
||||
|
||||
public function setDynamicallyRegistered(bool $dynamicallyRegistered): self
|
||||
{
|
||||
$this->dynamicallyRegistered = $dynamicallyRegistered;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\UserSystem\OAuth;
|
||||
|
||||
use App\Entity\UserSystem\ApiToken;
|
||||
use App\Repository\UserSystem\OAuth\OAuthRefreshTokenRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A revocation record for a refresh token issued alongside an OAuth2 access token (which is itself
|
||||
* just an ApiToken, see AccessTokenRepository). Refresh tokens are rotated on every use; reuse of an
|
||||
* already-revoked refresh token revokes the whole token family (see
|
||||
* App\Security\OAuth\Repository\RefreshTokenRepository::isRefreshTokenRevoked()).
|
||||
*
|
||||
* league/oauth2-server hands the client an encrypted, self-contained refresh token and never asks for
|
||||
* this data again - it only calls RefreshTokenRepository::isRefreshTokenRevoked()/revokeRefreshToken()
|
||||
* by identifier. So unlike OAuthClient, this entity does not need to implement
|
||||
* RefreshTokenEntityInterface; App\Security\OAuth\Entity\TransientRefreshToken fills that role during
|
||||
* issuance.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: OAuthRefreshTokenRepository::class)]
|
||||
#[ORM\Table(name: 'oauth_refresh_tokens')]
|
||||
class OAuthRefreshToken
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
#[ORM\GeneratedValue]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, unique: true)]
|
||||
private string $identifier;
|
||||
|
||||
/**
|
||||
* The ApiToken that this refresh token can be exchanged for a new access token for. Cascading the
|
||||
* delete means revoking/deleting the ApiToken (e.g. via the "revoke" button in user settings)
|
||||
* automatically invalidates the refresh token too.
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: ApiToken::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ApiToken $apiToken;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: OAuthClient::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private OAuthClient $client;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $expiryDateTime;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $revoked = false;
|
||||
|
||||
/**
|
||||
* Shared by every refresh token in the same rotation chain (i.e. copied forward on each rotation,
|
||||
* starting from the one issued at the original authorization). Lets
|
||||
* App\Security\OAuth\Repository\RefreshTokenRepository::isRefreshTokenRevoked() revoke the whole
|
||||
* chain's *currently active* token when an old, already-rotated-out refresh token is replayed -
|
||||
* not just re-delete the (already dead) token from when this one was issued.
|
||||
*/
|
||||
#[ORM\Column(type: Types::STRING)]
|
||||
private string $familyId;
|
||||
|
||||
public function __construct(string $identifier, ApiToken $apiToken, OAuthClient $client, \DateTimeImmutable $expiryDateTime, string $familyId)
|
||||
{
|
||||
$this->identifier = $identifier;
|
||||
$this->apiToken = $apiToken;
|
||||
$this->client = $client;
|
||||
$this->expiryDateTime = $expiryDateTime;
|
||||
$this->familyId = $familyId;
|
||||
}
|
||||
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
public function getApiToken(): ApiToken
|
||||
{
|
||||
return $this->apiToken;
|
||||
}
|
||||
|
||||
public function getClient(): OAuthClient
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
public function getExpiryDateTime(): \DateTimeImmutable
|
||||
{
|
||||
return $this->expiryDateTime;
|
||||
}
|
||||
|
||||
public function getFamilyId(): string
|
||||
{
|
||||
return $this->familyId;
|
||||
}
|
||||
|
||||
public function isRevoked(): bool
|
||||
{
|
||||
return $this->revoked;
|
||||
}
|
||||
|
||||
public function setRevoked(bool $revoked): self
|
||||
{
|
||||
$this->revoked = $revoked;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\UserSystem\OAuth;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class OAuthAuthCodeRepository extends EntityRepository
|
||||
{
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\UserSystem\OAuth;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class OAuthClientRepository extends EntityRepository
|
||||
{
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\UserSystem\OAuth;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class OAuthRefreshTokenRepository extends EntityRepository
|
||||
{
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
|||
namespace App\Security;
|
||||
|
||||
use App\Entity\UserSystem\ApiToken;
|
||||
use App\Entity\UserSystem\ApiTokenType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
|
@ -88,7 +89,15 @@ class ApiTokenAuthenticator implements AuthenticatorInterface
|
|||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return null === $this->accessTokenExtractor->extractAccessToken($request) ? false : null;
|
||||
$accessToken = $this->accessTokenExtractor->extractAccessToken($request);
|
||||
if ($accessToken === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Only claim tokens that look like our own (tcp_... Personal Access Tokens); anything else
|
||||
//(e.g. an OAuth2-issued JWT) is left for App\Security\OAuth\OAuthBearerAuthenticator, which is
|
||||
//mutually exclusive with this authenticator for exactly this reason - see that class.
|
||||
return ApiTokenType::isRecognizedToken($accessToken);
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Entity;
|
||||
|
||||
use App\Entity\UserSystem\User;
|
||||
use League\OAuth2\Server\Entities\UserEntityInterface;
|
||||
|
||||
/**
|
||||
* Adapts App\Entity\UserSystem\User (whose Symfony UserInterface identifier is its username) to
|
||||
* league/oauth2-server's UserEntityInterface, which identifies a user by an opaque scalar - we use the
|
||||
* numeric database id, since that's what App\Security\OAuth\Repository\AccessTokenRepository and
|
||||
* AuthCodeRepository resolve back into a User via EntityManager::find().
|
||||
*/
|
||||
class OAuthUserEntity implements UserEntityInterface
|
||||
{
|
||||
public function __construct(private readonly int $id)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromUser(User $user): self
|
||||
{
|
||||
return new self($user->getID());
|
||||
}
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return (string) $this->id;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Entity;
|
||||
|
||||
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
|
||||
use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
|
||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
||||
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
|
||||
|
||||
/**
|
||||
* A purely in-memory AccessTokenEntityInterface used only while league/oauth2-server is issuing a
|
||||
* token. It is never persisted itself: our AccessTokenRepository::persistNewAccessToken() creates a
|
||||
* real App\Entity\UserSystem\ApiToken from it and overwrites its identifier with the ApiToken's own
|
||||
* "oat_..." token string, so the value returned to the client and the value stored in the api_tokens
|
||||
* table are identical - see AccessTokenRepository for why (keeps ApiTokenAuthenticator as the single
|
||||
* validation path for both PATs and OAuth-issued tokens).
|
||||
*/
|
||||
class TransientAccessToken implements AccessTokenEntityInterface
|
||||
{
|
||||
use AccessTokenTrait;
|
||||
use EntityTrait;
|
||||
use TokenEntityTrait;
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Entity;
|
||||
|
||||
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
|
||||
use League\OAuth2\Server\Entities\Traits\AuthCodeTrait;
|
||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
||||
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
|
||||
|
||||
/**
|
||||
* A purely in-memory AuthCodeEntityInterface used only while league/oauth2-server is issuing an
|
||||
* authorization code. The code_challenge/method and all other request data end up embedded directly
|
||||
* in the encrypted code handed to the client (league/oauth2-server never asks the repository for them
|
||||
* again); our AuthCodeRepository only needs to persist a small revocation record
|
||||
* (App\Entity\UserSystem\OAuth\OAuthAuthCode) keyed by identifier, for single-use enforcement.
|
||||
*/
|
||||
class TransientAuthCode implements AuthCodeEntityInterface
|
||||
{
|
||||
use AuthCodeTrait;
|
||||
use EntityTrait;
|
||||
use TokenEntityTrait;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Entity;
|
||||
|
||||
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
|
||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
||||
use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait;
|
||||
|
||||
/**
|
||||
* A purely in-memory RefreshTokenEntityInterface used only while league/oauth2-server is issuing a
|
||||
* refresh token. league/oauth2-server itself never stores the refresh token's content server-side -
|
||||
* it hands the client an encrypted, self-contained payload and decrypts/validates it directly on the
|
||||
* next request. Our RefreshTokenRepository only needs to persist a small revocation record
|
||||
* (App\Entity\UserSystem\OAuth\OAuthRefreshToken) keyed by identifier; see that repository.
|
||||
*/
|
||||
class TransientRefreshToken implements RefreshTokenEntityInterface
|
||||
{
|
||||
use EntityTrait;
|
||||
use RefreshTokenTrait;
|
||||
}
|
||||
158
src/Security/OAuth/OAuthBearerAuthenticator.php
Normal file
158
src/Security/OAuth/OAuthBearerAuthenticator.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth;
|
||||
|
||||
use App\Entity\UserSystem\ApiTokenType;
|
||||
use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token;
|
||||
use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationException;
|
||||
use League\Bundle\OAuth2ServerBundle\Security\Exception\OAuth2AuthenticationFailedException;
|
||||
use League\Bundle\OAuth2ServerBundle\Security\Passport\Badge\ScopeBadge;
|
||||
use League\Bundle\OAuth2ServerBundle\Security\User\ClientCredentialsUser;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use League\OAuth2\Server\ResourceServer;
|
||||
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserProviderInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||
|
||||
/**
|
||||
* Validates OAuth2-issued Bearer tokens (auto-provisioned API/MCP app credentials, see
|
||||
* config/packages/league_oauth2_server.yaml) against the bundle's own ResourceServer/token storage.
|
||||
*
|
||||
* This is functionally almost identical to the bundle's own
|
||||
* League\Bundle\OAuth2ServerBundle\Security\Authenticator\OAuth2Authenticator - reimplemented here
|
||||
* (rather than reused) for one reason: supports() must be mutually exclusive with
|
||||
* App\Security\ApiTokenAuthenticator. Symfony's AuthenticatorManager runs *every* authenticator whose
|
||||
* supports() matches a request, not just the first one that succeeds - so if both authenticators
|
||||
* claimed every "Authorization: Bearer ..." request, whichever one runs second would immediately
|
||||
* re-validate (and fail on) a token meant for the other, overwriting an already-successful
|
||||
* authentication with a 401. Restricting each authenticator to the token shapes it actually owns
|
||||
* (ApiTokenAuthenticator: our own "tcp_..." Personal Access Tokens; this class: everything else, i.e.
|
||||
* OAuth2-issued JWTs) avoids that collision entirely. See App\Entity\UserSystem\ApiTokenType::isRecognizedToken().
|
||||
*/
|
||||
class OAuthBearerAuthenticator implements AuthenticatorInterface, AuthenticationEntryPointInterface
|
||||
{
|
||||
/**
|
||||
* Must match config/packages/league_oauth2_server.yaml's authorization_server.role_prefix.
|
||||
*/
|
||||
private const ROLE_PREFIX = 'ROLE_API_';
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'league.oauth2_server.factory.psr_http')]
|
||||
private readonly HttpMessageFactoryInterface $httpMessageFactory,
|
||||
private readonly ResourceServer $resourceServer,
|
||||
#[Autowire(service: 'security.user.provider.concrete.app_user_provider')]
|
||||
private readonly UserProviderInterface $userProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
$header = $request->headers->get('Authorization', '');
|
||||
if (!str_starts_with((string) $header, 'Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !ApiTokenType::isRecognizedToken(substr($header, 7));
|
||||
}
|
||||
|
||||
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||
{
|
||||
return new Response($authException?->getMessage() ?? 'Authentication required', Response::HTTP_UNAUTHORIZED, ['WWW-Authenticate' => 'Bearer']);
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
try {
|
||||
$psr7Request = $this->resourceServer->validateAuthenticatedRequest($this->httpMessageFactory->createRequest($request));
|
||||
} catch (OAuthServerException $e) {
|
||||
throw OAuth2AuthenticationFailedException::create('The resource server rejected the request.', $e);
|
||||
}
|
||||
|
||||
/** @var string $userIdentifier */
|
||||
$userIdentifier = $psr7Request->getAttribute('oauth_user_id', '');
|
||||
|
||||
/** @var string $accessTokenId */
|
||||
$accessTokenId = $psr7Request->getAttribute('oauth_access_token_id');
|
||||
|
||||
/** @var list<string> $scopes */
|
||||
$scopes = $psr7Request->getAttribute('oauth_scopes', []);
|
||||
|
||||
/** @var non-empty-string $oauthClientId */
|
||||
$oauthClientId = $psr7Request->getAttribute('oauth_client_id', '');
|
||||
|
||||
$userLoader = function (string $userIdentifier) use ($oauthClientId): UserInterface {
|
||||
if ($oauthClientId === $userIdentifier) {
|
||||
return new ClientCredentialsUser($oauthClientId);
|
||||
}
|
||||
|
||||
return $this->userProvider->loadUserByIdentifier($userIdentifier);
|
||||
};
|
||||
|
||||
$passport = new SelfValidatingPassport(new UserBadge($userIdentifier, $userLoader), [
|
||||
new ScopeBadge($scopes),
|
||||
]);
|
||||
|
||||
$passport->setAttribute('accessTokenId', $accessTokenId);
|
||||
$passport->setAttribute('oauthClientId', $oauthClientId);
|
||||
|
||||
return $passport;
|
||||
}
|
||||
|
||||
public function createToken(Passport $passport, string $firewallName): TokenInterface
|
||||
{
|
||||
/** @var string $accessTokenId */
|
||||
$accessTokenId = $passport->getAttribute('accessTokenId');
|
||||
|
||||
/** @var ScopeBadge $scopeBadge */
|
||||
$scopeBadge = $passport->getBadge(ScopeBadge::class);
|
||||
|
||||
/** @var string $oauthClientId */
|
||||
$oauthClientId = $passport->getAttribute('oauthClientId', '');
|
||||
|
||||
return new OAuth2Token($passport->getUser(), $accessTokenId, $oauthClientId, $scopeBadge->getScopes(), self::ROLE_PREFIX);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
if ($exception instanceof OAuth2AuthenticationException) {
|
||||
return new Response($exception->getMessage(), $exception->getStatusCode(), $exception->getHeaders());
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth;
|
||||
|
||||
use App\Entity\UserSystem\ApiTokenLevel;
|
||||
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
||||
use League\OAuth2\Server\Entities\Traits\EntityTrait;
|
||||
use League\OAuth2\Server\Entities\Traits\ScopeTrait;
|
||||
|
||||
/**
|
||||
* An OAuth2 scope. There is no separate scope taxonomy: scopes are just the existing ApiTokenLevel
|
||||
* enum values (read_only/edit/admin/full), so an OAuth-granted token gets exactly the same Symfony
|
||||
* roles as an equivalent manually-created Personal Access Token. See OAuthScopeRepository.
|
||||
*/
|
||||
class OAuthScope implements ScopeEntityInterface
|
||||
{
|
||||
use EntityTrait;
|
||||
use ScopeTrait;
|
||||
|
||||
public function __construct(private readonly ApiTokenLevel $level)
|
||||
{
|
||||
$this->identifier = self::scopeIdentifierFor($level);
|
||||
}
|
||||
|
||||
public function getLevel(): ApiTokenLevel
|
||||
{
|
||||
return $this->level;
|
||||
}
|
||||
|
||||
public static function scopeIdentifierFor(ApiTokenLevel $level): string
|
||||
{
|
||||
return strtolower($level->name);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth;
|
||||
|
||||
use League\OAuth2\Server\ResponseTypes\BearerTokenResponse;
|
||||
use LogicException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* league/oauth2-server's default BearerTokenResponse returns a signed JWT as the "access_token" value
|
||||
* (via AccessTokenEntityInterface::__toString()/convertToJWT()). We don't want that: our access tokens
|
||||
* ARE App\Entity\UserSystem\ApiToken rows (see App\Security\OAuth\Repository\AccessTokenRepository),
|
||||
* validated by the existing App\Security\ApiTokenAuthenticator via a plain DB lookup, so the string
|
||||
* handed to the client must be exactly ApiToken::getToken() rather than a JWT wrapping it.
|
||||
*
|
||||
* This is otherwise an exact copy of BearerTokenResponse::generateHttpResponse(), the only change
|
||||
* being `$this->accessToken->getIdentifier()` instead of `(string) $this->accessToken`. Overriding this
|
||||
* method (rather than just the identifier) is necessary because the base class doesn't expose a smaller
|
||||
* extension point for it - see the "extra params" hook, which is for adding *additional* fields, not
|
||||
* for changing how access_token itself is rendered.
|
||||
*/
|
||||
class OpaqueBearerTokenResponse extends BearerTokenResponse
|
||||
{
|
||||
public function generateHttpResponse(ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$expireDateTime = $this->accessToken->getExpiryDateTime()->getTimestamp();
|
||||
|
||||
$responseParams = [
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => $expireDateTime - time(),
|
||||
'access_token' => $this->accessToken->getIdentifier(),
|
||||
];
|
||||
|
||||
//$refreshToken is a typed, non-nullable property with no default - isset() (rather than an
|
||||
//instanceof/truthiness check, which would throw on an uninitialized typed property) is the
|
||||
//correct way to test whether setRefreshToken() was ever called for this response.
|
||||
if (isset($this->refreshToken)) {
|
||||
$refreshTokenPayload = json_encode([
|
||||
'client_id' => $this->accessToken->getClient()->getIdentifier(),
|
||||
'refresh_token_id' => $this->refreshToken->getIdentifier(),
|
||||
'access_token_id' => $this->accessToken->getIdentifier(),
|
||||
'scopes' => $this->accessToken->getScopes(),
|
||||
'user_id' => $this->accessToken->getUserIdentifier(),
|
||||
'expire_time' => $this->refreshToken->getExpiryDateTime()->getTimestamp(),
|
||||
]);
|
||||
|
||||
if ($refreshTokenPayload === false) {
|
||||
throw new LogicException('Error encountered JSON encoding the refresh token payload');
|
||||
}
|
||||
|
||||
$responseParams['refresh_token'] = $this->encrypt($refreshTokenPayload);
|
||||
}
|
||||
|
||||
$responseParams = json_encode(array_merge($this->getExtraParams($this->accessToken), $responseParams));
|
||||
|
||||
if ($responseParams === false) {
|
||||
throw new LogicException('Error encountered JSON encoding response parameters');
|
||||
}
|
||||
|
||||
$response = $response
|
||||
->withStatus(200)
|
||||
->withHeader('pragma', 'no-cache')
|
||||
->withHeader('cache-control', 'no-store')
|
||||
->withHeader('content-type', 'application/json; charset=UTF-8');
|
||||
|
||||
$response->getBody()->write($responseParams);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Repository;
|
||||
|
||||
use App\Entity\UserSystem\ApiToken;
|
||||
use App\Entity\UserSystem\ApiTokenLevel;
|
||||
use App\Entity\UserSystem\ApiTokenType;
|
||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Security\OAuth\Entity\TransientAccessToken;
|
||||
use App\Security\OAuth\OAuthScope;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
|
||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
||||
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Makes an OAuth2 access token just be a regular App\Entity\UserSystem\ApiToken (with a new
|
||||
* ApiTokenType::OAUTH_ACCESS_TOKEN prefix), so App\Security\ApiTokenAuthenticator validates it with no
|
||||
* changes at all - no parallel JWT-verification path, no second revocation mechanism. See
|
||||
* TransientAccessToken for why the AccessTokenEntityInterface object itself is never persisted.
|
||||
*/
|
||||
class AccessTokenRepository implements AccessTokenRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
public function getNewToken(ClientEntityInterface $clientEntity, array $scopes, ?string $userIdentifier = null): AccessTokenEntityInterface
|
||||
{
|
||||
$accessToken = new TransientAccessToken();
|
||||
$accessToken->setClient($clientEntity);
|
||||
foreach ($scopes as $scope) {
|
||||
$accessToken->addScope($scope);
|
||||
}
|
||||
if ($userIdentifier !== null) {
|
||||
$accessToken->setUserIdentifier($userIdentifier);
|
||||
}
|
||||
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
public function persistNewAccessToken(AccessTokenEntityInterface $accessTokenEntity): void
|
||||
{
|
||||
$client = $accessTokenEntity->getClient();
|
||||
if (!$client instanceof OAuthClient) {
|
||||
throw new \LogicException('Expected an OAuthClient instance.');
|
||||
}
|
||||
|
||||
$user = $this->entityManager->find(User::class, $accessTokenEntity->getUserIdentifier());
|
||||
if (!$user instanceof User) {
|
||||
throw new \LogicException('Could not resolve the user the access token was issued to.');
|
||||
}
|
||||
|
||||
//ScopeRepository::finalizeScopes() always collapses the requested scopes down to a single
|
||||
//ApiTokenLevel, so there is exactly one scope here.
|
||||
$level = ApiTokenLevel::READ_ONLY;
|
||||
foreach ($accessTokenEntity->getScopes() as $scope) {
|
||||
if ($scope instanceof OAuthScope) {
|
||||
$level = $scope->getLevel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$apiToken = new ApiToken(ApiTokenType::OAUTH_ACCESS_TOKEN);
|
||||
$apiToken->setUser($user);
|
||||
$apiToken->setLevel($level);
|
||||
$apiToken->setName(sprintf('OAuth: %s', $client->getName()));
|
||||
$apiToken->setValidUntil($accessTokenEntity->getExpiryDateTime());
|
||||
$apiToken->setOauthClient($client);
|
||||
|
||||
$this->entityManager->persist($apiToken);
|
||||
$this->entityManager->flush();
|
||||
|
||||
//Overwrite league's randomly generated identifier with our own ApiToken's token string, so the
|
||||
//bearer token string returned to the client and api_tokens.token are the same value.
|
||||
$accessTokenEntity->setIdentifier($apiToken->getToken());
|
||||
}
|
||||
|
||||
public function revokeAccessToken(string $tokenId): void
|
||||
{
|
||||
//A bulk DQL delete instead of load+remove()+flush(): during refresh token rotation,
|
||||
//RefreshTokenGrant::respondToAccessTokenRequest() revokes this access token and then, in the
|
||||
//same request, touches the OLD OAuthRefreshToken row that still references it - which is the
|
||||
//very same managed object sitting in the identity map since issuance. Loading (and therefore
|
||||
//re-managing) this ApiToken via the ORM here makes Doctrine try to validate that association on
|
||||
//the next flush() and fail ("a new entity was found through the relationship..."). A bulk delete
|
||||
//never touches the UnitOfWork's per-entity changeset tracking, so it can't trigger that.
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(ApiToken::class, 't')
|
||||
->where('t.token = :token')
|
||||
->setParameter('token', $tokenId)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function isAccessTokenRevoked(string $tokenId): bool
|
||||
{
|
||||
$apiToken = $this->findByToken($tokenId);
|
||||
|
||||
return !$apiToken instanceof ApiToken || !$apiToken->isValid();
|
||||
}
|
||||
|
||||
private function findByToken(string $token): ?ApiToken
|
||||
{
|
||||
return $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $token]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Repository;
|
||||
|
||||
use App\Entity\UserSystem\OAuth\OAuthAuthCode;
|
||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Security\OAuth\Entity\TransientAuthCode;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\OAuth2\Server\Entities\AuthCodeEntityInterface;
|
||||
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
|
||||
|
||||
/**
|
||||
* See TransientAuthCode and OAuthAuthCode for why this only ever persists a small revocation record,
|
||||
* never the code's actual content.
|
||||
*/
|
||||
class AuthCodeRepository implements AuthCodeRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
public function getNewAuthCode(): AuthCodeEntityInterface
|
||||
{
|
||||
return new TransientAuthCode();
|
||||
}
|
||||
|
||||
public function persistNewAuthCode(AuthCodeEntityInterface $authCodeEntity): void
|
||||
{
|
||||
$client = $authCodeEntity->getClient();
|
||||
if (!$client instanceof OAuthClient) {
|
||||
throw new \LogicException('Expected an OAuthClient instance.');
|
||||
}
|
||||
|
||||
$user = $this->entityManager->find(User::class, $authCodeEntity->getUserIdentifier());
|
||||
if (!$user instanceof User) {
|
||||
throw new \LogicException('Could not resolve the user the authorization code was issued to.');
|
||||
}
|
||||
|
||||
$scopeIdentifiers = array_map(
|
||||
static fn ($scope) => $scope->getIdentifier(),
|
||||
$authCodeEntity->getScopes()
|
||||
);
|
||||
|
||||
$authCode = new OAuthAuthCode(
|
||||
$authCodeEntity->getIdentifier(),
|
||||
$client,
|
||||
$user,
|
||||
$scopeIdentifiers,
|
||||
$authCodeEntity->getExpiryDateTime(),
|
||||
$authCodeEntity->getRedirectUri(),
|
||||
);
|
||||
|
||||
$this->entityManager->persist($authCode);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function revokeAuthCode(string $codeId): void
|
||||
{
|
||||
$authCode = $this->findByIdentifier($codeId);
|
||||
if ($authCode instanceof OAuthAuthCode) {
|
||||
$authCode->setRevoked(true);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function isAuthCodeRevoked(string $codeId): bool
|
||||
{
|
||||
$authCode = $this->findByIdentifier($codeId);
|
||||
|
||||
//A code we have no record of can't be validated, so treat it as revoked/invalid.
|
||||
return !$authCode instanceof OAuthAuthCode || $authCode->isRevoked();
|
||||
}
|
||||
|
||||
private function findByIdentifier(string $codeId): ?OAuthAuthCode
|
||||
{
|
||||
return $this->entityManager->getRepository(OAuthAuthCode::class)
|
||||
->findOneBy(['identifier' => $codeId]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Repository;
|
||||
|
||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
||||
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Only public clients (Authorization Code + PKCE, no client secret) are supported, so validateClient()
|
||||
* never checks a secret - it just checks the client is registered.
|
||||
*/
|
||||
class ClientRepository implements ClientRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
public function getClientEntity(string $clientIdentifier): ?ClientEntityInterface
|
||||
{
|
||||
return $this->entityManager->getRepository(OAuthClient::class)
|
||||
->findOneBy(['identifier' => $clientIdentifier]);
|
||||
}
|
||||
|
||||
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
|
||||
{
|
||||
return $this->getClientEntity($clientIdentifier) instanceof OAuthClient;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Repository;
|
||||
|
||||
use App\Entity\UserSystem\ApiToken;
|
||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
||||
use App\Entity\UserSystem\OAuth\OAuthRefreshToken;
|
||||
use App\Security\OAuth\Entity\TransientRefreshToken;
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
|
||||
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Refresh tokens are rotated on every use. Reuse of an already-revoked (i.e. already rotated-out)
|
||||
* refresh token is a signal of token theft, so isRefreshTokenRevoked() responds by revoking the whole
|
||||
* token family - deleting the linked ApiToken, not just this refresh token - per OAuth 2.1 guidance.
|
||||
*
|
||||
* revokeRefreshToken()/isRefreshTokenRevoked()'s family-kill path deliberately use bulk DQL
|
||||
* updates/deletes instead of loading entities through the ORM: during rotation,
|
||||
* League\OAuth2\Server\Grant\RefreshTokenGrant::respondToAccessTokenRequest() revokes the old access
|
||||
* token and old refresh token in the same request/EntityManager that originally persisted them, so the
|
||||
* OLD OAuthRefreshToken is still the exact same managed object sitting in the identity map from
|
||||
* issuance. Loading and mutating it via the ORM after AccessTokenRepository::revokeAccessToken() has
|
||||
* already deleted its linked ApiToken makes Doctrine try to validate that (now-stale) "apiToken"
|
||||
* association on the next flush() and fail ("a new entity was found through the relationship...").
|
||||
* Bulk DQL bypasses the UnitOfWork's per-entity changeset/association checks entirely.
|
||||
*/
|
||||
class RefreshTokenRepository implements RefreshTokenRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* The family id of the refresh token most recently revoked (via revokeRefreshToken()) in this
|
||||
* request, so the token about to be persisted (via persistNewRefreshToken(), which
|
||||
* league/oauth2-server always calls immediately afterward during rotation - see RefreshTokenGrant::
|
||||
* respondToAccessTokenRequest()) can carry the same family id forward instead of starting a new one.
|
||||
*/
|
||||
private ?string $pendingFamilyId = null;
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
public function getNewRefreshToken(): ?RefreshTokenEntityInterface
|
||||
{
|
||||
return new TransientRefreshToken();
|
||||
}
|
||||
|
||||
public function persistNewRefreshToken(RefreshTokenEntityInterface $refreshTokenEntity): void
|
||||
{
|
||||
$accessToken = $refreshTokenEntity->getAccessToken();
|
||||
|
||||
$apiToken = $this->entityManager->getRepository(ApiToken::class)
|
||||
->findOneBy(['token' => $accessToken->getIdentifier()]);
|
||||
if (!$apiToken instanceof ApiToken) {
|
||||
throw new \LogicException('Could not resolve the ApiToken this refresh token belongs to.');
|
||||
}
|
||||
|
||||
$client = $accessToken->getClient();
|
||||
if (!$client instanceof OAuthClient) {
|
||||
throw new \LogicException('Expected an OAuthClient instance.');
|
||||
}
|
||||
|
||||
$familyId = $this->pendingFamilyId ?? bin2hex(random_bytes(16));
|
||||
$this->pendingFamilyId = null;
|
||||
|
||||
$refreshToken = new OAuthRefreshToken(
|
||||
$refreshTokenEntity->getIdentifier(),
|
||||
$apiToken,
|
||||
$client,
|
||||
$refreshTokenEntity->getExpiryDateTime(),
|
||||
$familyId,
|
||||
);
|
||||
|
||||
$this->entityManager->persist($refreshToken);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
public function revokeRefreshToken(string $tokenId): void
|
||||
{
|
||||
$familyId = $this->entityManager->createQueryBuilder()
|
||||
->select('r.familyId')
|
||||
->from(OAuthRefreshToken::class, 'r')
|
||||
->where('r.identifier = :identifier')
|
||||
->setParameter('identifier', $tokenId)
|
||||
->getQuery()
|
||||
->getOneOrNullResult(AbstractQuery::HYDRATE_SINGLE_SCALAR);
|
||||
|
||||
if ($familyId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pendingFamilyId = $familyId;
|
||||
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->update(OAuthRefreshToken::class, 'r')
|
||||
->set('r.revoked', ':revoked')
|
||||
->where('r.identifier = :identifier')
|
||||
->setParameter('revoked', true)
|
||||
->setParameter('identifier', $tokenId)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function isRefreshTokenRevoked(string $tokenId): bool
|
||||
{
|
||||
$row = $this->entityManager->createQueryBuilder()
|
||||
->select('r.revoked', 'r.familyId')
|
||||
->from(OAuthRefreshToken::class, 'r')
|
||||
->where('r.identifier = :identifier')
|
||||
->setParameter('identifier', $tokenId)
|
||||
->getQuery()
|
||||
->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY);
|
||||
|
||||
if ($row === null) {
|
||||
//No record at all - can't validate, treat as revoked/invalid.
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($row['revoked']) {
|
||||
//Reuse of an already-rotated-out refresh token: kill the whole family - every refresh token
|
||||
//(and thus ApiToken) descended from the same original grant, not just this one, since the
|
||||
//currently active token in the chain is what actually needs to stop working.
|
||||
$apiTokenIds = $this->entityManager->createQueryBuilder()
|
||||
->select('IDENTITY(r.apiToken) AS apiTokenId')
|
||||
->from(OAuthRefreshToken::class, 'r')
|
||||
->where('r.familyId = :familyId')
|
||||
->setParameter('familyId', $row['familyId'])
|
||||
->getQuery()
|
||||
->getSingleColumnResult();
|
||||
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(OAuthRefreshToken::class, 'r')
|
||||
->where('r.familyId = :familyId')
|
||||
->setParameter('familyId', $row['familyId'])
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
if ($apiTokenIds !== []) {
|
||||
$this->entityManager->createQueryBuilder()
|
||||
->delete(ApiToken::class, 't')
|
||||
->where('t.id IN (:ids)')
|
||||
->setParameter('ids', $apiTokenIds)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security\OAuth\Repository;
|
||||
|
||||
use App\Entity\UserSystem\ApiTokenLevel;
|
||||
use App\Security\OAuth\OAuthScope;
|
||||
use League\OAuth2\Server\Entities\ClientEntityInterface;
|
||||
use League\OAuth2\Server\Entities\ScopeEntityInterface;
|
||||
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Scopes are just the existing ApiTokenLevel enum (read_only/edit/admin/full) - see OAuthScope. There
|
||||
* is no fine-grained per-endpoint scoping; an OAuth-granted token gets exactly the same Symfony roles
|
||||
* as an equivalent manually-created Personal Access Token of that level.
|
||||
*/
|
||||
class ScopeRepository implements ScopeRepositoryInterface
|
||||
{
|
||||
public function getScopeEntityByIdentifier(string $identifier): ?ScopeEntityInterface
|
||||
{
|
||||
$level = $this->levelFromIdentifier($identifier);
|
||||
|
||||
return $level !== null ? new OAuthScope($level) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ScopeEntityInterface[] $scopes
|
||||
* @return ScopeEntityInterface[]
|
||||
*/
|
||||
public function finalizeScopes(array $scopes, string $grantType, ClientEntityInterface $clientEntity, ?string $userIdentifier = null, ?string $authCodeId = null): array
|
||||
{
|
||||
//ApiTokenLevel is cumulative (a higher level already includes everything a lower one grants),
|
||||
//so a token only ever has a single level. If several scopes were requested, keep only the
|
||||
//highest one; if none were requested/valid, default to the least-privileged level.
|
||||
$highest = ApiTokenLevel::READ_ONLY;
|
||||
foreach ($scopes as $scope) {
|
||||
if ($scope instanceof OAuthScope && $scope->getLevel()->value > $highest->value) {
|
||||
$highest = $scope->getLevel();
|
||||
}
|
||||
}
|
||||
|
||||
return [new OAuthScope($highest)];
|
||||
}
|
||||
|
||||
private function levelFromIdentifier(string $identifier): ?ApiTokenLevel
|
||||
{
|
||||
foreach (ApiTokenLevel::cases() as $level) {
|
||||
if (OAuthScope::scopeIdentifierFor($level) === $identifier) {
|
||||
return $level;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ use App\Entity\UserSystem\User;
|
|||
use App\Repository\UserRepository;
|
||||
use App\Security\ApiTokenAuthenticatedToken;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
|
@ -88,8 +89,13 @@ final class VoterHelper
|
|||
$user = $this->resolveUser($token);
|
||||
}
|
||||
|
||||
//If the token is a APITokenAuthenticated
|
||||
if ($token instanceof ApiTokenAuthenticatedToken) {
|
||||
//If the token is a APITokenAuthenticated token (Personal Access Token) or an OAuth2Token (an
|
||||
//OAuth2-issued token, see App\Security\OAuth\OAuthBearerAuthenticator), the token can only ever
|
||||
//grant a *subset* of what the user could already do - apply the same API-level ceiling check to
|
||||
//both, keyed off the token's own role list (ROLE_API_* either way - see
|
||||
//App\Entity\UserSystem\ApiTokenLevel::getAdditionalRoles() and
|
||||
//config/packages/league_oauth2_server.yaml's role_prefix/scopes).
|
||||
if ($token instanceof ApiTokenAuthenticatedToken || $token instanceof OAuth2Token) {
|
||||
//Use the special API token checker
|
||||
return $this->permissionManager->inheritWithAPILevel($user, $token->getRoleNames(), $permission, $operation);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,246 +0,0 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security\OAuth;
|
||||
|
||||
use App\Entity\UserSystem\ApiToken;
|
||||
use App\Entity\UserSystem\ApiTokenLevel;
|
||||
use App\Entity\UserSystem\ApiTokenType;
|
||||
use App\Entity\UserSystem\OAuth\OAuthClient;
|
||||
use App\Entity\UserSystem\User;
|
||||
use App\Security\OAuth\Entity\OAuthUserEntity;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use League\OAuth2\Server\AuthorizationServer;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Drives League\OAuth2\Server\AuthorizationServer directly (no HTTP layer yet - that's Phase 2/3 of the
|
||||
* OAuth auto-provisioning work) to verify the foundations laid in Phase 1: our Doctrine-backed
|
||||
* repositories, the opaque (non-JWT) access token response, and - most importantly - that the token
|
||||
* handed back to the client round-trips as a real App\Entity\UserSystem\ApiToken row, findable exactly
|
||||
* the way App\Security\ApiTokenAuthenticator finds any other token.
|
||||
*/
|
||||
final class AuthorizationServerTest extends KernelTestCase
|
||||
{
|
||||
private AuthorizationServer $authServer;
|
||||
private EntityManagerInterface $entityManager;
|
||||
private Psr17Factory $psr17Factory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->authServer = self::getContainer()->get(AuthorizationServer::class);
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->psr17Factory = new Psr17Factory();
|
||||
}
|
||||
|
||||
private function createTestClient(string $redirectUri): OAuthClient
|
||||
{
|
||||
$client = new OAuthClient(
|
||||
'test-client-'.bin2hex(random_bytes(8)),
|
||||
'Test Client',
|
||||
[$redirectUri],
|
||||
bin2hex(random_bytes(16)),
|
||||
);
|
||||
$this->entityManager->persist($client);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function getAdminUser(): User
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
||||
self::assertInstanceOf(User::class, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string} [code_verifier, code_challenge]
|
||||
*/
|
||||
private function generatePkcePair(): array
|
||||
{
|
||||
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
return [$verifier, $challenge];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the full authorize -> approve -> token exchange flow and returns the decoded token response.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function runAuthorizationCodeFlow(OAuthClient $client, User $user, string $redirectUri, string $scope): array
|
||||
{
|
||||
[$verifier, $challenge] = $this->generatePkcePair();
|
||||
|
||||
$authorizeRequest = $this->psr17Factory->createServerRequest('GET', 'https://part-db.test/oauth/authorize?'.http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $client->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
'scope' => $scope,
|
||||
]));
|
||||
|
||||
$authRequest = $this->authServer->validateAuthorizationRequest($authorizeRequest);
|
||||
$authRequest->setUser(OAuthUserEntity::fromUser($user));
|
||||
$authRequest->setAuthorizationApproved(true);
|
||||
|
||||
$redirectResponse = $this->authServer->completeAuthorizationRequest($authRequest, $this->psr17Factory->createResponse());
|
||||
$location = $redirectResponse->getHeaderLine('Location');
|
||||
parse_str((string) parse_url($location, PHP_URL_QUERY), $query);
|
||||
self::assertArrayHasKey('code', $query, 'Expected an authorization code in the redirect: '.$location);
|
||||
|
||||
$tokenRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
||||
->withParsedBody([
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $client->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code' => $query['code'],
|
||||
'code_verifier' => $verifier,
|
||||
]);
|
||||
|
||||
$tokenResponse = $this->authServer->respondToAccessTokenRequest($tokenRequest, $this->psr17Factory->createResponse());
|
||||
$body = (string) $tokenResponse->getBody();
|
||||
|
||||
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function testAuthorizationCodeFlowIssuesRealApiToken(): void
|
||||
{
|
||||
$redirectUri = 'https://client.example.invalid/callback';
|
||||
$client = $this->createTestClient($redirectUri);
|
||||
$user = $this->getAdminUser();
|
||||
|
||||
$data = $this->runAuthorizationCodeFlow($client, $user, $redirectUri, 'edit');
|
||||
|
||||
self::assertSame('Bearer', $data['token_type']);
|
||||
self::assertArrayHasKey('access_token', $data);
|
||||
self::assertArrayHasKey('refresh_token', $data);
|
||||
//The access token must NOT look like a JWT (no dots) - see OpaqueBearerTokenResponse.
|
||||
self::assertStringStartsWith(ApiTokenType::OAUTH_ACCESS_TOKEN->getTokenPrefix(), $data['access_token']);
|
||||
self::assertStringNotContainsString('.', $data['access_token']);
|
||||
|
||||
$this->entityManager->clear();
|
||||
$apiToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $data['access_token']]);
|
||||
|
||||
self::assertInstanceOf(ApiToken::class, $apiToken);
|
||||
self::assertTrue($apiToken->isValid());
|
||||
self::assertSame(ApiTokenType::OAUTH_ACCESS_TOKEN, $apiToken->getTokenType());
|
||||
self::assertSame(ApiTokenLevel::EDIT, $apiToken->getLevel());
|
||||
self::assertSame($user->getID(), $apiToken->getUser()?->getID());
|
||||
self::assertSame($client->getId(), $apiToken->getOauthClient()?->getId());
|
||||
}
|
||||
|
||||
public function testInvalidPkceVerifierIsRejected(): void
|
||||
{
|
||||
$redirectUri = 'https://client.example.invalid/callback';
|
||||
$client = $this->createTestClient($redirectUri);
|
||||
$user = $this->getAdminUser();
|
||||
|
||||
[, $challenge] = $this->generatePkcePair();
|
||||
|
||||
$authorizeRequest = $this->psr17Factory->createServerRequest('GET', 'https://part-db.test/oauth/authorize?'.http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $client->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]));
|
||||
|
||||
$authRequest = $this->authServer->validateAuthorizationRequest($authorizeRequest);
|
||||
$authRequest->setUser(OAuthUserEntity::fromUser($user));
|
||||
$authRequest->setAuthorizationApproved(true);
|
||||
|
||||
$redirectResponse = $this->authServer->completeAuthorizationRequest($authRequest, $this->psr17Factory->createResponse());
|
||||
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
|
||||
|
||||
$tokenRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
||||
->withParsedBody([
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $client->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code' => $query['code'],
|
||||
//Wrong verifier - does not match the code_challenge used above.
|
||||
'code_verifier' => str_repeat('a', 43),
|
||||
]);
|
||||
|
||||
$this->expectException(OAuthServerException::class);
|
||||
$this->authServer->respondToAccessTokenRequest($tokenRequest, $this->psr17Factory->createResponse());
|
||||
}
|
||||
|
||||
public function testRefreshTokenRotationIssuesNewApiTokenAndRevokesOld(): void
|
||||
{
|
||||
$redirectUri = 'https://client.example.invalid/callback';
|
||||
$client = $this->createTestClient($redirectUri);
|
||||
$user = $this->getAdminUser();
|
||||
|
||||
$first = $this->runAuthorizationCodeFlow($client, $user, $redirectUri, 'read_only');
|
||||
$originalAccessToken = $first['access_token'];
|
||||
|
||||
$refreshRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
||||
->withParsedBody([
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $client->getIdentifier(),
|
||||
'refresh_token' => $first['refresh_token'],
|
||||
]);
|
||||
|
||||
$refreshResponse = $this->authServer->respondToAccessTokenRequest($refreshRequest, $this->psr17Factory->createResponse());
|
||||
$second = json_decode((string) $refreshResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertNotSame($originalAccessToken, $second['access_token']);
|
||||
|
||||
$this->entityManager->clear();
|
||||
|
||||
//The old access token must have been revoked (deleted) as part of rotation.
|
||||
$oldToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $originalAccessToken]);
|
||||
self::assertNull($oldToken, 'The original access token should have been revoked on refresh.');
|
||||
|
||||
$newToken = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $second['access_token']]);
|
||||
self::assertInstanceOf(ApiToken::class, $newToken);
|
||||
self::assertSame(ApiTokenLevel::READ_ONLY, $newToken->getLevel());
|
||||
|
||||
//Reusing the now-rotated-out refresh token must fail and revoke the whole family.
|
||||
$reuseRequest = $this->psr17Factory->createServerRequest('POST', 'https://part-db.test/oauth/token')
|
||||
->withParsedBody([
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $client->getIdentifier(),
|
||||
'refresh_token' => $first['refresh_token'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->authServer->respondToAccessTokenRequest($reuseRequest, $this->psr17Factory->createResponse());
|
||||
self::fail('Expected reuse of a rotated-out refresh token to be rejected.');
|
||||
} catch (OAuthServerException) {
|
||||
//Expected.
|
||||
}
|
||||
|
||||
$this->entityManager->clear();
|
||||
$newTokenAfterReuse = $this->entityManager->getRepository(ApiToken::class)->findOneBy(['token' => $second['access_token']]);
|
||||
self::assertNull($newTokenAfterReuse, 'Reuse of a revoked refresh token should revoke the whole token family.');
|
||||
}
|
||||
}
|
||||
237
tests/Security/OAuth/OAuthBearerAuthenticationTest.php
Normal file
237
tests/Security/OAuth/OAuthBearerAuthenticationTest.php
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security\OAuth;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use League\Bundle\OAuth2ServerBundle\Entity\User as LeagueUser;
|
||||
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||
use League\Bundle\OAuth2ServerBundle\Model\Client as OAuthClientModel;
|
||||
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||
use League\OAuth2\Server\AuthorizationServer;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Drives the OAuth2 authorization code + PKCE flow end to end (against league/oauth2-server-bundle's
|
||||
* own Doctrine persistence - see config/packages/league_oauth2_server.yaml), then makes *real* HTTP
|
||||
* requests bearing the issued token against protected API routes. This specifically exercises the two
|
||||
* pieces of custom code this design needs on top of the bundle: App\Security\OAuth\OAuthBearerAuthenticator
|
||||
* (routing OAuth2 bearer tokens to the bundle's ResourceServer without colliding with
|
||||
* App\Security\ApiTokenAuthenticator) and the App\Services\UserSystem\VoterHelper fix that makes an
|
||||
* OAuth2-issued token's scope act as a permission ceiling, exactly like a Personal Access Token's level.
|
||||
*
|
||||
* All container/service access happens through the single kernel that static::createClient() boots for
|
||||
* the test (via $client->getContainer()) - mixing that with a separately-booted kernel leads to
|
||||
* inconsistent state (e.g. entities persisted through one EntityManager not visible to the other).
|
||||
*/
|
||||
final class OAuthBearerAuthenticationTest extends ApiTestCase
|
||||
{
|
||||
private function createTestClient(Client $client, string $redirectUri, array $scopes): OAuthClientModel
|
||||
{
|
||||
$oauthClient = new OAuthClientModel('Test Client', 'test-client-'.bin2hex(random_bytes(8)), null);
|
||||
$oauthClient->setRedirectUris(new RedirectUri($redirectUri));
|
||||
$oauthClient->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
|
||||
$oauthClient->setScopes(...array_map(static fn (string $s) => new Scope($s), $scopes));
|
||||
$oauthClient->setActive(true);
|
||||
|
||||
$client->getContainer()->get(ClientManagerInterface::class)->save($oauthClient);
|
||||
|
||||
return $oauthClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string} [code_verifier, code_challenge]
|
||||
*/
|
||||
private function generatePkcePair(): array
|
||||
{
|
||||
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
return [$verifier, $challenge];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives AuthorizationServer directly (bypassing the not-yet-built consent-screen UI, which will
|
||||
* sit in front of the bundle's own /authorize route) to get a real access/refresh token pair for
|
||||
* the given scopes, exactly as if a user had approved that grant.
|
||||
*
|
||||
* @param string[] $scopes
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function issueTokenForScopes(Client $client, OAuthClientModel $oauthClient, string $redirectUri, array $scopes): array
|
||||
{
|
||||
$authServer = $client->getContainer()->get(AuthorizationServer::class);
|
||||
$psr17 = new Psr17Factory();
|
||||
|
||||
[$verifier, $challenge] = $this->generatePkcePair();
|
||||
|
||||
$authorizeRequest = $psr17->createServerRequest('GET', 'https://part-db.test/authorize?'.http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $oauthClient->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
'scope' => implode(' ', $scopes),
|
||||
]));
|
||||
|
||||
$authRequest = $authServer->validateAuthorizationRequest($authorizeRequest);
|
||||
$leagueUser = new LeagueUser();
|
||||
$leagueUser->setIdentifier('admin');
|
||||
$authRequest->setUser($leagueUser);
|
||||
$authRequest->setAuthorizationApproved(true);
|
||||
|
||||
$redirectResponse = $authServer->completeAuthorizationRequest($authRequest, $psr17->createResponse());
|
||||
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
|
||||
self::assertArrayHasKey('code', $query, 'Expected an authorization code in the redirect.');
|
||||
|
||||
$tokenRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
|
||||
->withParsedBody([
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $oauthClient->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code' => $query['code'],
|
||||
'code_verifier' => $verifier,
|
||||
]);
|
||||
|
||||
$tokenResponse = $authServer->respondToAccessTokenRequest($tokenRequest, $psr17->createResponse());
|
||||
|
||||
return json_decode((string) $tokenResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function bearer(string $accessToken): array
|
||||
{
|
||||
return ['headers' => ['authorization' => 'Bearer '.$accessToken]];
|
||||
}
|
||||
|
||||
public function testReadOnlyScopedTokenCanReadButNotEdit(): void
|
||||
{
|
||||
$httpClient = static::createClient();
|
||||
$redirectUri = 'https://client.example.invalid/callback';
|
||||
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
|
||||
$data = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
|
||||
|
||||
self::assertArrayHasKey('access_token', $data);
|
||||
//A real JWT, not one of our own "tcp_..." Personal Access Tokens.
|
||||
self::assertStringContainsString('.', $data['access_token']);
|
||||
|
||||
$httpClient->request('GET', '/api/parts', $this->bearer($data['access_token']));
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$httpClient->request('POST', '/api/footprints', array_merge(['json' => ['name' => 'oauth read_only test']], $this->bearer($data['access_token'])));
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
public function testFullScopedTokenCanEdit(): void
|
||||
{
|
||||
$httpClient = static::createClient();
|
||||
$redirectUri = 'https://client.example.invalid/callback';
|
||||
//A real "full"-level PAT gets the cumulative ROLE_API_READ_ONLY/EDIT/ADMIN/FULL roles (see
|
||||
//App\Entity\UserSystem\ApiTokenLevel::getAdditionalRoles()); OAuth2 scopes are not cumulative,
|
||||
//so the client/token must request every level it needs explicitly.
|
||||
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only', 'edit', 'admin', 'full']);
|
||||
$data = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only', 'edit', 'admin', 'full']);
|
||||
|
||||
$httpClient->request('GET', '/api/parts', $this->bearer($data['access_token']));
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$httpClient->request('POST', '/api/footprints', array_merge(['json' => ['name' => 'oauth full test']], $this->bearer($data['access_token'])));
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
public function testInvalidPkceVerifierIsRejected(): void
|
||||
{
|
||||
$httpClient = static::createClient();
|
||||
$redirectUri = 'https://client.example.invalid/callback';
|
||||
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
|
||||
|
||||
$authServer = $httpClient->getContainer()->get(AuthorizationServer::class);
|
||||
$psr17 = new Psr17Factory();
|
||||
|
||||
[, $challenge] = $this->generatePkcePair();
|
||||
|
||||
$authorizeRequest = $psr17->createServerRequest('GET', 'https://part-db.test/authorize?'.http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $oauthClient->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
'scope' => 'read_only',
|
||||
]));
|
||||
|
||||
$authRequest = $authServer->validateAuthorizationRequest($authorizeRequest);
|
||||
$leagueUser = new LeagueUser();
|
||||
$leagueUser->setIdentifier('admin');
|
||||
$authRequest->setUser($leagueUser);
|
||||
$authRequest->setAuthorizationApproved(true);
|
||||
|
||||
$redirectResponse = $authServer->completeAuthorizationRequest($authRequest, $psr17->createResponse());
|
||||
parse_str((string) parse_url($redirectResponse->getHeaderLine('Location'), PHP_URL_QUERY), $query);
|
||||
|
||||
$tokenRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
|
||||
->withParsedBody([
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $oauthClient->getIdentifier(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
'code' => $query['code'],
|
||||
//Wrong verifier - does not match the code_challenge used above.
|
||||
'code_verifier' => str_repeat('a', 43),
|
||||
]);
|
||||
|
||||
$this->expectException(OAuthServerException::class);
|
||||
$authServer->respondToAccessTokenRequest($tokenRequest, $psr17->createResponse());
|
||||
}
|
||||
|
||||
public function testRefreshTokenRotationIssuesNewAccessTokenAndRevokesOld(): void
|
||||
{
|
||||
$httpClient = static::createClient();
|
||||
$redirectUri = 'https://client.example.invalid/callback';
|
||||
$oauthClient = $this->createTestClient($httpClient, $redirectUri, ['read_only']);
|
||||
$first = $this->issueTokenForScopes($httpClient, $oauthClient, $redirectUri, ['read_only']);
|
||||
|
||||
$authServer = $httpClient->getContainer()->get(AuthorizationServer::class);
|
||||
$psr17 = new Psr17Factory();
|
||||
|
||||
$refreshRequest = $psr17->createServerRequest('POST', 'https://part-db.test/token')
|
||||
->withParsedBody([
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $oauthClient->getIdentifier(),
|
||||
'refresh_token' => $first['refresh_token'],
|
||||
]);
|
||||
|
||||
$refreshResponse = $authServer->respondToAccessTokenRequest($refreshRequest, $psr17->createResponse());
|
||||
$second = json_decode((string) $refreshResponse->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
self::assertNotSame($first['access_token'], $second['access_token']);
|
||||
|
||||
//The old access token must now be rejected by the resource server (revoked on rotation).
|
||||
$httpClient->request('GET', '/api/parts', $this->bearer($first['access_token']));
|
||||
self::assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
|
||||
|
||||
$httpClient->request('GET', '/api/parts', $this->bearer($second['access_token']));
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue