diff --git a/README.md b/README.md index 85f1016..ba9a808 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Define the following required environment variables: | `IMGFLOAT_GITHUB_CLIENT_OWNER` | GitHub user or org which has the client repository | imgfloat | | `IMGFLOAT_GITHUB_CLIENT_REPO` | Client repository name | client | | `IMGFLOAT_GITHUB_CLIENT_VERSION` | Client release version used for download links | 1.2.3 | +| `IMGFLOAT_TOKEN_ENCRYPTION_KEY` | Base64-encoded 256-bit (32 byte) key used to encrypt OAuth tokens at rest (store in a secret manager or KMS) | x5A8tS8Lk4q2qY0xRkz8r9bq2bx0R4A9a0m0k5Y8mCk= | | `SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE` | Maximum upload file size | 10MB | | `SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE` | Maximum upload request size | 10MB | | `TWITCH_CLIENT_ID` | Oauth2 client id | i1bjnh4whieht5kzn307nvu3rn5pqi | @@ -27,8 +28,11 @@ Optional: | Variable | Description | Example Value | |----------|-------------|---------------| | `IMGFLOAT_COMMIT_URL_PREFIX` | Git commit URL prefix used for the build link badge (unset to hide the badge) | https://github.com/imgfloat/server/commit/ | +| `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` | Comma-delimited base64 keys to allow decryption after key rotation (oldest last) | oldKey1==,oldKey2== | | `TWITCH_REDIRECT_URI` | Override default redirect URI | http://localhost:8080/login/oauth2/code/twitch | +OAuth tokens are encrypted at rest using the key provided by `IMGFLOAT_TOKEN_ENCRYPTION_KEY`. Store this key in a secret manager or KMS and inject it via environment variables or a secret provider in production. When rotating keys, update `IMGFLOAT_TOKEN_ENCRYPTION_KEY` with the new key and populate `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` with the old keys so existing tokens can be decrypted. After rotation, re-authenticate users or clear the `oauth2_authorized_client` table to re-encrypt tokens with the new key. + During development environment variables can be placed in the `.env` file at the project root to automatically load them. Be aware that these are only loaded when using the [Makefile](./Makefile) command `make run`. If you want to use the default development setup your `.env` file should look like this: diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/OAuthTokenCipher.java b/src/main/java/dev/kruhlmann/imgfloat/config/OAuthTokenCipher.java new file mode 100644 index 0000000..3e8147c --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/OAuthTokenCipher.java @@ -0,0 +1,114 @@ +package dev.kruhlmann.imgfloat.config; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OAuthTokenCipher { + + private static final Logger LOG = LoggerFactory.getLogger(OAuthTokenCipher.class); + private static final String KEY_ENV = "IMGFLOAT_TOKEN_ENCRYPTION_KEY"; + private static final String PREVIOUS_KEYS_ENV = "IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS"; + private static final String PREFIX = "v1:"; + private static final int IV_LENGTH_BYTES = 12; + private static final int TAG_LENGTH_BITS = 128; + + private final SecretKey encryptionKey; + private final List decryptionKeys; + + public OAuthTokenCipher(SecretKey encryptionKey, List decryptionKeys) { + this.encryptionKey = encryptionKey; + this.decryptionKeys = List.copyOf(decryptionKeys); + } + + public static OAuthTokenCipher fromEnvironment() { + String base64Key = System.getenv(KEY_ENV); + if (base64Key == null || base64Key.isBlank()) { + throw new IllegalStateException(KEY_ENV + " is required to encrypt OAuth tokens"); + } + SecretKey primaryKey = decodeKey(base64Key, KEY_ENV); + List keys = new ArrayList<>(); + keys.add(primaryKey); + + String previousKeys = System.getenv(PREVIOUS_KEYS_ENV); + if (previousKeys != null && !previousKeys.isBlank()) { + for (String value : previousKeys.split(",")) { + String trimmed = value.trim(); + if (!trimmed.isEmpty()) { + keys.add(decodeKey(trimmed, PREVIOUS_KEYS_ENV)); + } + } + } + + return new OAuthTokenCipher(primaryKey, keys); + } + + public String encrypt(String plaintext) { + if (plaintext == null) { + return null; + } + byte[] iv = new byte[IV_LENGTH_BYTES]; + new SecureRandom().nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, encryptionKey, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + byte[] payload = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, payload, 0, iv.length); + System.arraycopy(ciphertext, 0, payload, iv.length, ciphertext.length); + return PREFIX + Base64.getEncoder().encodeToString(payload); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("Unable to encrypt OAuth token", ex); + } + } + + public String decrypt(String value) { + if (value == null) { + return null; + } + if (!value.startsWith(PREFIX)) { + return value; + } + byte[] payload = Base64.getDecoder().decode(value.substring(PREFIX.length())); + if (payload.length <= IV_LENGTH_BYTES) { + throw new IllegalStateException("Invalid encrypted OAuth token payload"); + } + byte[] iv = new byte[IV_LENGTH_BYTES]; + byte[] ciphertext = new byte[payload.length - IV_LENGTH_BYTES]; + System.arraycopy(payload, 0, iv, 0, IV_LENGTH_BYTES); + System.arraycopy(payload, IV_LENGTH_BYTES, ciphertext, 0, ciphertext.length); + for (SecretKey key : decryptionKeys) { + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (GeneralSecurityException ex) { + LOG.debug("Failed to decrypt OAuth token with a configured key", ex); + } + } + throw new IllegalStateException("Unable to decrypt OAuth token with configured keys"); + } + + public boolean isEncrypted(String value) { + return value != null && value.startsWith(PREFIX); + } + + private static SecretKey decodeKey(String base64Key, String source) { + byte[] decoded = Base64.getDecoder().decode(base64Key); + if (decoded.length != 32) { + throw new IllegalArgumentException( + source + " must be a base64-encoded 256-bit (32 byte) key" + ); + } + return new SecretKeySpec(decoded, "AES"); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java b/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java index aa34c8f..c89d36f 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientService.java @@ -26,13 +26,23 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie private final JdbcOperations jdbcOperations; private final ClientRegistrationRepository clientRegistrationRepository; private final RowMapper rowMapper; + private final OAuthTokenCipher tokenCipher; public SQLiteOAuth2AuthorizedClientService( JdbcOperations jdbcOperations, ClientRegistrationRepository clientRegistrationRepository + ) { + this(jdbcOperations, clientRegistrationRepository, OAuthTokenCipher.fromEnvironment()); + } + + SQLiteOAuth2AuthorizedClientService( + JdbcOperations jdbcOperations, + ClientRegistrationRepository clientRegistrationRepository, + OAuthTokenCipher tokenCipher ) { this.jdbcOperations = jdbcOperations; this.clientRegistrationRepository = clientRegistrationRepository; + this.tokenCipher = tokenCipher; this.rowMapper = (rs, rowNum) -> { String registrationId = rs.getString("client_registration_id"); String principalName = rs.getString("principal_name"); @@ -41,18 +51,41 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie return null; } + String accessTokenValue = decryptToken( + registrationId, + principalName, + rs.getString("access_token_value"), + "access" + ); + if (accessTokenValue == null) { + return null; + } + OAuth2AccessToken accessToken = new OAuth2AccessToken( OAuth2AccessToken.TokenType.BEARER, - rs.getString("access_token_value"), + accessTokenValue, toInstant(rs.getObject("access_token_issued_at")), toInstant(rs.getObject("access_token_expires_at")), scopesFrom(rs.getString("access_token_scopes")) ); Object refreshValue = rs.getObject("refresh_token_value"); - OAuth2RefreshToken refreshToken = refreshValue == null - ? null - : new OAuth2RefreshToken(refreshValue.toString(), toInstant(rs.getObject("refresh_token_issued_at"))); + OAuth2RefreshToken refreshToken = null; + if (refreshValue != null) { + String refreshTokenValue = decryptToken( + registrationId, + principalName, + refreshValue.toString(), + "refresh" + ); + if (refreshTokenValue == null) { + return null; + } + refreshToken = new OAuth2RefreshToken( + refreshTokenValue, + toInstant(rs.getObject("refresh_token_issued_at")) + ); + } return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken); }; @@ -104,7 +137,7 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes())); OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken(); if (refreshToken != null) { - preparedStatement.setString(7, refreshToken.getTokenValue()); + preparedStatement.setString(7, tokenCipher.encrypt(refreshToken.getTokenValue())); preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT); } else { preparedStatement.setNull(7, java.sql.Types.VARCHAR); @@ -134,10 +167,34 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token) throws java.sql.SQLException { - ps.setString(startIndex, token.getTokenValue()); + ps.setString(startIndex, tokenCipher.encrypt(token.getTokenValue())); ps.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT); } + private String decryptToken( + String registrationId, + String principalName, + String tokenValue, + String tokenType + ) { + if (tokenValue == null) { + return null; + } + try { + return tokenCipher.decrypt(tokenValue); + } catch (RuntimeException ex) { + LOG.warn( + "Failed to decrypt {} token for registration ID '{}' and principal '{}'; clearing stored tokens", + tokenType, + registrationId, + principalName, + ex + ); + removeAuthorizedClient(registrationId, principalName); + return null; + } + } + private Instant toInstant(Object value) { if (value == null) { return null; diff --git a/src/test/java/dev/kruhlmann/imgfloat/config/OAuthTokenCipherTest.java b/src/test/java/dev/kruhlmann/imgfloat/config/OAuthTokenCipherTest.java new file mode 100644 index 0000000..bc0c167 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/config/OAuthTokenCipherTest.java @@ -0,0 +1,54 @@ +package dev.kruhlmann.imgfloat.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; + +class OAuthTokenCipherTest { + + @Test + void encryptDecryptRoundTrip() { + OAuthTokenCipher cipher = new OAuthTokenCipher(keyFrom("primary-key"), List.of(keyFrom("primary-key"))); + + String encrypted = cipher.encrypt("access-token"); + + assertThat(encrypted).startsWith("v1:"); + assertThat(cipher.decrypt(encrypted)).isEqualTo("access-token"); + } + + @Test + void decryptPlaintextReturnsOriginalValue() { + OAuthTokenCipher cipher = new OAuthTokenCipher(keyFrom("primary-key"), List.of(keyFrom("primary-key"))); + + assertThat(cipher.decrypt("plain-token")).isEqualTo("plain-token"); + } + + @Test + void decryptsTokensWithPreviousKey() { + OAuthTokenCipher previousCipher = new OAuthTokenCipher( + keyFrom("previous-key"), + List.of(keyFrom("previous-key")) + ); + String encrypted = previousCipher.encrypt("refresh-token"); + OAuthTokenCipher cipher = new OAuthTokenCipher( + keyFrom("primary-key"), + List.of(keyFrom("primary-key"), keyFrom("previous-key")) + ); + + assertThat(cipher.decrypt(encrypted)).isEqualTo("refresh-token"); + } + + private SecretKey keyFrom(String seed) { + byte[] bytes = Base64.getDecoder().decode( + Base64.getEncoder().encodeToString(seed.getBytes(StandardCharsets.UTF_8)) + ); + byte[] keyBytes = new byte[32]; + System.arraycopy(bytes, 0, keyBytes, 0, Math.min(bytes.length, keyBytes.length)); + return new SecretKeySpec(keyBytes, "AES"); + } +} diff --git a/src/test/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientServiceTest.java new file mode 100644 index 0000000..1f37821 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/config/SQLiteOAuth2AuthorizedClientServiceTest.java @@ -0,0 +1,207 @@ +package dev.kruhlmann.imgfloat.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.Map; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcOperations; +import org.springframework.jdbc.core.PreparedStatementSetter; +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; + +class SQLiteOAuth2AuthorizedClientServiceTest { + + @Test + void saveAuthorizedClientEncryptsTokenValues() throws Exception { + JdbcOperations jdbcOperations = mock(JdbcOperations.class); + ClientRegistrationRepository repository = mock(ClientRegistrationRepository.class); + OAuthTokenCipher cipher = new OAuthTokenCipher(keyFrom("primary-key"), List.of(keyFrom("primary-key"))); + SQLiteOAuth2AuthorizedClientService service = + new SQLiteOAuth2AuthorizedClientService(jdbcOperations, repository, cipher); + Map stringValues = new HashMap<>(); + PreparedStatement preparedStatement = mock(PreparedStatement.class); + doAnswer(invocation -> { + stringValues.put(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(preparedStatement).setString(anyInt(), anyString()); + doAnswer(invocation -> { + PreparedStatementSetter setter = invocation.getArgument(1); + setter.setValues(preparedStatement); + return 1; + }).when(jdbcOperations).update(anyString(), any(PreparedStatementSetter.class)); + + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "access-token", + Instant.now(), + Instant.now().plusSeconds(3600), + Set.of("scope:one") + ); + OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now()); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient( + clientRegistration(), + "principal", + accessToken, + refreshToken + ); + Authentication principal = mock(Authentication.class); + when(principal.getName()).thenReturn("principal"); + + service.saveAuthorizedClient(authorizedClient, principal); + + assertThat(stringValues.get(3)).startsWith("v1:").isNotEqualTo("access-token"); + assertThat(stringValues.get(7)).startsWith("v1:").isNotEqualTo("refresh-token"); + } + + @Test + void loadAuthorizedClientDecryptsStoredTokens() throws Exception { + JdbcOperations jdbcOperations = mock(JdbcOperations.class); + ClientRegistrationRepository repository = mock(ClientRegistrationRepository.class); + OAuthTokenCipher cipher = new OAuthTokenCipher(keyFrom("primary-key"), List.of(keyFrom("primary-key"))); + SQLiteOAuth2AuthorizedClientService service = + new SQLiteOAuth2AuthorizedClientService(jdbcOperations, repository, cipher); + ClientRegistration registration = clientRegistration(); + when(repository.findByRegistrationId("twitch")).thenReturn(registration); + + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true); + when(resultSet.getString("client_registration_id")).thenReturn("twitch"); + when(resultSet.getString("principal_name")).thenReturn("principal"); + when(resultSet.getString("access_token_value")).thenReturn(cipher.encrypt("access-token")); + when(resultSet.getObject("access_token_issued_at")).thenReturn(Instant.now().toEpochMilli()); + when(resultSet.getObject("access_token_expires_at")).thenReturn(Instant.now().plusSeconds(3600).toEpochMilli()); + when(resultSet.getString("access_token_scopes")).thenReturn("scope:one"); + when(resultSet.getObject("refresh_token_value")).thenReturn(cipher.encrypt("refresh-token")); + when(resultSet.getObject("refresh_token_issued_at")).thenReturn(Instant.now().toEpochMilli()); + + doAnswer(invocation -> { + PreparedStatementSetter setter = invocation.getArgument(1); + setter.setValues(mock(PreparedStatement.class)); + ResultSetExtractor extractor = invocation.getArgument(2); + return extractor.extractData(resultSet); + }).when(jdbcOperations).query(anyString(), any(PreparedStatementSetter.class), any(ResultSetExtractor.class)); + + OAuth2AuthorizedClient loaded = service.loadAuthorizedClient("twitch", "principal"); + + assertThat(loaded).isNotNull(); + assertThat(loaded.getAccessToken().getTokenValue()).isEqualTo("access-token"); + assertThat(loaded.getRefreshToken()).isNotNull(); + assertThat(loaded.getRefreshToken().getTokenValue()).isEqualTo("refresh-token"); + } + + @Test + void loadAuthorizedClientPassesThroughPlaintextTokens() throws Exception { + JdbcOperations jdbcOperations = mock(JdbcOperations.class); + ClientRegistrationRepository repository = mock(ClientRegistrationRepository.class); + OAuthTokenCipher cipher = new OAuthTokenCipher(keyFrom("primary-key"), List.of(keyFrom("primary-key"))); + SQLiteOAuth2AuthorizedClientService service = + new SQLiteOAuth2AuthorizedClientService(jdbcOperations, repository, cipher); + ClientRegistration registration = clientRegistration(); + when(repository.findByRegistrationId("twitch")).thenReturn(registration); + + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true); + when(resultSet.getString("client_registration_id")).thenReturn("twitch"); + when(resultSet.getString("principal_name")).thenReturn("principal"); + when(resultSet.getString("access_token_value")).thenReturn("access-token"); + when(resultSet.getObject("access_token_issued_at")).thenReturn(Instant.now().toEpochMilli()); + when(resultSet.getObject("access_token_expires_at")).thenReturn(Instant.now().plusSeconds(3600).toEpochMilli()); + when(resultSet.getString("access_token_scopes")).thenReturn("scope:one"); + when(resultSet.getObject("refresh_token_value")).thenReturn("refresh-token"); + when(resultSet.getObject("refresh_token_issued_at")).thenReturn(Instant.now().toEpochMilli()); + + doAnswer(invocation -> { + PreparedStatementSetter setter = invocation.getArgument(1); + setter.setValues(mock(PreparedStatement.class)); + ResultSetExtractor extractor = invocation.getArgument(2); + return extractor.extractData(resultSet); + }).when(jdbcOperations).query(anyString(), any(PreparedStatementSetter.class), any(ResultSetExtractor.class)); + + OAuth2AuthorizedClient loaded = service.loadAuthorizedClient("twitch", "principal"); + + assertThat(loaded).isNotNull(); + assertThat(loaded.getAccessToken().getTokenValue()).isEqualTo("access-token"); + assertThat(loaded.getRefreshToken()).isNotNull(); + assertThat(loaded.getRefreshToken().getTokenValue()).isEqualTo("refresh-token"); + } + + @Test + void loadAuthorizedClientClearsTokensOnDecryptionFailure() throws Exception { + JdbcOperations jdbcOperations = mock(JdbcOperations.class); + ClientRegistrationRepository repository = mock(ClientRegistrationRepository.class); + OAuthTokenCipher cipher = new OAuthTokenCipher(keyFrom("primary-key"), List.of(keyFrom("primary-key"))); + OAuthTokenCipher otherCipher = new OAuthTokenCipher(keyFrom("other-key"), List.of(keyFrom("other-key"))); + SQLiteOAuth2AuthorizedClientService service = + new SQLiteOAuth2AuthorizedClientService(jdbcOperations, repository, cipher); + ClientRegistration registration = clientRegistration(); + when(repository.findByRegistrationId("twitch")).thenReturn(registration); + + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(true); + when(resultSet.getString("client_registration_id")).thenReturn("twitch"); + when(resultSet.getString("principal_name")).thenReturn("principal"); + when(resultSet.getString("access_token_value")).thenReturn(otherCipher.encrypt("access-token")); + when(resultSet.getObject("access_token_issued_at")).thenReturn(Instant.now().toEpochMilli()); + when(resultSet.getObject("access_token_expires_at")).thenReturn(Instant.now().plusSeconds(3600).toEpochMilli()); + when(resultSet.getString("access_token_scopes")).thenReturn("scope:one"); + when(resultSet.getObject("refresh_token_value")).thenReturn(otherCipher.encrypt("refresh-token")); + when(resultSet.getObject("refresh_token_issued_at")).thenReturn(Instant.now().toEpochMilli()); + + doAnswer(invocation -> { + PreparedStatementSetter setter = invocation.getArgument(1); + setter.setValues(mock(PreparedStatement.class)); + ResultSetExtractor extractor = invocation.getArgument(2); + return extractor.extractData(resultSet); + }).when(jdbcOperations).query(anyString(), any(PreparedStatementSetter.class), any(ResultSetExtractor.class)); + + OAuth2AuthorizedClient loaded = service.loadAuthorizedClient("twitch", "principal"); + + assertThat(loaded).isNull(); + verify(jdbcOperations).update(startsWith("DELETE FROM oauth2_authorized_client"), any(PreparedStatementSetter.class)); + } + + private ClientRegistration clientRegistration() { + return ClientRegistration.withRegistrationId("twitch") + .clientId("client-id") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationUri("https://id.twitch.tv/oauth2/authorize") + .tokenUri("https://id.twitch.tv/oauth2/token") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("scope:one") + .build(); + } + + private SecretKey keyFrom(String seed) { + byte[] bytes = Base64.getDecoder().decode( + Base64.getEncoder().encodeToString(seed.getBytes(StandardCharsets.UTF_8)) + ); + byte[] keyBytes = new byte[32]; + System.arraycopy(bytes, 0, keyBytes, 0, Math.min(bytes.length, keyBytes.length)); + return new SecretKeySpec(keyBytes, "AES"); + } +}