Skip to content

Commit 803ad60

Browse files
committed
Require a token to be deactivated before regenerating its secret
Motivation: Regenerating the secret of an active token breaks its clients immediately with no way to prepare, while the token still looks active in the UI. Deactivation already serves as the immediate kill switch, so there is no reason to rotate an active token in place. Modifications: - Reject regenerating the secret of an active token; the rotation procedure is now deactivate, regenerate, distribute the new secret and activate. - Fail with a conflict when the token was recreated or regenerated concurrently after the caller was authorized, so that nobody distributes a secret that will never work. - Disable the 'Regenerate secret' button for active tokens with a tooltip that guides to deactivate first, and hide it for tokens scheduled for deletion. - Document the rotation procedure. Result: - A token secret can be rotated only while the token is deactivated, so the new secret can be distributed to the clients before it takes effect.
1 parent a476a76 commit 803ad60

11 files changed

Lines changed: 219 additions & 122 deletions

File tree

server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/AppIdentityRegistryService.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,10 @@ public CompletableFuture<AppIdentity> updateAppIdentity(ServiceRequestContext ct
251251
/**
252252
* POST /appIdentities/{appId}/secret
253253
*
254-
* <p>Regenerates the secret of the token of the specified {@code appId} and returns the token with
255-
* a newly-generated secret. The old secret is revoked in the same commit, but it may take a short
256-
* time for the revocation to be propagated to the authorization cache.
254+
* <p>Regenerates the secret of the deactivated token of the specified {@code appId} and returns the
255+
* token with a newly-generated secret. The token must be deactivated first and the new secret does
256+
* not authenticate until the token is activated, so that the new secret can be distributed to the
257+
* clients before it takes effect.
257258
*/
258259
@Post("/appIdentities/{appId}/secret")
259260
public CompletableFuture<Token> regenerateTokenSecret(ServiceRequestContext ctx,
@@ -265,9 +266,13 @@ public CompletableFuture<Token> regenerateTokenSecret(ServiceRequestContext ctx,
265266
throw new IllegalArgumentException(
266267
"You can't regenerate the secret of the token scheduled for deletion.");
267268
}
268-
// Pass the creation metadata of the authorized token so that a token recreated
269-
// with the same application ID in the meantime is not rotated.
270-
return mds.regenerateTokenSecret(author, appId, token.creation());
269+
if (token.isActive()) {
270+
throw new IllegalArgumentException(
271+
"You can't regenerate the secret of an active token. Deactivate it first.");
272+
}
273+
// Pass the authorized token so that the regeneration fails if the token is
274+
// recreated or regenerated concurrently in the meantime.
275+
return mds.regenerateTokenSecret(author, appId, token);
271276
});
272277
}
273278

server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityService.java

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import static java.util.Objects.requireNonNull;
3131

3232
import java.util.Map;
33+
import java.util.Objects;
3334
import java.util.UUID;
3435
import java.util.concurrent.CompletableFuture;
3536
import java.util.concurrent.atomic.AtomicReference;
@@ -176,7 +177,7 @@ CompletableFuture<Token> regenerateTokenSecret(Author author, String appId) {
176177
}
177178

178179
CompletableFuture<Token> regenerateTokenSecret(Author author, String appId,
179-
@Nullable UserAndTimestamp expectedCreation) {
180+
@Nullable Token expectedToken) {
180181
requireNonNull(author, "author");
181182
requireNonNull(appId, "appId");
182183

@@ -196,11 +197,25 @@ CompletableFuture<Token> regenerateTokenSecret(Author author, String appId,
196197
"The app identity is already destroyed: " + appId);
197198
}
198199
throwIfInvalidType(appId, appIdentity, AppIdentityType.TOKEN);
199-
if (expectedCreation != null && !expectedCreation.equals(appIdentity.creation())) {
200+
if (expectedToken != null &&
201+
!expectedToken.creation().equals(appIdentity.creation())) {
200202
// The token the caller was authorized for has been recreated in the meantime.
201203
throw new ChangeConflictException(
202204
"The app identity has been recreated concurrently: " + appId);
203205
}
206+
if (expectedToken != null &&
207+
!Objects.equals(expectedToken.secret(), ((Token) appIdentity).secret())) {
208+
// Another regeneration has been committed in the meantime; failing loudly
209+
// prevents the caller from distributing a secret that will never work.
210+
throw new ChangeConflictException(
211+
"The secret has been regenerated concurrently: " + appId);
212+
}
213+
if (appIdentity.deactivation() == null) {
214+
// Regenerating the secret of an active token would break its clients with no
215+
// way to prepare, so the token must be deactivated first.
216+
throw new ChangeConflictException(
217+
"The token must be deactivated before regenerating its secret: " + appId);
218+
}
204219

205220
final Token token = (Token) appIdentity;
206221
final String oldSecret = token.secret();
@@ -216,12 +231,11 @@ CompletableFuture<Token> regenerateTokenSecret(Author author, String appId,
216231
newTokenRef.set(newToken);
217232
final Map<String, AppIdentity> newAppIds =
218233
updateMap(registry.appIds(), appId, newToken);
219-
final Map<String, String> secretsWithoutOld =
220-
removeFromMap(registry.secrets(), oldSecret);
221-
// A deactivated token has no entry in the secret map.
234+
// A deactivated token has no entry in the secret map, so the new secret is not
235+
// added; it is registered when the token is activated. The old secret is removed
236+
// defensively in case a stale entry is left over.
222237
final Map<String, String> newSecrets =
223-
token.isActive() ? addToMap(secretsWithoutOld, newSecret, appId)
224-
: secretsWithoutOld;
238+
removeFromMap(registry.secrets(), oldSecret);
225239
return new AppIdentityRegistry(newAppIds, newSecrets, registry.certificateIds());
226240
});
227241
return appIdentityRegistryRepo.push(INTERNAL_PROJECT_DOGMA, Project.REPO_DOGMA, author,

server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,26 +1166,27 @@ public CompletableFuture<Revision> deactivateToken(Author author, String appId)
11661166
}
11671167

11681168
/**
1169-
* Regenerates the secret of the {@link Token} of the specified {@code appId} and returns the
1170-
* {@link Token} with the newly-generated secret. The old secret is revoked in the same commit,
1171-
* but it may take a short time for the revocation to be propagated to the authorization cache.
1169+
* Regenerates the secret of the deactivated {@link Token} of the specified {@code appId} and
1170+
* returns the {@link Token} with the newly-generated secret. The token must be deactivated first
1171+
* and the new secret does not authenticate until the token is activated. The regeneration fails
1172+
* with a {@link ChangeConflictException} if the token is still active.
11721173
*/
11731174
public CompletableFuture<Token> regenerateTokenSecret(Author author, String appId) {
11741175
return appIdentityService.regenerateTokenSecret(author, appId);
11751176
}
11761177

11771178
/**
1178-
* Regenerates the secret of the {@link Token} of the specified {@code appId} and returns the
1179-
* {@link Token} with the newly-generated secret. The old secret is revoked in the same commit,
1180-
* but it may take a short time for the revocation to be propagated to the authorization cache.
1181-
* The regeneration fails with a {@link ChangeConflictException} if the token's creation metadata
1182-
* does not match {@code expectedCreation}, which prevents rotating a token that was recreated
1183-
* with the same application ID after the caller was authorized.
1179+
* Regenerates the secret of the deactivated {@link Token} of the specified {@code appId} and
1180+
* returns the {@link Token} with the newly-generated secret. The token must be deactivated first
1181+
* and the new secret does not authenticate until the token is activated. The regeneration fails
1182+
* with a {@link ChangeConflictException} if the token is still active, or if the token does not
1183+
* match {@code expectedToken} anymore because it was recreated or regenerated after the caller
1184+
* was authorized.
11841185
*/
11851186
public CompletableFuture<Token> regenerateTokenSecret(Author author, String appId,
1186-
UserAndTimestamp expectedCreation) {
1187-
requireNonNull(expectedCreation, "expectedCreation");
1188-
return appIdentityService.regenerateTokenSecret(author, appId, expectedCreation);
1187+
Token expectedToken) {
1188+
requireNonNull(expectedToken, "expectedToken");
1189+
return appIdentityService.regenerateTokenSecret(author, appId, expectedToken);
11891190
}
11901191

11911192
/**

server/src/test/java/com/linecorp/centraldogma/server/internal/api/AppIdentityRegistryServiceViaHttpTest.java

Lines changed: 49 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ void regenerateTokenSecret() throws JsonProcessingException {
8888
assertThat(newTokenClient(oldSecret).get(API_V1_PATH_PREFIX + "appIdentities").aggregate().join()
8989
.status()).isEqualTo(HttpStatus.OK);
9090

91+
// Deactivate the token to revoke the old secret.
92+
final RequestHeaders patchHeaders =
93+
RequestHeaders.of(HttpMethod.PATCH, API_V1_PATH_PREFIX + "appIdentities/forRegenerate",
94+
HttpHeaderNames.CONTENT_TYPE, MediaType.JSON);
95+
assertThat(systemAdminClient.execute(patchHeaders, "{\"status\":\"inactive\"}").aggregate().join()
96+
.status()).isEqualTo(HttpStatus.OK);
97+
// The registry used by the authorizer is updated asynchronously so await the changes.
98+
await().untilAsserted(() -> {
99+
assertThat(newTokenClient(oldSecret).get(API_V1_PATH_PREFIX + "appIdentities").aggregate().join()
100+
.status()).isEqualTo(HttpStatus.UNAUTHORIZED);
101+
});
102+
91103
final AggregatedHttpResponse regenerateResponse =
92104
systemAdminClient.post(API_V1_PATH_PREFIX + "appIdentities/forRegenerate/secret",
93105
HttpData.empty())
@@ -99,20 +111,48 @@ void regenerateTokenSecret() throws JsonProcessingException {
99111
final String newSecret = regenerated.get("secret").asText();
100112
assertThat(newSecret).startsWith("appToken-")
101113
.isNotEqualTo(oldSecret);
102-
103-
// The old secret is revoked and the new secret authenticates requests.
104-
// The registry used by the authorizer is updated asynchronously so await the changes.
114+
// The token remains deactivated so neither secret authenticates yet.
115+
assertThat(regenerated.get("deactivation")).isNotNull();
105116
await().untilAsserted(() -> {
117+
assertThat(newTokenClient(newSecret).get(API_V1_PATH_PREFIX + "appIdentities").aggregate().join()
118+
.status()).isEqualTo(HttpStatus.UNAUTHORIZED);
106119
assertThat(newTokenClient(oldSecret).get(API_V1_PATH_PREFIX + "appIdentities").aggregate().join()
107120
.status()).isEqualTo(HttpStatus.UNAUTHORIZED);
121+
});
122+
123+
// Activating the token makes the new secret usable while the old one stays revoked.
124+
assertThat(systemAdminClient.execute(patchHeaders, "{\"status\":\"active\"}").aggregate().join()
125+
.status()).isEqualTo(HttpStatus.OK);
126+
await().untilAsserted(() -> {
108127
assertThat(newTokenClient(newSecret).get(API_V1_PATH_PREFIX + "appIdentities").aggregate().join()
109128
.status()).isEqualTo(HttpStatus.OK);
129+
assertThat(newTokenClient(oldSecret).get(API_V1_PATH_PREFIX + "appIdentities").aggregate().join()
130+
.status()).isEqualTo(HttpStatus.UNAUTHORIZED);
110131
});
111132
}
112133

134+
@Test
135+
void cannotRegenerateSecretOfActiveToken() {
136+
assertThat(systemAdminClient.post(API_V1_PATH_PREFIX + "appIdentities",
137+
QueryParams.of("appId", "forActive", "type", "TOKEN",
138+
"isSystemAdmin", false),
139+
HttpData.empty())
140+
.aggregate()
141+
.join()
142+
.status()).isEqualTo(HttpStatus.CREATED);
143+
144+
final AggregatedHttpResponse response =
145+
systemAdminClient.post(API_V1_PATH_PREFIX + "appIdentities/forActive/secret",
146+
HttpData.empty())
147+
.aggregate()
148+
.join();
149+
assertThat(response.status()).isEqualTo(HttpStatus.BAD_REQUEST);
150+
assertThat(response.contentUtf8()).contains("Deactivate it first");
151+
}
152+
113153
@Test
114154
void ownerCanRegenerateOwnTokenSecret() throws JsonProcessingException {
115-
// A non-admin user creates its own token and regenerates its secret.
155+
// A non-admin user creates its own token, deactivates it and regenerates its secret.
116156
final WebClient userClient =
117157
WebClient.builder(dogma.httpClient().uri())
118158
.auth(AuthToken.ofOAuth2(getAccessToken(dogma.httpClient(),
@@ -128,6 +168,11 @@ void ownerCanRegenerateOwnTokenSecret() throws JsonProcessingException {
128168
.aggregate()
129169
.join()
130170
.status()).isEqualTo(HttpStatus.CREATED);
171+
final RequestHeaders patchHeaders =
172+
RequestHeaders.of(HttpMethod.PATCH, API_V1_PATH_PREFIX + "appIdentities/ownedByUser2",
173+
HttpHeaderNames.CONTENT_TYPE, MediaType.JSON);
174+
assertThat(userClient.execute(patchHeaders, "{\"status\":\"inactive\"}").aggregate().join()
175+
.status()).isEqualTo(HttpStatus.OK);
131176

132177
final AggregatedHttpResponse response =
133178
userClient.post(API_V1_PATH_PREFIX + "appIdentities/ownedByUser2/secret", HttpData.empty())
@@ -136,38 +181,6 @@ void ownerCanRegenerateOwnTokenSecret() throws JsonProcessingException {
136181
assertThat(response.status()).isEqualTo(HttpStatus.OK);
137182
}
138183

139-
@Test
140-
void regenerateSecretOfDeactivatedTokenViaHttp() throws JsonProcessingException {
141-
assertThat(systemAdminClient.post(API_V1_PATH_PREFIX + "appIdentities",
142-
QueryParams.of("appId", "forInactive", "type", "TOKEN",
143-
"isSystemAdmin", false),
144-
HttpData.empty())
145-
.aggregate()
146-
.join()
147-
.status()).isEqualTo(HttpStatus.CREATED);
148-
final RequestHeaders headers = RequestHeaders.of(HttpMethod.PATCH,
149-
API_V1_PATH_PREFIX + "appIdentities/forInactive",
150-
HttpHeaderNames.CONTENT_TYPE, MediaType.JSON);
151-
assertThat(systemAdminClient.execute(headers, "{\"status\":\"inactive\"}").aggregate().join()
152-
.status()).isEqualTo(HttpStatus.OK);
153-
154-
final AggregatedHttpResponse response =
155-
systemAdminClient.post(API_V1_PATH_PREFIX + "appIdentities/forInactive/secret",
156-
HttpData.empty())
157-
.aggregate()
158-
.join();
159-
assertThat(response.status()).isEqualTo(HttpStatus.OK);
160-
final JsonNode regenerated = Jackson.readTree(response.contentUtf8());
161-
final String newSecret = regenerated.get("secret").asText();
162-
assertThat(newSecret).startsWith("appToken-");
163-
// The token remains deactivated so the new secret does not authenticate until activation.
164-
assertThat(regenerated.get("deactivation")).isNotNull();
165-
await().untilAsserted(() -> {
166-
assertThat(newTokenClient(newSecret).get(API_V1_PATH_PREFIX + "appIdentities").aggregate().join()
167-
.status()).isEqualTo(HttpStatus.UNAUTHORIZED);
168-
});
169-
}
170-
171184
@Test
172185
void cannotRegenerateSecretOfMissingToken() {
173186
assertThat(systemAdminClient.post(API_V1_PATH_PREFIX + "appIdentities/nonexistent/secret",

0 commit comments

Comments
 (0)