1
0
mirror of synced 2026-08-31 22:46:02 +00:00

Compare commits

...

93 Commits

Author SHA1 Message Date
Josh Cummings 43f5ae5b21 Remove Auto-merge for .x Branches
Closes gh-19632

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-31 10:43:16 -06:00
Josh Cummings b2a6bbf314 Merge branch '7.1.x' 2026-08-31 10:42:04 -06:00
Josh Cummings a161421409 Turn Off Auto-merge for Maintenance Branch
This commit removes the auto-merge workflow on 7.1.x since it
is a maintenance branch.

Closes gh-19631

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-31 10:41:54 -06:00
Joe Grandja 36aa2ad744 Merge branch '7.1.x' 2026-08-31 12:02:21 -04:00
Josh Cummings ed4e0e2ea8 Merge branch '7.1.x' 2026-08-31 09:55:00 -06:00
Joe Grandja 978eb4396a Polish gh-19585 2026-08-31 11:09:18 -04:00
Evgeniy Cheban 770c860d9d Ensure WebSession ID is not changed after token refresh (Reactive)
Closes gh-19424

Signed-off-by: Evgeniy Cheban <mister.cheban@gmail.com>
2026-08-31 10:11:13 -04:00
dependabot[bot] 11890f2f49 Bump io.spring.gradle:spring-security-release-plugin
Bumps [io.spring.gradle:spring-security-release-plugin](https://github.com/spring-io/spring-security-release-tools) from 1.0.16 to 1.0.17.
- [Release notes](https://github.com/spring-io/spring-security-release-tools/releases)
- [Changelog](https://github.com/spring-io/spring-security-release-tools/blob/main/RELEASE.adoc)
- [Commits](https://github.com/spring-io/spring-security-release-tools/compare/v1.0.16...v1.0.17)

---
updated-dependencies:
- dependency-name: io.spring.gradle:spring-security-release-plugin
  dependency-version: 1.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 03:17:31 +00:00
dependabot[bot] 5809451de7 Bump spring-io/spring-release-actions/compute-next-version
Bumps [spring-io/spring-release-actions/compute-next-version](https://github.com/spring-io/spring-release-actions) from 0.0.5 to 0.0.6.
- [Release notes](https://github.com/spring-io/spring-release-actions/releases)
- [Commits](https://github.com/spring-io/spring-release-actions/compare/a1f321783a0769dd2aea4fad6c2ae2f95a52b885...1b8671612c3eb3d9b9763e2d7b66f1a80d00ee95)

---
updated-dependencies:
- dependency-name: spring-io/spring-release-actions/compute-next-version
  dependency-version: 0.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 00:50:20 +00:00
dependabot[bot] 31d379a7bf Bump spring-io/spring-security-release-tools/.github/workflows/release-announcements-stage.yml
Bumps [spring-io/spring-security-release-tools/.github/workflows/release-announcements-stage.yml](https://github.com/spring-io/spring-security-release-tools) from 1.0.16 to 1.0.17.
- [Release notes](https://github.com/spring-io/spring-security-release-tools/releases)
- [Changelog](https://github.com/spring-io/spring-security-release-tools/blob/main/RELEASE.adoc)
- [Commits](https://github.com/spring-io/spring-security-release-tools/compare/d6c65d3013c0888e2c9cbae9f4beda610994776c...3f6cc7ffc137ca160061749d5f34dc30d5f36986)

---
updated-dependencies:
- dependency-name: spring-io/spring-security-release-tools/.github/workflows/release-announcements-stage.yml
  dependency-version: 1.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 00:47:19 +00:00
dependabot[bot] 825370f8e3 Bump spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml
Bumps [spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml](https://github.com/spring-io/spring-security-release-tools) from 1.0.15 to 1.0.17.
- [Release notes](https://github.com/spring-io/spring-security-release-tools/releases)
- [Changelog](https://github.com/spring-io/spring-security-release-tools/blob/main/RELEASE.adoc)
- [Commits](https://github.com/spring-io/spring-security-release-tools/compare/v1.0.15...3f6cc7ffc137ca160061749d5f34dc30d5f36986)

---
updated-dependencies:
- dependency-name: spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml
  dependency-version: 1.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 00:47:16 +00:00
Josh Cummings 61feae94a0 Merge branch '7.1.x' 2026-08-26 10:17:45 -06:00
Josh Cummings edaae1d17d Merge branch '7.0.x' into 7.1.x 2026-08-26 10:17:34 -06:00
dependabot[bot] e8084090ff Bump org.hibernate.orm:hibernate-core from 7.4.5.Final to 7.4.6.Final
Bumps [org.hibernate.orm:hibernate-core](https://github.com/hibernate/hibernate-orm) from 7.4.5.Final to 7.4.6.Final.
- [Release notes](https://github.com/hibernate/hibernate-orm/releases)
- [Changelog](https://github.com/hibernate/hibernate-orm/blob/7.4.6/changelog.txt)
- [Commits](https://github.com/hibernate/hibernate-orm/compare/7.4.5...7.4.6)

---
updated-dependencies:
- dependency-name: org.hibernate.orm:hibernate-core
  dependency-version: 7.4.6.Final
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 03:24:42 +00:00
dependabot[bot] 4773b40f02 Bump gradle-wrapper from 9.7.0 to 9.7.1
Bumps [gradle-wrapper](https://github.com/gradle/gradle) from 9.7.0 to 9.7.1.
- [Release notes](https://github.com/gradle/gradle/releases)
- [Commits](https://github.com/gradle/gradle/compare/v9.7.0...v9.7.1)

---
updated-dependencies:
- dependency-name: gradle-wrapper
  dependency-version: 9.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 03:28:40 +00:00
dependabot[bot] 9a4a965b0d Bump io.spring.gradle:spring-security-release-plugin
Bumps [io.spring.gradle:spring-security-release-plugin](https://github.com/spring-io/spring-security-release-tools) from 1.0.15 to 1.0.16.
- [Release notes](https://github.com/spring-io/spring-security-release-tools/releases)
- [Changelog](https://github.com/spring-io/spring-security-release-tools/blob/main/RELEASE.adoc)
- [Commits](https://github.com/spring-io/spring-security-release-tools/compare/v1.0.15...v1.0.16)

---
updated-dependencies:
- dependency-name: io.spring.gradle:spring-security-release-plugin
  dependency-version: 1.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 03:27:38 +00:00
Josh Cummings 0bae8a73d1 Next Development Version
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-21 09:14:37 -06:00
Josh Cummings 461ccd817d Next Development Version
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-21 09:14:09 -06:00
Josh Cummings d449950b36 Merge branch '7.1.x'
Closes gh-19573
2026-08-20 16:58:15 -06:00
Josh Cummings 8dc341af8c Merge branch '7.0.x' into 7.1.x
Closes gh-19572
2026-08-20 16:58:02 -06:00
Josh Cummings 381c556f78 Add Release Announcement Workflows
Closes gh-19571

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 16:57:48 -06:00
Josh Cummings 19afc9121e Merge branch '7.1.x'
Closes gh-19568
# Conflicts:
#	gradle/libs.versions.toml
2026-08-20 12:29:12 -06:00
Josh Cummings 6b2f892e66 Merge branch '7.0.x' into 7.1.x
Closes gh-19567
# Conflicts:
#	config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationConfigurer.java
#	config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java
#	config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/DPoPAuthenticationTests.java
#	gradle/libs.versions.toml
#	oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java
#	oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofJwtDecoderFactory.java
#	oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/DPoPProofReplayValidator.java
#	oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/DPoPAuthenticationProvider.java
2026-08-20 12:23:20 -06:00
Josh Cummings 3b276e6c0d Update to Spring Data 2025.1.7
Closes gh-19485

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:24:08 -06:00
Josh Cummings f2115fbef8 Update to Spring LDAP 4.0.5
Closes gh-19486

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:24:01 -06:00
Josh Cummings 298335a57e Update to Micrometer 1.16.7
Closes gh-19484

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:55 -06:00
Josh Cummings c6566ee772 Update to Reactor 2025.0.7
Closes gh-19483

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:50 -06:00
Josh Cummings 7442214225 Update to Spring Framework 7.0.9
Closes gh-19482

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:44 -06:00
Josh Cummings 0123ae0981 Deprecate AesBytesEncryptor
This commit separates AesBytesEncryptor into two separate
implememtations, allowing for a migration away from default
arrangements that used a null IV

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:36 -06:00
Josh Cummings 11a648d106 Improve Equivalence Tests
This commit adds equals and hashCode implementations as well
as a readResolve implementation to ensure that deserialization
mechanisms can correctly assess the equality of a constnat
and a corresponding deserialized instance. For defense-in-depth
reasons, this commit also favors .equals over == for these
constants.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:29 -06:00
Joe Grandja 536e09756c Bind UnboundIdContainer to loopback address 2026-08-20 11:23:22 -06:00
Joe Grandja ac15cdbea3 Apply html encoding in DefaultConsentPage 2026-08-20 11:23:14 -06:00
Joe Grandja ad812aefc4 Provide ability to configure DPoP proof replay 2026-08-20 11:23:08 -06:00
Josh Cummings d8769fb183 Use Constant-Time Comparison
This commit updates password encoders to use a
constant-time comparison method to defend against
timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:23:02 -06:00
Josh Cummings ae997a4f46 Use Utf8#isEqual
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:55 -06:00
Josh Cummings bbc6273abf Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:49 -06:00
Josh Cummings 475af0d3db Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:43 -06:00
Josh Cummings f8a61e66d1 Add Utf8#isEqual
This commit adds a constant-time equals method,
useful for comparing password hashes or other
sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:36 -06:00
Josh Cummings 8830e39073 Use MessageDigest#isEqual
This commit favors constant-time comparison
to mitigate timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:31 -06:00
Josh Cummings 304cc21523 Add Tests for User and Device Code
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:22:25 -06:00
Josh Cummings 7048b08b9c Update to Spring Data 2026.0.1
Closes gh-19490

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:29 -06:00
Josh Cummings ff108a49a5 Update to Spring LDAP 4.1.1
Closes gh-19491

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:29 -06:00
Josh Cummings 9a70d73036 Update to Micrometer 1.17.1
Closes gh-19489

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:28 -06:00
Josh Cummings cf613ad2d3 Update to Reactor 2025.0.7
Closes gh-19488

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:28 -06:00
Josh Cummings fdec2d7617 Update to Spring Framework 7.0.9
Closes gh-19487

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:28 -06:00
Josh Cummings 748e3c9cf0 Use jspecify Nullable in DPoPProofReplayValidator
org.springframework.lang.Nullable is banned by the
bannedNullabilityImports checkstyle rule on this line; the embargoed
commit that introduced this file predates that rule's adoption here.
Switch to org.jspecify.annotations.Nullable to match the rest of the
codebase (already used correctly elsewhere in this same file).

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:21 -06:00
Josh Cummings 057581584a Align Null Behavior
Previously, ExternalInetAddressMatcher treated a null InetAddress
as external by negating InternalInetAddressMatcher's result.

This commit makes ExternalInetAddressMatcher return false for a
null address, so that neither the internal nor the external
matcher classifies an unknown address as a match.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:20 -06:00
Josh Cummings 502ecf9241 Deprecate AesBytesEncryptor
This commit separates AesBytesEncryptor into two separate
implememtations, allowing for a migration away from default
arrangements that used a null IV

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:20 -06:00
Josh Cummings 7db67f923c Improve Equivalence Tests
This commit adds equals and hashCode implementations as well
as a readResolve implementation to ensure that deserialization
mechanisms can correctly assess the equality of a constnat
and a corresponding deserialized instance. For defense-in-depth
reasons, this commit also favors .equals over == for these
constants.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:14 -06:00
Joe Grandja 35859220cb Bind UnboundIdContainer to loopback address 2026-08-20 11:21:14 -06:00
Joe Grandja 3836091fe2 Apply html encoding in DefaultConsentPage 2026-08-20 11:21:14 -06:00
Joe Grandja 4c41928bc7 Provide ability to configure DPoP proof replay 2026-08-20 11:21:13 -06:00
Josh Cummings 61be628ad6 Use Constant-Time Comparison
This commit updates password encoders to use a
constant-time comparison method to defend against
timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:06 -06:00
Josh Cummings e6208a4832 Use Utf8#isEqual
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:06 -06:00
Josh Cummings 01452e328f Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:05 -06:00
Josh Cummings 836190546d Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:21:05 -06:00
Josh Cummings 9f94fb92c5 Add Utf8#isEqual
This commit adds a constant-time equals method,
useful for comparing password hashes or other
sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:20:58 -06:00
Josh Cummings 47804ee834 Use MessageDigest#isEqual
This commit favors constant-time comparison
to mitigate timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:20:58 -06:00
Josh Cummings 865085e0b0 Add Tests for User and Device Code
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:20:58 -06:00
Josh Cummings 15a31d7dd7 Check isAnyLocalAddress
This commit updates InternalInetAddressMatcher to check
InetAddress#isAnyLocalAddress in order to catch
additional IP addresses

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 11:20:58 -06:00
Josh Cummings 1fe59232f0 Update to Spring Data 2026.1.0-M1
Closes gh-19531

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:10 -06:00
Josh Cummings 8f13ab216f Update to Micrometer 1.18.0-M1
Closes gh-19530

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:09 -06:00
Josh Cummings 8beb08974b Update to Reactor 2026.0.0-M1
Closes gh-19529

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:09 -06:00
Josh Cummings 49c89546c6 Update to Spring Framework 7.1.0-M1
Closes gh-19528

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:09 -06:00
Josh Cummings 5a74026036 Use jspecify Nullable in DPoPProofReplayValidator
org.springframework.lang.Nullable is banned by the
bannedNullabilityImports checkstyle rule on this line; the embargoed
commit that introduced this file predates that rule's adoption here.
Switch to org.jspecify.annotations.Nullable to match the rest of the
codebase (already used correctly elsewhere in this same file).

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:09 -06:00
Josh Cummings c9220ca39e Align Null Behavior
Previously, ExternalInetAddressMatcher treated a null InetAddress
as external by negating InternalInetAddressMatcher's result.

This commit makes ExternalInetAddressMatcher return false for a
null address, so that neither the internal nor the external
matcher classifies an unknown address as a match.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:09 -06:00
Josh Cummings b6b9d743a0 Deprecate AesBytesEncryptor
This commit separates AesBytesEncryptor into two separate
implememtations, allowing for a migration away from default
arrangements that used a null IV

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:08 -06:00
Josh Cummings 8ded9dac6a Improve Equivalence Tests
This commit adds equals and hashCode implementations as well
as a readResolve implementation to ensure that deserialization
mechanisms can correctly assess the equality of a constnat
and a corresponding deserialized instance. For defense-in-depth
reasons, this commit also favors .equals over == for these
constants.

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:08 -06:00
Joe Grandja 1b84c74776 Bind UnboundIdContainer to loopback address 2026-08-20 10:38:08 -06:00
Joe Grandja 83dfc0790e Apply html encoding in DefaultConsentPage 2026-08-20 10:38:08 -06:00
Joe Grandja b55f66695e Provide ability to configure DPoP proof replay 2026-08-20 10:38:08 -06:00
Josh Cummings 177e482783 Use Constant-Time Comparison
This commit updates password encoders to use a
constant-time comparison method to defend against
timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:07 -06:00
Josh Cummings 04d95818e3 Use Utf8#isEqual
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:07 -06:00
Josh Cummings 627a412748 Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:07 -06:00
Josh Cummings addebb61ed Use Constant-Time Equals
This commit updates the filter to use constant-time
equals for sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:07 -06:00
Josh Cummings 75d985fb13 Add Utf8#isEqual
This commit adds a constant-time equals method,
useful for comparing password hashes or other
sensitive material

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:07 -06:00
Josh Cummings 342b24d43e Use MessageDigest#isEqual
This commit favors constant-time comparison
to mitigate timing attacks

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:07 -06:00
Josh Cummings 2f97d8dac2 Add Tests for User and Device Code
Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-20 10:38:06 -06:00
dependabot[bot] 9d8026fcf3 Bump com.fasterxml.jackson:jackson-bom from 2.22.1 to 2.22.2
Bumps [com.fasterxml.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 2.22.1 to 2.22.2.
- [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-2.22.1...jackson-bom-2.22.2)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson:jackson-bom
  dependency-version: 2.22.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 03:19:14 +00:00
dependabot[bot] 3881de5d6c Bump com.fasterxml.jackson:jackson-bom from 2.22.1 to 2.22.2
Bumps [com.fasterxml.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 2.22.1 to 2.22.2.
- [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-2.22.1...jackson-bom-2.22.2)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson:jackson-bom
  dependency-version: 2.22.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-20 03:17:09 +00:00
Josh Cummings 1fa37a9479 Merge branch '7.1.x' 2026-08-19 16:55:49 -06:00
Josh Cummings 4e92ea215c Merge branch '7.0.x' into 7.1.x 2026-08-19 16:55:38 -06:00
Tadaya Tsuyukubo d111029078 Prevent sharing SecurityContext across threads
`SecurityContextHolderThreadLocalAccessor` currently propagates the
same `SecurityContext` to other threads when Micrometer
Context Propagation is used. This leads to unintended sharing of
mutable state and can cause authentication to leak between threads.

This change updates the accessor to create a new
`SecurityContext` for the target thread while reusing only the
`Authentication` value. Each thread now receives its own
`SecurityContext` instance, preventing cross-thread interference and
aligning with recommended `SecurityContext` usage.

Signed-off-by: Tadaya Tsuyukubo <tadaya@ttddyy.net>
2026-08-19 16:54:43 -06:00
Josh Cummings d7ea83017a Make MockSecurityContextHolderStrategy Thread-safe
Closes gh-19547

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
2026-08-19 16:54:12 -06:00
Joe Grandja 6401fde9f2 Merge branch '7.1.x' 2026-08-18 12:17:53 -04:00
Joe Grandja 7491e37310 Polish gh-19421 2026-08-18 12:11:09 -04:00
Peter Phillips d90714f07d Fix to allow null servlet request/response in ServletOAuth2AuthorizedClientExchangeFilterFunction
Issue gh-17819

Closes gh-19421

Signed-off-by: Peter Phillips <5099053+petergphillips@users.noreply.github.com>
2026-08-18 11:32:07 -04:00
dependabot[bot] 3cf0867955 Bump tools.jackson:jackson-bom from 3.2.1 to 3.2.2
Bumps [tools.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 3.2.1 to 3.2.2.
- [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-3.2.1...jackson-bom-3.2.2)

---
updated-dependencies:
- dependency-name: tools.jackson:jackson-bom
  dependency-version: 3.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 03:22:02 +00:00
dependabot[bot] 7f219ea530 Bump ch.qos.logback:logback-classic from 1.6.2 to 1.6.3
Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.6.2 to 1.6.3.
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.6.2...v_1.6.3)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-classic
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 03:19:31 +00:00
dependabot[bot] b622028f84 Bump tools.jackson:jackson-bom from 3.2.1 to 3.2.2
Bumps [tools.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 3.2.1 to 3.2.2.
- [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-3.2.1...jackson-bom-3.2.2)

---
updated-dependencies:
- dependency-name: tools.jackson:jackson-bom
  dependency-version: 3.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 03:16:54 +00:00
dependabot[bot] 8b92e71cb2 Bump ch.qos.logback:logback-classic from 1.6.2 to 1.6.3
Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.6.2 to 1.6.3.
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.6.2...v_1.6.3)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-classic
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 03:16:27 +00:00
dependabot[bot] 2c0c09b9a6 Bump ch.qos.logback:logback-classic from 1.6.1 to 1.6.2
Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.6.1 to 1.6.2.
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.6.1...v_1.6.2)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-classic
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 03:27:57 +00:00
dependabot[bot] 6ed154527e Bump ch.qos.logback:logback-classic from 1.6.1 to 1.6.2
Bumps [ch.qos.logback:logback-classic](https://github.com/qos-ch/logback) from 1.6.1 to 1.6.2.
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.6.1...v_1.6.2)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-classic
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 03:18:43 +00:00
57 changed files with 2376 additions and 276 deletions
+1 -2
View File
@@ -4,7 +4,6 @@ on:
pull_request:
branches:
- main
- '*.x'
- 'docs-build'
run-name: Merge Dependabot PR ${{ github.ref_name }}
@@ -14,4 +13,4 @@ jobs:
permissions: write-all
uses: spring-io/spring-github-workflows/.github/workflows/spring-merge-dependabot-pr.yml@0d3f15bb384839966a1ff5c4383731a2b747f24b # v7
with:
mergeArguments: --auto --rebase
mergeArguments: --auto --rebase
@@ -39,7 +39,7 @@ jobs:
deploy-schema:
name: Deploy Schema
needs: [ build ]
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml@b92832ecbc7cbe969201e6beafbde0ee400cf095 # v1.0.15
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml@3f6cc7ffc137ca160061749d5f34dc30d5f36986 # v1.0.17
with:
should-deploy-schema: ${{ needs.build.outputs.should-deploy-artifacts }}
java-version: 25
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
milestone-token: ${{ secrets.GITHUB_TOKEN }}
- name: Compute Next Version
id: next-version
uses: spring-io/spring-release-actions/compute-next-version@a1f321783a0769dd2aea4fad6c2ae2f95a52b885 # 0.0.5
uses: spring-io/spring-release-actions/compute-next-version@1b8671612c3eb3d9b9763e2d7b66f1a80d00ee95 # 0.0.6
with:
version: ${{ steps.todays-release.outputs.release-version }}
- name: Schedule Next Milestone
@@ -0,0 +1,30 @@
name: Release Announcements - Publish
on:
workflow_dispatch:
inputs:
version:
description: The version to publish
required: true
type: string
permissions:
actions: write
contents: read
issues: write
jobs:
deploy-schema:
name: Deploy Schema
uses: spring-io/spring-security-release-tools/.github/workflows/deploy-schema.yml@3f6cc7ffc137ca160061749d5f34dc30d5f36986 # v1.0.17
with:
should-deploy-schema: true
secrets: inherit
publish-release-announcements:
name: Publish Release Announcements
needs: [ deploy-schema ]
if: ${{ !github.event.repository.fork }}
uses: spring-io/spring-security-release-tools/.github/workflows/release-announcements-publish.yml@d6c65d3013c0888e2c9cbae9f4beda610994776c # v1.0.16
with:
version: ${{ inputs.version }}
secrets: inherit
@@ -0,0 +1,24 @@
name: Release Announcements - Stage
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
inputs:
version:
description: The version to stage
required: true
type: string
permissions:
contents: read
jobs:
stage-release-announcements:
name: Stage Release Announcements
if: ${{ !github.event.repository.fork }}
uses: spring-io/spring-security-release-tools/.github/workflows/release-announcements-stage.yml@3f6cc7ffc137ca160061749d5f34dc30d5f36986 # v1.0.17
with:
version: ${{ inputs.version || github.ref_name }}
secrets: inherit
@@ -17,6 +17,7 @@
package org.springframework.security.config.annotation.authentication.configurers.ldap;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
@@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer<B extends ProviderManagerBuild
}
private int getDefaultPort() {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
return serverSocket.getLocalPort();
}
catch (IOException ex) {
@@ -706,8 +706,10 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
}
private void configure(H http) {
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
getTokenAuthenticationManager(http));
http.authenticationProvider(postProcess(authenticationProvider));
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
getAuthenticationConverter());
authenticationFilter.setRequestMatcher(getRequestMatcher());
@@ -17,6 +17,7 @@
package org.springframework.security.config.ldap;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import org.w3c.dom.Element;
@@ -188,7 +189,7 @@ public class LdapServerBeanDefinitionParser implements BeanDefinitionParser {
}
private String getDefaultPort() {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
return String.valueOf(serverSocket.getLocalPort());
}
catch (IOException ex) {
@@ -25,12 +25,12 @@ public class MockSecurityContextHolderStrategy implements SecurityContextHolderS
private SecurityContext context;
@Override
public void clearContext() {
public synchronized void clearContext() {
this.context = null;
}
@Override
public SecurityContext getContext() {
public synchronized SecurityContext getContext() {
if (this.context == null) {
this.context = createEmptyContext();
}
@@ -38,7 +38,7 @@ public class MockSecurityContextHolderStrategy implements SecurityContextHolderS
}
@Override
public void setContext(SecurityContext context) {
public synchronized void setContext(SecurityContext context) {
this.context = context;
}
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
@@ -30,6 +31,7 @@ import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
@@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.test.SpringTestContext;
import org.springframework.security.config.test.SpringTestContextExtension;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.jose.TestJwks;
import org.springframework.security.oauth2.jose.TestKeys;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.DPoPProofContext;
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -247,8 +255,8 @@ public class DPoPAuthenticationTests {
)
.oauth2ResourceServer((oauth2) -> oauth2
.jwt(Customizer.withDefaults())
.dPoP(Customizer.withDefaults()));
.dPoP(Customizer.withDefaults())
.withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer()));
// @formatter:on
return http.build();
}
@@ -258,6 +266,25 @@ public class DPoPAuthenticationTests {
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
}
private ObjectPostProcessor<DPoPAuthenticationProvider> dPoPProofVerifierFactoryCustomizer() {
return new ObjectPostProcessor<>() {
@Override
public <O extends DPoPAuthenticationProvider> O postProcess(O authenticationProvider) {
DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache();
inMemoryCache.setMaxSize(50_000);
inMemoryCache.setMaxRequestsPerKey(500);
DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache);
dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60));
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DPoPProofJwtDecoderFactory
.createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator));
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory);
return authenticationProvider;
}
};
}
}
@RestController
@@ -32,6 +32,7 @@ import org.springframework.util.Assert;
* {@link java.util.ServiceLoader} mechanism when context-propagation is on the classpath.
*
* @author Steve Riesenberg
* @author Tadaya Tsuyukubo
* @since 6.5
* @see io.micrometer.context.ContextRegistry
*/
@@ -53,7 +54,9 @@ public final class SecurityContextHolderThreadLocalAccessor implements ThreadLoc
@Override
public void setValue(SecurityContext securityContext) {
Assert.notNull(securityContext, "securityContext cannot be null");
SecurityContextHolder.setContext(securityContext);
SecurityContext newContext = SecurityContextHolder.createEmptyContext();
newContext.setAuthentication(securityContext.getAuthentication());
SecurityContextHolder.setContext(newContext);
}
@Override
@@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi
// Verification
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret);
Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure");
Assert.isTrue(Utf8.isEqual(expectedSha512Hex, sha1Hex), "Key verification failure");
return new DefaultToken(key, creationTime, extendedInfo.toString());
}
@@ -259,7 +259,9 @@ public final class InetAddressMatchers {
if (address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress()) {
return true;
}
if (address.isAnyLocalAddress()) {
return true;
}
byte[] rawAddress = address.getAddress();
if (rawAddress.length == 16) {
@@ -335,6 +337,9 @@ public final class InetAddressMatchers {
@Override
public boolean matches(@Nullable InetAddress address) {
if (address == null) {
return false;
}
return !this.internalMatcher.matches(address);
}
@@ -16,11 +16,18 @@
package org.springframework.security.core.context;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -29,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link SecurityContextHolderThreadLocalAccessor}.
*
* @author Steve Riesenberg
* @author Tadaya Tsuyukubo
*/
public class SecurityContextHolderThreadLocalAccessorTests {
@@ -65,9 +73,11 @@ public class SecurityContextHolderThreadLocalAccessorTests {
@Test
public void setValueWhenSecurityContextThenSetsSecurityContextHolder() {
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
Authentication authentication = new TestingAuthenticationToken("user", "password");
securityContext.setAuthentication(authentication);
this.threadLocalAccessor.setValue(securityContext);
assertThat(SecurityContextHolder.getContext()).isSameAs(securityContext);
assertThat(SecurityContextHolder.getContext()).isNotSameAs(securityContext);
assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(authentication);
}
@Test
@@ -90,4 +100,41 @@ public class SecurityContextHolderThreadLocalAccessorTests {
assertThat(SecurityContextHolder.getContext()).isEqualTo(emptyContext);
}
@Test
public void newSecurityContextInDifferentThread() throws Exception {
Authentication authA = new TestingAuthenticationToken("foo", "password");
Authentication authB = new TestingAuthenticationToken("bar", "password");
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authA);
SecurityContextHolder.setContext(securityContext);
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<SecurityContext> contextHolder = new AtomicReference<>();
AtomicReference<Authentication> authHolder = new AtomicReference<>();
Runnable runnable = () -> {
SecurityContext context = SecurityContextHolder.getContext();
contextHolder.set(context);
authHolder.set(context.getAuthentication());
context.setAuthentication(authB);
latch.countDown();
};
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
executor.afterPropertiesSet();
executor.execute(runnable);
boolean finished = latch.await(10, TimeUnit.SECONDS);
assertThat(finished).isTrue();
assertThat(contextHolder.get()).isNotSameAs(securityContext);
assertThat(authHolder.get()).isSameAs(authA);
SecurityContext current = SecurityContextHolder.getContext();
assertThat(current).isSameAs(securityContext);
assertThat(current.getAuthentication()).isSameAs(authA);
}
}
@@ -276,16 +276,17 @@ class InetAddressMatchersTests {
}
@ParameterizedTest
@ValueSource(strings = { "127.0.0.1", "127.0.0.255" })
@ValueSource(strings = { "127.0.0.1", "127.0.0.255", "0.0.0.0" })
void matchesWhenIpv4LoopbackThenReturnsTrue(String address) throws Exception {
InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
}
@Test
void matchesWhenIpv6LoopbackThenReturnsTrue() throws Exception {
@ParameterizedTest
@ValueSource(strings = { "::1", "::" })
void matchesWhenIpv6LoopbackThenReturnsTrue(String address) throws Exception {
InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
assertThat(matcher.matches(InetAddress.getByName("::1"))).isTrue();
assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
}
@ParameterizedTest
@@ -415,6 +416,12 @@ class InetAddressMatchersTests {
@Nested
class ExternalInetAddressMatcherTests {
@Test
void matchesWhenInetAddressNullThenReturnsFalse() {
InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
assertThat(matcher.matches((InetAddress) null)).isFalse();
}
@ParameterizedTest
@ValueSource(strings = { "8.8.8.8", "1.1.1.1" })
void matchesWhenIpv4PublicThenReturnsTrue(String address) throws Exception {
@@ -21,6 +21,9 @@ import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import org.jspecify.annotations.Nullable;
/**
* UTF-8 Charset encoder/decoder.
@@ -66,4 +69,21 @@ public final class Utf8 {
}
}
/**
* Constant time comparison to prevent against timing attacks.
* @param expected the expected {@link CharSequence}
* @param actual the actual {@link CharSequence}
* @return true if {@code expected} and {@code actual} are equal, false otherwise
* @since 5.7.26
*/
public static boolean isEqual(@Nullable CharSequence expected, @Nullable CharSequence actual) {
byte[] expectedBytes = bytesUtf8(expected);
byte[] actualBytes = bytesUtf8(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}
private static byte @Nullable [] bytesUtf8(@Nullable CharSequence s) {
return (s != null) ? Utf8.encode(s) : null;
}
}
@@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils;
*
* @author Keith Donald
* @author Dave Syer
* @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead.
*/
@Deprecated
public final class AesBytesEncryptor implements BytesEncryptor {
private final SecretKey secretKey;
@@ -0,0 +1,165 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.crypto.encrypt;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.util.EncodingUtils;
/**
* {@link BytesEncryptor} that uses 256-bit AES/CBC/PKCS5Padding with a random 16-byte
* initialization vector. The IV is prepended to the ciphertext on encrypt and stripped on
* decrypt.
*
* <p>
* Note that CBC mode provides confidentiality but <em>not</em> integrity or authenticity.
* Applications that require authenticated encryption should prefer
* {@link AesGcmBytesEncryptor}. See the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
* OWASP Cryptographic Storage Cheat Sheet</a> for guidance on choosing a cipher mode.
*
* <p>
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
* per the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
* the encryptor instance should be created once and reused rather than constructed
* per-operation.
*
* @author Josh Cummings
* @since 5.7.26
* @see AesGcmBytesEncryptor
* @see AesBytesEncryptor
*/
public final class AesCbcBytesEncryptor implements BytesEncryptor {
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
private static final int IV_LENGTH_BYTES = 16;
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
private final SecretKey secretKey;
private final Cipher encryptor;
private final Cipher decryptor;
private final BytesKeyGenerator ivGenerator;
private AesCbcBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
this.encryptor = CipherUtils.newCipher(ALGORITHM);
this.decryptor = CipherUtils.newCipher(ALGORITHM);
this.ivGenerator = ivGenerator;
}
/**
* Creates an encryptor that derives its key from the given password and hex-encoded
* salt using PBKDF2WithHmacSHA1.
* @param password the password value
* @param salt the hex-encoded salt value
*/
public static Builder withPassword(String password, CharSequence salt) {
return new Builder(password, salt);
}
/**
* Creates an encryptor using the supplied {@link SecretKey}.
* @param secretKey the secret (symmetric) key
*/
public static Builder withSecretKey(SecretKey secretKey) {
return new Builder(secretKey);
}
@Override
public byte[] encrypt(byte[] bytes) {
synchronized (this.encryptor) {
byte[] iv = this.ivGenerator.generateKey();
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
return EncodingUtils.concatenate(iv, ciphertext);
}
}
@Override
public byte[] decrypt(byte[] encryptedBytes) {
int ivLength = this.ivGenerator.getKeyLength();
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
synchronized (this.decryptor) {
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
return CipherUtils.doFinal(this.decryptor, ciphertext);
}
}
private static SecretKey deriveKey(String password, CharSequence salt) {
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
}
/**
* A Builder for {@link AesCbcBytesEncryptor}.
*/
public static final class Builder {
private final SecretKey secretKey;
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
private Builder(SecretKey secretKey) {
this.secretKey = secretKey;
}
private Builder(String password, CharSequence salt) {
this.secretKey = deriveKey(password, salt);
}
/**
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
* vector.
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
* initialization vector
* @return this builder
*/
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
this.ivGenerator = ivGenerator;
return this;
}
/**
* Builds the {@link AesCbcBytesEncryptor}.
* @return the {@link AesCbcBytesEncryptor}
*/
public AesCbcBytesEncryptor build() {
return new AesCbcBytesEncryptor(this.secretKey, this.ivGenerator);
}
}
}
@@ -0,0 +1,172 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.crypto.encrypt;
import java.util.Objects;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.util.EncodingUtils;
/**
* {@link BytesEncryptor} that uses 256-bit AES/GCM/NoPadding with a random 16-byte
* initialization vector and a 128-bit authentication tag. The IV is prepended to the
* ciphertext on encrypt and stripped on decrypt. GCM provides authenticated encryption
* (AEAD): both confidentiality and integrity are protected, and decryption throws if the
* ciphertext has been tampered with.
*
* <p>
* This class uses a 16-byte (128-bit) IV rather than the 12-byte (96-bit) IV recommended
* by NIST SP 800-38D for GCM. Both lengths are cryptographically valid; the 16-byte
* choice maintains consistency with the rest of the Spring Security crypto module. For
* additional guidance, please see the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
* OWASP Cryptographic Storage Cheat Sheet</a>.
*
* <p>
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
* per the <a href=
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
* the encryptor instance should be created once and reused rather than constructed
* per-operation.
*
* @author Josh Cummings
* @since 5.7.26
* @see AesCbcBytesEncryptor
* @see AesBytesEncryptor
*/
public final class AesGcmBytesEncryptor implements BytesEncryptor {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int IV_LENGTH_BYTES = 16;
private static final int TAG_LENGTH_BITS = 128;
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
private final SecretKey secretKey;
private final Cipher encryptor;
private final Cipher decryptor;
private final BytesKeyGenerator ivGenerator;
private AesGcmBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
this.encryptor = CipherUtils.newCipher(ALGORITHM);
this.decryptor = CipherUtils.newCipher(ALGORITHM);
this.ivGenerator = ivGenerator;
}
/**
* Creates an encryptor that derives its key from the given password and hex-encoded
* salt using PBKDF2WithHmacSHA1.
* @param password the password value
* @param salt the hex-encoded salt value
*/
public static Builder withPassword(String password, CharSequence salt) {
return new Builder(password, salt);
}
/**
* Creates an encryptor using the supplied {@link SecretKey}.
* @param secretKey the secret (symmetric) key
*/
public static Builder withSecretKey(SecretKey secretKey) {
return new Builder(secretKey);
}
@Override
public byte[] encrypt(byte[] bytes) {
synchronized (this.encryptor) {
byte[] iv = this.ivGenerator.generateKey();
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey,
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
return EncodingUtils.concatenate(iv, ciphertext);
}
}
@Override
public byte[] decrypt(byte[] encryptedBytes) {
int ivLength = this.ivGenerator.getKeyLength();
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
synchronized (this.decryptor) {
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey,
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
return CipherUtils.doFinal(this.decryptor, ciphertext);
}
}
private static SecretKey deriveKey(String password, CharSequence salt) {
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
}
/**
* A Builder for {@link AesGcmBytesEncryptor}.
*/
public static final class Builder {
private final SecretKey secretKey;
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
private Builder(SecretKey secretKey) {
this.secretKey = secretKey;
}
private Builder(String password, CharSequence salt) {
this.secretKey = deriveKey(password, salt);
}
/**
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
* vector.
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
* initialization vector
* @return this builder
*/
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
this.ivGenerator = ivGenerator;
return this;
}
/**
* Builds the {@link AesGcmBytesEncryptor}.
* @return the {@link AesGcmBytesEncryptor}
*/
public AesGcmBytesEncryptor build() {
return new AesGcmBytesEncryptor(this.secretKey, this.ivGenerator);
}
}
}
@@ -16,7 +16,6 @@
package org.springframework.security.crypto.encrypt;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
import org.springframework.security.crypto.keygen.KeyGenerators;
/**
@@ -42,9 +41,14 @@ public final class Encryptors {
* not be shared
* @param salt a hex-encoded, random, site-global salt value to use to generate the
* key
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
* instead.
*/
@Deprecated
@SuppressWarnings("deprecation")
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) {
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM);
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.GCM);
}
/**
@@ -53,18 +57,16 @@ public final class Encryptors {
* Function #2). Salts the password to prevent dictionary attacks against the key. The
* provided salt is expected to be hex-encoded; it should be random and at least 8
* bytes in length. Also applies a random 16-byte initialization vector to ensure each
* encrypted message will be unique. Requires Java 6. NOTE: This mode is not
* <a href="https://en.wikipedia.org/wiki/Authenticated_encryption">authenticated</a>
* and does not provide any guarantees about the authenticity of the data. For a more
* secure alternative, users should prefer
* {@link #stronger(CharSequence, CharSequence)}.
* encrypted message will be unique. Requires Java 6.
* @param password the password used to generate the encryptor's secret key; should
* not be shared
* @param salt a hex-encoded, random, site-global salt value to use to generate the
* key
*
* @see Encryptors#stronger(CharSequence, CharSequence)
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
* instead.
*/
@Deprecated
@SuppressWarnings("deprecation")
public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
}
@@ -74,8 +76,12 @@ public final class Encryptors {
* text is hex-encoded.
* @param password the password used to generate the encryptor's secret key; should
* not be shared
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
* instead.
* @see Encryptors#stronger(CharSequence, CharSequence)
*/
@Deprecated
@SuppressWarnings("deprecation")
public static TextEncryptor delux(CharSequence password, CharSequence salt) {
return new HexEncodingTextEncryptor(stronger(password, salt));
}
@@ -85,8 +91,12 @@ public final class Encryptors {
* text is hex-encoded.
* @param password the password used to generate the encryptor's secret key; should
* not be shared
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
* instead.
* @see Encryptors#standard(CharSequence, CharSequence)
*/
@Deprecated
@SuppressWarnings("deprecation")
public static TextEncryptor text(CharSequence password, CharSequence salt) {
return new HexEncodingTextEncryptor(standard(password, salt));
}
@@ -16,8 +16,6 @@
package org.springframework.security.crypto.password;
import java.security.MessageDigest;
import org.jspecify.annotations.Nullable;
import org.springframework.security.crypto.codec.Utf8;
@@ -39,15 +37,7 @@ final class PasswordEncoderUtils {
* @return
*/
static boolean equals(String expected, @Nullable String actual) {
byte[] expectedBytes = bytesUtf8(expected);
byte[] actualBytes = bytesUtf8(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}
private static byte @Nullable [] bytesUtf8(@Nullable String s) {
// need to check if Utf8.encode() runs in constant time (probably not).
// This may leak length of string.
return (s != null) ? Utf8.encode(s) : null;
return Utf8.isEqual(expected, actual);
}
}
@@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction;
import com.password4j.Hash;
import com.password4j.Password;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert;
@@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP
String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
return expectedHash.equals(hash.getResult());
return Utf8.isEqual(expectedHash, hash.getResult());
}
catch (IllegalArgumentException ex) {
// Invalid Base64 encoding
@@ -24,6 +24,7 @@ import com.password4j.Hash;
import com.password4j.PBKDF2Function;
import com.password4j.Password;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert;
@@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE
String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
return expectedHash.equals(hash.getResult());
return Utf8.isEqual(expectedHash, hash.getResult());
}
catch (IllegalArgumentException ex) {
// Invalid Base64 encoding
@@ -37,4 +37,48 @@ public class Utf8Tests {
assertThat(decoded).isEqualTo("6048b75ed560785c");
}
@Test
public void isEqualWhenDifferentLengthThenFalse() {
assertThat(Utf8.isEqual("abc", "a")).isFalse();
assertThat(Utf8.isEqual("a", "abc")).isFalse();
}
@Test
public void isEqualWhenNullAndNotEmptyThenFalse() {
assertThat(Utf8.isEqual(null, "a")).isFalse();
assertThat(Utf8.isEqual("a", null)).isFalse();
}
@Test
public void isEqualWhenNullAndNullThenTrue() {
assertThat(Utf8.isEqual(null, null)).isTrue();
}
@Test
public void isEqualWhenNullAndEmptyThenFalse() {
assertThat(Utf8.isEqual(null, "")).isFalse();
assertThat(Utf8.isEqual("", null)).isFalse();
}
@Test
public void isEqualWhenNotEmptyAndEmptyThenFalse() {
assertThat(Utf8.isEqual("abc", "")).isFalse();
assertThat(Utf8.isEqual("", "abc")).isFalse();
}
@Test
public void isEqualWhenEmptyAndEmptyThenTrue() {
assertThat(Utf8.isEqual("", "")).isTrue();
}
@Test
public void isEqualWhenDifferentCaseThenFalse() {
assertThat(Utf8.isEqual("aBc", "abc")).isFalse();
}
@Test
public void isEqualWhenSameThenTrue() {
assertThat(Utf8.isEqual("abcdef", "abcdef")).isTrue();
}
}
@@ -0,0 +1,203 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.crypto.encrypt;
import java.nio.charset.StandardCharsets;
import javax.crypto.SecretKey;
import javax.crypto.spec.PBEKeySpec;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
import org.springframework.security.crypto.util.EncodingUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link AesCbcBytesEncryptor}.
*/
class AesCbcBytesEncryptorTests {
private final String secret = "value";
private final String password = "password";
private final String hexSalt = "deadbeef";
@Test
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeCBCJCE();
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeCBCJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(secretKey).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
CryptoAssumptions.assumeCBCJCE();
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
given(mockGenerator.getKeyLength()).willReturn(16);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(mockGenerator)
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(Hex.encode(encrypted)).isEqualTo("4b0febebd439db7ca77153cb254520c3b7232ac29355d07869433f1ecf55fe94");
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptProducesUniqueOutputAndIvIsPrePended() {
CryptoAssumptions.assumeCBCJCE();
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] first = encryptor.encrypt(this.secret.getBytes());
byte[] second = encryptor.encrypt(this.secret.getBytes());
assertThat(first).isNotEqualTo(second);
assertThat(first.length).isGreaterThan(16);
}
@Test
@SuppressWarnings("deprecation")
void migratesFromDeprecatedNullIvCbcToAesCbcBytesEncryptor() {
CryptoAssumptions.assumeCBCJCE();
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt);
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
AesCbcBytesEncryptor modern = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
BytesEncryptor migrating = new MigratingBytesEncryptor("{CBC}", modern, deprecated);
assertThat(new String(migrating.decrypt(encrypted))).isEqualTo(this.secret);
byte[] migrated = migrating.encrypt(this.secret.getBytes());
assertThat(migrated[0]).isEqualTo((byte) '{');
assertThat(migrated[1]).isEqualTo((byte) 'C');
assertThat(migrated[2]).isEqualTo((byte) 'B');
assertThat(migrated[3]).isEqualTo((byte) 'C');
assertThat(migrated[4]).isEqualTo((byte) '}');
assertThat(new String(migrating.decrypt(migrated))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
CryptoAssumptions.assumeCBCJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.CBC);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void aesBytesEncryptorWhenEncryptsThenAesCbcBytesEncryptorDecrypts() {
CryptoAssumptions.assumeCBCJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.CBC);
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingCustomIvGeneratorThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeCBCJCE();
BytesKeyGenerator customIvGenerator = mock(BytesKeyGenerator.class);
given(customIvGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
given(customIvGenerator.getKeyLength()).willReturn(16);
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(customIvGenerator)
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
CryptoAssumptions.assumeCBCJCE();
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
KeyGenerators.secureRandom(16));
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
}
private static final class MigratingBytesEncryptor implements BytesEncryptor {
private final byte[] prefix;
private final BytesEncryptor write;
private final BytesEncryptor deprecated;
MigratingBytesEncryptor(String prefix, BytesEncryptor write, BytesEncryptor deprecated) {
this.prefix = prefix.getBytes(StandardCharsets.US_ASCII);
this.write = write;
this.deprecated = deprecated;
}
@Override
public byte[] encrypt(byte[] bytes) {
return EncodingUtils.concatenate(this.prefix, this.write.encrypt(bytes));
}
@Override
public byte[] decrypt(byte[] encryptedBytes) {
if (startsWith(encryptedBytes, this.prefix)) {
byte[] bytes = EncodingUtils.subArray(encryptedBytes, this.prefix.length, encryptedBytes.length);
return this.write.decrypt(bytes);
}
return this.deprecated.decrypt(encryptedBytes);
}
private static boolean startsWith(byte[] data, byte[] prefix) {
if (data.length < prefix.length) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if (data[i] != prefix[i]) {
return false;
}
}
return true;
}
}
}
@@ -0,0 +1,146 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.crypto.encrypt;
import javax.crypto.SecretKey;
import javax.crypto.spec.PBEKeySpec;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
import org.springframework.security.crypto.keygen.KeyGenerators;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link AesGcmBytesEncryptor}.
*/
class AesGcmBytesEncryptorTests {
private final String secret = "value";
private final String password = "password";
private final String hexSalt = "deadbeef";
@Test
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeGCMJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(secretKey).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
CryptoAssumptions.assumeGCMJCE();
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
given(mockGenerator.getKeyLength()).willReturn(16);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(mockGenerator)
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(Hex.encode(encrypted))
.isEqualTo("4b0febebd439db7ca77153cb254520c3e4d61ae38207b4e42b820d311dc3d4e0e2f37ed5ee");
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void encryptProducesUniqueOutputAndIvIsPrepended() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] first = encryptor.encrypt(this.secret.getBytes());
byte[] second = encryptor.encrypt(this.secret.getBytes());
assertThat(first).isNotEqualTo(second);
assertThat(first.length).isGreaterThan(32);
}
@Test
@SuppressWarnings("deprecation")
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
CryptoAssumptions.assumeGCMJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
AesBytesEncryptor.CipherAlgorithm.GCM);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void aesBytesEncryptorWhenEncryptsThenAesGcmBytesEncryptorDecrypts() {
CryptoAssumptions.assumeGCMJCE();
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key)
.ivGenerator(KeyGenerators.secureRandom(12))
.build();
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(12),
AesBytesEncryptor.CipherAlgorithm.GCM);
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
void roundtripWhenUsingCustomIvGeneratorLengthThenEncryptsAndDecrypts() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
.ivGenerator(KeyGenerators.secureRandom(12))
.build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
}
@Test
@SuppressWarnings("deprecation")
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
CryptoAssumptions.assumeGCMJCE();
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
KeyGenerators.secureRandom(16), AesBytesEncryptor.CipherAlgorithm.GCM);
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
}
@Test
void decryptDetectsAuthenticationTagTampering() {
CryptoAssumptions.assumeGCMJCE();
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
encrypted[17] ^= 0xFF;
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
}
}
@@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException;
import org.junit.jupiter.api.Assumptions;
import org.opentest4j.TestAbortedException;
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
public final class CryptoAssumptions {
private CryptoAssumptions() {
}
public static void assumeGCMJCE() {
assumeAes256(CipherAlgorithm.GCM);
assumeAes256("GCM");
}
public static void assumeCBCJCE() {
assumeAes256(CipherAlgorithm.CBC);
assumeAes256("CBC");
}
private static void assumeAes256(CipherAlgorithm cipherAlgorithm) {
private static void assumeAes256(String cipherAlgorithm) {
boolean aes256Available = false;
try {
Cipher.getInstance(cipherAlgorithm.toString());
Cipher.getInstance(cipherAlgorithm);
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
}
catch (NoSuchAlgorithmException ex) {
@@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an
[[spring-security-crypto-encryption]]
== Encryptors
The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors.
This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form.
Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors.
These can be used to encrypt data in raw `byte[]` form.
You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
Encryptors are thread-safe.
@@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m
[[spring-security-crypto-encryption-bytes]]
=== BytesEncryptor
You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`:
Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption:
.BytesEncryptor
[tabs]
@@ -29,24 +29,22 @@ Java::
+
[source,java,role="primary"]
----
Encryptors.stronger("password", "salt");
AesGcmBytesEncryptor.withPassword("password", "salt").build();
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
Encryptors.stronger("password", "salt")
AesGcmBytesEncryptor.withPassword("password", "salt").build()
----
======
The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with
Galois Counter Mode (GCM).
It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
This method requires Java 6.
The password used to generate the `SecretKey` should be kept in a secure place and should not be shared.
The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised.
A 16-byte random initialization vector is also applied so that each encrypted message is unique.
`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD).
It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
The password used to generate the SecretKey should be kept in a secure place and not be shared.
The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised.
A 16-byte random initialization vector is also applied so each encrypted message is unique.
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length.
You can generate such a salt by using a `KeyGenerator`:
@@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal
----
======
You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode.
Users who require CBC mode may use `AesCbcBytesEncryptor`:
.AesCbcBytesEncryptor
[tabs]
======
Java::
+
[source,java,role="primary"]
----
AesCbcBytesEncryptor.withPassword("password", "salt").build();
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
AesCbcBytesEncryptor.withPassword("password", "salt").build()
----
======
This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
guarantees about the authenticity of the data.
For a more secure alternative, use `Encryptors.stronger`.
For a more secure alternative, users should prefer `AesGcmBytesEncryptor`.
[[spring-security-crypto-encryption-text]]
=== TextEncryptor
You can use the `Encryptors.text` factory method to construct a standard TextEncryptor:
Use `AesCbcBytesEncryptor` to encrypt text data:
.TextEncryptor
[tabs]
@@ -85,19 +102,22 @@ Java::
+
[source,java,role="primary"]
----
Encryptors.text("password", "salt");
AesCbcBytesEncryptor.withPassword("password", "salt").build();
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
Encryptors.text("password", "salt")
AesCbcBytesEncryptor.withPassword("password", "salt").build()
----
======
A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data.
Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database.
`AesCbcBytesEncryptor` encrypts data as raw bytes.
You can hex-encode the result for easy storage on the filesystem or in the database.
NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security.
Instead, look to your data store for a mechanism to query encrypted data.
[[spring-security-crypto-keygenerators]]
== Key Generators
+9 -9
View File
@@ -1,6 +1,6 @@
[versions]
com-squareup-okhttp3 = "3.14.9"
io-micrometer = "1.17.0"
io-micrometer = "1.18.0-M1"
io-rsocket = "1.1.5"
io-spring-javaformat = "0.0.47"
io-spring-nohttp = "0.0.11"
@@ -14,12 +14,12 @@ org-jetbrains-kotlin = "2.4.10"
org-jetbrains-kotlinx = "1.11.0"
org-mockito = "5.23.0"
org-opensaml5 = "5.2.3"
org-springframework = "7.0.8"
org-springframework = "7.1.0-M1"
com-password4j = "1.8.4"
[libraries]
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.6.1"
com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.22.1"
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.6.3"
com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.22.2"
com-google-inject-guice = "com.google.inject:guice:3.0"
com-netflix-nebula-nebula-project-plugin = "com.netflix.nebula:nebula-project-plugin:8.2.0"
com-nimbusds-nimbus-jose-jwt = "com.nimbusds:nimbus-jose-jwt:10.9.1"
@@ -33,13 +33,13 @@ io-micrometer-context-propagation = "io.micrometer:context-propagation:1.2.1"
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" }
io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
io-mockk = "io.mockk:mockk:1.14.11"
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.6"
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2026.0.0-M1"
io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" }
io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" }
io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
io-spring-nohttp-nohttp-checkstyle = { module = "io.spring.nohttp:nohttp-checkstyle", version.ref = "io-spring-nohttp" }
io-spring-nohttp-nohttp-gradle = { module = "io.spring.nohttp:nohttp-gradle", version.ref = "io-spring-nohttp" }
io-spring-security-release-plugin = "io.spring.gradle:spring-security-release-plugin:1.0.15"
io-spring-security-release-plugin = "io.spring.gradle:spring-security-release-plugin:1.0.17"
jakarta-annotation-jakarta-annotation-api = "jakarta.annotation:jakarta.annotation-api:3.0.0"
jakarta-inject-jakarta-inject-api = "jakarta.inject:jakarta.inject-api:2.0.1"
jakarta-persistence-jakarta-persistence-api = "jakarta.persistence:jakarta.persistence-api:3.2.0"
@@ -85,11 +85,11 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2026.0.0"
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.1.0"
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2026.1.0-M1"
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.1.1"
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.2.1"
tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.2.2"
com-google-code-gson-gson = "com.google.code.gson:gson:2.14.0"
com-thaiopensource-trag = "com.thaiopensource:trang:20091111"
@@ -21,9 +21,11 @@ import java.net.ServerSocket;
import java.util.ArrayList;
import java.util.List;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import org.junit.jupiter.api.Test;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -48,6 +50,24 @@ public class UnboundIdContainerTests {
}
}
@Test
public void startLdapServerThenListenerBindsToLoopbackAddressOnly() throws Exception {
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
server.setApplicationContext(new GenericApplicationContext());
List<Integer> ports = getDefaultPorts(1);
server.setPort(ports.get(0));
try {
server.afterPropertiesSet();
InMemoryDirectoryServer directoryServer = (InMemoryDirectoryServer) ReflectionTestUtils.getField(server,
"directoryServer");
assertThat(directoryServer.getListenAddress().isLoopbackAddress()).isTrue();
}
finally {
server.destroy();
}
}
@Test
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
@@ -17,6 +17,7 @@
package org.springframework.security.ldap.server;
import java.io.InputStream;
import java.net.InetAddress;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
@@ -98,7 +99,8 @@ public class UnboundIdContainer
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret");
config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", this.port));
config.setListenerConfigs(
InMemoryListenerConfig.createLDAPConfig("LDAP", InetAddress.getLoopbackAddress(), this.port, null));
config.setEnforceSingleStructuralObjectClass(false);
config.setEnforceAttributeSyntaxCompliance(true);
DN dn = new DN(this.defaultPartitionSuffix);
@@ -25,6 +25,7 @@ import java.util.concurrent.ConcurrentHashMap;
import org.jspecify.annotations.Nullable;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
@@ -191,38 +192,38 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
}
private static boolean matchesState(OAuth2Authorization authorization, String token) {
return token.equals(authorization.getAttribute(OAuth2ParameterNames.STATE));
return Utf8.isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token);
}
private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
.getToken(OAuth2AuthorizationCode.class);
return authorizationCode != null && authorizationCode.getToken().getTokenValue().equals(token);
return authorizationCode != null && Utf8.isEqual(authorizationCode.getToken().getTokenValue(), token);
}
private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class);
return accessToken != null && accessToken.getToken().getTokenValue().equals(token);
return accessToken != null && Utf8.isEqual(accessToken.getToken().getTokenValue(), token);
}
private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class);
return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token);
return refreshToken != null && Utf8.isEqual(refreshToken.getToken().getTokenValue(), token);
}
private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);
return idToken != null && idToken.getToken().getTokenValue().equals(token);
return idToken != null && Utf8.isEqual(idToken.getToken().getTokenValue(), token);
}
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
return deviceCode != null && Utf8.isEqual(deviceCode.getToken().getTokenValue(), token);
}
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
return userCode != null && userCode.getToken().getTokenValue().equals(token);
return userCode != null && Utf8.isEqual(userCode.getToken().getTokenValue(), token);
}
@SuppressWarnings("serial")
@@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.web.util.HtmlUtils;
/**
* For internal use only.
@@ -56,6 +57,12 @@ final class DefaultConsentPage {
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
Set<String> requestedScopes, Set<String> authorizedScopes, String state,
Map<String, String> additionalParameters) {
String encodedClientId = HtmlUtils.htmlEscape(clientId);
String encodedState = HtmlUtils.htmlEscape(state);
String encodedPrincipalName = HtmlUtils.htmlEscape(principal.getName());
String encodedRequestUri = HtmlUtils.htmlEscape(request.getRequestURI());
Set<String> scopesToAuthorize = new HashSet<>();
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
for (String scope : requestedScopes) {
@@ -74,6 +81,7 @@ final class DefaultConsentPage {
// the "user_code" being displayed on the device to confirm they are
// authorizing the correct device.
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null;
// @formatter:off
StringBuilder builder = new StringBuilder();
@@ -98,13 +106,13 @@ final class DefaultConsentPage {
builder.append(" </div>");
builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">");
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + encodedClientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + encodedPrincipalName + "</span></p>");
builder.append(" </div>");
builder.append(" </div>");
if (userCode != null) {
builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">");
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + encodedUserCode + "</span>. Verify that this code matches what is shown on your device.</p>");
builder.append(" </div>");
builder.append(" </div>");
}
@@ -115,26 +123,28 @@ final class DefaultConsentPage {
builder.append(" </div>");
builder.append(" <div class=\"row\">");
builder.append(" <div class=\"col text-center\">");
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + encodedRequestUri + "\">");
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + encodedClientId + "\">");
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + encodedState + "\">");
if (userCode != null) {
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + encodedUserCode + "\">");
}
for (String scope : scopesToAuthorize) {
String encodedScope = HtmlUtils.htmlEscape(scope);
builder.append(" <div class=\"form-group form-check py-1\">");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + encodedScope + "\" id=\"" + encodedScope + "\">");
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
builder.append(" </div>");
}
if (!scopesPreviouslyAuthorized.isEmpty()) {
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
for (String scope : scopesPreviouslyAuthorized) {
String encodedScope = HtmlUtils.htmlEscape(scope);
builder.append(" <div class=\"form-group form-check py-1\">");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + encodedScope + "\" checked disabled>");
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
builder.append(" </div>");
}
}
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
import org.springframework.security.oauth2.core.OAuth2UserCode;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
@@ -60,6 +62,10 @@ public class InMemoryOAuth2AuthorizationServiceTests {
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
private InMemoryOAuth2AuthorizationService authorizationService;
@BeforeEach
@@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests {
assertThat(authorization).isEqualTo(result);
}
@Test
public void findByTokenWhenDeviceCodeExistsThenFound() {
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now(),
Instant.now().plus(5, ChronoUnit.MINUTES));
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
.id(ID)
.principalName(PRINCIPAL_NAME)
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
.token(deviceCode)
.build();
this.authorizationService.save(authorization);
OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(),
DEVICE_CODE_TOKEN_TYPE);
assertThat(authorization).isEqualTo(result);
result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
assertThat(authorization).isEqualTo(result);
}
@Test
public void findByTokenWhenUserCodeExistsThenFound() {
OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now(),
Instant.now().plus(5, ChronoUnit.MINUTES));
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
.id(ID)
.principalName(PRINCIPAL_NAME)
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
.token(userCode)
.build();
this.authorizationService.save(authorization);
OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(),
USER_CODE_TOKEN_TYPE);
assertThat(authorization).isEqualTo(result);
result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
assertThat(authorization).isEqualTo(result);
}
@Test
public void findByTokenWhenWrongTokenTypeThenNotFound() {
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
@@ -64,6 +64,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.util.StringUtils;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
@@ -581,6 +582,39 @@ public class OAuth2AuthorizationEndpointFilterTests {
}
}
@Test
public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponseHtmlEscaped() throws Exception {
String unencodedScope = "<scope2>";
String unencodedState = "<state>";
Set<String> requestedScopes = new HashSet<>(Arrays.asList("scope1", unencodedScope));
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
scopes.clear();
scopes.addAll(requestedScopes);
}).build();
// No scopes previously approved
OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthenticationResult = new OAuth2AuthorizationConsentAuthenticationToken(
AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, unencodedState, new HashSet<>(),
null);
authorizationConsentAuthenticationResult.setAuthenticated(true);
given(this.authenticationManager.authenticate(any())).willReturn(authorizationConsentAuthenticationResult);
MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = mock(FilterChain.class);
this.filter.doFilter(request, response, filterChain);
verify(this.authenticationManager).authenticate(any());
verifyNoInteractions(filterChain);
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
String html = response.getContentAsString();
assertThat(html).doesNotContain(unencodedScope);
assertThat(html).doesNotContain(unencodedState);
assertThat(html).contains(HtmlUtils.htmlEscape(unencodedScope));
assertThat(html).contains("name=\"state\" value=\"" + HtmlUtils.htmlEscape(unencodedState) + "\"");
}
@Test
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
@@ -24,9 +24,12 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;
import reactor.core.publisher.Mono;
import org.springframework.core.log.LogMessage;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
@@ -79,7 +82,7 @@ public final class RefreshOidcUserReactiveOAuth2AuthorizationSuccessHandler
.map((c) -> c.get(ServerWebExchange.class));
// @formatter:on
private ServerSecurityContextRepository serverSecurityContextRepository = new WebSessionServerSecurityContextRepository();
private ServerSecurityContextRepository serverSecurityContextRepository = new NonRotatingWebSessionServerSecurityContextRepository();
private ReactiveJwtDecoderFactory<ClientRegistration> jwtDecoderFactory = new ReactiveOidcIdTokenDecoderFactory();
@@ -141,8 +144,7 @@ public final class RefreshOidcUserReactiveOAuth2AuthorizationSuccessHandler
/**
* Sets a {@link ServerSecurityContextRepository} to use for refreshing a
* {@link SecurityContext}, defaults to
* {@link WebSessionServerSecurityContextRepository}.
* {@link SecurityContext}.
* @param serverSecurityContextRepository the {@link ServerSecurityContextRepository}
* to use
*/
@@ -316,4 +318,28 @@ public final class RefreshOidcUserReactiveOAuth2AuthorizationSuccessHandler
return this.serverSecurityContextRepository.save(exchange, securityContext);
}
private static final class NonRotatingWebSessionServerSecurityContextRepository
implements ServerSecurityContextRepository {
private static final Log logger = LogFactory.getLog(NonRotatingWebSessionServerSecurityContextRepository.class);
@Override
public Mono<SecurityContext> load(ServerWebExchange exchange) {
return Mono.empty();
}
@Override
public Mono<Void> save(ServerWebExchange exchange, @Nullable SecurityContext context) {
Assert.notNull(context, "context cannot be null");
// Save SecurityContext in WebSession without rotating session id.
return exchange.getSession().doOnNext((session) -> {
session.getAttributes()
.put(WebSessionServerSecurityContextRepository.DEFAULT_SPRING_SECURITY_CONTEXT_ATTR_NAME, context);
logger.debug(LogMessage.format("Saved SecurityContext '%s' in WebSession: '%s'", context, session));
}).then();
}
}
}
@@ -511,9 +511,6 @@ public final class ServletOAuth2AuthorizedClientExchangeFilterFunction implement
}
HttpServletRequest servletRequest = getRequest(attrs);
HttpServletResponse servletResponse = getResponse(attrs);
if (servletRequest == null || servletResponse == null) {
return Mono.empty();
}
OAuth2AuthorizeRequest.Builder builder = OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
.principal(authentication);
builder.attributes((attributes) -> addToAttributes(attributes, servletRequest, servletResponse));
@@ -538,9 +535,6 @@ public final class ServletOAuth2AuthorizedClientExchangeFilterFunction implement
}
HttpServletRequest servletRequest = getRequest(attrs);
HttpServletResponse servletResponse = getResponse(attrs);
if (servletRequest == null || servletResponse == null) {
return Mono.just(authorizedClient);
}
OAuth2AuthorizeRequest.Builder builder = OAuth2AuthorizeRequest.withAuthorizedClient(authorizedClient)
.principal(authentication);
builder.attributes((attributes) -> addToAttributes(attributes, servletRequest, servletResponse));
@@ -552,8 +546,8 @@ public final class ServletOAuth2AuthorizedClientExchangeFilterFunction implement
.subscribeOn(Schedulers.boundedElastic());
}
private void addToAttributes(Map<String, Object> attributes, HttpServletRequest servletRequest,
HttpServletResponse servletResponse) {
private void addToAttributes(Map<String, Object> attributes, @Nullable HttpServletRequest servletRequest,
@Nullable HttpServletResponse servletResponse) {
if (servletRequest != null) {
attributes.put(HTTP_SERVLET_REQUEST_ATTR_NAME, servletRequest);
}
@@ -50,7 +50,9 @@ import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory;
import org.springframework.security.web.server.context.WebSessionServerSecurityContextRepository;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
@@ -140,6 +142,51 @@ class RefreshOidcUserReactiveOAuth2AuthorizationSuccessHandlerTests {
.verifyComplete();
}
// gh-19424
@Test
void onAuthorizationSuccessWhenDefaultServerSecurityContextRepositoryThenWebSessionIdNotChanged() {
ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
Instant authTime = Instant.now();
DefaultOidcUser principal = createOidcUser(authTime);
OAuth2AuthenticationToken authenticationToken = new OAuth2AuthenticationToken(principal,
principal.getAuthorities(), clientRegistration.getRegistrationId());
OAuth2AccessToken accessToken = createAccessToken();
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(clientRegistration, principal.getName(),
accessToken, null);
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/").build());
Map<String, Object> attributes = Map.of(ServerWebExchange.class.getName(), exchange,
OidcParameterNames.ID_TOKEN, "id-token-1234");
Map<String, Object> claims = new HashMap<>();
claims.put("iss", principal.getIssuer());
claims.put("sub", principal.getSubject());
claims.put("aud", principal.getAudience());
claims.put("auth_time", authTime);
claims.put("nonce", principal.getNonce());
Jwt jwt = mock(Jwt.class);
given(jwt.getTokenValue()).willReturn("id-token-1234");
given(jwt.getIssuedAt()).willReturn(principal.getIssuedAt());
given(jwt.getClaims()).willReturn(claims);
ReactiveJwtDecoder jwtDecoder = mock(ReactiveJwtDecoder.class);
given(jwtDecoder.decode(any())).willReturn(Mono.just(jwt));
ReactiveJwtDecoderFactory<ClientRegistration> reactiveJwtDecoderFactory = mock(ReactiveJwtDecoderFactory.class);
given(reactiveJwtDecoderFactory.createDecoder(any())).willReturn(jwtDecoder);
ReactiveOAuth2UserService<OidcUserRequest, OidcUser> userService = mock(ReactiveOAuth2UserService.class);
given(userService.loadUser(any())).willReturn(Mono.just(principal));
RefreshOidcUserReactiveOAuth2AuthorizationSuccessHandler handler = new RefreshOidcUserReactiveOAuth2AuthorizationSuccessHandler();
handler.setJwtDecoderFactory(reactiveJwtDecoderFactory);
handler.setUserService(userService);
String originalSessionId = exchange.getSession().map(WebSession::getId).block();
StepVerifier.create(handler.onAuthorizationSuccess(authorizedClient, authenticationToken, attributes))
.verifyComplete();
StepVerifier.create(exchange.getSession())
.assertNext((session) -> assertThat(session.getId()).isEqualTo(originalSessionId))
.verifyComplete();
WebSessionServerSecurityContextRepository securityContextRepository = new WebSessionServerSecurityContextRepository();
StepVerifier.create(securityContextRepository.load(exchange).mapNotNull(SecurityContext::getAuthentication))
.expectNext(authenticationToken)
.verifyComplete();
}
@Test
void onAuthorizationSuccessWhenIdTokenIssuerNotSameThenException() {
ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration().build();
@@ -35,6 +35,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import reactor.util.context.Context;
import org.springframework.http.HttpHeaders;
@@ -77,6 +78,7 @@ import org.springframework.web.reactive.function.client.ExchangeFunction;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebSession;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -320,6 +322,8 @@ public class ServerOAuth2AuthorizedClientExchangeFilterFunctionITests {
doReturn(Mono.just(authorizedClient)).when(this.authorizedClientRepository)
.loadAuthorizedClient(eq(clientRegistration.getRegistrationId()), eq(this.authentication),
eq(this.exchange));
// Capture the original session id.
String originalSessionId = this.exchange.getSession().map(WebSession::getId).block();
this.webClient.get()
.uri(this.serverUrl)
.attributes(ServletOAuth2AuthorizedClientExchangeFilterFunction
@@ -356,6 +360,10 @@ public class ServerOAuth2AuthorizedClientExchangeFilterFunctionITests {
assertThat(oidcUser.getSubject()).isEqualTo("subject-1234");
assertThat(oidcUser.getName()).isEqualTo("refreshed-username");
});
// Verify that session id was not changed.
StepVerifier.create(this.exchange.getSession())
.assertNext((session) -> assertThat(session.getId()).isEqualTo(originalSessionId))
.verifyComplete();
}
@Test
@@ -40,8 +40,10 @@ import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
@@ -166,6 +168,67 @@ public class ServletOAuth2AuthorizedClientExchangeFilterFunctionITests {
assertThat(authorizedClientCaptor.getValue().getClientRegistration()).isSameAs(clientRegistration);
}
// gh-19421
@Test
public void requestWhenNoServletRequestThenAuthorizeAndSendRequest() {
RequestContextHolder.resetRequestAttributes();
final OAuth2AuthorizedClientService delegate = new InMemoryOAuth2AuthorizedClientService(
this.clientRegistrationRepository);
OAuth2AuthorizedClientService authorizedClientService = spy(new OAuth2AuthorizedClientService() {
@Override
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId,
String principal) {
return delegate.loadAuthorizedClient(clientRegistrationId, principal);
}
@Override
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
delegate.saveAuthorizedClient(authorizedClient, principal);
}
@Override
public void removeAuthorizedClient(String clientRegistrationId, String principal) {
delegate.removeAuthorizedClient(clientRegistrationId, principal);
}
});
this.authorizedClientFilter = new ServletOAuth2AuthorizedClientExchangeFilterFunction(
new AuthorizedClientServiceOAuth2AuthorizedClientManager(this.clientRegistrationRepository,
authorizedClientService));
this.webClient = WebClient.builder().apply(this.authorizedClientFilter.oauth2Configuration()).build();
// @formatter:off
String accessTokenResponse = "{\n"
+ " \"access_token\": \"access-token-1234\",\n"
+ " \"token_type\": \"bearer\",\n"
+ " \"expires_in\": \"3600\",\n"
+ " \"scope\": \"read write\"\n"
+ "}\n";
String clientResponse = "{\n"
+ " \"attribute1\": \"value1\",\n"
+ " \"attribute2\": \"value2\"\n"
+ "}\n";
// @formatter:on
this.server.enqueue(jsonResponse(accessTokenResponse));
this.server.enqueue(jsonResponse(clientResponse));
ClientRegistration clientRegistration = TestClientRegistrations.clientCredentials()
.tokenUri(this.serverUrl)
.build();
given(this.clientRegistrationRepository.findByRegistrationId(eq(clientRegistration.getRegistrationId())))
.willReturn(clientRegistration);
this.webClient.get()
.uri(this.serverUrl)
.attributes(ServletOAuth2AuthorizedClientExchangeFilterFunction
.clientRegistrationId(clientRegistration.getRegistrationId()))
.retrieve()
.bodyToMono(String.class)
.block();
assertThat(this.server.getRequestCount()).isEqualTo(2);
ArgumentCaptor<OAuth2AuthorizedClient> authorizedClientCaptor = ArgumentCaptor
.forClass(OAuth2AuthorizedClient.class);
verify(authorizedClientService).saveAuthorizedClient(authorizedClientCaptor.capture(), eq(this.authentication));
assertThat(authorizedClientCaptor.getValue().getClientRegistration()).isSameAs(clientRegistration);
}
@Test
public void requestWhenAuthorizedButExpiredThenRefreshAndSendRequest() {
// @formatter:off
@@ -67,12 +67,14 @@ import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.ClientAuthorizationException;
import org.springframework.security.oauth2.client.JwtBearerOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizationFailureHandler;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.RefreshTokenOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequest;
@@ -134,6 +136,9 @@ public class ServletOAuth2AuthorizedClientExchangeFilterFunctionTests {
@Mock
private OAuth2AuthorizedClientRepository authorizedClientRepository;
@Mock
private OAuth2AuthorizedClientService authorizedClientService;
@Mock
private ClientRegistrationRepository clientRegistrationRepository;
@@ -661,6 +666,43 @@ public class ServletOAuth2AuthorizedClientExchangeFilterFunctionTests {
authentication, servletRequest);
}
// gh-19421
@Test
public void filterWhenServletRequestNullAndClientRegistrationIdFromAuthenticationAndCustomPrincipalResolverThenAuthorizedClientResolved() {
this.function = new ServletOAuth2AuthorizedClientExchangeFilterFunction(
new AuthorizedClientServiceOAuth2AuthorizedClientManager(this.clientRegistrationRepository,
this.authorizedClientService));
this.function.setDefaultOAuth2AuthorizedClient(true);
OAuth2User user = mock(OAuth2User.class);
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER");
OAuth2AuthenticationToken initialAuthentication = new OAuth2AuthenticationToken(user, authorities,
"initial-registration-id");
OAuth2AuthenticationToken authentication = new OAuth2AuthenticationToken(user, authorities,
this.registration.getRegistrationId());
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.registration, "principalName",
this.accessToken);
given(this.clientRegistrationRepository.findByRegistrationId(this.registration.getRegistrationId()))
.willReturn(this.registration);
given(this.authorizedClientService.loadAuthorizedClient(this.registration.getRegistrationId(),
initialAuthentication.getName()))
.willReturn(authorizedClient);
final ClientRequest clientRequest = ClientRequest.create(HttpMethod.GET, URI.create("https://example.com"))
.build();
this.function.setPrincipalResolver((request) -> authentication);
this.function.filter(clientRequest, this.exchange)
.contextWrite(context(null, null, initialAuthentication))
.block();
List<ClientRequest> requests = this.exchange.getRequests();
assertThat(requests).hasSize(1);
ClientRequest request = requests.get(0);
assertThat(request.headers().getFirst(HttpHeaders.AUTHORIZATION)).isEqualTo("Bearer token-0");
assertThat(request.url().toASCIIString()).isEqualTo("https://example.com");
assertThat(request.method()).isEqualTo(HttpMethod.GET);
assertThat(getBody(request)).isEmpty();
verify(this.authorizedClientService).loadAuthorizedClient(this.registration.getRegistrationId(),
authentication.getName());
}
@Test
public void filterWhenUnauthorizedThenInvokeFailureHandler() {
assertHttpStatusInvokesFailureHandler(HttpStatus.UNAUTHORIZED, OAuth2ErrorCodes.INVALID_TOKEN);
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
private final Collection<OAuth2TokenValidator<T>> tokenValidators;
private boolean failOnError;
/**
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to
@@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
Collection<OAuth2Error> errors = new ArrayList<>();
for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
errors.addAll(validator.validate(token).getErrors());
if (!errors.isEmpty() && this.failOnError) {
return OAuth2TokenValidatorResult.failure(errors);
}
}
return OAuth2TokenValidatorResult.failure(errors);
}
/**
* Fail-fast when a delegate errors, defaults to {@code false}.
* @param failOnError fail-fast when a delegate errors
* @since 6.5.12
*/
public void setFailOnError(boolean failOnError) {
this.failOnError = failOnError;
}
}
@@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
@@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
/**
* The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
* {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof
* {@link Jwt}.
* {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti}
* claims of the DPoP Proof {@link Jwt}.
*/
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory();
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory(
Collections.emptyList());
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
new JOSEObjectType("dpop+jwt"));
@@ -94,6 +98,73 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
this.jwtValidatorFactory = jwtValidatorFactory;
}
/**
* Creates a factory that provides an {@link OAuth2TokenValidator} for the specified
* {@link DPoPProofContext} and is used by the {@link JwtDecoder}. The returned
* factory provides a validator that validates the {@code htm}, {@code htu},
* {@code iat}, {@code jkt}, {@code ath} and {@code jti} claims, along with any custom
* validators provided.
* @param validators the custom validators to add
* @return a factory that provides an {@link OAuth2TokenValidator} for the specified
* {@link DPoPProofContext}
* @since 6.5.12
*/
public static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> createDefaultJwtValidatorFactory(
List<OAuth2TokenValidator<Jwt>> validators) {
Assert.notNull(validators, "validators cannot be null");
List<OAuth2TokenValidator<Jwt>> customValidators = new ArrayList<>();
if (!CollectionUtils.isEmpty(validators)) {
customValidators.addAll(validators);
}
final Duration clockSkew = Duration.ofSeconds(30);
final JwtIssuedAtValidator jwtIssuedAtValidator;
JwtIssuedAtValidator existingJwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators,
JwtIssuedAtValidator.class);
if (existingJwtIssuedAtValidator != null) {
jwtIssuedAtValidator = existingJwtIssuedAtValidator;
customValidators.remove(jwtIssuedAtValidator);
}
else {
jwtIssuedAtValidator = new JwtIssuedAtValidator(true);
jwtIssuedAtValidator.setClockSkew(clockSkew);
}
final DPoPProofReplayValidator dPoPProofReplayValidator;
DPoPProofReplayValidator existingDPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators,
DPoPProofReplayValidator.class);
if (existingDPoPProofReplayValidator != null) {
dPoPProofReplayValidator = existingDPoPProofReplayValidator;
customValidators.remove(dPoPProofReplayValidator);
}
else {
dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache());
dPoPProofReplayValidator.setClockSkew(clockSkew);
}
return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator,
customValidators);
}
private static OAuth2TokenValidator<Jwt> createDefaultJwtValidatorFactory(DPoPProofContext context,
JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator,
List<OAuth2TokenValidator<Jwt>> customValidators) {
// Add custom validators first then default validators in a specific order
List<OAuth2TokenValidator<Jwt>> tokenValidators = new ArrayList<>();
if (!CollectionUtils.isEmpty(customValidators)) {
tokenValidators.addAll(customValidators);
}
tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase));
tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals));
tokenValidators.add(jwtIssuedAtValidator);
if (context.getAccessToken() != null) {
tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken()));
tokenValidators.add(new AthClaimValidator(context.getAccessToken()));
}
tokenValidators.add(dPoPProofReplayValidator);
DelegatingOAuth2TokenValidator<Jwt> delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>(
tokenValidators);
delegatingTokenValidator.setFailOnError(true);
return delegatingTokenValidator;
}
private static NimbusJwtDecoder buildDecoder() {
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
@@ -137,39 +208,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
};
}
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() {
return (context) -> new DelegatingOAuth2TokenValidator<>(
new JwtClaimValidator<>("htm", context.getMethod()::equals),
new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(),
new JwtIssuedAtValidator(true));
}
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2Token accessToken;
private static final Map<String, Long> JTI_CACHE = Collections.synchronizedMap(new JtiCache());
private AthClaimValidator(OAuth2Token accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
this.accessToken = accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jti = jwt.getId();
if (!StringUtils.hasText(jti)) {
OAuth2Error error = createOAuth2Error("jti claim is required.");
String accessTokenHashClaim = jwt.getClaimAsString("ath");
if (!StringUtils.hasText(accessTokenHashClaim)) {
OAuth2Error error = createOAuth2Error("ath claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
// Enforce single-use to protect against DPoP proof replay
String jtiHash;
String accessTokenHash;
try {
jtiHash = computeSHA256(jti);
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
return OAuth2TokenValidatorResult.failure(error);
}
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS);
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) {
// Already used
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
if (!accessTokenHashClaim.equals(accessTokenHash)) {
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
@@ -185,20 +251,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
@SuppressWarnings("serial")
private static final class JtiCache extends LinkedHashMap<String, Long> {
}
private static final int MAX_SIZE = 1000;
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
if (size() > MAX_SIZE) {
return true;
}
Instant expiry = Instant.ofEpochMilli(eldest.getValue());
return Instant.now().isAfter(expiry);
private final OAuth2Token accessToken;
private final ClaimAccessor claims;
private JwkThumbprintValidator(OAuth2Token accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor");
this.accessToken = accessToken;
this.claims = (ClaimAccessor) accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jwkThumbprintClaim = null;
Map<String, Object> confirmationMethodClaim = this.claims.getClaimAsMap("cnf");
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
}
if (jwkThumbprintClaim == null) {
OAuth2Error error = createOAuth2Error("jkt claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
JWK jwk = null;
@SuppressWarnings("unchecked")
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
try {
jwk = JWK.parse(jwkJson);
}
catch (Exception ignored) {
}
if (jwk == null) {
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jwkThumbprint;
try {
jwkThumbprint = jwk.computeThumbprint().toString();
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
return OAuth2TokenValidatorResult.failure(error);
}
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
}
@@ -0,0 +1,447 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.oauth2.jwt;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
import com.nimbusds.jose.jwk.JWK;
import org.jspecify.annotations.Nullable;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* An {@link OAuth2TokenValidator} that mitigates DPoP Proof Replay.
*
* <p>
* This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof:
* <ul>
* <li>contains the {@code iat} (issued at) claim, and it's within an acceptable time
* window (configured via {@link #setClockSkew(Duration)})</li>
* <li>contains the {@code jti} (JWT ID) claim, and it has not been used previously</li>
* </ul>
*
* <p>
* This implementation uses a {@link Cache} to store the {@code jti} claim (along with
* other information in {@link CacheValue CacheValue}) to enforce single-use. The
* {@code jti} is retained in the cache until the DPoP Proof expires, which is calculated
* as {@code iat + clockSkew}.
*
* @author Joe Grandja
* @since 6.5.12
* @see OAuth2TokenValidator
* @see DPoPProofJwtDecoderFactory
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc9449#section-11.1">Section 11.1. DPoP Proof
* Replay</a>
*/
public final class DPoPProofReplayValidator implements OAuth2TokenValidator<Jwt> {
private final Cache cache;
private Duration clockSkew = Duration.ofSeconds(30);
private Clock clock = Clock.systemUTC();
/**
* Constructs a {@code DPoPProofReplayValidator} using the provided parameters.
* @param cache the {@link Cache} used to store {@link CacheValue} which contains
* information of the used DPoP Proof {@link Jwt}'s
*/
public DPoPProofReplayValidator(Cache cache) {
Assert.notNull(cache, "cache cannot be null");
this.cache = cache;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jti = jwt.getId();
if (!StringUtils.hasText(jti)) {
OAuth2Error error = createOAuth2Error("jti claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
Instant issuedAt = jwt.getIssuedAt();
if (issuedAt == null) {
OAuth2Error error = createOAuth2Error("iat claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
// Ensure acceptable time window
Instant now = Instant.now(this.clock);
Instant notBefore = now.minus(this.clockSkew);
Instant notAfter = now.plus(this.clockSkew);
if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) {
OAuth2Error error = createOAuth2Error("iat claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jwkThumbprint;
try {
@SuppressWarnings("unchecked")
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
JWK jwk = JWK.parse(jwkJson);
jwkThumbprint = jwk.computeThumbprint().toString();
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jtiHash;
try {
jtiHash = computeSHA256(jti);
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
Instant expiresAt = issuedAt.plus(this.clockSkew);
CacheValue cacheValue = new CacheValue(issuedAt, expiresAt, jwkThumbprint);
// Enforce single-use to protect against DPoP proof replay
if (this.cache.putIfAbsent(jtiHash, cacheValue) != null) {
// Already used or cache full or key limit reached
OAuth2Error error = createOAuth2Error("jti claim is invalid or unable to cache.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
/**
* Sets the clock skew. The default is 30 seconds.
* @param clockSkew the clock skew
*/
public void setClockSkew(Duration clockSkew) {
Assert.notNull(clockSkew, "clockSkew cannot be null");
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
this.clockSkew = clockSkew;
}
/**
* Sets the {@link Clock} used in {@link Instant#now(Clock)}.
* @param clock the clock
*/
public void setClock(Clock clock) {
Assert.notNull(clock, "clock cannot be null");
this.clock = clock;
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
private static String computeSHA256(String value) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
/**
* An in-memory {@link Cache} implementation backed by a {@link ConcurrentHashMap}.
*
* <p>
* <b>NOTE:</b> This implementation has limitations as it only works in a single-node
* setup. For production (and clustered) environments, it is recommended to use a
* distributed {@link Cache} implementation (e.g. Redis, Hazelcast, etc.).
*
* <p>
* This implementation can be fine-tuned based on the following configuration
* settings:
* <ul>
* <li>{@link #setMaxSize(int)} - Sets the maximum number of entries the cache can
* hold. The default is 100,000.</li>
* <li>{@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests
* allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is
* 1000.</li>
* </ul>
*/
public static final class InMemoryCache implements Cache {
private static final String DEFAULT_NAME = InMemoryCache.class.getName().concat(".DPOP-PROOF-CACHE");
private static final int DEFAULT_MAX_SIZE = 100_000;
private static final int DEFAULT_MAX_REQUESTS_PER_KEY = 1000;
private static final int CLEANUP_INTERVAL_SECS = 10;
private final ConcurrentMap<String, CacheValue> cache = new ConcurrentHashMap<>();
private final ConcurrentMap<String, Integer> requestsPerKey = new ConcurrentHashMap<>();
private final AtomicBoolean cleaning = new AtomicBoolean(false);
private long lastCleanup = System.currentTimeMillis();
private int maxSize = DEFAULT_MAX_SIZE;
private int maxRequestsPerKey = DEFAULT_MAX_REQUESTS_PER_KEY;
/**
* Returns the maximum number of entries the cache can hold.
* @return the maximum number of entries the cache can hold
*/
public int getMaxSize() {
return this.maxSize;
}
/**
* Sets the maximum number of entries the cache can hold. The default is 100,000.
* @param maxSize the maximum number of entries the cache can hold
*/
public void setMaxSize(int maxSize) {
Assert.isTrue(maxSize > 0, "maxSize must be > 0");
this.maxSize = maxSize;
}
/**
* Returns the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}.
* @return the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
*/
public int getMaxRequestsPerKey() {
return this.maxRequestsPerKey;
}
/**
* Sets the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is 1000.
* @param maxRequestsPerKey the maximum number of requests allowed per
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
*/
public void setMaxRequestsPerKey(int maxRequestsPerKey) {
Assert.isTrue(maxRequestsPerKey > 0, "maxRequestsPerKey must be > 0");
this.maxRequestsPerKey = maxRequestsPerKey;
}
@Override
public String getName() {
return DEFAULT_NAME;
}
@Override
public Object getNativeCache() {
return this.cache;
}
@Override
public @Nullable ValueWrapper get(Object key) {
Object value = this.cache.get(key);
return (value != null) ? new SimpleValueWrapper(value) : null;
}
@SuppressWarnings("unchecked")
@Override
public <T> @Nullable T get(Object key, @Nullable Class<T> type) {
Object value = this.cache.get(key);
if (value != null && type != null && !type.isInstance(value)) {
throw new IllegalStateException(
"Cached value is not of required type [" + type.getName() + "]: " + value);
}
return (T) value;
}
@Override
public <T> @Nullable T get(Object key, Callable<T> valueLoader) {
throw new UnsupportedOperationException();
}
@Override
public void put(Object key, @Nullable Object value) {
putIfAbsent(key, value);
}
@Override
public @Nullable ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
Assert.notNull(value, "value cannot be null");
String jti = (String) key;
CacheValue cacheValue = (CacheValue) value;
cleanupIfNecessary();
if (this.cache.size() >= this.maxSize) {
// Force an immediate cleanup when we hit the limit before the cleanup
// interval
cleanup();
if (this.cache.size() >= this.maxSize) {
// Cache full - return non-null value
return new SimpleValueWrapper(cacheValue);
}
}
// Limit the number of requests per key
AtomicBoolean limitExceeded = new AtomicBoolean(false);
this.requestsPerKey.compute(cacheValue.jwkThumbprint, (k, v) -> {
if (v != null && v >= this.maxRequestsPerKey) {
limitExceeded.set(true);
return v;
}
// Increment
return (v != null) ? v + 1 : 1;
});
if (limitExceeded.get()) {
// Key limit reached - return non-null value
return new SimpleValueWrapper(cacheValue);
}
if (this.cache.putIfAbsent(jti, cacheValue) != null) {
// jti exists - revert the increment and return non-null value
this.requestsPerKey.computeIfPresent(cacheValue.jwkThumbprint, (k, v) -> (v > 1) ? v - 1 : null);
return new SimpleValueWrapper(cacheValue);
}
return null;
}
@Override
public void evict(Object key) {
}
@Override
public void clear() {
}
private void cleanupIfNecessary() {
long now = System.currentTimeMillis();
long last = this.lastCleanup;
if ((now - last) > (CLEANUP_INTERVAL_SECS * 1000)) {
cleanup();
}
}
private void cleanup() {
if (this.cleaning.compareAndSet(false, true)) {
try {
Instant now = Instant.now();
for (Map.Entry<String, CacheValue> entry : this.cache.entrySet()) {
if (now.isAfter(entry.getValue().expiresAt)) {
this.cache.remove(entry.getKey());
this.requestsPerKey.computeIfPresent(entry.getValue().jwkThumbprint,
(k, v) -> (v > 1) ? v - 1 : null);
}
}
this.lastCleanup = System.currentTimeMillis();
}
finally {
this.cleaning.set(false);
}
}
}
}
/**
* A representation of the value to which the {@link Cache} maps a (hashed)
* {@code (jti)} claim as the key.
*/
public static final class CacheValue {
private final Instant issuedAt;
private final Instant expiresAt;
private final String jwkThumbprint;
/**
* Constructs a {@code CacheValue} using the provided parameters.
* @param issuedAt the issued at claim which identifies the time at which the DPoP
* Proof {@link Jwt} was issued
* @param expiresAt the expiration time when this {@code CacheValue} will be
* evicted from the cache
* @param jwkThumbprint the SHA-256 thumbprint of the public key of the JSON Web
* Key (JWK) corresponding to the key used to digitally sign the DPoP Proof
* {@link Jwt}
*/
public CacheValue(Instant issuedAt, Instant expiresAt, String jwkThumbprint) {
Assert.notNull(issuedAt, "issuedAt cannot be null");
Assert.notNull(expiresAt, "expiresAt cannot be null");
Assert.hasText(jwkThumbprint, "jwkThumbprint cannot be empty");
this.issuedAt = issuedAt;
this.expiresAt = expiresAt;
this.jwkThumbprint = jwkThumbprint;
}
/**
* Returns the issued at {@code (iat)} claim which identifies the time at which
* the DPoP Proof {@link Jwt} was issued.
* @return the issued at claim which identifies the time at which the DPoP Proof
* {@link Jwt} was issued
*/
public Instant getIssuedAt() {
return this.issuedAt;
}
/**
* Returns the expiration time when this {@code CacheValue} will be evicted from
* the cache.
* @return the expiration time when this {@code CacheValue} will be evicted from
* the cache
*/
public Instant getExpiresAt() {
return this.expiresAt;
}
/**
* Returns the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
* corresponding to the key used to digitally sign the DPoP Proof {@link Jwt}.
* @return the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
*/
public String getJwkThumbprint() {
return this.jwkThumbprint;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (obj == null || obj.getClass() != this.getClass()) {
return false;
}
CacheValue that = (CacheValue) obj;
return Objects.equals(this.issuedAt, that.issuedAt) && Objects.equals(this.expiresAt, that.expiresAt)
&& Objects.equals(this.jwkThumbprint, that.jwkThumbprint);
}
@Override
public int hashCode() {
return Objects.hash(this.issuedAt, this.expiresAt, this.jwkThumbprint);
}
}
}
@@ -16,14 +16,9 @@
package org.springframework.security.oauth2.server.resource.authentication;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
import java.util.function.Function;
import com.nimbusds.jose.jwk.JWK;
import org.jspecify.annotations.Nullable;
import org.springframework.security.authentication.AuthenticationManager;
@@ -31,22 +26,16 @@ import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.ClaimAccessor;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.DPoPProofContext;
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
/**
* An {@link AuthenticationProvider} implementation that is responsible for authenticating
@@ -73,18 +62,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) {
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null");
this.tokenAuthenticationManager = tokenAuthenticationManager;
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = (context) -> {
OAuth2AccessTokenClaims accessToken = context.getAccessToken();
Assert.notNull(accessToken, "accessToken cannot be null");
return new DelegatingOAuth2TokenValidator<>(
// Use default validators
DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context),
// Add custom validators
new AthClaimValidator(accessToken), new JwkThumbprintValidator(accessToken));
};
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory;
this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory();
}
@Override
@@ -119,7 +97,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
try {
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof());
}
catch (JwtException ex) {
catch (Exception ex) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF);
throw new OAuth2AuthenticationException(error, ex);
}
@@ -144,108 +122,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
this.dPoPProofVerifierFactory = dPoPProofVerifierFactory;
}
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2AccessTokenClaims accessToken;
private AthClaimValidator(OAuth2AccessTokenClaims accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
this.accessToken = accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String accessTokenHashClaim = jwt.getClaimAsString("ath");
if (!StringUtils.hasText(accessTokenHashClaim)) {
OAuth2Error error = createOAuth2Error("ath claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
String accessTokenHash;
try {
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
return OAuth2TokenValidatorResult.failure(error);
}
if (!accessTokenHashClaim.equals(accessTokenHash)) {
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
private static String computeSHA256(String value) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
private final OAuth2AccessTokenClaims accessToken;
private JwkThumbprintValidator(OAuth2AccessTokenClaims accessToken) {
Assert.notNull(accessToken, "accessToken cannot be null");
this.accessToken = accessToken;
}
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
String jwkThumbprintClaim = null;
Map<String, Object> confirmationMethodClaim = this.accessToken.getClaimAsMap("cnf");
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
}
if (jwkThumbprintClaim == null) {
OAuth2Error error = createOAuth2Error("jkt claim is required.");
return OAuth2TokenValidatorResult.failure(error);
}
JWK jwk = null;
@SuppressWarnings("unchecked")
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
try {
jwk = JWK.parse(jwkJson);
}
catch (Exception ignored) {
}
if (jwk == null) {
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
String jwkThumbprint;
try {
jwkThumbprint = jwk.computeThumbprint().toString();
}
catch (Exception ex) {
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
return OAuth2TokenValidatorResult.failure(error);
}
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
return OAuth2TokenValidatorResult.failure(error);
}
return OAuth2TokenValidatorResult.success();
}
private static OAuth2Error createOAuth2Error(String reason) {
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
}
}
private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor {
private final OAuth2Token accessToken;
@@ -51,6 +51,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.core.userdetails.cache.NullUserCache;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
@@ -167,7 +168,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
}
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
// If digest is incorrect, try refreshing from backend and recomputing
if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse()) && cacheWasUsed) {
logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
user = this.userDetailsService.loadUserByUsername(username);
this.userCache.putUserInCache(user);
@@ -181,7 +182,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
return;
}
// If digest is still incorrect, definitely reject authentication attempt
if (!serverDigestMd5.equals(digestAuth.getResponse())) {
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse())) {
logger.debug(LogMessage.format(
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
serverDigestMd5, digestAuth.getResponse()));
@@ -405,7 +406,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
}
// Check signature of nonce matches this expiry time
String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
if (!expectedNonceSignature.equals(nonceTokens[1])) {
if (!Utf8.isEqual(expectedNonceSignature, nonceTokens[1])) {
throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
"Nonce token compromised {0}"));
@@ -16,6 +16,7 @@
package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial;
import java.io.Serializable;
@@ -121,4 +122,25 @@ public final class AuthenticatorTransport implements Serializable {
return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL };
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof AuthenticatorTransport other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
return valueOf(this.value);
}
}
@@ -16,6 +16,7 @@
package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial;
import java.io.Serializable;
@@ -58,4 +59,25 @@ public final class PublicKeyCredentialType implements Serializable {
return new PublicKeyCredentialType(value);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof PublicKeyCredentialType other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
return valueOf(this.value);
}
}
@@ -16,9 +16,12 @@
package org.springframework.security.web.webauthn.api;
import java.io.ObjectStreamException;
import java.io.Serial;
import java.io.Serializable;
import org.jspecify.annotations.Nullable;
/**
* <a href=
* "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
@@ -72,4 +75,34 @@ public final class UserVerificationRequirement implements Serializable {
return this.value;
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof UserVerificationRequirement other)) {
return false;
}
return this.value.equals(other.value);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
@Serial
private Object readResolve() throws ObjectStreamException {
switch (this.value) {
case "required":
return REQUIRED;
case "preferred":
return PREFERRED;
case "discouraged":
return DISCOURAGED;
default:
return this;
}
}
}
@@ -257,8 +257,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
byte[] clientDataJSON = response.getClientDataJSON().getBytes();
Challenge challenge = new DefaultChallenge(base64Challenge);
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
boolean userVerificationRequired = creationOptions.getAuthenticatorSelection()
.getUserVerification() == UserVerificationRequirement.REQUIRED;
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
// requireUserPresence The constant Boolean value true
// https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
boolean userPresenceRequired = true;
@@ -317,8 +317,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j(
PublicKeyCredentialParameters parameter) {
PublicKeyCredentialType credentialType = PublicKeyCredentialType.valueOf(parameter.getType().getValue());
if (credentialType != PublicKeyCredentialType.PUBLIC_KEY) {
if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) {
throw new IllegalArgumentException(
"Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j");
}
@@ -394,8 +393,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
String rpId = requestOptions.getRpId();
Assert.notNull(rpId, "rpId cannot be null");
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
boolean userVerificationRequired = request.getRequestOptions()
.getUserVerification() == UserVerificationRequirement.REQUIRED;
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
.equals(request.getRequestOptions().getUserVerification());
com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest(
request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(),
@@ -0,0 +1,42 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that {@link AuthenticatorAttachment} correctly restores singleton identity
* after Java deserialization. This class already implements {@code readResolve()} and
* serves as the reference for the pattern required by the other pseudo-enum types.
*/
class AuthenticatorAttachmentTests {
@Test
void platformWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.PLATFORM))
.isSameAs(AuthenticatorAttachment.PLATFORM);
}
@Test
void crossPlatformWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.CROSS_PLATFORM))
.isSameAs(AuthenticatorAttachment.CROSS_PLATFORM);
}
}
@@ -0,0 +1,76 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AuthenticatorTransportTests {
@Test
void usbWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.USB))
.isSameAs(AuthenticatorTransport.USB);
}
@Test
void nfcWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.NFC))
.isSameAs(AuthenticatorTransport.NFC);
}
@Test
void bleWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.BLE))
.isSameAs(AuthenticatorTransport.BLE);
}
@Test
void smartCardWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.SMART_CARD))
.isSameAs(AuthenticatorTransport.SMART_CARD);
}
@Test
void hybridWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.HYBRID))
.isSameAs(AuthenticatorTransport.HYBRID);
}
@Test
void internalWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.INTERNAL))
.isSameAs(AuthenticatorTransport.INTERNAL);
}
@Test
void usbWhenSameValueThenEquals() {
assertThat(new AuthenticatorTransport("usb")).isEqualTo(AuthenticatorTransport.USB);
}
@Test
void usbWhenSameValueThenHashCodeMatches() {
assertThat(new AuthenticatorTransport("usb")).hasSameHashCodeAs(AuthenticatorTransport.USB);
}
@Test
void usbWhenDifferentValueThenNotEquals() {
assertThat(AuthenticatorTransport.USB).isNotEqualTo(AuthenticatorTransport.NFC);
}
}
@@ -0,0 +1,49 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.web.webauthn.api;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialDescriptorTests {
@Test
void typeWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
.id(TestBytes.get())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.build();
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
assertThat(deserialized.getType()).isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
}
@Test
void transportsWhenDeserializedThenSameAsConstants() {
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
.id(TestBytes.get())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.transports(Set.of(AuthenticatorTransport.USB, AuthenticatorTransport.HYBRID))
.build();
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
assertThat(deserialized.getTransports()).containsExactlyInAnyOrder(AuthenticatorTransport.USB,
AuthenticatorTransport.HYBRID);
}
}
@@ -0,0 +1,43 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialRequestOptionsTests {
@Test
void userVerificationRequiredWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
.userVerification(UserVerificationRequirement.REQUIRED)
.build();
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.REQUIRED);
}
@Test
void userVerificationPreferredWhenDeserializedThenSameAsConstant() {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
.userVerification(UserVerificationRequirement.PREFERRED)
.build();
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.PREFERRED);
}
}
@@ -0,0 +1,51 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PublicKeyCredentialTypeTests {
@Test
void publicKeyWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(PublicKeyCredentialType.PUBLIC_KEY))
.isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
}
@Test
void publicKeyWhenSameValueThenEquals() {
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
assertThat(first).isEqualTo(second);
}
@Test
void publicKeyWhenSameValueThenHashCodeMatches() {
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
assertThat(first).hasSameHashCodeAs(second);
}
@Test
void publicKeyWhenDifferentValueThenNotEquals() {
assertThat(PublicKeyCredentialType.valueOf("custom-1"))
.isNotEqualTo(PublicKeyCredentialType.valueOf("custom-2"));
}
}
@@ -0,0 +1,47 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.web.webauthn.api;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public final class SerializationTestUtils {
private SerializationTestUtils() {
}
@SuppressWarnings("unchecked")
public static <T extends Serializable> T serializeAndDeserialize(T object) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(object);
}
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
return (T) ois.readObject();
}
}
catch (Exception ex) {
throw new RuntimeException("Serialization round-trip failed", ex);
}
}
}
@@ -0,0 +1,58 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.security.web.webauthn.api;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class UserVerificationRequirementTests {
@Test
void requiredWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.REQUIRED))
.isSameAs(UserVerificationRequirement.REQUIRED);
}
@Test
void preferredWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.PREFERRED))
.isSameAs(UserVerificationRequirement.PREFERRED);
}
@Test
void discouragedWhenDeserializedThenSameAsConstant() {
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.DISCOURAGED))
.isSameAs(UserVerificationRequirement.DISCOURAGED);
}
@Test
void requiredWhenSameValueThenEquals() {
assertThat(new UserVerificationRequirement("required")).isEqualTo(UserVerificationRequirement.REQUIRED);
}
@Test
void requiredWhenSameValueThenHashCodeMatches() {
assertThat(new UserVerificationRequirement("required")).hasSameHashCodeAs(UserVerificationRequirement.REQUIRED);
}
@Test
void requiredWhenDifferentValueThenNotEquals() {
assertThat(UserVerificationRequirement.REQUIRED).isNotEqualTo(UserVerificationRequirement.PREFERRED);
}
}