mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Encrypt tokens
This commit is contained in:
@@ -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_OWNER` | GitHub user or org which has the client repository | imgfloat |
|
||||||
| `IMGFLOAT_GITHUB_CLIENT_REPO` | Client repository name | client |
|
| `IMGFLOAT_GITHUB_CLIENT_REPO` | Client repository name | client |
|
||||||
| `IMGFLOAT_GITHUB_CLIENT_VERSION` | Client release version used for download links | 1.2.3 |
|
| `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_FILE_SIZE` | Maximum upload file size | 10MB |
|
||||||
| `SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE` | Maximum upload request size | 10MB |
|
| `SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE` | Maximum upload request size | 10MB |
|
||||||
| `TWITCH_CLIENT_ID` | Oauth2 client id | i1bjnh4whieht5kzn307nvu3rn5pqi |
|
| `TWITCH_CLIENT_ID` | Oauth2 client id | i1bjnh4whieht5kzn307nvu3rn5pqi |
|
||||||
@@ -27,8 +28,11 @@ Optional:
|
|||||||
| Variable | Description | Example Value |
|
| 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_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 |
|
| `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`.
|
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:
|
If you want to use the default development setup your `.env` file should look like this:
|
||||||
|
|||||||
@@ -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<SecretKey> decryptionKeys;
|
||||||
|
|
||||||
|
public OAuthTokenCipher(SecretKey encryptionKey, List<SecretKey> 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<SecretKey> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,23 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
|||||||
private final JdbcOperations jdbcOperations;
|
private final JdbcOperations jdbcOperations;
|
||||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||||
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
|
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
|
||||||
|
private final OAuthTokenCipher tokenCipher;
|
||||||
|
|
||||||
public SQLiteOAuth2AuthorizedClientService(
|
public SQLiteOAuth2AuthorizedClientService(
|
||||||
JdbcOperations jdbcOperations,
|
JdbcOperations jdbcOperations,
|
||||||
ClientRegistrationRepository clientRegistrationRepository
|
ClientRegistrationRepository clientRegistrationRepository
|
||||||
|
) {
|
||||||
|
this(jdbcOperations, clientRegistrationRepository, OAuthTokenCipher.fromEnvironment());
|
||||||
|
}
|
||||||
|
|
||||||
|
SQLiteOAuth2AuthorizedClientService(
|
||||||
|
JdbcOperations jdbcOperations,
|
||||||
|
ClientRegistrationRepository clientRegistrationRepository,
|
||||||
|
OAuthTokenCipher tokenCipher
|
||||||
) {
|
) {
|
||||||
this.jdbcOperations = jdbcOperations;
|
this.jdbcOperations = jdbcOperations;
|
||||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||||
|
this.tokenCipher = tokenCipher;
|
||||||
this.rowMapper = (rs, rowNum) -> {
|
this.rowMapper = (rs, rowNum) -> {
|
||||||
String registrationId = rs.getString("client_registration_id");
|
String registrationId = rs.getString("client_registration_id");
|
||||||
String principalName = rs.getString("principal_name");
|
String principalName = rs.getString("principal_name");
|
||||||
@@ -41,18 +51,41 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String accessTokenValue = decryptToken(
|
||||||
|
registrationId,
|
||||||
|
principalName,
|
||||||
|
rs.getString("access_token_value"),
|
||||||
|
"access"
|
||||||
|
);
|
||||||
|
if (accessTokenValue == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||||
OAuth2AccessToken.TokenType.BEARER,
|
OAuth2AccessToken.TokenType.BEARER,
|
||||||
rs.getString("access_token_value"),
|
accessTokenValue,
|
||||||
toInstant(rs.getObject("access_token_issued_at")),
|
toInstant(rs.getObject("access_token_issued_at")),
|
||||||
toInstant(rs.getObject("access_token_expires_at")),
|
toInstant(rs.getObject("access_token_expires_at")),
|
||||||
scopesFrom(rs.getString("access_token_scopes"))
|
scopesFrom(rs.getString("access_token_scopes"))
|
||||||
);
|
);
|
||||||
|
|
||||||
Object refreshValue = rs.getObject("refresh_token_value");
|
Object refreshValue = rs.getObject("refresh_token_value");
|
||||||
OAuth2RefreshToken refreshToken = refreshValue == null
|
OAuth2RefreshToken refreshToken = null;
|
||||||
? null
|
if (refreshValue != null) {
|
||||||
: new OAuth2RefreshToken(refreshValue.toString(), toInstant(rs.getObject("refresh_token_issued_at")));
|
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);
|
return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken);
|
||||||
};
|
};
|
||||||
@@ -104,7 +137,7 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
|||||||
preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
|
preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
|
||||||
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
|
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
|
||||||
if (refreshToken != null) {
|
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);
|
preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT);
|
||||||
} else {
|
} else {
|
||||||
preparedStatement.setNull(7, java.sql.Types.VARCHAR);
|
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)
|
private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token)
|
||||||
throws java.sql.SQLException {
|
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);
|
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) {
|
private Instant toInstant(Object value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Integer, String> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user