diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index b2d51dea..5980d6cd 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -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_, e.g. ROLE_API_READ_ONLY - see the "scopes" comment above. + role_prefix: 'ROLE_API_' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index d9bf0400..811e2c62 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -26,6 +26,7 @@ security: custom_authenticators: - App\Security\ApiTokenAuthenticator + - App\Security\OAuth\OAuthBearerAuthenticator two_factor: auth_form_path: 2fa_login diff --git a/config/services.yaml b/config/services.yaml index fb0e4991..aa476bbb 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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: diff --git a/migrations/Version20260726170000.php b/migrations/Version20260726170000.php deleted file mode 100644 index 824f79d0..00000000 --- a/migrations/Version20260726170000.php +++ /dev/null @@ -1,279 +0,0 @@ -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'); - } -} diff --git a/migrations/Version20260726180454.php b/migrations/Version20260726180454.php new file mode 100644 index 00000000..2541db1c --- /dev/null +++ b/migrations/Version20260726180454.php @@ -0,0 +1,209 @@ +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'); + } +} diff --git a/src/Command/OAuthGenerateKeysCommand.php b/src/Command/OAuthGenerateKeysCommand.php index abcebfe6..87d38142 100644 --- a/src/Command/OAuthGenerateKeysCommand.php +++ b/src/Command/OAuthGenerateKeysCommand.php @@ -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; } diff --git a/src/Entity/UserSystem/ApiToken.php b/src/Entity/UserSystem/ApiToken.php index 86576c12..d017b479 100644 --- a/src/Entity/UserSystem/ApiToken.php +++ b/src/Entity/UserSystem/ApiToken.php @@ -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; - } - } \ No newline at end of file diff --git a/src/Entity/UserSystem/ApiTokenType.php b/src/Entity/UserSystem/ApiTokenType.php index daecddc1..d53393ca 100644 --- a/src/Entity/UserSystem/ApiTokenType.php +++ b/src/Entity/UserSystem/ApiTokenType.php @@ -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; + } + } } diff --git a/src/Entity/UserSystem/OAuth/OAuthAuthCode.php b/src/Entity/UserSystem/OAuth/OAuthAuthCode.php deleted file mode 100644 index e5618927..00000000 --- a/src/Entity/UserSystem/OAuth/OAuthAuthCode.php +++ /dev/null @@ -1,141 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Entity/UserSystem/OAuth/OAuthClient.php b/src/Entity/UserSystem/OAuth/OAuthClient.php deleted file mode 100644 index 9d25e9e1..00000000 --- a/src/Entity/UserSystem/OAuth/OAuthClient.php +++ /dev/null @@ -1,161 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Entity/UserSystem/OAuth/OAuthRefreshToken.php b/src/Entity/UserSystem/OAuth/OAuthRefreshToken.php deleted file mode 100644 index 92ae3506..00000000 --- a/src/Entity/UserSystem/OAuth/OAuthRefreshToken.php +++ /dev/null @@ -1,132 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Repository/UserSystem/OAuth/OAuthAuthCodeRepository.php b/src/Repository/UserSystem/OAuth/OAuthAuthCodeRepository.php deleted file mode 100644 index bdadb743..00000000 --- a/src/Repository/UserSystem/OAuth/OAuthAuthCodeRepository.php +++ /dev/null @@ -1,29 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Repository\UserSystem\OAuth; - -use Doctrine\ORM\EntityRepository; - -class OAuthAuthCodeRepository extends EntityRepository -{ -} diff --git a/src/Repository/UserSystem/OAuth/OAuthClientRepository.php b/src/Repository/UserSystem/OAuth/OAuthClientRepository.php deleted file mode 100644 index 47667f9f..00000000 --- a/src/Repository/UserSystem/OAuth/OAuthClientRepository.php +++ /dev/null @@ -1,29 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Repository\UserSystem\OAuth; - -use Doctrine\ORM\EntityRepository; - -class OAuthClientRepository extends EntityRepository -{ -} diff --git a/src/Repository/UserSystem/OAuth/OAuthRefreshTokenRepository.php b/src/Repository/UserSystem/OAuth/OAuthRefreshTokenRepository.php deleted file mode 100644 index b1cf04f4..00000000 --- a/src/Repository/UserSystem/OAuth/OAuthRefreshTokenRepository.php +++ /dev/null @@ -1,29 +0,0 @@ -. - */ - -declare(strict_types=1); - -namespace App\Repository\UserSystem\OAuth; - -use Doctrine\ORM\EntityRepository; - -class OAuthRefreshTokenRepository extends EntityRepository -{ -} diff --git a/src/Security/ApiTokenAuthenticator.php b/src/Security/ApiTokenAuthenticator.php index a52b1f7c..1d06f376 100644 --- a/src/Security/ApiTokenAuthenticator.php +++ b/src/Security/ApiTokenAuthenticator.php @@ -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 diff --git a/src/Security/OAuth/Entity/OAuthUserEntity.php b/src/Security/OAuth/Entity/OAuthUserEntity.php deleted file mode 100644 index a0bb3841..00000000 --- a/src/Security/OAuth/Entity/OAuthUserEntity.php +++ /dev/null @@ -1,49 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Security/OAuth/Entity/TransientAccessToken.php b/src/Security/OAuth/Entity/TransientAccessToken.php deleted file mode 100644 index bf248d93..00000000 --- a/src/Security/OAuth/Entity/TransientAccessToken.php +++ /dev/null @@ -1,43 +0,0 @@ -. - */ - -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; -} diff --git a/src/Security/OAuth/Entity/TransientAuthCode.php b/src/Security/OAuth/Entity/TransientAuthCode.php deleted file mode 100644 index 74450758..00000000 --- a/src/Security/OAuth/Entity/TransientAuthCode.php +++ /dev/null @@ -1,42 +0,0 @@ -. - */ - -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; -} diff --git a/src/Security/OAuth/Entity/TransientRefreshToken.php b/src/Security/OAuth/Entity/TransientRefreshToken.php deleted file mode 100644 index cc186b2b..00000000 --- a/src/Security/OAuth/Entity/TransientRefreshToken.php +++ /dev/null @@ -1,40 +0,0 @@ -. - */ - -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; -} diff --git a/src/Security/OAuth/OAuthBearerAuthenticator.php b/src/Security/OAuth/OAuthBearerAuthenticator.php new file mode 100644 index 00000000..f4390b9f --- /dev/null +++ b/src/Security/OAuth/OAuthBearerAuthenticator.php @@ -0,0 +1,158 @@ +. + */ + +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 $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; + } +} diff --git a/src/Security/OAuth/OAuthScope.php b/src/Security/OAuth/OAuthScope.php deleted file mode 100644 index fe8119ac..00000000 --- a/src/Security/OAuth/OAuthScope.php +++ /dev/null @@ -1,54 +0,0 @@ -. - */ - -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); - } -} diff --git a/src/Security/OAuth/OpaqueBearerTokenResponse.php b/src/Security/OAuth/OpaqueBearerTokenResponse.php deleted file mode 100644 index baf5cfcf..00000000 --- a/src/Security/OAuth/OpaqueBearerTokenResponse.php +++ /dev/null @@ -1,90 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Security/OAuth/Repository/AccessTokenRepository.php b/src/Security/OAuth/Repository/AccessTokenRepository.php deleted file mode 100644 index 612b70df..00000000 --- a/src/Security/OAuth/Repository/AccessTokenRepository.php +++ /dev/null @@ -1,128 +0,0 @@ -. - */ - -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]); - } -} diff --git a/src/Security/OAuth/Repository/AuthCodeRepository.php b/src/Security/OAuth/Repository/AuthCodeRepository.php deleted file mode 100644 index 628e9aa1..00000000 --- a/src/Security/OAuth/Repository/AuthCodeRepository.php +++ /dev/null @@ -1,100 +0,0 @@ -. - */ - -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]); - } -} diff --git a/src/Security/OAuth/Repository/ClientRepository.php b/src/Security/OAuth/Repository/ClientRepository.php deleted file mode 100644 index 218f1db9..00000000 --- a/src/Security/OAuth/Repository/ClientRepository.php +++ /dev/null @@ -1,50 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Security/OAuth/Repository/RefreshTokenRepository.php b/src/Security/OAuth/Repository/RefreshTokenRepository.php deleted file mode 100644 index 3f362b4b..00000000 --- a/src/Security/OAuth/Repository/RefreshTokenRepository.php +++ /dev/null @@ -1,172 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Security/OAuth/Repository/ScopeRepository.php b/src/Security/OAuth/Repository/ScopeRepository.php deleted file mode 100644 index 9682ceab..00000000 --- a/src/Security/OAuth/Repository/ScopeRepository.php +++ /dev/null @@ -1,74 +0,0 @@ -. - */ - -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; - } -} diff --git a/src/Services/UserSystem/VoterHelper.php b/src/Services/UserSystem/VoterHelper.php index d3c5368c..2a717168 100644 --- a/src/Services/UserSystem/VoterHelper.php +++ b/src/Services/UserSystem/VoterHelper.php @@ -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); } diff --git a/tests/Security/OAuth/AuthorizationServerTest.php b/tests/Security/OAuth/AuthorizationServerTest.php deleted file mode 100644 index 31e17326..00000000 --- a/tests/Security/OAuth/AuthorizationServerTest.php +++ /dev/null @@ -1,246 +0,0 @@ -. - */ - -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 - */ - 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.'); - } -} diff --git a/tests/Security/OAuth/OAuthBearerAuthenticationTest.php b/tests/Security/OAuth/OAuthBearerAuthenticationTest.php new file mode 100644 index 00000000..0e9eec68 --- /dev/null +++ b/tests/Security/OAuth/OAuthBearerAuthenticationTest.php @@ -0,0 +1,237 @@ +. + */ + +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 + */ + 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(); + } +}