Skip to content

feat: Implement Client-Side CAB token generation. #1598

Merged
nbayati merged 9 commits intogoogleapis:client-side-cabfrom
huangjiahua:generate-cab
Jan 17, 2025
Merged

feat: Implement Client-Side CAB token generation. #1598
nbayati merged 9 commits intogoogleapis:client-side-cabfrom
huangjiahua:generate-cab

Conversation

@huangjiahua
Copy link
Contributor

@huangjiahua huangjiahua commented Dec 12, 2024

Implement Client-Side CAB token generation.


Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly:

  • Make sure to open an issue as a bug/issue before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea
  • Ensure the tests and linter pass
  • Code coverage does not decrease (if any source code was changed)
  • Appropriate docs were updated (if necessary)

Fixes #<issue_number_goes_here> ☕️

If you write sample code, please follow the samples format.

@product-auto-label product-auto-label bot added the size: l Pull request size is large. label Dec 12, 2024
@huangjiahua huangjiahua changed the title Generate cab feat: Implement Client-Side CAB token generation. Dec 12, 2024
@huangjiahua huangjiahua marked this pull request as ready for review December 12, 2024 22:10
@huangjiahua huangjiahua requested a review from a team December 12, 2024 22:10
@huangjiahua huangjiahua requested a review from a team as a code owner December 12, 2024 22:10
String intermediaryToken, sessionKey;
Date intermediaryTokenExpirationTime;

synchronized (this) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need a synchronized block here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the value is written in a synchronized block:

synchronized (refreshLock) {
try {
this.intermediateCredentials = Futures.getDone(finishedTask);
} finally {
if (this.refreshTask != null && this.refreshTask.task == finishedTask) {
this.refreshTask = null;
}
}
}
}
.

I'll update my code to use the refreshLock and the IntermediateCredentials wrapper.

@huangjiahua huangjiahua marked this pull request as draft January 8, 2025 23:04
@huangjiahua huangjiahua force-pushed the generate-cab branch 5 times, most recently from 2bd4316 to 855a2cc Compare January 9, 2025 00:57
@huangjiahua huangjiahua marked this pull request as ready for review January 9, 2025 00:59
@lsirac lsirac requested review from aeitzman and lqiu96 January 13, 2025 19:14
Change-Id: I2c217656584cf5805297f02340cbbabca471f609
try {
AeadConfig.register();
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Error occurred when registering Tink");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you throw with the exception as well IllegalStateException(String, Exception)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

…ption during Tink initialization

Change-Id: I12af5b84eae4dcec5865adfdad1f9396d54c0200
Copy link
Contributor

@nbayati nbayati left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@huangjiahua huangjiahua requested a review from lqiu96 January 15, 2025 19:14
Aead aead =
keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead.class);

return aead.encrypt(restriction, /*associatedData=*/new byte[0]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can you note the significance of byte[0]?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean documenting why we need a byte[0] here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, mostly because byte[0] seems random and not related to anything. Wondering why it's needed/ what it's doing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already added the comment.


return aead.encrypt(restriction, /*associatedData=*/new byte[0]);
} catch (GeneralSecurityException exception) {
throw new InternalError("Failed to parse keyset: " + exception.getMessage());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qq, what's the benefit of re-throwing this as InternalError instead of keeping it as GeneralSecurityException?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to throw GeneralSecurityException

Copy link
Member

@lqiu96 lqiu96 Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, do you know what the error message is from the GeneralSecurityException when it fails? If it's too vague (i'm assuming it may be because it says General), perhaps we can re-throw with the error message you wrote above. If we do re-throw, I'm not too certain about InternalError. I think a different exception that would highlight the error type would be best.

If the error message is relatively specific (i.e. not something like something wrong occurred) then I think I'm fine with it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message is "Parse keyset failed". I think it's specific.

Change-Id: If8c94c786ee39201029d9c27856fd2eafb61e51c
Comment on lines 631 to 660
byte[] rawKey = Base64.getDecoder().decode(
transportFactory.transport.getAccessBoundarySessionKey());

KeysetHandle keysetHandle = TinkProtoKeysetFormat.parseKeyset(
rawKey, InsecureSecretKeyAccess.get());

Aead aead =
keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead.class);
byte[] rawRestrictions =
aead.decrypt(Base64.getUrlDecoder().decode(parts[1]), new byte[0]);
ClientSideAccessBoundary clientSideAccessBoundary =
ClientSideAccessBoundary.parseFrom(rawRestrictions);
assertEquals(clientSideAccessBoundary.getAccessBoundaryRulesCount(), 1);
ClientSideAccessBoundaryRule rule =
clientSideAccessBoundary.getAccessBoundaryRules(0);
assertEquals(rule.getAvailableResource(),
"//storage.googleapis.com/projects/_/buckets/example-bucket");
assertEquals(rule.getAvailablePermissions(0),
"inRole:roles/storage.objectViewer");
Expr expr = rule.getCompiledAvailabilityCondition();
assertEquals(expr.getCallExpr()
.getTarget()
.getSelectExpr()
.getOperand()
.getIdentExpr()
.getName(),
"resource");
assertEquals(expr.getCallExpr().getFunction(), "startsWith");
assertEquals(expr.getCallExpr().getArgs(0).getConstExpr().getStringValue(),
"projects/_/buckets/example-bucket/objects/customer-a");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, can we break testing two main logic components (serializeCredentialAccessBoundary and encryptRestrictions) into smaller unit tests? I think it may be easier to read, but let me know if you have any concerns with this.

i.e. Perhaps something like

  1. serializeCredentialAccessBoundary_withAvailabilityRestrictions_success
  2. serializeCredentialAccessBoundary_withoutAvailabilityRestrictions_success
  3. serializeCredentialAccessBoundary_failure

Then also for encryptRestrictions

  1. encryptRestrictions_success
  2. encryptRestrictions_failure

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer not exposing private methods but testing the public generateToken as a whole, but I see currently we don't have a test to cover encryption failures, so I'll add one with invalid keys from STS. I'll also rename the test cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can make the methods package-private. It will still be accessible via the tests and hidden from the user. We can also annotate them with @VisibleForTesting

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered using @VisibleForTesting here, but I'm hesitant to introduce it as I believe it can sometimes be a shortcut for testing things we should ideally test through public APIs. We can fully test the behavior we need using the generateToken method, even though the assertions are a bit longer. I don't think the added verbosity warrants using @VisibleForTesting in this case. Let me know if you see other issues or have a different perspective.

Copy link
Member

@lqiu96 lqiu96 Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean by shortcut for testing things and why they must be tested via the public APIs. Most of the core logic is inside the internal methods and they can be tested separately for the behavior that we expect. If it's easier to test from the public method and the individual methods require lots of workarounds and mocks, I understand and I think that would be fine (just document the rationale).

My concerns with these test stem around readability and maintenance. The success unit tests are 50+ lines long and is not easy to figure out what is going on. For example, generateToken() ends up encrypting all these values and the only way to confirm is to decrypt the values of the token. From what I see, it looks like the general flow/ logic is duplicated twice (one in library to encrypt and one in the unit test to decrypt). If we something like byte[0] in Aead changes, then we have to change it in multiple places.

Inside generateToken(), it also can spit out all these different success and failure scenarios (availabilitycondition exists or doesn't, Cel Exception, Aead Exception, etc). For example, it's not easy to trace what exactly causes a CelException if I have to go read through 2-3 nested methods worth of source (generateToken() -> serializeCredentialAccessBoundary() -> compileCel()) while keeping track of the state.

Perhaps we can keep the success tests from the public method and test the error cases from the individual methods? i.e. compileCel_throwsCelException(), etc

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand your concerns. I'd like to address them by introducing helper functions in the test code to improve readability and adding comments to clarify the purpose of each test.

I'm still hesitant to add tests for private methods such as compileCel or encryptRestrictions because I believe it goes against the principle that:

If you change your software's internal implementation, your tests should not break as long as the change is not observable by users. Therefore, per the black-box testing principle, most of the time you should test your code through its public interfaces.

Testing through the public API allows us to validate the system's behavior as a user would experience it. For instance, we can ensure that an invalid CEL expression results in a CelValidationException for the user. Even if we tested private method failures directly, those tests wouldn't necessarily cover the public API, and we couldn't be certain the same errors would be exposed to the user.

If you think failure test cases for cel compilation and encryption are necessary. I can add tests for those methods but I would still keep the failure cases for the public method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored the tests as I mentioned above.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave this up to you since I believe test unit tests do a thorough enough test and I'm confident about the changes. I don't want to make this a blocker for this PR.

I don't think these unit tests don't seem to follow the ideas above besides just calling from the public method. If you're using the internal logic/ code (the black box) to validate the output, I'm not sure why it needs to be a done via black box testing to begin with. To me, it's opening the black box and making the validation reliant what's in the black box, which seems to ruin the whole idea of having the black box. For example If Cel now requires you to use byte[100] instead of byte[0] to encrypt and this also returns a different, valid access token, these unit tests would probably fail since it's decrypting with byte[0].

Logic within refreshCredentialsIfRequired() also is only run, but that isn't tested within this black-box testing principle. Instead we have dedicated unit tests for refreshCredentialsIfRequired. Had this been done via the blackbox testing principle, I would think we'd see a whole matrix of new test cases pop up:

  1. generateToken_blocking_availabilityConditionsExist_success()
  2. generateToken_blocking_availabilityConditionsExist_celExceptionError()
    etc

Perhaps I don't understand how these tests follow black-box testing and I'll leave it as that.

Change-Id: Ib41cb81c779534fc6efd74d66bf4728efd743906
@huangjiahua huangjiahua requested a review from lqiu96 January 15, 2025 23:49
Change-Id: I9cfc589ade8a91040fc9c447740493fd49e392af
Change-Id: Icfd0bc24c1694f220bcbffc6cde41462c59119c4
Comment on lines 879 to 880
assertThrows(IllegalArgumentException.class,
() -> { factory.generateToken(accessBoundary); });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for this one, can you document where IllegalArgumentException may stem from. This is a runtimeexception and isn't marked as thrown by generateToken() so it may be confusing for future devs to figure out where it's coming from.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from the base64 decoding of the session key. Since the session key is from the server, I changed it to catch it and re-throw it as an IllegalStateExeption.

@huangjiahua
Copy link
Contributor Author

huangjiahua commented Jan 16, 2025

@lqiu96 Looks like CLA check fails because it can't associate your email to Google I see, it's the auto commit does not pick up the work email. Let me clean that.

Change-Id: I5fa0c25fe020e9612735e4ac5df2b85a2a5aab11
Change-Id: I46572488dcd28de450a6b1b2f732bee5baa86910
Change-Id: Icef9ef5f7c3567224ec507303543b78e61f43ec1
@nbayati nbayati merged commit 5573169 into googleapis:client-side-cab Jan 17, 2025
11 of 12 checks passed
lqiu96 pushed a commit that referenced this pull request Feb 4, 2025
…ality (#1629)

* feat: Implement ClientSideCredentialAccessBoundaryFactory (#1562)

* feat: Implement ClientSideCredentialAccessBoundaryFactory.refreshCredentials()

Set up the ClientSideCredentialAccessBoundaryFactory class and module.
Implement the function to fetch and refresh intermediary tokens from STS.

* feat: Add the generated ClientSideAccessBoundaryProto class for Client-Side CAB feature. (#1571)

Change-Id: Ic7ef3cbd80b2ad778d61b9ccabf780561d3cc709

* feat: Implement refreshCredentialsIfRequired for intermediate token r… (#1583)

* feat: Implement refreshCredentialsIfRequired for intermediate token refresh

Implement `refreshCredentialsIfRequired`, called by `generateToken()`, to handle token refresh. It uses `refreshMargin` and `minimumTokenLifetime` to decide on synchronous or asynchronous refresh

* Add unit tests for the builder and refreshCredentials()

* Improve concurrency handling during credential refresh.

Introduced a refresh task to manage concurrent refresh requests, preventing redundant attempts and potential race conditions. This aligns the refresh mechanism with the pattern used in OAuth2Credentials and ensures more robust credential management.

* Update existing unit tests for compatibility and readability.

* Add unit tests for refreshCredentialsIfRequired.

* Fix a merge issue.

* Temporary add sonatype-snapshots repository and cel version to fix the build error.

* Remove duplicated code.

* Fix lint issue.

* Fix: Propagate credential refresh exceptions in blocking refresh.

* Change cel version

* Change cel version

* Add jsr305 dependency

* Fix Javadoc error

* Minor code readability enhancements.

* Revert "Fix Javadoc error"

This reverts commit 2157fdb.

* Address comments (add javadoc and use assertThrows in tests)

* Run format script

* feat: Implement Client-Side CAB token generation.  (#1598)

* feat: Implement Client-Side CAB token generation.

Change-Id: I2c217656584cf5805297f02340cbbabca471f609

* Use IllegalStateException(String, Throwable) to capture upstream exception during Tink initialization

Change-Id: I12af5b84eae4dcec5865adfdad1f9396d54c0200

* Rethrow exceptions from tink and CEL

Change-Id: If8c94c786ee39201029d9c27856fd2eafb61e51c

* Add tests for invalid keys from upstream, and rename test cases.

Change-Id: Ib41cb81c779534fc6efd74d66bf4728efd743906

* Add additional throws comment for generatToken method.

Change-Id: I9cfc589ade8a91040fc9c447740493fd49e392af

* Refactor tests for better readability.

Change-Id: Icfd0bc24c1694f220bcbffc6cde41462c59119c4

* Catch and rethrow the exception of session key not being base64 encoded.

Change-Id: I5fa0c25fe020e9612735e4ac5df2b85a2a5aab11

* Format the code using mvn com.coveo:fmt-maven-plugin:format.

Change-Id: I46572488dcd28de450a6b1b2f732bee5baa86910

* Fix a typo in the javadoc comment.

Change-Id: Icef9ef5f7c3567224ec507303543b78e61f43ec1

* chore: Update version tag in cab-token-generator pom.xml

This commit updates the version tag in the pom.xml file.

* feat: Add integration test for the client side cab

* Remove volatile keyword and use refreshLock when reading intermediateCredentials.

* Define new default values for refreshMargin and minimumTokenLifetime.

* Update version in pom.xml

* Run formatter to resolve lint errors

* add missing dependency

* Swap the assertEquals parameters so the expected value is first.

* Docs: Added javadocs

Improvements: Cleaned up code, resolved readability enhancements

---------

Co-authored-by: Jiahua Huang <jh@jiahuah.com>
Co-authored-by: aeitzman <12433791+aeitzman@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size: l Pull request size is large.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants