This is an automated email from the ASF dual-hosted git repository. smolnar82 pushed a commit to branch knox_idf in repository https://gitbox.apache.org/repos/asf/knox.git
commit dcd1e01779b95bfc3d39214d820ba35b8d950b44 Author: Sandor Molnar <[email protected]> AuthorDate: Mon Aug 10 21:10:11 2026 +0200 KNOX-3414: harden secrets-at-rest, auth-code single-use, and persistence (1.5, 2.4, 2.11) Three KnoxIDF security/correctness hardening findings from the OIDC-provider review, landed together. 1.5 - Secrets at rest - Federated OP client_secret is now resolvable via AliasService. New optional topology param federated.op.<name>.clientSecret.alias (FederatedOpConfiguration). AuthorizeResource.resolveClientSecret prefers the alias (getPasswordFromAliasForCluster, cluster from GATEWAY_CLUSTER_ATTRIBUTE with NO_CLUSTER_NAME fallback); fails closed if a configured alias is unresolvable rather than leaking the plaintext param. Plaintext clientSecret retained only as a fallback when no alias is set. - Federated OP access token is no longer persisted at rest: decorateAuthCodeToken stores only the FEDERATED_IDENTITY_ID pointer. Removed the now-dead split/join/chunk token helpers and FEDERATED_*_TOKEN_PREFIX constants. 2.4 - Single-use authorization codes (close the replay window) - SPI TokenStateService: new default consumeToken(String) - atomic single-use consume; exactly one concurrent caller receives true, an absent token is false (never throws). - DefaultTokenStateService: overrides consumeToken with a true atomic claim (tokenExpirations.remove(id) != null), evicting the remaining per-token state. - JDBCTokenStateService: overrides consumeToken using the primary-key DELETE as the atomic arbiter, evicts the in-memory cache, and fails closed on SQLException (the inherited removeToken swallows it and would falsely report a win). Derby inherits this. - idf TokenResource: validateAuthCode now returns the metadata it already reads; handleAuthorizationCodeFlow validates, then atomically consumes the code BEFORE issuing any token, stashing the metadata in a per-request attribute consumed by the issuance steps (getAuthCodeMetadata). The revoke-in-finally is removed. A code that fails validation is deliberately NOT consumed, so replaying with bad params cannot burn a victim's still-valid code. 2.11 - Persistence hardening - Consent key reshaped to fit KNOX_TOKEN_METADATA.md_name VARCHAR(32): AuthorizeResource.consentMetadataKey(subject) = "consent_" + first-20-hex(SHA-256(subject)) (28 chars), used by both hasConsent and markConsentAccepted so read and write agree. No schema change to md_name. - Derby DDL parity: added the missing NOT NULL constraints to the federated identity and attribute tables (the UNIQUE index was already present). - TOCTOU: JdbcFederatedIdentityService.addFederatedIdentity now inserts and catches instead of check-then-insert; a unique-constraint violation (SQLIntegrityConstraintViolationException or SQLState class 23, walked up the cause chain) is treated as a benign already-exists. The unique index is the arbiter. - Transaction boundary: FederatedIdentityDatabase.addFederatedIdentity writes the core row and attribute rows on a single connection with autocommit off, then commits (rollback on failure) so an identity is never persisted without its attributes. - Double-checked locking: added the missing inner recheck in JdbcFederatedIdentityService.init; corrected its misleading exception message. - SELECT * replaced with the explicit column list in the by-provider/issuer/subject query; KnoxDatabase resolves DDL via getClass().getClassLoader() so each subclass loads its own create*.sql. Tests - DefaultTokenStateServiceTest: +3 consumeToken tests (single-use, state removed, unknown id). - New TokenResourceAuthCodeReplayTest: a losing (already-consumed) redemption yields invalid_grant with no issuance; a winning redemption issues exactly once. - New ConsentMetadataKeyTest: key fits VARCHAR(32) for realistic subjects, deterministic, distinct subjects -> distinct keys. - FederatedOpConfigurationTest: +2 (clientSecret alias read / absent by default). Co-Authored-By: Claude Opus 4.8 <[email protected]> --- .../apache/knox/gateway/database/KnoxDatabase.java | 7 +- .../federation/FederatedIdentityDatabase.java | 56 +++++--- .../FederatedIdentityServiceMessages.java | 3 + .../federation/JdbcFederatedIdentityService.java | 54 ++++++-- .../token/impl/DefaultTokenStateService.java | 16 +++ .../services/token/impl/JDBCTokenStateService.java | 19 +++ ...noxIDFFederatedIdentityAttributesTableDerby.sql | 4 +- .../createKnoxIDFFederatedIdentityTableDerby.sql | 10 +- .../token/impl/DefaultTokenStateServiceTest.java | 36 +++++ .../gateway/service/knoxidf/AuthorizeResource.java | 69 +++++++++- .../gateway/service/knoxidf/TokenResource.java | 62 +++++++-- .../service/knoxidf/ConsentMetadataKeyTest.java | 65 +++++++++ .../knoxidf/TokenResourceAuthCodeReplayTest.java | 153 +++++++++++++++++++++ .../services/security/token/TokenStateService.java | 26 ++++ .../util/knoxidf/FederatedOpConfiguration.java | 14 ++ .../gateway/util/knoxidf/KnoxIDFConstants.java | 2 - .../knox/gateway/util/knoxidf/KnoxIDFUtils.java | 28 ---- .../util/knoxidf/FederatedOpConfigurationTest.java | 25 ++++ 18 files changed, 558 insertions(+), 91 deletions(-) diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java index 261b36899..dd6ee68e8 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/database/KnoxDatabase.java @@ -16,8 +16,6 @@ */ package org.apache.knox.gateway.database; -import org.apache.knox.gateway.services.token.impl.TokenStateDatabase; - import javax.sql.DataSource; public class KnoxDatabase { @@ -30,7 +28,10 @@ public class KnoxDatabase { protected void createTableIfNotExists(String tableName, String createSqlFileName) throws Exception { if (!JDBCUtils.tableExists(tableName, dataSource)) { - JDBCUtils.createTableFromSQL(createSqlFileName, dataSource, TokenStateDatabase.class.getClassLoader()); + // Resolve the DDL resource via the actual subclass's classloader so each KnoxDatabase + // subclass (TokenStateDatabase, FederatedIdentityDatabase) loads its own create*.sql + // rather than being coupled to one hardcoded sibling class's classloader. + JDBCUtils.createTableFromSQL(createSqlFileName, dataSource, getClass().getClassLoader()); } } } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java index 649bf3038..0fc2790a4 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityDatabase.java @@ -35,8 +35,8 @@ class FederatedIdentityDatabase extends KnoxDatabase { + " (id, user_id, provider, external_subject, external_issuer, created_at) VALUES (?, ?, ?, ?, ?, ?)"; private static final String ADD_FEDERATED_IDENTITY_ATTR_SQL = "INSERT INTO " + FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME + " (identity_id, attr_key, attr_value) VALUES (?, ?, ?)"; - private static final String FETCH_FEDERATED_IDENTITY_BY_PROV_ISS_SUB_SQL = "SELECT * FROM " + FEDERATED_IDENTITY_TABLE_NAME + - " WHERE provider = ? AND external_issuer = ? AND external_subject = ?"; + private static final String FETCH_FEDERATED_IDENTITY_BY_PROV_ISS_SUB_SQL = "SELECT id, user_id, provider, external_subject, external_issuer, created_at FROM " + + FEDERATED_IDENTITY_TABLE_NAME + " WHERE provider = ? AND external_issuer = ? AND external_subject = ?"; private static final String FETCH_FEDERATED_IDENTITY_SQL_BY_ID = "SELECT id, user_id, provider, external_subject, external_issuer, created_at FROM " + FEDERATED_IDENTITY_TABLE_NAME + " WHERE id = ?"; private static final String FETCH_FEDERATED_IDENTITY_ATTR_SQL = "SELECT attr_key, attr_value FROM " + FEDERATED_IDENTITY_ATTRIBUTES_TABLE_NAME + " WHERE identity_id = ?"; @@ -49,26 +49,42 @@ class FederatedIdentityDatabase extends KnoxDatabase { } void addFederatedIdentity(FederatedIdentity identity) throws SQLException { - // save core metadata first - try (Connection connection = dataSource.getConnection(); PreparedStatement addFederatedIdentityStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_SQL)) { - addFederatedIdentityStatement.setString(1, identity.getId()); - addFederatedIdentityStatement.setString(2, identity.getUserId()); - addFederatedIdentityStatement.setString(3, identity.getProvider()); - addFederatedIdentityStatement.setString(4, identity.getExternalSubject()); - addFederatedIdentityStatement.setString(5, identity.getExternalIssuer()); - addFederatedIdentityStatement.setTimestamp(6, Timestamp.from(identity.getCreatedAt())); - addFederatedIdentityStatement.executeUpdate(); - } + // Persist the core identity row and its attribute rows atomically on a single connection + // with autocommit off: either the identity and all its attributes commit together, or the + // whole write rolls back. Previously each INSERT ran on its own auto-committed connection, + // so a failure between them could leave an identity persisted without its attributes. + try (Connection connection = dataSource.getConnection()) { + final boolean previousAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + // save core metadata first + try (PreparedStatement addFederatedIdentityStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_SQL)) { + addFederatedIdentityStatement.setString(1, identity.getId()); + addFederatedIdentityStatement.setString(2, identity.getUserId()); + addFederatedIdentityStatement.setString(3, identity.getProvider()); + addFederatedIdentityStatement.setString(4, identity.getExternalSubject()); + addFederatedIdentityStatement.setString(5, identity.getExternalIssuer()); + addFederatedIdentityStatement.setTimestamp(6, Timestamp.from(identity.getCreatedAt())); + addFederatedIdentityStatement.executeUpdate(); + } - // save attributes - try (Connection connection = dataSource.getConnection(); PreparedStatement addFederatedIdentityAttrStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_ATTR_SQL)) { - for (var attribute : identity.getAttributes().entrySet()) { - addFederatedIdentityAttrStatement.setString(1, identity.getId()); - addFederatedIdentityAttrStatement.setString(2, attribute.getKey()); - addFederatedIdentityAttrStatement.setString(3, attribute.getValue()); - addFederatedIdentityAttrStatement.addBatch(); + // save attributes + try (PreparedStatement addFederatedIdentityAttrStatement = connection.prepareStatement(ADD_FEDERATED_IDENTITY_ATTR_SQL)) { + for (var attribute : identity.getAttributes().entrySet()) { + addFederatedIdentityAttrStatement.setString(1, identity.getId()); + addFederatedIdentityAttrStatement.setString(2, attribute.getKey()); + addFederatedIdentityAttrStatement.setString(3, attribute.getValue()); + addFederatedIdentityAttrStatement.addBatch(); + } + addFederatedIdentityAttrStatement.executeBatch(); + } + connection.commit(); + } catch (SQLException e) { + connection.rollback(); + throw e; + } finally { + connection.setAutoCommit(previousAutoCommit); } - addFederatedIdentityAttrStatement.executeBatch(); } } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java index d6f820a1d..1241e54e7 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/FederatedIdentityServiceMessages.java @@ -32,4 +32,7 @@ public interface FederatedIdentityServiceMessages { @Message(level = MessageLevel.ERROR, text = "An error occurred while fetching federated identity ({0}) from the database : {1}") void errorFetchingFederatedIdentityFromDatabase(String id, String errorMessage, @StackTrace(level = MessageLevel.DEBUG) Exception e); + + @Message(level = MessageLevel.DEBUG, text = "Federated identity ({0} / {1} / {2}) already exists; skipping insert") + void federatedIdentityAlreadyExists(String provider, String issuer, String subject); } diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java index 8e26bdc1d..546536e78 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/knoxidf/federation/JdbcFederatedIdentityService.java @@ -23,6 +23,7 @@ import org.apache.knox.gateway.services.ServiceLifecycleException; import org.apache.knox.gateway.services.security.AliasService; import java.sql.SQLException; +import java.sql.SQLIntegrityConstraintViolationException; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; @@ -42,14 +43,18 @@ public class JdbcFederatedIdentityService implements FederatedIdentityService { if (!initialized.get()) { initLock.lock(); try { - if (aliasService == null) { - throw new ServiceLifecycleException("The required AliasService reference has not been set."); - } - try { - this.federatedIdentityDatabase = new FederatedIdentityDatabase(DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType()); - initialized.set(true); - } catch (Exception e) { - throw new ServiceLifecycleException("Error while initiating JDBCTokenStateService: " + e, e); + // Double-checked locking: re-test under the lock so a thread that blocked while + // another was initialising does not re-initialise the database a second time. + if (!initialized.get()) { + if (aliasService == null) { + throw new ServiceLifecycleException("The required AliasService reference has not been set."); + } + try { + this.federatedIdentityDatabase = new FederatedIdentityDatabase(DataSourceProvider.getDataSource(config, aliasService), config.getDatabaseType()); + initialized.set(true); + } catch (Exception e) { + throw new ServiceLifecycleException("Error while initiating JdbcFederatedIdentityService: " + e, e); + } } } finally { initLock.unlock(); @@ -75,16 +80,43 @@ public class JdbcFederatedIdentityService implements FederatedIdentityService { @Override public void addFederatedIdentity(FederatedIdentity identity) { + // Insert-and-catch rather than check-then-insert: the UNIQUE(provider, external_issuer, + // external_subject) index is the atomic arbiter, so two concurrent requests for the same + // external identity cannot both insert. A unique-constraint violation means the row already + // exists, which is exactly the desired end state, so it is treated as benign rather than + // surfaced as an error (closing the prior TOCTOU race between the pre-check and the insert). try { - if (findByProviderAndSubject(identity.getProvider(), identity.getExternalIssuer(), identity.getExternalSubject()).isEmpty()) { - federatedIdentityDatabase.addFederatedIdentity(identity); - } + federatedIdentityDatabase.addFederatedIdentity(identity); } catch (SQLException e) { + if (isUniqueConstraintViolation(e)) { + LOG.federatedIdentityAlreadyExists(identity.getProvider(), identity.getExternalIssuer(), identity.getExternalSubject()); + return; + } LOG.errorSavingFederatedIdentityInDatabase(identity.getId(), e.getMessage(), e); throw new FederatedIdentityServiceException("An error occurred while saving Federated Identity " + identity.getId() + " in the database", e); } } + /** + * Recognises a unique/primary-key constraint violation across dialects: either a + * {@link SQLIntegrityConstraintViolationException} or any {@link SQLException} in the cause + * chain whose SQLState is in the {@code 23} (integrity constraint violation) class. + */ + private static boolean isUniqueConstraintViolation(SQLException e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof SQLIntegrityConstraintViolationException) { + return true; + } + if (t instanceof SQLException) { + final String sqlState = ((SQLException) t).getSQLState(); + if (sqlState != null && sqlState.startsWith("23")) { + return true; + } + } + } + return false; + } + @Override public Optional<FederatedIdentity> findByProviderAndSubject(String provider, String issuer, String subject) { try { diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java index afca435bd..95d0b8eab 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateService.java @@ -265,6 +265,22 @@ public class DefaultTokenStateService implements TokenStateService { log.revokedToken(Tokens.getTokenIDDisplayText(tokenId)); } + @Override + public boolean consumeToken(final String tokenId) { + validateTokenIdentifier(tokenId); + // Atomic single-use claim: ConcurrentHashMap#remove returns the prior value to exactly one + // caller, so concurrent redemptions of the same token can never both observe it as present. + final boolean claimed = tokenExpirations.remove(tokenId) != null; + if (claimed) { + // Evict the remaining per-token state (idempotent for a token we won the race for). + tokenIssueTimes.remove(tokenId); + maxTokenLifetimes.remove(tokenId); + metadataMap.remove(tokenId); + log.revokedToken(Tokens.getTokenIDDisplayText(tokenId)); + } + return claimed; + } + @Override public boolean isExpired(final JWTToken token) throws UnknownTokenException { return getTokenExpiration(token) <= System.currentTimeMillis(); diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java index 4dfc46d29..5d60fbe33 100644 --- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java +++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/JDBCTokenStateService.java @@ -240,6 +240,25 @@ public class JDBCTokenStateService extends AbstractPersistentTokenStateService i } } + @Override + public boolean consumeToken(String tokenId) { + // The single-row primary-key DELETE is the atomic arbiter: only the caller whose statement + // actually removed the row observes rowsAffected == 1, so exactly one concurrent redemption + // wins. Fail closed on a SQL error (report "not consumed by us") rather than the inherited + // removeToken() behaviour of swallowing the exception, which would falsely signal a win. + try { + final boolean removed = tokenDatabase.removeToken(tokenId); + if (removed) { + super.removeTokens(Collections.singleton(tokenId)); // evict the in-memory cache copy + log.removedTokenFromDatabase(Tokens.getTokenIDDisplayText(tokenId)); + } + return removed; + } catch (SQLException e) { + log.errorRemovingTokenFromDatabase(Tokens.getTokenIDDisplayText(tokenId), e.getMessage(), e); + return false; + } + } + @Override protected void evictExpiredTokens() { try { diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql index 13ae9ab11..90c70ff0f 100644 --- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql +++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityAttributesTableDerby.sql @@ -14,8 +14,8 @@ -- the License. CREATE TABLE FEDERATED_IDENTITY_ATTR ( - identity_id VARCHAR(36), - attr_key VARCHAR(128), + identity_id VARCHAR(36) NOT NULL, + attr_key VARCHAR(128) NOT NULL, attr_value CLOB, PRIMARY KEY (identity_id, attr_key), CONSTRAINT fk_fed_attr FOREIGN KEY (identity_id) REFERENCES FEDERATED_IDENTITY(id) ON DELETE CASCADE diff --git a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql index 7152d4c71..acaf1c340 100644 --- a/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql +++ b/gateway-server/src/main/resources/createKnoxIDFFederatedIdentityTableDerby.sql @@ -15,11 +15,11 @@ CREATE TABLE FEDERATED_IDENTITY ( id VARCHAR(36) PRIMARY KEY, - user_id VARCHAR(36), - provider VARCHAR(64), - external_subject VARCHAR(255), - external_issuer VARCHAR(255), - created_at TIMESTAMP + user_id VARCHAR(36) NOT NULL, + provider VARCHAR(64) NOT NULL, + external_subject VARCHAR(255) NOT NULL, + external_issuer VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL ); CREATE UNIQUE INDEX UX_FED_IDENTITY ON FEDERATED_IDENTITY (provider, external_issuer, external_subject); \ No newline at end of file diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java index 419d077c6..3cb0b2738 100644 --- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java +++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java @@ -162,6 +162,42 @@ public class DefaultTokenStateServiceTest { tss.isExpired(token); } + @Test + public void testConsumeToken_SingleUse() throws Exception { + // Single-use semantics for auth codes: the first consume wins, a second consume of the same + // id loses (the token is already gone), and the token state is actually removed. + final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60)); + final TokenStateService tss = createTokenStateService(); + final String tokenId = TokenUtils.getTokenId(token); + + addToken(tss, token, System.currentTimeMillis()); + + assertTrue("First consume should win.", tss.consumeToken(tokenId)); + assertFalse("Second consume of the same token must lose.", tss.consumeToken(tokenId)); + } + + @Test(expected = UnknownTokenException.class) + public void testConsumeToken_RemovesState() throws Exception { + final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60)); + final TokenStateService tss = createTokenStateService(); + final String tokenId = TokenUtils.getTokenId(token); + + addToken(tss, token, System.currentTimeMillis()); + assertTrue(tss.consumeToken(tokenId)); + + // The token must no longer be known after being consumed. + tss.getTokenExpiration(tokenId); + } + + @Test + public void testConsumeToken_UnknownToken() throws Exception { + // An id that was never stored cannot be "won" - consume reports false rather than throwing. + final JWTToken token = createMockToken(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(60)); + final TokenStateService tss = createTokenStateService(); + + assertFalse(tss.consumeToken(TokenUtils.getTokenId(token))); + } + @Test public void testRenewal() throws Exception { final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)); diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java index 4fd703c7a..d778aa7e1 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/AuthorizeResource.java @@ -37,6 +37,7 @@ import org.apache.knox.gateway.services.ServiceType; import org.apache.http.ssl.SSLContexts; import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentity; import org.apache.knox.gateway.services.knoxidf.federation.FederatedIdentityService; +import org.apache.knox.gateway.services.security.AliasService; import org.apache.knox.gateway.services.security.AliasServiceException; import org.apache.knox.gateway.services.security.KeystoreService; import org.apache.knox.gateway.services.security.token.JWTokenAuthority; @@ -51,7 +52,6 @@ import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadata; import org.apache.knox.gateway.util.knoxidf.AuthorizeRequestMetadataStore; import org.apache.knox.gateway.util.knoxidf.FederatedOpConfiguration; import org.apache.knox.gateway.util.knoxidf.FederatedOpConfigurationStore; -import org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils; import javax.annotation.PostConstruct; import javax.servlet.ServletContext; @@ -66,6 +66,8 @@ import javax.net.ssl.SSLContext; import java.io.UnsupportedEncodingException; import java.net.URI; import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; @@ -189,7 +191,7 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { private boolean hasConsent(final AuthorizeRequestMetadata authorizeRequestMetadata) { try { final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(authorizeRequestMetadata.getClientId()); - final String consentKey = "consentAccepted_" + authorizeRequestMetadata.getSubject(); + final String consentKey = consentMetadataKey(authorizeRequestMetadata.getSubject()); final String storedScopes = tokenMetadata.getMetadataMap().get(consentKey); if (storedScopes == null || storedScopes.isEmpty()) { return false; @@ -204,10 +206,36 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { private void markConsentAccepted(AuthorizeRequestMetadata authorizeRequestMetadata) { final TokenMetadata consentAcceptedMetadata = new TokenMetadata(); - consentAcceptedMetadata.add("consentAccepted_" + authorizeRequestMetadata.getSubject(), authorizeRequestMetadata.getJoinedRequestedScopes()); + consentAcceptedMetadata.add(consentMetadataKey(authorizeRequestMetadata.getSubject()), authorizeRequestMetadata.getJoinedRequestedScopes()); tokenStateService.addMetadata(authorizeRequestMetadata.getClientId(), consentAcceptedMetadata); } + /** + * Derives the metadata key under which a subject's granted consent scopes are stored. Consent is + * persisted in {@code KNOX_TOKEN_METADATA.md_name}, which is {@code VARCHAR(32)}; the previous + * {@code "consentAccepted_" + subject} key overflowed that for realistic subjects (federated + * UUID subjects, long usernames), silently truncating or failing the write on strict dialects. + * This derives a fixed-width key {@code "consent_" + first-20-hex-chars(SHA-256(subject))} = 28 + * chars, comfortably within the column. ~80 bits of hash is collision-safe for any realistic + * user population, and the derivation is uniform for plain usernames and UUID subjects alike. + * {@link #hasConsent} and {@link #markConsentAccepted} both route through here so read and write + * always agree on the key. + */ + static String consentMetadataKey(final String subject) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hash = digest.digest((subject == null ? "" : subject).getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder("consent_"); + for (int i = 0; i < 10; i++) { // 10 bytes -> 20 hex chars + hex.append(String.format(Locale.US, "%02x", hash[i])); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is a required algorithm on every JRE; its absence is unrecoverable. + throw new IllegalStateException("SHA-256 is required but unavailable", e); + } + } + private Response getAuthCodeFromKnox(final AuthorizeRequestMetadata authorizeRequestMetadata, final Pair<String, String> federatedTokens) { final Response tokenResponse = getAuthenticationToken(); if (tokenResponse.getStatus() == Response.Status.OK.getStatusCode()) { @@ -307,8 +335,11 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { authCodeTokenMap.put(CODE_CHALLENGE_METHOD, authorizeRequestMetadata.getCodeChallengeMethod()); } if (federatedTokens != null) { + // Persist only the pointer to the (separately stored) federated identity. The OP's + // access token (federatedTokens.getRight()) is deliberately NOT persisted: nothing reads + // it back, and storing an OP bearer secret in plaintext token metadata is a secret-at-rest + // exposure. If a future feature needs it, store it encrypted, not in the clear. authCodeTokenMap.put(FEDERATED_IDENTITY_ID, federatedTokens.getLeft()); - authCodeTokenMap.putAll(KnoxIDFUtils.splitFederatedToken(federatedTokens.getRight(), false)); } tokenStateService.addMetadata(tokenId, new TokenMetadata(authCodeTokenMap)); } @@ -413,13 +444,41 @@ public class AuthorizeResource extends PasscodeTokenResourceBase { } } + /** + * Resolves the federated OP's client secret for the back-channel token request. An + * {@code AliasService} credential alias ({@code federated.op.<name>.clientSecret.alias}) is the + * preferred, secure source and takes precedence: when it resolves to a value, that value is + * used and the plaintext {@code clientSecret} topology param is never consulted. The plaintext + * param remains supported as a fallback only when no alias is configured, so existing + * deployments keep working. If an alias is configured but cannot be resolved we fail closed + * (return {@code null}) rather than silently leaking through to the plaintext param, so a + * misconfigured alias surfaces as an auth failure instead of masking the intended secure source. + */ + private String resolveClientSecret(final FederatedOpConfiguration opConfig) { + final String alias = opConfig.getClientSecretAlias(); + if (StringUtils.isBlank(alias)) { + return opConfig.getClientSecret(); + } + try { + final AliasService aliasService = getGatewayServices().getService(ServiceType.ALIAS_SERVICE); + String clusterName = (String) servletContext.getAttribute(GatewayServices.GATEWAY_CLUSTER_ATTRIBUTE); + if (StringUtils.isBlank(clusterName)) { + clusterName = AliasService.NO_CLUSTER_NAME; + } + final char[] secret = aliasService.getPasswordFromAliasForCluster(clusterName, alias, false); + return secret == null ? null : new String(secret); + } catch (AliasServiceException e) { + return null; + } + } + private Response fetchFederatedTokens(final String code, FederatedOpConfiguration opConfig) { final List<NameValuePair> params = new ArrayList<>(); params.add(new BasicNameValuePair(CODE, code)); params.add(new BasicNameValuePair(REDIRECT_URI, opConfig.getAuthorizeCallback())); params.add(new BasicNameValuePair(GRANT_TYPE, "authorization_code")); params.add(new BasicNameValuePair(CLIENT_ID, opConfig.getClientId())); - params.add(new BasicNameValuePair(CLIENT_SECRET, opConfig.getClientSecret())); + params.add(new BasicNameValuePair(CLIENT_SECRET, resolveClientSecret(opConfig))); try (CloseableHttpClient httpClient = createFederatedHttpClient()) { HttpPost post = new HttpPost(opConfig.getTokenEndpoint()); diff --git a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java index 97e24378c..540df06f6 100644 --- a/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java +++ b/gateway-service-knoxidf/src/main/java/org/apache/knox/gateway/service/knoxidf/TokenResource.java @@ -81,10 +81,18 @@ import static org.apache.knox.gateway.util.knoxidf.KnoxIDFUtils.error; @Produces(MediaType.APPLICATION_JSON) public class TokenResource extends PasscodeTokenResourceBase { static final String RESOURCE_PATH = BASE_RESORCE_PATH + "/token"; + + // Per-request stash for the auth-code TokenMetadata read during validation. The code is + // atomically consumed (deleted) BEFORE token issuance to close the replay window, so the + // issuance steps (buildUserContext/addArbitraryTokenMetadata/buildResponseMap) can no longer + // re-read it from the store; they read this request attribute instead. This resource is a + // singleton, but the @Context request is a per-request proxy, so the attribute is request-scoped. + private static final String AUTH_CODE_METADATA_ATTR = "knoxidf.authCode.metadata"; + private UserParamsProvider userParamsProvider; @Context - private HttpServletRequest request; + HttpServletRequest request; // package-private for test injection; @Context injection is reflective @Context private ServletContext servletContext; @@ -140,8 +148,7 @@ public class TokenResource extends PasscodeTokenResourceBase { @Override protected UserContext buildUserContext(HttpServletRequest request) { try { - final String code = getRequestParam(CODE); - final TokenMetadata tokenMetadata = tokenStateService.getTokenMetadata(code); + final TokenMetadata tokenMetadata = getAuthCodeMetadata(); final String scope = tokenMetadata.getMetadata(SCOPE); final Map<String, Object> userParams = userParamsProvider.getParamsFor(tokenMetadata.getUserName(), scope); userParams.put(SCOPE, scope); @@ -158,7 +165,7 @@ public class TokenResource extends PasscodeTokenResourceBase { super.addArbitraryTokenMetadata(tokenMetadata); final String code = getRequestParam(CODE); if (StringUtils.isNotBlank(code)) { - final TokenMetadata authCodeTokenMetadata = tokenStateService.getTokenMetadata(code); + final TokenMetadata authCodeTokenMetadata = getAuthCodeMetadata(); //if the auth code token was a result of a federated OIDC call, we need to save the associated //federated identity ID in the JWT too (so that it can be looked up while fetching user info) @@ -181,7 +188,7 @@ public class TokenResource extends PasscodeTokenResourceBase { TokenMetadata authCodeTokenMetadata = null; if (StringUtils.isNotBlank(code)) { try { - authCodeTokenMetadata = tokenStateService.getTokenMetadata(code); + authCodeTokenMetadata = getAuthCodeMetadata(); } catch (UnknownTokenException e) { //NOP } @@ -251,25 +258,49 @@ public class TokenResource extends PasscodeTokenResourceBase { } } - private Response handleAuthorizationCodeFlow() { + // Package-private for testability (single-use replay guard is exercised by + // TokenResourceAuthCodeReplayTest); not part of the public resource API. + Response handleAuthorizationCodeFlow() { final String code = getRequestParam(CODE); final String redirectUri = getRequestParam(REDIRECT_URI); + final TokenMetadata authCodeMetadata; try { - validateAuthCode(code, redirectUri); - return getAuthenticationToken(); + authCodeMetadata = validateAuthCode(code, redirectUri); } catch (AuthTokenValidationError e) { return error("Auth code validation error", e.getMessage()); - } finally { - try { - tokenStateService.revokeToken(code); - } catch (UnknownTokenException e) { - //NOP: this should have been handled by the above UnknownTokenException already - } } + + // Enforce single-use: atomically consume the code BEFORE issuing any token. Of N concurrent + // redemptions of the same code, exactly one wins the consume and proceeds; the losers get + // invalid_grant. This closes the replay window that existed when the code was only revoked + // in a finally block AFTER issuance. A code that fails validation above is deliberately NOT + // consumed here, so replaying with bad params cannot burn a victim's still-valid code. + if (!tokenStateService.consumeToken(code)) { + return error("invalid_grant", "Authorization code has already been redeemed"); + } + + // The code is now gone from the store; hand the already-validated metadata to the issuance + // path via a request attribute (see getAuthCodeMetadata) so it need not re-read the code. + request.setAttribute(AUTH_CODE_METADATA_ATTR, authCodeMetadata); + return getAuthenticationToken(); + } + + /** + * Returns the auth-code {@link TokenMetadata} captured at validation time and stashed in a + * request attribute by {@link #handleAuthorizationCodeFlow()}. Because the code is consumed + * (deleted) before token issuance, the issuance steps can no longer re-read it from the store; + * this serves the cached copy, falling back to a store read only if the attribute is absent. + */ + private TokenMetadata getAuthCodeMetadata() throws UnknownTokenException { + final Object cached = request.getAttribute(AUTH_CODE_METADATA_ATTR); + if (cached instanceof TokenMetadata) { + return (TokenMetadata) cached; + } + return tokenStateService.getTokenMetadata(getRequestParam(CODE)); } - private void validateAuthCode(String code, String redirectUri) throws AuthTokenValidationError { + private TokenMetadata validateAuthCode(String code, String redirectUri) throws AuthTokenValidationError { try { if (code == null || code.isEmpty()) { throw new AuthTokenValidationError("Invalid request: missing code"); @@ -316,6 +347,7 @@ public class TokenResource extends PasscodeTokenResourceBase { } else if (!isValidClientSecret(clientId, getRequestParam(CLIENT_SECRET))) { throw new AuthTokenValidationError("Invalid client authentication"); } + return authCodeTokenMetadata; } catch (UnknownTokenException e) { throw new AuthTokenValidationError("Unknown auth_code"); } diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java new file mode 100644 index 000000000..e4feee4d6 --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/ConsentMetadataKeyTest.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * <p> + * http://www.apache.org/licenses/LICENSE-2.0 + * <p> + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.knox.gateway.service.knoxidf; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Verifies the consent metadata key derivation (finding 2.11). Consent is stored in + * {@code KNOX_TOKEN_METADATA.md_name VARCHAR(32)}; the key must therefore stay within 32 chars for + * every subject, be deterministic (so a later read finds an earlier write), and separate distinct + * subjects. + */ +public class ConsentMetadataKeyTest { + + /** The backing column is VARCHAR(32). */ + private static final int MD_NAME_MAX = 32; + + @Test + public void testKeyFitsColumnForRealisticSubjects() { + final String[] subjects = { + "alice", + "[email protected]", + // a federated UUID subject - the case that overflowed the old "consentAccepted_" + subject + "b9f8e7d6-c5a4-4321-9876-0123456789abcdef-very-long-external-subject-identifier", + "", + }; + for (final String subject : subjects) { + final String key = AuthorizeResource.consentMetadataKey(subject); + assertTrue("Key '" + key + "' (" + key.length() + " chars) must fit VARCHAR(" + MD_NAME_MAX + ")", + key.length() <= MD_NAME_MAX); + assertTrue("Key should carry the consent_ prefix", key.startsWith("consent_")); + } + } + + @Test + public void testKeyIsDeterministic() { + final String subject = "b9f8e7d6-c5a4-4321-9876-0123456789ab"; + assertEquals("Same subject must always derive the same key (read must match write).", + AuthorizeResource.consentMetadataKey(subject), AuthorizeResource.consentMetadataKey(subject)); + } + + @Test + public void testDistinctSubjectsDeriveDistinctKeys() { + assertNotEquals(AuthorizeResource.consentMetadataKey("alice"), + AuthorizeResource.consentMetadataKey("bob")); + } +} diff --git a/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java new file mode 100644 index 000000000..ac0fb6cab --- /dev/null +++ b/gateway-service-knoxidf/src/test/java/org/apache/knox/gateway/service/knoxidf/TokenResourceAuthCodeReplayTest.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * <p> + * http://www.apache.org/licenses/LICENSE-2.0 + * <p> + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package org.apache.knox.gateway.service.knoxidf; + +import static org.apache.knox.gateway.security.CommonTokenConstants.CLIENT_SECRET; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CLIENT_ID; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.CODE_CHALLENGE; +import static org.apache.knox.gateway.util.knoxidf.KnoxIDFConstants.REDIRECT_URI; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.core.Response; + +import org.apache.knox.gateway.services.security.token.TokenMetadata; +import org.apache.knox.gateway.services.security.token.TokenStateService; +import org.apache.knox.gateway.services.security.token.impl.TokenMAC; +import org.easymock.EasyMock; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies the single-use enforcement of the authorization_code grant (finding 2.4). The code must + * be atomically consumed BEFORE any token is issued: exactly one of N concurrent redemptions wins + * the consume and proceeds to issuance; the losers are rejected with {@code invalid_grant} and no + * token is minted. This closes the replay window that existed when the code was only revoked in a + * {@code finally} block after issuance. + */ +public class TokenResourceAuthCodeReplayTest { + + private static final String AUTH_CODE = "auth-code-xyz"; + private static final String CLIENT = "client-abc"; + private static final String REDIRECT = "https://app.example/cb"; + private static final String USER_NAME = "alice"; + private static final long ISSUE_TIME = 1_700_000_000_000L; + private static final String RAW_PASSCODE = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + + private TokenStateService tokenStateService; + private TestableTokenResource resource; + private final AtomicInteger issuedCount = new AtomicInteger(); + + /** + * Exposes field injection and stubs out the heavy token-issuance path so the replay guard can be + * exercised in isolation. {@code getAuthenticationToken} is the step that mints tokens; here it + * only records that issuance was reached and returns a sentinel. + */ + final class TestableTokenResource extends TokenResource { + void inject(final TokenStateService tss, final TokenMAC mac, final HttpServletRequest req) { + this.tokenStateService = tss; + this.tokenMAC = mac; + this.request = req; + } + + @Override + public Response getAuthenticationToken() { + issuedCount.incrementAndGet(); + return Response.ok("issued").build(); + } + } + + private static String wireSecret(final String tokenId, final String rawPasscode) { + final String inner = Base64.getEncoder().encodeToString(tokenId.getBytes(StandardCharsets.UTF_8)) + + "::" + Base64.getEncoder().encodeToString(rawPasscode.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(inner.getBytes(StandardCharsets.UTF_8)); + } + + @Before + public void setUp() throws Exception { + final TokenMAC tokenMAC = new TokenMAC("HmacSHA256", "0123456789abcdef0123456789abcdef".toCharArray()); + final String storedPasscodeHash = tokenMAC.hash(CLIENT, ISSUE_TIME, USER_NAME, RAW_PASSCODE); + + // Metadata for the authorization code being redeemed (confidential client, no PKCE challenge). + final TokenMetadata authCodeMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(authCodeMetadata.isAuthCode()).andReturn(true).anyTimes(); + EasyMock.expect(authCodeMetadata.getMetadata(REDIRECT_URI)).andReturn(REDIRECT).anyTimes(); + EasyMock.expect(authCodeMetadata.getMetadata(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(authCodeMetadata.getMetadata(CODE_CHALLENGE)).andReturn(null).anyTimes(); + EasyMock.replay(authCodeMetadata); + + // Metadata for the confidential client, used to authenticate the client_secret. + final TokenMetadata clientMetadata = EasyMock.createNiceMock(TokenMetadata.class); + EasyMock.expect(clientMetadata.getUserName()).andReturn(USER_NAME).anyTimes(); + EasyMock.expect(clientMetadata.getPasscode()).andReturn(storedPasscodeHash).anyTimes(); + EasyMock.replay(clientMetadata); + + tokenStateService = EasyMock.createNiceMock(TokenStateService.class); + EasyMock.expect(tokenStateService.getTokenMetadata(AUTH_CODE)).andReturn(authCodeMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenExpiration(AUTH_CODE)) + .andReturn(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5)).anyTimes(); + EasyMock.expect(tokenStateService.getTokenMetadata(CLIENT)).andReturn(clientMetadata).anyTimes(); + EasyMock.expect(tokenStateService.getTokenIssueTime(CLIENT)).andReturn(ISSUE_TIME).anyTimes(); + // consumeToken behaviour is set per-test (win vs. lose) before replay(). + + final HttpServletRequest req = EasyMock.createNiceMock(HttpServletRequest.class); + EasyMock.expect(req.getParameter(CODE)).andReturn(AUTH_CODE).anyTimes(); + EasyMock.expect(req.getParameter(REDIRECT_URI)).andReturn(REDIRECT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_ID)).andReturn(CLIENT).anyTimes(); + EasyMock.expect(req.getParameter(CLIENT_SECRET)).andReturn(wireSecret(CLIENT, RAW_PASSCODE)).anyTimes(); + EasyMock.replay(req); + + resource = new TestableTokenResource(); + resource.inject(tokenStateService, tokenMAC, req); + } + + @Test + public void testFirstRedemptionConsumesThenIssues() { + EasyMock.expect(tokenStateService.consumeToken(AUTH_CODE)).andReturn(true).once(); + EasyMock.replay(tokenStateService); + + final Response response = resource.handleAuthorizationCodeFlow(); + + assertEquals("A validated, freshly-consumed code should issue a token.", + Response.Status.OK.getStatusCode(), response.getStatus()); + assertEquals("Exactly one issuance for a single winning redemption.", 1, issuedCount.get()); + EasyMock.verify(tokenStateService); + } + + @Test + public void testReplayedCodeIsRejectedWithoutIssuing() { + // Simulate a concurrent redemption having already consumed the code: consumeToken loses. + EasyMock.expect(tokenStateService.consumeToken(AUTH_CODE)).andReturn(false).once(); + EasyMock.replay(tokenStateService); + + final Response response = resource.handleAuthorizationCodeFlow(); + + assertEquals("A code already consumed by a concurrent redemption must be rejected.", + Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + assertTrue("The error body should identify the invalid_grant condition.", + String.valueOf(response.getEntity()).contains("invalid_grant")); + assertEquals("A losing redemption must not mint any token.", 0, issuedCount.get()); + EasyMock.verify(tokenStateService); + } +} diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java index 2d3ea1b20..82d3331ee 100644 --- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java +++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java @@ -102,6 +102,32 @@ public interface TokenStateService extends Service { */ void revokeToken(String tokenId) throws UnknownTokenException; + /** + * Atomically consume (revoke) the specified token, reporting whether <em>this</em> caller + * performed the removal. This enforces single-use semantics (e.g. OAuth authorization codes) + * under concurrent redemption: of N callers racing to consume the same token, exactly one + * receives {@code true} and all others receive {@code false} because the token was already gone. + * Unlike {@link #revokeToken(String)}, an absent token is reported as {@code false} rather than + * raising {@link UnknownTokenException}. + * <p> + * The default implementation delegates to {@link #revokeToken(String)} and is only as atomic as + * that method; implementations backed by a store that can remove-and-report atomically (a + * concurrent-map removal or a primary-key {@code DELETE}) should override this to provide a true + * single-winner guarantee. + * + * @param tokenId The token unique identifier. + * @return {@code true} iff this invocation removed a present token; {@code false} if it was + * already absent (never existed, or consumed by a concurrent caller). + */ + default boolean consumeToken(String tokenId) { + try { + revokeToken(tokenId); + return true; + } catch (UnknownTokenException e) { + return false; + } + } + /** * Extend the lifetime of the specified token by the default amount of time. * diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java index 8ebdea5b1..b82d34441 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfiguration.java @@ -23,6 +23,7 @@ public class FederatedOpConfiguration { private final String name; private final String clientId; private final String clientSecret; + private final String clientSecretAlias; private final String tokenEndpoint; private final String authorizeEndpoint; private final String userInfoEndpoint; @@ -41,6 +42,10 @@ public class FederatedOpConfiguration { this.enabled = Boolean.parseBoolean(servletContext.getInitParameter(prefix + "enabled")); this.clientId = servletContext.getInitParameter(prefix + "clientId"); this.clientSecret = servletContext.getInitParameter(prefix + "clientSecret"); + // Preferred, secure source for the OP client secret: an AliasService credential alias. + // Resolved at point of use (AuthorizeResource) since this holder has no access to services. + // When set it takes precedence over the plaintext clientSecret param above. + this.clientSecretAlias = servletContext.getInitParameter(prefix + "clientSecret.alias"); this.tokenEndpoint = servletContext.getInitParameter(prefix + "token.endpoint"); this.authorizeEndpoint = servletContext.getInitParameter(prefix + "authorize.endpoint"); this.authorizeCallback = servletContext.getInitParameter(prefix + "authorize.callback"); @@ -70,6 +75,15 @@ public class FederatedOpConfiguration { return clientSecret; } + /** + * @return the name of the AliasService credential alias holding this OP's client secret, or + * {@code null}/blank when the deployment supplies the secret via the plaintext + * {@code clientSecret} param instead. When set, the alias is authoritative. + */ + public String getClientSecretAlias() { + return clientSecretAlias; + } + String getAuthorizeEndpoint() { return authorizeEndpoint; } diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java index aabb85684..57e8b3caf 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFConstants.java @@ -52,8 +52,6 @@ public interface KnoxIDFConstants { String SCOPE_ATTRIBUTE = "X-Token-Scope"; String FEDERATED_IDENTITY_ID = "federated_identity_id"; - String FEDERATED_ID_TOKEN_PREFIX = "fed_id_"; - String FEDERATED_ACCESS_TOKEN_PREFIX = "fed_access_"; String FEDERATED_OP_CONFIG_PREFIX = "federated.op."; String FEDERATED_OP_CONFIG_NAMES = FEDERATED_OP_CONFIG_PREFIX + "names"; diff --git a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java index 989a17e61..98aeb8520 100644 --- a/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java +++ b/gateway-util-common/src/main/java/org/apache/knox/gateway/util/knoxidf/KnoxIDFUtils.java @@ -25,42 +25,14 @@ import javax.servlet.http.HttpServletRequest; import javax.ws.rs.core.Response; import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; -import java.util.stream.Collectors; public class KnoxIDFUtils { - private static final int CHUNK_SIZE = 255; - - public static Map<String, String> splitFederatedToken(String token, boolean idToken) { - final String prefix = idToken ? KnoxIDFConstants.FEDERATED_ID_TOKEN_PREFIX : KnoxIDFConstants.FEDERATED_ACCESS_TOKEN_PREFIX; - final Map<String, String> parts = new LinkedHashMap<>(); - int i = 0, part = 1; - while (i < token.length()) { - int end = Math.min(i + CHUNK_SIZE, token.length()); - parts.put(prefix + part++, token.substring(i, end)); - i = end; - } - return parts; - } - - public static String joinFederatedToken(Map<String, String> tokenMetadataMap, boolean idToken) { - final String prefix = idToken ? KnoxIDFConstants.FEDERATED_ID_TOKEN_PREFIX : KnoxIDFConstants.FEDERATED_ACCESS_TOKEN_PREFIX; - return tokenMetadataMap.entrySet().stream() - .filter(e -> e.getKey().startsWith(prefix)) - .sorted(Map.Entry.comparingByKey( - Comparator.comparingInt(k -> Integer.parseInt(k.replace(prefix, ""))) - )) - .map(Map.Entry::getValue) - .collect(Collectors.joining()); - } - public static Response error(String error, String description) { final Map<String, String> errorMap = new HashMap<>(); errorMap.put("error", error); diff --git a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java index cfe74d8d9..8d1b87e54 100644 --- a/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java +++ b/gateway-util-common/src/test/java/org/apache/knox/gateway/util/knoxidf/FederatedOpConfigurationTest.java @@ -59,6 +59,31 @@ public class FederatedOpConfigurationTest { assertEquals("RS256", config.getSignatureAlgorithm()); } + @Test + public void testClientSecretAliasIsRead() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.expect(context.getInitParameter(PREFIX + "clientSecret")).andReturn("plaintext-secret").anyTimes(); + EasyMock.expect(context.getInitParameter(PREFIX + "clientSecret.alias")).andReturn("keycloak-op-secret").anyTimes(); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + // Both are exposed; AuthorizeResource#resolveClientSecret decides precedence (alias wins). + assertEquals("plaintext-secret", config.getClientSecret()); + assertEquals("keycloak-op-secret", config.getClientSecretAlias()); + } + + @Test + public void testClientSecretAliasAbsentByDefault() { + final ServletContext context = EasyMock.createNiceMock(ServletContext.class); + EasyMock.replay(context); + + final FederatedOpConfiguration config = new FederatedOpConfiguration(context, OP); + + // No alias configured -> resolveClientSecret falls back to the plaintext clientSecret param. + assertNull(config.getClientSecretAlias()); + } + @Test public void testVerificationParamsAbsentByDefault() { final ServletContext context = EasyMock.createNiceMock(ServletContext.class);
