diff --git a/.cursor/rules/code-style-formatting.mdc b/.cursor/rules/code-style-formatting.mdc new file mode 100644 index 00000000000..efbcc1adc40 --- /dev/null +++ b/.cursor/rules/code-style-formatting.mdc @@ -0,0 +1,525 @@ +--- +description: Comprehensive code style and formatting conventions for the Spring Security codebase, including indentation, naming, documentation, build tools, and best practices +alwaysApply: false +--- + +# Spring Security Code Style & Formatting + +## Build Tool & Formatting Infrastructure + +### Spring Java Format Plugin + +The codebase uses **Spring Java Format** (`io.spring.javaformat`) for automated code formatting: + +- **Version**: `0.0.47` (from `gradle/libs.versions.toml`) +- **Plugin**: Applied to all projects except BOM and docs (`build.gradle` line 53) +- **Gradle Command**: `./gradlew format && ./gradlew check` + +**Key Components**: +- **Formatter**: `spring-javaformat-gradle-plugin` - Auto-formats code +- **Checkstyle Integration**: `spring-javaformat-checkstyle` - Enforces style rules +- **Checkstyle Version**: 8.34 + +### Checkstyle Configuration + +Primary configuration file: `etc/checkstyle/checkstyle.xml` + +**Key Modules**: + +```xml + + + +``` + +**Custom Checks**: +1. **Header Check**: Apache License 2.0 (see `etc/checkstyle/header.txt`) +2. **Package Info Check**: Requires `package-info.java` for all packages +3. **NullMarked Check**: All packages must have `@NullMarked` annotation +4. **Static Import Restrictions**: Avoid static imports (with specific exceptions) +5. **AssertJ Restrictions**: Prefer `assertThatExceptionOfType` over other exception assertions +6. **Locale Requirements**: `String.toLowerCase()` must specify locale +7. **Nullability Import Bans**: Only JSpecify annotations allowed + +Suppressions: `etc/checkstyle/checkstyle-suppressions.xml` + +## Indentation & Whitespace + +### Tab-Based Indentation + +From `.editorconfig`: + +```ini +[*.{java,xml}] +indent_style = tab +indent_size = 4 +continuation_indent_size = 8 +``` + +**Rules**: +- **Primary indent**: Tabs (displayed as 4 spaces) +- **Continuation indent**: 8 spaces (for line wraps) +- **Max line length**: 120 characters +- **End of line**: LF (Unix style) +- **Final newline**: Required +- **Trailing whitespace**: Trimmed +- **Smart tabs**: Disabled +- **Multiline parameter alignment**: Disabled + +### Example Indentation + +```java +public class Example { + // Tab for class-level indentation + + private String field; + + public void method(String param1, + String param2) { // 8-space continuation indent + if (condition) { + // Tab for each level + } + } +} +``` + +## File Structure & Headers + +### Copyright Header + +Every Java file must start with Apache License 2.0 header: + +```java +/* + * 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. + */ +``` + +**Variations**: +- Acegi Technology files: `Copyright 2004, 2005, 2006 Acegi Technology Pty Limited` +- Standard files: `Copyright 2004-present the original author or authors.` + +### Package Structure + +**Order**: +1. Copyright header +2. Package declaration +3. Import statements (grouped, no wildcards except for static imports) +4. Class/interface declaration + +**Example**: `core/src/main/java/org/springframework/security/core/Authentication.java` + +```java +/* + * Copyright header... + */ + +package org.springframework.security.core; + +import java.io.Serializable; +import java.security.Principal; +import java.util.Collection; +import java.util.function.Consumer; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Javadoc... + */ +public interface Authentication extends Principal, Serializable { +``` + +### Package-Info Files + +**Required**: Every package must have `package-info.java` + +**Structure**: +```java +/* + * Copyright header... + */ + +/** + * Package description. + */ +@NullMarked +package org.springframework.security.authentication; + +import org.jspecify.annotations.NullMarked; +``` + +**Example**: `core/src/main/java/org/springframework/security/access/package-info.java` + +## Naming Conventions + +### Classes & Interfaces + +- **Classes**: PascalCase (e.g., `AuthenticationManager`, `AbstractAuthenticationToken`) +- **Interfaces**: PascalCase, no "I" prefix (e.g., `Authentication`, `SecurityFilterChain`) +- **Test Classes**: Suffix with `Tests` (e.g., `AbstractAuthenticationTokenTests`) +- **Abstract Classes**: Prefix with `Abstract` (e.g., `AbstractAuthenticationToken`) + +### Methods & Fields + +- **Methods**: camelCase (e.g., `getAuthentication()`, `attemptAuthentication()`) +- **Fields**: camelCase (e.g., `authenticated`, `details`) +- **Constants**: UPPER_SNAKE_CASE (e.g., `NO_AUTHORITIES`, `serialVersionUID`) +- **Boolean methods**: Prefix with `is`/`has`/`should` (e.g., `isAuthenticated()`) + +### Variables + +- **Local variables**: camelCase +- **Parameters**: camelCase +- **Type parameters**: Single uppercase letter (e.g., `T`, `B`) or PascalCase (e.g., `Builder`) + +## Javadoc Conventions + +### Class-Level Documentation + +```java +/** + * Brief description on first line. + *

+ * Additional details in separate paragraphs. + *

+ * + * @author Ben Alex + * @author Luke Taylor + * @since 3.1 + */ +``` + +**Rules**: +- First sentence is summary +- Use `

` tags for paragraphs +- `@author` tags for contributors +- `@since` tags for version introduced (on main branch only) +- Use HTML tags (``, ``) for inline code +- Use `{@link}` for cross-references + +### Method-Level Documentation + +```java +/** + * Short description of what method does. + *

+ * Additional details about behavior. + *

+ * @param parameter description of parameter (may note if null allowed) + * @return description of return value + * @throws ExceptionType when this exception is thrown + */ +``` + +**Note**: Checkstyle suppressions disable most Javadoc method checks, but documentation is still encouraged. + +## Null Safety with JSpecify + +### Core Principle + +**Package-Level Default**: All types are non-null by default via `@NullMarked` + +```java +@NullMarked +package org.springframework.security.authentication; + +import org.jspecify.annotations.NullMarked; +``` + +### @Nullable Annotation Patterns + +**Type-Use Annotation**: Place `@Nullable` **before the type** + +```java +// Method return types +public @Nullable Object getPrincipal() { ... } + +// Method parameters +public void setDetails(@Nullable Object details) { ... } + +// Fields +private @Nullable Object details; + +// With modifiers (modifiers come first) +private volatile @Nullable String cache; +private static final @Nullable String CONFIG = null; +``` + +### Array Annotations + +**Syntax**: `Type @Nullable []` means nullable array of non-null elements + +```java +// Nullable array of non-null strings +public String @Nullable [] getParameterNames(Method method) { ... } + +// Nullable array of nullable strings +@Nullable String @Nullable [] result = parser.split(input); +``` + +### Generic Type Arguments + +```java +// Nullable type argument +public void process(BasicPolymorphicTypeValidator.@Nullable Builder builder) { ... } +``` + +### Banned Annotations + +**Only JSpecify allowed**. These are **banned**: +- `javax.annotation.*` (JSR-305) +- `org.jetbrains.annotations.*` (JetBrains) +- `reactor.util.annotation.*` (Reactor) +- Any other `@Nullable`, `@NonNull`, `@Nonnull` not from `org.jspecify.annotations` + +### Annotation Placement Rules + +**Correct Order**: +```java +private final @Nullable Object field; +``` + +**Incorrect** (will fail checkstyle): +```java +private @Nullable final Object field; +``` + +Rule: `@Nullable` must appear **immediately before the type**, after all other modifiers. + +## Code Organization + +### Class Member Order + +Typical order (Spring convention): +1. Static constants +2. Instance fields (with `@Serial` for serialVersionUID) +3. Constructors +4. Static factory methods +5. Interface methods (with `@Override`) +6. Public methods +7. Protected methods +8. Private methods +9. Inner classes/interfaces (especially builders) + +**Example**: `core/src/main/java/org/springframework/security/authentication/AbstractAuthenticationToken.java` + +### Import Organization + +1. Java/Jakarta imports +2. Third-party imports (JSpecify, etc.) +3. Spring imports +4. Static imports (minimal, with exceptions) + +**Static Import Exceptions** (allowed): +- AssertJ: `org.assertj.core.api.Assertions.*` +- Test utilities: `org.springframework.security.test.web.*` +- Specific matchers and test helpers + +## Testing Conventions + +### Test Class Structure + +```java +/** + * Tests {@link ClassUnderTest}. + * + * @author Author Name + */ +public class ClassUnderTestTests { // Note: "Tests" suffix + + private FieldType field; + + @BeforeEach + public void setUp() { + // Setup + } + + @Test + public void testMethodName() { // Or: methodName_condition_expectedBehavior() + // Test + } +} +``` + +**Assertions**: Prefer AssertJ +```java +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +assertThat(result).isNotNull(); +assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> method()); +``` + +**Banned**: `catchThrowable`, `assertThatThrownBy` (use `assertThatExceptionOfType` instead) + +## Code Style Guidelines + +### Prefer For Loops Over Streams + +From `CONTRIBUTING.adoc`: +> Since Streams are much slower than `for` loops, please use them judiciously. The team may ask you to change to a `for` loop if the given code is along a hot path. + +### String Case Conversions + +**Must specify locale**: +```java +// Correct +string.toLowerCase(Locale.ROOT) +string.toUpperCase(Locale.ENGLISH) + +// Incorrect (will fail checkstyle) +string.toLowerCase() +string.toUpperCase() +``` + +**Exception**: Test files are exempt from this rule. + +### Modern Java Features + +**Pattern Matching** (used throughout): +```java +if (this.getPrincipal() instanceof UserDetails userDetails) { + return userDetails.getUsername(); +} +``` + +**Records**: Used for DTOs and value objects + +**Sealed Classes**: Used where appropriate + +## Git & Commit Conventions + +### Commit Message Format + +From `CONTRIBUTING.adoc`: + +**Format**: +- **Subject line**: 55 characters max, imperative tense +- **Body**: 72 characters per line +- **Footer**: `Closes gh-ISSUE_NUMBER` + +**Example**: +``` +Address NullPointerException + +Closes gh-22276 +``` + +**Tense**: Use imperative (e.g., "Fix", "Add", "Update") not present/past tense + +### Copyright Headers + +**Update copyright year** if editing existing files: +```java +// If file has: +// Copyright 2002-2023 the original author... + +// And current year is 2024, change to: +// Copyright 2002-2024 the original author... +``` + +### Developer Certificate of Origin + +All commits must include `Signed-off-by` trailer + +## Reactive Code Patterns + +### Mono/Flux Instead of @Nullable + +**Reactive return types**: Don't use `@Nullable` for `Mono`/`Flux` returns + +```java +// Correct +Mono generate(GenerateOneTimeTokenRequest request); + +// Incorrect +@Nullable Mono generate(GenerateOneTimeTokenRequest request); +``` + +**Reasoning**: `Mono.empty()` represents absence in reactive streams + +## Module-Specific Notes + +### Core Module +- 40+ packages with `@NullMarked` +- 600+ `@Nullable` annotations +- Plugin: `security-nullability` in `core/spring-security-core.gradle` + +### Web Module +- 55+ packages with `@NullMarked` +- 737+ `@Nullable` annotations +- Extensive servlet and reactive (WebFlux) support +- Plugin: `security-nullability` in `web/spring-security-web.gradle` + +### Modules Without JSpecify (Suppressed) +Per `etc/checkstyle/checkstyle-suppressions.xml`: +- `access/` - Legacy module +- `aspects/` - AOP module +- `config/` - Configuration module +- `oauth2-*` - OAuth2 modules (partial migration) +- `saml2-service-provider/` - SAML2 module +- `ldap/` - LDAP module + +## Formatting Commands + +### Format Code +```bash +./gradlew format +``` + +### Check Style +```bash +./gradlew check +``` + +### Combined (Recommended) +```bash +./gradlew format && ./gradlew check +``` + +## Key References + +### Primary Configuration Files +- `.editorconfig` - IDE settings (indentation, line endings) +- `build.gradle` - Build configuration with formatter plugin +- `etc/checkstyle/checkstyle.xml` - Checkstyle rules +- `etc/checkstyle/checkstyle-suppressions.xml` - Rule exceptions +- `CONTRIBUTING.adoc` - Contributing guidelines + +### Documentation Resources +- Spring Framework Code Style: https://github.com/spring-projects/spring-framework/wiki/Code-Style +- Spring Framework IntelliJ Settings: https://github.com/spring-projects/spring-framework/wiki/IntelliJ-IDEA-Editor-Settings + +### Null Safety Documentation +- `.cursor/rules/null-safety/jspecify-core-module.mdc` - Core module patterns +- `.cursor/rules/null-safety/jspecify-web-module.mdc` - Web module patterns + +## Summary + +The Spring Security codebase follows a **rigorous, automated code style** enforced through: + +1. **Spring Java Format** plugin for consistent formatting +2. **Tab-based indentation** (4-space display width) +3. **Checkstyle** with custom Spring rules +4. **JSpecify null safety** with package-level `@NullMarked` +5. **Comprehensive Javadoc** with specific tag conventions +6. **Apache License 2.0** headers on all files +7. **EditorConfig** for IDE consistency + +The style prioritizes **readability, maintainability, and type safety** while maintaining compatibility across the extensive Spring Security codebase. diff --git a/.cursor/rules/null-safety/jspecify-core-module.mdc b/.cursor/rules/null-safety/jspecify-core-module.mdc new file mode 100644 index 00000000000..37fe60c1784 --- /dev/null +++ b/.cursor/rules/null-safety/jspecify-core-module.mdc @@ -0,0 +1,361 @@ +--- +description: JSpecify null safety implementation patterns and best practices for the Spring Security @core module +alwaysApply: false +--- + +# JSpecify Null Safety Implementation - @core Module + +## Overview + +The Spring Security @core module has adopted **JSpecify** for null safety annotations, providing compile-time null safety guarantees across the codebase. This implementation leverages JSpecify's type-use annotations for precise nullability declarations. + +## Build Configuration + +### Gradle Plugin Setup + +**Plugin Declaration**: `core/spring-security-core.gradle` + +```gradle +plugins { + id 'security-nullability' +} +``` + +The `security-nullability` plugin is defined in `buildSrc/src/main/groovy/security-nullability.gradle`: + +```gradle +plugins { + id 'io.spring.nullability' +} +``` + +**Dependency**: `gradle/libs.versions.toml` + +```toml +spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.10' +``` + +### Checkstyle Enforcement + +**Configuration**: `etc/checkstyle/checkstyle.xml` + +```xml + + +``` + +This ensures **only JSpecify annotations** are used throughout the codebase, preventing mixing with other nullability frameworks (JSR-305, JetBrains, etc.). + +## Implementation Patterns + +### 1. Package-Level `@NullMarked` + +**Default Non-Null Context**: All packages use `@NullMarked` to establish a non-null default for all types. + +**Example**: `core/src/main/java/org/springframework/security/authentication/package-info.java` + +```java +@NullMarked +package org.springframework.security.authentication; + +import org.jspecify.annotations.NullMarked; +``` + +**Packages with `@NullMarked`** (40+ packages in core): +- `org.springframework.security.access` +- `org.springframework.security.authentication` +- `org.springframework.security.authorization` +- `org.springframework.security.core` +- `org.springframework.security.jackson` +- `org.springframework.security.aot.hint` +- And many subpackages... + +**Benefit**: Within a `@NullMarked` package, all types are non-null by default unless explicitly annotated with `@Nullable`. + +### 2. Method Return Types with `@Nullable` + +**Pattern**: When methods can return null, annotate the return type with `@Nullable`. + +**Example**: `core/src/main/java/org/springframework/security/authorization/method/MethodInvocationResult.java` + +```java +private final @Nullable Object result; + +public @Nullable Object getResult() { + return this.result; +} +``` + +**Example**: `core/src/main/java/org/springframework/security/authentication/AuthenticationTrustResolver.java` + +```java +boolean isAnonymous(@Nullable Authentication authentication); +boolean isRememberMe(@Nullable Authentication authentication); +``` + +**Key Insight**: The `@Nullable` annotation is placed **before the type**, using JSpecify's type-use annotation style. + +### 3. Method Parameters with `@Nullable` + +**Pattern**: Parameters that accept null values are annotated with `@Nullable`. + +**Example**: `core/src/main/java/org/springframework/security/authentication/UsernamePasswordAuthenticationToken.java` + +```java +public UsernamePasswordAuthenticationToken(@Nullable Object principal, @Nullable Object credentials) { + super((Collection) null); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(false); +} + +public static UsernamePasswordAuthenticationToken authenticated(Object principal, @Nullable Object credentials, + Collection authorities) { + return new UsernamePasswordAuthenticationToken(principal, credentials, authorities); +} +``` + +**Note**: `principal` in the authenticated factory method is **not nullable** (no annotation), while `credentials` is nullable. + +### 4. Field Declarations with `@Nullable` + +**Pattern**: Fields that may hold null values use `@Nullable` **before the type**. + +**Example**: `core/src/main/java/org/springframework/security/authentication/AbstractAuthenticationToken.java` + +```java +private @Nullable Object details; + +public @Nullable Object getDetails() { + return this.details; +} + +public void setDetails(@Nullable Object details) { + this.details = details; +} +``` + +**Example - volatile fields**: `core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java` + +```java +private volatile @Nullable String userNotFoundEncodedPassword; +``` + +**Pattern**: Modifiers (`private`, `protected`, `volatile`) come **before** `@Nullable`, which comes **before** the type. + +### 5. Array Type Annotations + +**Critical Pattern**: JSpecify supports type-use annotations on array components. + +**Example**: `core/src/main/java/org/springframework/security/authentication/jaas/memory/InMemoryConfiguration.java` + +```java +private final AppConfigurationEntry @Nullable [] defaultConfiguration; + +public InMemoryConfiguration(Map mappedConfigurations, + AppConfigurationEntry @Nullable [] defaultConfiguration) { + this.mappedConfigurations = mappedConfigurations; + this.defaultConfiguration = defaultConfiguration; +} + +public AppConfigurationEntry @Nullable [] getAppConfigurationEntry(String name) { + AppConfigurationEntry[] mappedResult = this.mappedConfigurations.get(name); + return (mappedResult != null) ? mappedResult : this.defaultConfiguration; +} +``` + +**Syntax**: `Type @Nullable []` means "a nullable array of non-null Type elements" +- The array reference itself can be null +- Array elements are non-null (unless explicitly annotated) + +**More Examples**: `core/src/main/java/org/springframework/security/core/parameters/AnnotationParameterNameDiscoverer.java` + +```java +public String @Nullable [] getParameterNames(Method method) { ... } +public String @Nullable [] getParameterNames(Constructor constructor) { ... } +private String @Nullable [] lookupParameterNames(...) { ... } +``` + +**Complex Example**: `core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java` + +```java +@Nullable String @Nullable [] parameterNames = this.parameterNameDiscoverer.getParameterNames(specificMethod); +``` + +This means: +- The array reference itself may be null (outer `@Nullable`) +- Each element in the array may also be null (inner `@Nullable`) + +### 6. Generic Types with `@Nullable` + +**Pattern**: Generic type arguments can be annotated for nullability. + +**Example**: `core/src/main/java/org/springframework/security/jackson/SecurityJacksonModules.java` + +```java +public static List getModules(ClassLoader loader, + BasicPolymorphicTypeValidator.@Nullable Builder typeValidatorBuilder) { + // ... +} +``` + +**Pattern**: The type argument (`Builder`) is nullable, but the containing type (`BasicPolymorphicTypeValidator`) is not. + +### 7. Builder Pattern Support + +**Pattern**: Builders commonly have nullable parameters for optional configuration. + +**Example**: `core/src/main/java/org/springframework/security/authentication/AbstractAuthenticationToken.java` + +```java +public abstract static class AbstractAuthenticationBuilder> { + + private boolean authenticated; + private @Nullable Object details; + private final Collection authorities; + + @Override + public B details(@Nullable Object details) { + this.details = details; + return (B) this; + } +} +``` + +**All Authentication builders** follow this pattern: +- `UsernamePasswordAuthenticationToken.Builder` +- `TestingAuthenticationToken.Builder` +- `RememberMeAuthenticationToken.Builder` + +### 8. Interface Method Declarations + +**Pattern**: Interfaces declare nullability contracts that implementations must follow. + +**Example**: `core/src/main/java/org/springframework/security/authentication/AuthenticationProvider.java` + +```java +@Nullable Authentication authenticate(Authentication authentication) throws AuthenticationException; +``` + +**Example**: `core/src/main/java/org/springframework/security/core/annotation/SecurityAnnotationScanner.java` + +```java +public interface SecurityAnnotationScanner { + @Nullable A scan(Method method, Class targetClass); + @Nullable A scan(Parameter parameter); +} +``` + +### 9. Static Final Fields with `@Nullable` + +**Pattern**: Constants that may be null are explicitly annotated. + +**Example**: `core/src/main/java/org/springframework/security/core/SpringSecurityCoreVersion.java` + +```java +static final @Nullable String MIN_SPRING_VERSION = getSpringVersion(); + +private static @Nullable String getSpringVersion() { + // May return null +} +``` + +### 10. Contract Annotations + +**Pattern**: Use Spring's `@Contract` annotation alongside `@Nullable` for richer contracts. + +**Example**: `core/src/main/java/org/springframework/security/authentication/AuthenticationTrustResolver.java` + +```java +@Contract("null -> false") +default boolean isAuthenticated(@Nullable Authentication authentication) { + return authentication != null && authentication.isAuthenticated() && !isAnonymous(authentication); +} +``` + +**Benefit**: `@Contract` specifies behavior ("null input returns false"), complementing null safety. + +### 11. Reactive Types (Mono/Flux) + +**Pattern**: Reactive types typically don't need `@Nullable` for return values since they encapsulate absence. + +**Example**: `core/src/main/java/org/springframework/security/authentication/ott/reactive/ReactiveOneTimeTokenService.java` + +```java +Mono generate(GenerateOneTimeTokenRequest request); +Mono consume(OneTimeTokenAuthenticationToken authenticationToken); +``` + +**Key Insight**: `Mono` can be empty, so the return type doesn't need `@Nullable`. The value **inside** the Mono is what matters. + +### 12. Javadoc Integration + +**Pattern**: Update Javadoc to reflect null safety contracts. + +**Examples**: + +```java +/** + * @return the consumed {@link OneTimeToken} or {@code null} if the token is invalid + */ +@Nullable OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken); +``` + +```java +/** + * @param authentication to test (may be null in which case the method + * will always return false) + */ +boolean isAnonymous(@Nullable Authentication authentication); +``` + +## Best Practices Summary + +### ✅ DO + +1. **Apply `@NullMarked` at package level** via `package-info.java` +2. **Place `@Nullable` before the type** for fields, parameters, and return types +3. **Use type-use syntax** for arrays: `Type @Nullable []` +4. **Annotate nullable generic type arguments**: `Container.@Nullable TypeArg` +5. **Maintain Javadoc** that describes null behavior +6. **Keep modifiers before annotations**: `private @Nullable Type field;` +7. **Use `@Contract` for complex contracts** alongside `@Nullable` +8. **Make nullable explicit** - if something can be null, annotate it +9. **Use Reactive types** (`Mono`/`Flux`) to represent absence instead of `@Nullable` where appropriate + +### ❌ DON'T + +1. **Don't mix nullability frameworks** - use only `org.jspecify.annotations` +2. **Don't annotate types that can't be null** within `@NullMarked` packages (redundant) +3. **Don't use `@NonNull`** explicitly in `@NullMarked` contexts (it's the default) +4. **Don't forget constructor parameters** - they need `@Nullable` too +5. **Don't rely solely on Javadoc** - annotations provide compile-time checking + +## Coverage Statistics + +In the **@core module**: +- **40+ packages** are `@NullMarked` +- **600+ usages** of `@Nullable` annotations +- **100% consistency** with JSpecify (enforced by Checkstyle) + +## Related Modules + +Similar patterns are applied across: +- **oauth2-core** (limited usage, not fully migrated) +- **web** module +- **config** module +- **acl** module +- **kerberos** modules +- **test** module + +## Key Files Reference + +- **Build Config**: `core/spring-security-core.gradle` +- **Plugin Definition**: `buildSrc/src/main/groovy/security-nullability.gradle` +- **Dependency Version**: `gradle/libs.versions.toml` (line 104) +- **Checkstyle Rules**: `etc/checkstyle/checkstyle.xml` +- **Example Package**: `core/src/main/java/org/springframework/security/authentication/package-info.java` +- **Array Examples**: `core/src/main/java/org/springframework/security/authentication/jaas/memory/InMemoryConfiguration.java` +- **Builder Examples**: `core/src/main/java/org/springframework/security/authentication/AbstractAuthenticationToken.java` + +This implementation demonstrates **production-grade null safety** using modern type-use annotations with comprehensive build-time enforcement. diff --git a/.cursor/rules/null-safety/jspecify-implementation-plan.mdc b/.cursor/rules/null-safety/jspecify-implementation-plan.mdc new file mode 100644 index 00000000000..03b2ccf3256 --- /dev/null +++ b/.cursor/rules/null-safety/jspecify-implementation-plan.mdc @@ -0,0 +1,750 @@ +--- +description: Comprehensive, actionable implementation plan for JSpecify null safety in Spring Security modules, consolidating patterns from @core, @web, and Spring Framework modules +alwaysApply: false +--- + +# JSpecify Null Safety Implementation Plan for Spring Security Modules + +## Executive Summary + +This consolidated plan provides a complete, actionable guide for implementing JSpecify null safety in any Spring Security module. It synthesizes patterns from: +- Spring Security's [@core module](.cursor/rules/null-safety/jspecify-core-module.mdc) (40+ packages, 600+ `@Nullable` usages) +- Spring Security's [@web module](.cursor/rules/null-safety/jspecify-web-module.mdc) (55+ packages, 737+ `@Nullable` usages) +- Spring Framework's [spring-core module](.cursor/rules/null-safety/jspecify-spring-core-module.mdc) +- Spring Framework's [spring-web module](.cursor/rules/null-safety/jspecify-spring-web-module.mdc) + +Currently, **15 Spring Security modules** have the nullability plugin enabled: core, web, acl, cas, crypto, data, kerberos-client, kerberos-core, kerberos-test, kerberos-web, messaging, rsocket, taglibs, test, and webauthn. + +--- + +## Phase 1: Build Configuration + +### 1.1 Gradle Plugin Setup + +Add the `security-nullability` plugin to your module's Gradle file (e.g., `config/spring-security-config.gradle`): + +```gradle +plugins { + id 'io.spring.convention.spring-module' + id 'security-nullability' // Add this line +} +``` + +The plugin is defined in [`buildSrc/src/main/groovy/security-nullability.gradle`](buildSrc/src/main/groovy/security-nullability.gradle): + +```gradle +plugins { + id 'io.spring.nullability' +} +``` + +The dependency version is managed in [`gradle/libs.versions.toml`](gradle/libs.versions.toml) (line 104): + +```toml +spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.10' +``` + +### 1.2 Checkstyle Enforcement + +No additional configuration needed - [`etc/checkstyle/checkstyle.xml`](etc/checkstyle/checkstyle.xml) already enforces: + +```xml + +``` + +This ensures **only** `org.jspecify.annotations` are used, preventing mixing with JSR-305, JetBrains, Spring Legacy, or other nullability frameworks. + +--- + +## Phase 2: Package-Level Annotation + +### 2.1 Create `package-info.java` Files + +For **every package** in your module, create a `package-info.java` file with `@NullMarked`: + +```java +@NullMarked +package org.springframework.security.config; + +import org.jspecify.annotations.NullMarked; +``` + +**Key principle:** `@NullMarked` establishes **non-null as the default** for all types, parameters, return values, and fields within the package. Only nullable types need explicit annotation. + +### 2.2 Package Coverage Strategy + +Apply systematically: +- Root package: `org.springframework.security.{module}` +- All subpackages: `org.springframework.security.{module}.{feature}` +- All nested subpackages recursively + +**Example from @web module:** +- `org.springframework.security.web` +- `org.springframework.security.web.authentication` +- `org.springframework.security.web.authentication.logout` +- `org.springframework.security.web.authentication.rememberme` +- `org.springframework.security.web.csrf` +- ... (55+ packages total) + +--- + +## Phase 3: Annotation Implementation Patterns + +### 3.1 Method Return Types + +**Pattern:** Annotate return types with `@Nullable` when they can legitimately return null. + +```java +// Interface declaration +public interface RequestCache { + @Nullable SavedRequest getRequest(HttpServletRequest request, HttpServletResponse response); + + @Nullable HttpServletRequest getMatchingRequest(HttpServletRequest request, + HttpServletResponse response); +} + +// Implementation +public class HttpSessionRequestCache implements RequestCache { + @Override + public @Nullable SavedRequest getRequest(HttpServletRequest request, + HttpServletResponse response) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + return (SavedRequest) session.getAttribute(SAVED_REQUEST); + } +} +``` + +**From Spring Framework - AttributeAccessor pattern:** + +```java +public interface AttributeAccessor { + @Nullable Object getAttribute(String name); + + @Nullable Object removeAttribute(String name); +} +``` + +### 3.2 Method Parameters + +**Pattern:** Mark parameters with `@Nullable` when they accept null values. + +```java +public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken { + + // Constructor accepting nullable credentials + public UsernamePasswordAuthenticationToken(@Nullable Object principal, + @Nullable Object credentials) { + super((Collection) null); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(false); + } + + // Factory method with non-null principal, nullable credentials + public static UsernamePasswordAuthenticationToken authenticated(Object principal, + @Nullable Object credentials, + Collection authorities) { + return new UsernamePasswordAuthenticationToken(principal, credentials, authorities); + } +} +``` + +**Web module - setter pattern:** + +```java +public interface CsrfTokenRepository { + // Passing null removes the token + void saveToken(@Nullable CsrfToken token, HttpServletRequest request, + HttpServletResponse response); + + @Nullable CsrfToken loadToken(HttpServletRequest request); +} +``` + +### 3.3 Field Declarations + +**Pattern:** Place `@Nullable` before the type, after access modifiers. + +```java +public class AbstractAuthenticationToken implements Authentication { + + private @Nullable Object details; + + public @Nullable Object getDetails() { + return this.details; + } + + public void setDetails(@Nullable Object details) { + this.details = details; + } +} +``` + +**With volatile modifier:** + +```java +public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { + + private volatile @Nullable String userNotFoundEncodedPassword; +} +``` + +**Order:** `access_modifier` `volatile/static/final` `@Nullable` `Type` `fieldName` + +### 3.4 Array Type Annotations + +**Critical distinction:** + +```java +// Nullable array reference, non-null elements +AppConfigurationEntry @Nullable [] defaultConfiguration; + +// Non-null array reference, nullable elements +String @Nullable [] getParameterNames(Method method); + +// Both nullable (rare) +@Nullable String @Nullable [] parameterNames; +``` + +**Example from @core:** + +```java +public class InMemoryConfiguration extends Configuration { + + private final AppConfigurationEntry @Nullable [] defaultConfiguration; + + public InMemoryConfiguration(Map mappedConfigurations, + AppConfigurationEntry @Nullable [] defaultConfiguration) { + this.mappedConfigurations = mappedConfigurations; + this.defaultConfiguration = defaultConfiguration; + } + + public AppConfigurationEntry @Nullable [] getAppConfigurationEntry(String name) { + AppConfigurationEntry[] mappedResult = this.mappedConfigurations.get(name); + return (mappedResult != null) ? mappedResult : this.defaultConfiguration; + } +} +``` + +### 3.5 Generic Type Arguments + +**Pattern:** Annotate type arguments, not the container type. + +```java +// From @core - nullable type argument +public static List getModules(ClassLoader loader, + BasicPolymorphicTypeValidator.@Nullable Builder typeValidatorBuilder) { + // Implementation +} + +// From Spring Framework - nullable map values +public interface ErrorResponse { + default Object @Nullable [] getDetailMessageArguments() { + return null; + } +} +``` + +### 3.6 Builder Pattern + +**Pattern:** Builders accept `@Nullable` for optional configuration, always return non-null builder. + +```java +public abstract static class AbstractAuthenticationBuilder> { + + private boolean authenticated; + private @Nullable Object details; + private final Collection authorities; + + public B details(@Nullable Object details) { + this.details = details; + return (B) this; // Non-null return + } + + public B authenticated(boolean authenticated) { + this.authenticated = authenticated; + return (B) this; // Non-null return + } +} +``` + +**Web module - URI builder:** + +```java +public class UriComponentsBuilder implements UriBuilder, Cloneable { + + private @Nullable String scheme; + private @Nullable String userInfo; + private @Nullable String host; + private @Nullable String port; + private @Nullable String fragment; + + public UriComponentsBuilder scheme(@Nullable String scheme) { + this.scheme = scheme; + return this; + } + + public UriComponentsBuilder fragment(@Nullable String fragment) { + this.fragment = fragment; + return this; + } +} +``` + +### 3.7 Interface Contracts + +**Pattern:** Interfaces declare nullability that implementations must respect. + +```java +@FunctionalInterface +public interface AuthenticationConverter { + // May return null if request doesn't contain authentication + @Nullable Authentication convert(HttpServletRequest request); +} + +public interface AuthenticationProvider { + // May return null if provider doesn't support the authentication + @Nullable Authentication authenticate(Authentication authentication) + throws AuthenticationException; +} + +public interface SecurityAnnotationScanner { + @Nullable A scan(Method method, Class targetClass); + @Nullable A scan(Parameter parameter); +} +``` + +### 3.8 Static Final Fields + +**Pattern:** Constants that may be null are explicitly annotated. + +```java +public final class SpringSecurityCoreVersion { + + static final @Nullable String MIN_SPRING_VERSION = getSpringVersion(); + + private static @Nullable String getSpringVersion() { + Package pkg = SpringVersion.class.getPackage(); + return (pkg != null) ? pkg.getImplementationVersion() : null; + } +} +``` + +--- + +## Phase 4: Advanced Patterns + +### 4.1 Contract Annotations + +**Pattern:** Combine `@Contract` with `@Nullable` for flow-sensitive analysis. + +```java +public abstract class Assert { + + @Contract("null, _ -> fail") + public static void notNull(@Nullable Object object, String message) { + if (object == null) { + throw new IllegalArgumentException(message); + } + } + + @Contract("false, _ -> fail") + public static void isTrue(boolean expression, String message) { + if (!expression) { + throw new IllegalArgumentException(message); + } + } +} +``` + +**Web module - authentication checks:** + +```java +@Contract("null, _ -> false") +private boolean authenticationIsRequired(@Nullable Authentication existingAuth, + UsernamePasswordAuthenticationToken newAuth) { + if (existingAuth == null || !existingAuth.isAuthenticated()) { + return true; + } + return !existingAuth.getName().equals(newAuth.getName()); +} +``` + +### 4.2 Reactive Types (Mono/Flux) + +**Critical rule:** Use `Mono.empty()` instead of `@Nullable` for reactive return types. + +```java +// ✅ CORRECT - Reactive types handle absence +@FunctionalInterface +public interface ServerAuthenticationConverter { + Mono convert(ServerWebExchange exchange); +} + +public interface ServerRequestCache { + Mono saveRequest(ServerWebExchange exchange); + Mono getRedirectUri(ServerWebExchange exchange); + Mono removeMatchingRequest(ServerWebExchange exchange); +} + +// ❌ INCORRECT - Don't use @Nullable with Mono +@Nullable Mono convert(ServerWebExchange exchange); // Wrong! +``` + +**However:** Synchronous parameters in reactive code still use `@Nullable`: + +```java +public Mono resolveArgument(MethodParameter parameter, + BindingContext bindingContext, + ServerWebExchange exchange) { + return ReactiveSecurityContextHolder.getContext() + .mapNotNull(SecurityContext::getAuthentication) + .flatMap((authentication) -> resolvePrincipal(parameter, authentication.getPrincipal())); +} +``` + +### 4.3 HTTP-Specific Patterns (Web Modules) + +#### HTTP Headers + +```java +public class HttpHeaders implements Serializable { + + // Single-valued headers return @Nullable + public @Nullable String getFirst(String headerName) { + return this.headers.getFirst(headerName); + } + + public @Nullable MediaType getContentType() { + String value = getFirst(CONTENT_TYPE); + return (StringUtils.hasLength(value) ? MediaType.parseMediaType(value) : null); + } + + public @Nullable URI getLocation() { + String value = getFirst(LOCATION); + return (value != null ? URI.create(value) : null); + } +} +``` + +**Convention:** +- Single-valued headers → `@Nullable T` +- Multi-valued headers → non-null `List` (empty if absent) + +#### Client-Provided Metadata + +```java +public interface MultipartFile extends InputStreamSource { + + String getName(); // Always present - form parameter name + + @Nullable String getOriginalFilename(); // Browser-dependent + + @Nullable String getContentType(); // Optional +} +``` + +**Rule:** All client-provided metadata (filenames, content types, headers) should be `@Nullable`. + +#### Setter with Removal Semantics + +```java +public void setContentType(@Nullable MediaType mediaType) { + if (mediaType != null) { + set(CONTENT_TYPE, mediaType.toString()); + } + else { + remove(CONTENT_TYPE); // null = remove + } +} + +private void setOrRemove(String headerName, @Nullable String headerValue) { + if (headerValue != null) { + set(headerName, headerValue); + } + else { + remove(headerName); + } +} +``` + +### 4.4 Dual API Design (Required Variants) + +**Pattern:** Provide both nullable and required variants for optional values. + +```java +interface ResponseSpec { + + // Returns null if no body + @Nullable T body(Class bodyType); + + // Throws IllegalStateException if no body + T requiredBody(Class bodyType); + + // Returns null if no body + @Nullable T body(ParameterizedTypeReference bodyType); + + // Throws if no body + T requiredBody(ParameterizedTypeReference bodyType); +} +``` + +**Naming convention:** Methods prefixed with `required*` must not return null and should throw exceptions. + +### 4.5 Generic Type Parameters with @NonNull + +**Pattern:** Use `@NonNull` on generic type parameters to guarantee non-null values. + +```java +@FunctionalInterface +interface RequiredValueExchangeFunction<@NonNull T> extends ExchangeFunction<@NonNull T> { + + @Override + T exchange(HttpRequest clientRequest, ConvertibleClientHttpResponse clientResponse) + throws IOException; +} +``` + +--- + +## Phase 5: Strategic @SuppressWarnings + +### 5.1 When to Suppress + +Use `@SuppressWarnings("NullAway")` **only** for known tool limitations, always with explanation: + +#### Initialization Patterns + +```java +public abstract class AbstractPreAuthenticatedProcessingFilter extends GenericFilterBean { + + @SuppressWarnings("NullAway.Init") // Initialized in afterPropertiesSet() + private AuthenticationManager authenticationManager; + + @Override + public void afterPropertiesSet() { + Assert.notNull(this.authenticationManager, + "An AuthenticationManager must be set"); + } +} +``` + +#### Constructor Dataflow Limitations + +```java +@SuppressWarnings("NullAway") // Dataflow analysis limitation +public LogoutFilter(LogoutSuccessHandler logoutSuccessHandler, + LogoutHandler... handlers) { + this.handler = new CompositeLogoutHandler(handlers); + Assert.notNull(logoutSuccessHandler, "logoutSuccessHandler cannot be null"); + this.logoutSuccessHandler = logoutSuccessHandler; + setFilterProcessesUrl("/logout"); +} +``` + +#### Reactive Operator Limitations + +```java +@Override +@SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1290 +public Mono resolveArgument(MethodParameter parameter, + BindingContext bindingContext, + ServerWebExchange exchange) { + return ReactiveSecurityContextHolder.getContext() + .mapNotNull(SecurityContext::getAuthentication) // NullAway doesn't understand mapNotNull + .flatMap((authentication) -> resolvePrincipal(parameter, authentication.getPrincipal())); +} +``` + +### 5.2 What NOT to Suppress + +❌ Don't suppress to avoid fixing legitimate null safety issues +❌ Don't suppress without documenting the reason +❌ Don't suppress broadly at class level when method level suffices + +--- + +## Phase 6: Documentation Standards + +### 6.1 Javadoc Requirements + +**Pattern:** Explicitly state when and why null is returned. + +```java +/** + * Return the value of the attribute identified by {@code name}. + * Return {@code null} if the attribute doesn't exist. + * @param name the unique attribute key + * @return the current value of the attribute, if any + */ +@Nullable Object getAttribute(String name); + +/** + * Return the original filename in the client's filesystem. + * ... + * @return the original filename, or the empty String if no file has been chosen + * in the multipart form, or {@code null} if not defined or not available + */ +@Nullable String getOriginalFilename(); + +/** + * Return the {@linkplain MediaType media type} of the body, as specified + * by the {@code Content-Type} header. + * Returns {@code null} when the {@code Content-Type} header is not set. + * @throws InvalidMediaTypeException if the media type value cannot be parsed + */ +public @Nullable MediaType getContentType() { ... } +``` + +### 6.2 Javadoc Best Practices + +✅ State when null is returned +✅ Describe conditions leading to null +✅ Clarify semantic distinctions (null vs empty vs not present) +✅ Update Javadoc when adding `@Nullable` +✅ Be specific about nullable vs non-null parameters + +--- + +## Phase 7: Testing & Validation + +### 7.1 Build Validation + +Run Gradle build to check for null safety violations: + +```bash +./gradlew :{module}:build +``` + +NullAway will report errors like: +- Assigning nullable to non-null +- Returning nullable from non-null method +- Passing nullable to non-null parameter + +### 7.2 Coverage Metrics + +Track progress: +- **Packages marked**: Count `@NullMarked` package-info.java files +- **@Nullable usages**: Count annotations via `git grep "@Nullable"` +- **Files covered**: Count files with at least one nullability annotation + +**Example metrics:** +- @core: 40+ packages, 600+ `@Nullable` usages +- @web: 55+ packages, 737+ `@Nullable` usages across 257 files + +--- + +## Decision Tree: When to Use What + +``` +Is this a return type? +├─ Can return null? +│ ├─ Reactive (Mono/Flux)? → Use Mono.empty() / Flux.empty() +│ └─ Synchronous? → Add @Nullable +└─ Never returns null? + └─ No annotation needed (within @NullMarked) + +Is this a parameter? +├─ Accepts null? → Add @Nullable +└─ Requires non-null? → No annotation (within @NullMarked) + +Is this a field? +├─ Can be null? → private @Nullable Type field; +└─ Always non-null? → private Type field; + +Is this an array? +├─ Array reference nullable? → Type @Nullable [] +├─ Array elements nullable? → @Nullable Type[] +└─ Both? → @Nullable Type @Nullable [] + +Is this a generic type argument? +└─ Container.@Nullable TypeArg + +Is this for HTTP/web code? +├─ Client-provided data? → Always @Nullable +├─ HTTP header (single)? → @Nullable +├─ HTTP header (multi)? → Non-null List (empty if absent) +└─ Optional config? → @Nullable with null=remove semantics +``` + +--- + +## Best Practices Summary + +### ✅ DO + +1. Apply `@NullMarked` at **every** package level +2. Place `@Nullable` **before the type** for fields, parameters, return types +3. Use type-use syntax for arrays: `Type @Nullable []` +4. Annotate nullable generic type arguments: `Container.@Nullable TypeArg` +5. Update Javadoc to describe null behavior +6. Keep modifiers before annotations: `private @Nullable Type field;` +7. Use `@Contract` for complex null contracts +8. Make nullability explicit - if it can be null, annotate it +9. Use Reactive types (`Mono`/`Flux`) for reactive absence +10. Provide both `@Nullable` and `required*()` variants for optional values +11. Return empty collections instead of null for multi-valued data +12. Treat client-provided data as `@Nullable` in web code + +### ❌ DON'T + +1. Don't mix nullability frameworks - use **only** `org.jspecify.annotations` +2. Don't annotate non-null types within `@NullMarked` packages (redundant) +3. Don't use `@NonNull` explicitly in `@NullMarked` contexts (it's the default) +4. Don't forget constructor parameters - they need `@Nullable` too +5. Don't rely solely on Javadoc - annotations provide compile-time checking +6. Don't use `@Nullable` with reactive return types - use `Mono.empty()` +7. Don't suppress NullAway without documenting the reason +8. Don't mix `@Nullable` with `Optional` - choose one pattern + +--- + +## Module-Specific Considerations + +### For Configuration Modules +- Configuration properties often have nullable optional values +- Use builders with `@Nullable` setters +- Provide sensible defaults for null values + +### For Web Modules +- HTTP headers, parameters, cookies are often `@Nullable` +- Use `@Contract` for authentication/authorization checks +- Distinguish servlet (use `@Nullable`) vs reactive (use `Mono`) + +### For Core/Security Modules +- Authentication details commonly `@Nullable` +- Credentials commonly `@Nullable` +- Use interface contracts to enforce nullability + +### For Integration Modules (LDAP, SAML, OAuth2) +- External data sources may return null +- Protocol fields often optional +- Document protocol-specific null semantics + +--- + +## References + +- **JSpecify Annotations**: `org.jspecify.annotations.@NullMarked`, `@Nullable`, `@NonNull`, `@NullUnmarked` +- **Plugin**: `io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.10` +- **Gradle Config**: `buildSrc/src/main/groovy/security-nullability.gradle` +- **Checkstyle**: `etc/checkstyle/checkstyle.xml` +- **Example Modules**: core, web (most comprehensive implementations) + +--- + +## Summary + +This plan provides a complete implementation guide synthesizing patterns from 4 major modules. The key to successful JSpecify adoption is: + +1. **Start with build config** - plugin + Checkstyle enforcement +2. **Blanket package annotation** - `@NullMarked` everywhere establishes non-null default +3. **Systematic annotation** - work through types methodically (interfaces → abstract classes → concrete classes) +4. **Document thoroughly** - update Javadoc to reflect null contracts +5. **Test incrementally** - build frequently to catch violations early +6. **Leverage patterns** - follow established patterns for consistency + +The result is **compile-time null safety** with minimal runtime overhead, catching null pointer errors before production. diff --git a/.cursor/rules/null-safety/jspecify-oauth2-core-plan.mdc b/.cursor/rules/null-safety/jspecify-oauth2-core-plan.mdc new file mode 100644 index 00000000000..e1ffc761ed7 --- /dev/null +++ b/.cursor/rules/null-safety/jspecify-oauth2-core-plan.mdc @@ -0,0 +1,1099 @@ +--- +description: Comprehensive implementation plan for JSpecify null safety in the Spring Security oauth2-core module, covering 12 packages and ~70 Java files with detailed guidance on claim accessors, token types, converters, and HTTP message handling patterns specific to OAuth 2.0 and OpenID Connect +alwaysApply: false +--- + +# JSpecify Null Safety Implementation Plan for oauth2-core Module + +## Module Overview + +The [`oauth2/oauth2-core`](oauth2/oauth2-core) module is a foundational OAuth 2.0 and OpenID Connect module containing: + +- **70 Java files** across **12 packages** +- **13 package-info.java files** - Need `@NullMarked` annotation +- **Plugin status** - Enable `security-nullability` plugin in Gradle config +- **Minimal @SuppressWarnings policy** - Only 5 suppressions allowed in ClaimAccessor for intentional NPE behavior (see Critical Rule section) + +### Key Package Structure + +``` +org.springframework.security.oauth2.core +├── authorization/ (2 files - AuthorizationManagers) +├── converter/ (8 files - Claim converters) +├── endpoint/ (10 files - OAuth2 protocol endpoints) +├── http/converter/ (5 files - HTTP message converters) +├── oidc/ (8 files - OpenID Connect) +│ ├── endpoint/ (2 files) +│ └── user/ (4 files) +├── user/ (4 files - OAuth2 user) +└── web/reactive/function/ (2 files - Reactive support) +``` + +## Current State Analysis + +### Already Using `@Nullable` (Spring Framework's) + +The module currently uses `org.springframework.lang.Nullable` in 8 files that need migration: + +1. [`OAuth2Token.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Token.java) - `getIssuedAt()`, `getExpiresAt()` return types +2. [`OAuth2AuthenticatedPrincipal.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.java) - `getAttribute()` return type +3. [`AbstractOAuth2Token.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2Token.java) - constructor parameters, field declarations, return types +4. [`OAuth2AccessTokenResponse.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AccessTokenResponse.java) - `getRefreshToken()` return type +5. [`OAuth2UserAuthority.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java) - constructor parameters +6. [`OidcUserAuthority.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java) - constructor parameters +7. [`OAuth2TokenIntrospectionClaimAccessor.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaimAccessor.java) - 11 return types +8. [`GenericHttpMessageConverterAdapter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/GenericHttpMessageConverterAdapter.java) - 7 usages + +## Phase 1: Build Configuration + +### Step 1.1: Enable security-nullability Plugin + +**File:** [`oauth2/oauth2-core/spring-security-oauth2-core.gradle`](oauth2/oauth2-core/spring-security-oauth2-core.gradle) + +Add the plugin: + +```gradle +plugins { + id 'compile-warnings-error' + id 'security-nullability' // Add this line +} +``` + +This will enable NullAway static analysis for compile-time null safety checks. + +## Phase 2: Package-Level Annotations + +### Step 2.1: Add @NullMarked to All Packages + +Create or update all 13 `package-info.java` files with `@NullMarked`: + +#### Root Package +- `org.springframework.security.oauth2.core` - Add/verify @NullMarked + +#### Subpackages (create if missing) +- `org.springframework.security.oauth2.core.authorization` +- `org.springframework.security.oauth2.core.converter` +- `org.springframework.security.oauth2.core.endpoint` +- `org.springframework.security.oauth2.core.http` +- `org.springframework.security.oauth2.core.http.converter` +- `org.springframework.security.oauth2.core.oidc` +- `org.springframework.security.oauth2.core.oidc.endpoint` +- `org.springframework.security.oauth2.core.oidc.user` +- `org.springframework.security.oauth2.core.user` +- `org.springframework.security.oauth2.core.web` +- `org.springframework.security.oauth2.core.web.reactive` +- `org.springframework.security.oauth2.core.web.reactive.function` + +**Template for package-info.java:** + +```java +@NullMarked +package org.springframework.security.oauth2.core.PACKAGE_NAME; + +import org.jspecify.annotations.NullMarked; +``` + +## Phase 3: Core Interface Annotations + +### 3.1: Claim Accessor Interfaces + +#### [`ClaimAccessor.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java) + +Add `@Nullable` to all 7 claim getter methods (claims can be missing): + +```java + @Nullable T getClaim(String claim); +@Nullable String getClaimAsString(String claim); +@Nullable Boolean getClaimAsBoolean(String claim); +@Nullable Instant getClaimAsInstant(String claim); +@Nullable URL getClaimAsURL(String claim); +@Nullable Map getClaimAsMap(String claim); +@Nullable List getClaimAsStringList(String claim); +``` + +#### [`StandardClaimAccessor.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/StandardClaimAccessor.java) + +Add `@Nullable` to all 20+ standard claim methods (all delegate to nullable getters): + +```java +@Nullable default String getSubject() { ... } +@Nullable default String getFullName() { ... } +@Nullable default String getGivenName() { ... } +@Nullable default String getFamilyName() { ... } +// ... all other claim methods +``` + +**Rationale:** These delegate to `getClaimAsString()` which returns `@Nullable`, so all must also return `@Nullable`. + +#### [`IdTokenClaimAccessor.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/IdTokenClaimAccessor.java) + +Add `@Nullable` to all 12 ID token claim methods: + +```java +@Nullable default URL getIssuer() { ... } +@Nullable default String getSubject() { ... } +@Nullable default List getAudience() { ... } +@Nullable default Instant getExpiresAt() { ... } +// ... all other claim methods +``` + +#### [`AddressStandardClaim.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/AddressStandardClaim.java) + +Add `@Nullable` to all 6 address fields: + +```java +@Nullable String getFormatted(); +@Nullable String getStreetAddress(); +@Nullable String getLocality(); +@Nullable String getRegion(); +@Nullable String getPostalCode(); +@Nullable String getCountry(); +``` + +### 3.2: Token Interfaces + +#### [`OAuth2Token.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Token.java) + +Migrate to JSpecify: +- Change import from `org.springframework.lang.Nullable` to `org.jspecify.annotations.Nullable` +- Keep `@Nullable` on `getIssuedAt()` and `getExpiresAt()` + +#### [`AbstractOAuth2Token.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2Token.java) + +Migrate to JSpecify: +- Change import +- Keep all existing `@Nullable` annotations on: + - Constructor parameters: `@Nullable Instant issuedAt`, `@Nullable Instant expiresAt` + - Fields: `private final @Nullable Instant issuedAt`, `private final @Nullable Instant expiresAt` + - Getter return types + +#### [`OAuth2RefreshToken.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2RefreshToken.java) + +Add `@Nullable` to constructor parameters (inherits from AbstractOAuth2Token): + +```java +public OAuth2RefreshToken(String tokenValue, @Nullable Instant issuedAt, @Nullable Instant expiresAt) { + super(tokenValue, issuedAt, expiresAt); +} +``` + +#### [`OAuth2AccessToken.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java) + +Add JSpecify import and `@Nullable` to constructor parameters (inherits from AbstractOAuth2Token): + +```java +import org.jspecify.annotations.Nullable; + +public OAuth2AccessToken(TokenType tokenType, String tokenValue, @Nullable Instant issuedAt, + @Nullable Instant expiresAt) { + this(tokenType, tokenValue, issuedAt, expiresAt, Collections.emptySet()); +} + +public OAuth2AccessToken(TokenType tokenType, String tokenValue, @Nullable Instant issuedAt, + @Nullable Instant expiresAt, Set scopes) { + super(tokenValue, issuedAt, expiresAt); + Assert.notNull(tokenType, "tokenType cannot be null"); + this.tokenType = tokenType; + this.scopes = Collections.unmodifiableSet((scopes != null) ? scopes : Collections.emptySet()); +} +``` + +**Rationale:** Test utilities and production code commonly pass `null` for optional timestamps. Without `@Nullable` annotations, dependent modules (e.g., spring-security-test) will have NullAway violations. + +### 3.3: Principal Interfaces + +#### [`OAuth2AuthenticatedPrincipal.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.java) + +Migrate to JSpecify: +- Change import +- Keep ` @Nullable A getAttribute(String name)` + +#### [`DefaultOAuth2AuthenticatedPrincipal.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DefaultOAuth2AuthenticatedPrincipal.java) + +Add `@Nullable` to constructor parameter and implement fallback logic with empty string: + +```java +public DefaultOAuth2AuthenticatedPrincipal(@Nullable String name, Map attributes, + Collection authorities) { + Assert.notEmpty(attributes, "attributes cannot be empty"); + this.attributes = Collections.unmodifiableMap(attributes); + this.authorities = (authorities != null) ? Collections.unmodifiableCollection(authorities) + : AuthorityUtils.NO_AUTHORITIES; + // Ensure name is never null - use 'sub' attribute as fallback, then empty string + // This satisfies AuthenticatedPrincipal.getName() contract which never returns null + String resolvedName = (name != null) ? name : (String) this.attributes.get("sub"); + this.name = (resolvedName != null) ? resolvedName : ""; +} +``` + +**Rationale:** The `name` field must be non-null to satisfy the `AuthenticatedPrincipal.getName()` contract which states it should "Never return null". The empty string fallback is required to support OAuth 2.0 Token Introspection responses (RFC 7662), where the "sub" claim is optional. This class is used by `OAuth2IntrospectionAuthenticatedPrincipal` which may receive introspection responses without a "sub" claim. + +## Phase 4: Endpoint and Response Types + +### 4.1: OAuth2AccessTokenResponse + +#### [`OAuth2AccessTokenResponse.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AccessTokenResponse.java) + +Migrate existing `@Nullable` to JSpecify: +- `getRefreshToken()` - refresh tokens are optional + +Mark builder fields as nullable and implement proper null handling in `build()`: + +```java +public static final class Builder { + private String tokenValue; + private @Nullable OAuth2AccessToken.TokenType tokenType; + private @Nullable Instant issuedAt; + private @Nullable Instant expiresAt; + private @Nullable Set scopes; + private @Nullable String refreshToken; + private @Nullable Map additionalParameters; + + public OAuth2AccessTokenResponse build() { + Assert.notNull(this.tokenType, "tokenType cannot be null"); + // Convert nullable scopes to non-null for constructor + Set scopesToUse = (this.scopes != null) ? this.scopes : Collections.emptySet(); + // ... build logic + } +} +``` + +### 4.2: OAuth2AuthorizationRequest + +#### [`OAuth2AuthorizationRequest.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java) + +Add `@Nullable` to optional fields per OAuth2 spec: +- `redirectUri` - optional per OAuth2 spec +- `state` - optional but recommended +- Builder setter parameters + +Add `@Nullable` to `getAttribute()` method: + +```java +public @Nullable T getAttribute(String name) { + return (T) this.getAttributes().get(name); +} +``` + +### 4.3: OAuth2AuthorizationResponse + +#### [`OAuth2AuthorizationResponse.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java) + +Add `@Nullable` to optional fields: +- `state` +- `code` (optional - only present in success response) +- `error` (optional - only present in error response) + +### 4.4: OAuth2DeviceAuthorizationResponse + +#### [`OAuth2DeviceAuthorizationResponse.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java) + +Add `@Nullable` to optional field: +- `verificationUriComplete` - optional per device flow spec + +## Phase 5: Converter Implementations + +### 5.1: Claim Converters + +All 6 converters need `@Nullable` on convert method: + +- [`ObjectToBooleanConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java) +- [`ObjectToInstantConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java) +- [`ObjectToListStringConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java) +- [`ObjectToMapStringObjectConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java) +- [`ObjectToStringConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java) +- [`ObjectToURLConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java) + +**Pattern:** + +```java +@Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) +``` + +**Additional fixes:** + +- [`ClaimConversionService.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java) - Mark static field as nullable: + +```java +private static volatile @Nullable ClaimConversionService sharedInstance; +``` + +- [`ObjectToMapStringObjectConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java) - Maintain original logic while handling nullable descriptor: + +```java +@Override +public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + TypeDescriptor mapKeyTypeDescriptor = targetType.getMapKeyTypeDescriptor(); + return targetType.getElementTypeDescriptor() == null + || (mapKeyTypeDescriptor != null && mapKeyTypeDescriptor.getType().equals(String.class)); +} +``` + +**Important:** Don't change the original boolean logic - just extract the nullable descriptor to a variable and null-check it. The original logic was `|| keyType.getType()...` which needs to become `|| (keyType != null && keyType.getType()...)`. + +## Phase 6: HTTP Message Converters + +### 6.1: GenericHttpMessageConverterAdapter + +#### [`GenericHttpMessageConverterAdapter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/GenericHttpMessageConverterAdapter.java) + +Migrate 7 existing `@Nullable` usages to JSpecify. + +### 6.2: OAuth2 Protocol Converters + +- [`OAuth2AccessTokenResponseHttpMessageConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java) +- [`OAuth2DeviceAuthorizationResponseHttpMessageConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java) +- [`OAuth2ErrorHttpMessageConverter.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java) + +Review all methods for proper nullable handling. Add assertions to validate non-null JSON message converter: + +```java +public OAuth2AccessTokenResponseHttpMessageConverter() { + super(DEFAULT_CHARSET, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + GenericHttpMessageConverter converter = HttpMessageConverters.getJsonMessageConverter(); + Assert.notNull(converter, "Unable to locate a supported JSON message converter"); + this.jsonMessageConverter = converter; +} +``` + +### 6.3: HttpMessageConverters + +Mark static method return as nullable: + +```java +static @Nullable GenericHttpMessageConverter getJsonMessageConverter() { + // ... returns null if no JSON converter found +} +``` + +## Phase 7: Error Types + +### 7.1: OAuth2Error + +#### [`OAuth2Error.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Error.java) + +Add `@Nullable` to optional fields: + +```java +private final String errorCode; +private final @Nullable String description; +private final @Nullable String uri; + +public OAuth2Error(String errorCode, @Nullable String description, @Nullable String uri) { + Assert.hasText(errorCode, "errorCode cannot be empty"); + this.errorCode = errorCode; + this.description = description; + this.uri = uri; +} + +public @Nullable String getDescription() { + return this.description; +} + +public @Nullable String getUri() { + return this.uri; +} +``` + +### 7.2: OAuth2AuthenticationException + +#### [`OAuth2AuthenticationException.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java) + +Update constructor to handle nullable parameters properly: + +```java +public OAuth2AuthenticationException(OAuth2Error error, @Nullable String message) { + super(message); + Assert.notNull(error, "error cannot be null"); + this.error = error; +} + +public OAuth2AuthenticationException(OAuth2Error error, @Nullable String message, @Nullable Throwable cause) { + super(message); + Assert.notNull(error, "error cannot be null"); + this.error = error; + if (cause != null) { + initCause(cause); + } +} +``` + +## Phase 8: User Types + +### 8.1: OAuth2User Implementations + +#### [`DefaultOAuth2User.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java) + +Add `@Nullable` to authorities parameter and cache name value: + +```java +private final String nameAttributeKey; +private final String name; // Cached value + +public DefaultOAuth2User(@Nullable Collection authorities, + Map attributes, String nameAttributeKey) { + Assert.notEmpty(attributes, "attributes cannot be empty"); + Assert.hasText(nameAttributeKey, "nameAttributeKey cannot be empty"); + Object nameAttributeValue = attributes.get(nameAttributeKey); + Assert.notNull(nameAttributeValue, "Attribute value for '" + nameAttributeKey + "' cannot be null"); + + this.authorities = (authorities != null) + ? Collections.unmodifiableSet(new LinkedHashSet<>(this.sortAuthorities(authorities))) + : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); + this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); + this.nameAttributeKey = nameAttributeKey; + this.name = nameAttributeValue.toString(); // Cache non-null value +} + +@Override +public String getName() { + return this.name; // Return cached value +} +``` + +#### [`OAuth2UserAuthority.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java) + +Migrate to JSpecify and add `@Nullable` to userNameAttributeName field: + +```java +private final @Nullable String userNameAttributeName; + +public @Nullable String getUserNameAttributeName() { + return this.userNameAttributeName; +} +``` + +### 8.2: OIDC User Implementations + +#### [`OidcUser.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUser.java) + +Add `@Nullable` to getUserInfo() (UserInfo is optional in OIDC): + +```java +@Nullable OidcUserInfo getUserInfo(); +``` + +#### [`DefaultOidcUser.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java) + +Add `@Nullable` to constructor parameters and field: + +```java +private final @Nullable OidcUserInfo userInfo; + +public DefaultOidcUser(@Nullable Collection authorities, + OidcIdToken idToken, @Nullable OidcUserInfo userInfo) { + // ... +} + +@Override +public @Nullable OidcUserInfo getUserInfo() { + return this.userInfo; +} +``` + +#### [`OidcUserAuthority.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java) + +Migrate to JSpecify and update all constructors to accept nullable userInfo: + +```java +private final @Nullable OidcUserInfo userInfo; + +public OidcUserAuthority(OidcIdToken idToken, @Nullable OidcUserInfo userInfo) { + this("OIDC_USER", idToken, userInfo, null); +} + +public OidcUserAuthority(OidcIdToken idToken, @Nullable OidcUserInfo userInfo, + @Nullable String userNameAttributeName) { + this("OIDC_USER", idToken, userInfo, userNameAttributeName); +} + +public OidcUserAuthority(String authority, OidcIdToken idToken, @Nullable OidcUserInfo userInfo, + @Nullable String userNameAttributeName) { + super(authority, collectClaims(idToken, userInfo), userNameAttributeName); + this.idToken = idToken; + this.userInfo = userInfo; +} + +@Override +public @Nullable OidcUserInfo getUserInfo() { + return this.userInfo; +} + +@Override +public boolean equals(Object obj) { + if (!super.equals(obj)) { + return false; + } + OidcUserAuthority that = (OidcUserAuthority) obj; + return Objects.equals(this.getUserInfo(), that.getUserInfo()); +} +``` + +#### [`OidcIdToken.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java) + +Add `@Nullable` to constructor parameters (timestamps are optional): + +```java +public OidcIdToken(String tokenValue, @Nullable Instant issuedAt, @Nullable Instant expiresAt, + Map claims) { + super(tokenValue, issuedAt, expiresAt); + Assert.notEmpty(claims, "claims cannot be empty"); + this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims)); +} +``` + +Update Builder.toInstant() to return nullable: + +```java +private @Nullable Instant toInstant(@Nullable Object timestamp) { + if (timestamp != null) { + Assert.isInstanceOf(Instant.class, timestamp, "timestamps must be of type Instant"); + } + return (Instant) timestamp; +} +``` + +#### [`DefaultAddressStandardClaim.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/DefaultAddressStandardClaim.java) + +Add `@Nullable` to all fields and builder methods: + +```java +private @Nullable String formatted; +private @Nullable String streetAddress; +private @Nullable String locality; +private @Nullable String region; +private @Nullable String postalCode; +private @Nullable String country; + +public static class Builder { + private @Nullable String formatted; + private @Nullable String streetAddress; + // ... all fields @Nullable + + public Builder formatted(@Nullable String formatted) { ... } + public Builder streetAddress(@Nullable String streetAddress) { ... } + // ... all methods accept @Nullable +} +``` + +## Phase 9: Reactive Support + +### 9.1: OAuth2BodyExtractors + +#### [`OAuth2BodyExtractors.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/function/OAuth2BodyExtractors.java) + +**Critical Rule:** Do NOT use `@Nullable` with reactive return types (`Mono`/`Flux`). Use `Mono.empty()` for absence. + +Verify no `@Nullable` on reactive types. + +#### [`OAuth2AccessTokenResponseBodyExtractor.java`](oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/function/OAuth2AccessTokenResponseBodyExtractor.java) + +Add `@Nullable` to local variables that receive nullable values: + +```java +private @Nullable String getParameterValue(Map parameters, String name) { + Object obj = parameters.get(name); + return (obj != null) ? obj.toString() : null; +} +``` + +## Phase 10: Documentation Updates + +### 10.1: Javadoc Standards + +Update Javadoc for all nullable returns to explicitly state: +- When null is returned +- Conditions leading to null +- Alternative values (e.g., empty collections) + +**Example Pattern:** + +```java +/** + * Returns the refresh token. + * @return the refresh token, or {@code null} if not present in the response + */ +@Nullable OAuth2RefreshToken getRefreshToken(); +``` + +### 10.2: Files Requiring Javadoc Updates + +- All `ClaimAccessor` method documentation +- All `StandardClaimAccessor` method documentation +- Token getter methods +- Converter methods +- Principal attribute getters + +## Phase 11: Validation & Testing + +### 11.1: Build Validation + +```bash +# Clean build to catch all violations +./gradlew :spring-security-oauth2-core:clean :spring-security-oauth2-core:compileJava +``` + +### 11.2: Expected NullAway Checks + +NullAway will enforce: +- No passing nullable to non-null parameters +- No returning nullable from non-null methods +- No dereferencing potentially null values +- Proper initialization of non-null fields + +### 11.3: Fix All Violations + +When violations are found, use the solution patterns documented below. **DO NOT use @SuppressWarnings**. + +## Implementation Strategy + +### Recommended Order + +1. **Phase 1:** Enable plugin in Gradle (5 min) +2. **Phase 2:** Add all package-info.java files (20 min) +3. **Phase 3:** Annotate core interfaces (ClaimAccessor, OAuth2Token) (30 min) +4. **Phase 4:** Migrate existing `@Nullable` from Spring to JSpecify (15 min) +5. **Phase 5:** Annotate endpoint types (OAuth2AuthorizationRequest, responses) (45 min) +6. **Phase 6:** Annotate converters (30 min) +7. **Phase 7:** Annotate error types (30 min) +8. **Phase 8:** Annotate user types (30 min) +9. **Phase 9:** Verify reactive types (no @Nullable on Mono/Flux) (15 min) +10. **Phase 10:** Update Javadoc (45 min) +11. **Phase 11:** Build validation and fix NullAway violations (2-4 hours) +12. **Phase 12:** Fix dependent module tests if needed (30-60 min) + +**Total Estimated Effort:** 10-15 hours + +**Note:** Phase 11 typically reveals 20-30 NullAway violations that require careful fixes following the solution patterns. The build must be run iteratively to catch all violations. + +## Key Decision Points + +### 1. Optional Claims + +**Decision:** All claim accessor methods return `@Nullable` types. + +**Rationale:** OAuth2 and OIDC claims are optional by specification. Returning null for missing claims is the established pattern. + +### 2. Reactive Types + +**Decision:** No `@Nullable` on `Mono` or `Flux` return types. + +**Rationale:** Reactive types have built-in absence semantics (`Mono.empty()`). Adding `@Nullable` would be redundant and confusing. + +### 3. Constructor Parameters + +**Decision:** Mark constructor parameters as `@Nullable` only when: +- Parameter is explicitly optional +- Constructor handles null with fallback logic +- Null has semantic meaning (e.g., "use default") + +**Rationale:** Prefer non-null by default for constructor parameters unless null is a valid design choice. + +### 4. Builder Patterns + +**Decision:** Builder methods accept `@Nullable` for optional properties, always return non-null builder. + +**Rationale:** Builders are fluent APIs - setters should accept optional values, but the builder chain must never break. + +## Solution Patterns for Common Issues + +### Critical Rule: No @SuppressWarnings for NullAway + +**Policy:** `@SuppressWarnings("NullAway")` should be avoided for nullability errors in this module, with **one documented exception**. + +**Exception:** The ClaimAccessor interface methods (`getClaimAsBoolean`, `getClaimAsInstant`, `getClaimAsURL`, `getClaimAsMap`, `getClaimAsStringList`) intentionally allow NPE when claim values are null per their documented API contract. These 5 methods use `@SuppressWarnings("NullAway")` to permit dereferencing nullable claim values in assertion error messages. + +```java +@SuppressWarnings("NullAway") +default @Nullable Boolean getClaimAsBoolean(String claim) { + if (!hasClaim(claim)) { + return null; + } + Object claimValue = getClaims().get(claim); + Boolean convertedValue = ClaimConversionService.getSharedInstance().convert(claimValue, Boolean.class); + // claimValue.getClass() may throw NPE if claimValue is null - this is intentional per @throws NullPointerException + Assert.notNull(convertedValue, + () -> "Unable to convert claim '" + claim + "' of type '" + claimValue.getClass() + "' to Boolean."); + return convertedValue; +} +``` + +**Rationale:** All other nullability issues must be fixed properly rather than suppressed, ensuring compile-time null safety is enforced throughout the module. + +### Issue 1: Private Constructor Field Initialization + +**Problem:** Builder pattern with private constructor and uninitialized fields. + +**Solution:** Mark fields as `@Nullable` and add assertions in getters: + +```java +public final class OAuth2AccessTokenResponse { + private @Nullable OAuth2AccessToken accessToken; + private @Nullable Map additionalParameters; + + private OAuth2AccessTokenResponse() { + // No @SuppressWarnings needed + } + + public OAuth2AccessToken getAccessToken() { + Assert.notNull(this.accessToken, "accessToken cannot be null"); + return this.accessToken; + } + + public Map getAdditionalParameters() { + Assert.notNull(this.additionalParameters, "additionalParameters cannot be null"); + return this.additionalParameters; + } +} +``` + +### Issue 2: Constructor Dataflow Analysis + +**Problem:** NullAway doesn't understand Assert.notNull() contracts, and non-null fields must be initialized from potentially nullable sources. + +**Solution:** Use chained ternary operators with empty string fallback for nullable parameters that must result in non-null fields: + +```java +public DefaultOAuth2AuthenticatedPrincipal(@Nullable String name, Map attributes, + Collection authorities) { + Assert.notEmpty(attributes, "attributes cannot be empty"); + this.attributes = Collections.unmodifiableMap(attributes); + this.authorities = (authorities != null) ? Collections.unmodifiableCollection(authorities) + : AuthorityUtils.NO_AUTHORITIES; + // Ensure name is never null - use 'sub' attribute as fallback, then empty string + // This satisfies AuthenticatedPrincipal.getName() contract which never returns null + String resolvedName = (name != null) ? name : (String) this.attributes.get("sub"); + this.name = (resolvedName != null) ? resolvedName : ""; +} +``` + +**Rationale:** When a contract requires a non-null field (e.g., to satisfy `AuthenticatedPrincipal.getName()`), use a safe default value (like empty string) rather than throwing an exception. This pattern is required for OAuth 2.0 Token Introspection (RFC 7662), where the "sub" claim is optional. Alternative approaches using `Assert.notNull()` would break legitimate use cases. + +### Issue 3: Cached Field Values + +**Problem:** Getter calls nullable method repeatedly. + +**Solution:** Cache the value as a non-null field: + +```java +public class DefaultOAuth2User implements OAuth2User { + private final String nameAttributeKey; + private final String name; // Cached value + + public DefaultOAuth2User(@Nullable Collection authorities, + Map attributes, String nameAttributeKey) { + Assert.notEmpty(attributes, "attributes cannot be empty"); + Assert.hasText(nameAttributeKey, "nameAttributeKey cannot be empty"); + Object nameAttributeValue = attributes.get(nameAttributeKey); + Assert.notNull(nameAttributeValue, "Attribute value for '" + nameAttributeKey + "' cannot be null"); + + this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); + this.nameAttributeKey = nameAttributeKey; + this.name = nameAttributeValue.toString(); // Cache non-null value + } + + @Override + public String getName() { + return this.name; // Return cached value + } +} +``` + +### Issue 4: Parent Constructor Nullable Parameters + +**Problem:** Calling parent constructor that doesn't accept `@Nullable Throwable cause`. + +**Solution:** Accept nullable cause parameter and use `initCause()` when not null: + +```java +public OAuth2AuthenticationException(OAuth2Error error, @Nullable String message, @Nullable Throwable cause) { + super(message); // Call single-parameter constructor + Assert.notNull(error, "error cannot be null"); + this.error = error; + if (cause != null) { + initCause(cause); // Set cause only if not null + } +} +``` + +### Issue 4a: Inner Class Type Annotations + +**Problem:** Annotating nullable inner class types with incorrect syntax. + +**Solution:** For inner/nested classes, place `@Nullable` after the outer class name: + +```java +// ❌ INCORRECT - annotation in wrong position +private @Nullable OAuth2AccessToken.TokenType tokenType; + +// ✅ CORRECT - annotation after outer class name +private OAuth2AccessToken.@Nullable TokenType tokenType; +``` + +**Rationale:** Type-use annotations for nested types must follow the syntax `OuterClass.@Nullable InnerClass` to properly annotate the inner type reference. + +### Issue 5: Nullable to Non-Null Parameter Conversion + +**Problem:** Passing nullable value to method requiring non-null. + +**Solution:** Convert null to non-null value explicitly: + +```java +// Builder method accepts nullable +public Builder scopes(@Nullable Set scopes) { + this.scopes = scopes; + return this; +} + +// build() converts nullable to non-null +public OAuth2AccessTokenResponse build() { + Set scopesToUse = (this.scopes != null) ? this.scopes : Collections.emptySet(); + accessTokenResponse.accessToken = new OAuth2AccessToken(this.tokenType, this.tokenValue, + issuedAt, expiresAt, scopesToUse); // Pass non-null + return accessTokenResponse; +} +``` + +### Issue 6: Nullable Comparison in equals() + +**Problem:** Calling equals() on potentially null object. + +**Solution:** Use `Objects.equals()` which handles null: + +```java +@Override +public boolean equals(Object obj) { + if (!super.equals(obj)) { + return false; + } + OidcUserAuthority that = (OidcUserAuthority) obj; + return Objects.equals(this.getUserInfo(), that.getUserInfo()); +} +``` + +### Issue 7: Generic Type Arguments + +**Problem:** Complex generic types like `Map`. + +**Solution:** Use type-use syntax carefully: + +```java +// Nullable map values +Map attributes; + +// Nullable type argument +Converter<@Nullable Object, String> converter; +``` + +### Issue 8: Required Fields from External Data + +**Problem:** Converter receives external data (e.g., HTTP response) where a field should be required by protocol but is nullable from parsing. + +**Solution:** Validate required fields rather than defaulting to empty/null values. The codebase uses two styles: + +**Style A - Explicit if-throw (preferred for converters with multiple required fields):** +```java +public OAuth2DeviceAuthorizationResponse convert(Map parameters) { + String deviceCode = getParameterValue(parameters, OAuth2ParameterNames.DEVICE_CODE); + if (deviceCode == null) { + throw new IllegalArgumentException("Missing required parameter: " + OAuth2ParameterNames.DEVICE_CODE); + } + String userCode = getParameterValue(parameters, OAuth2ParameterNames.USER_CODE); + if (userCode == null) { + throw new IllegalArgumentException("Missing required parameter: " + OAuth2ParameterNames.USER_CODE); + } + // ... use validated values +} +``` + +**Style B - Assert utilities (preferred for simple validation):** +```java +public OAuth2Error convert(Map parameters) { + String errorCode = parameters.get(OAuth2ParameterNames.ERROR); + Assert.hasText(errorCode, "errorCode cannot be empty"); + return new OAuth2Error(errorCode, ...); +} +``` + +**Anti-pattern - never do this:** +```java +// ❌ INCORRECT - silently defaults to invalid value +public OAuth2Error convert(Map parameters) { + String errorCode = parameters.get(OAuth2ParameterNames.ERROR); + if (errorCode == null) { + errorCode = ""; // Creates invalid OAuth2Error + } + return new OAuth2Error(errorCode, ...); +} +``` + +**Guidelines:** +- **Use Style A** when converting complex responses with multiple required parameters (e.g., `OAuth2AccessTokenResponse`, `OAuth2DeviceAuthorizationResponse`) +- **Use Style B** when validating single required fields or for simpler conversions (e.g., `OAuth2Error`) +- **Both styles** throw `IllegalArgumentException` on validation failure (Assert utilities wrap explicit throws) +- **Never** silently default to empty/null values—protocol violations should fail fast with clear error messages + +**Rationale:** +- Protocol specifications (OAuth 2.0, OIDC) require certain fields to be present +- Silently defaulting creates invalid objects that fail later with unclear errors +- Explicit validation provides immediate feedback when external data violates protocol requirements +- Style A provides more specific error messages for complex conversions +- Style B is more concise for simple cases + +## Module-Specific Patterns + +### OAuth2/OIDC Claim Handling + +All claim-related methods follow this pattern: + +```java +// Interface declares nullable +@Nullable String getClaimAsString(String claim); + +// Implementation checks existence +default String getClaimAsString(String claim) { + return !hasClaim(claim) ? null : convert(getClaims().get(claim), String.class); +} +``` + +### Token Lifecycle + +Tokens have optional timestamps: + +```java +// Interface +@Nullable Instant getIssuedAt(); +@Nullable Instant getExpiresAt(); + +// Abstract base class +private final @Nullable Instant issuedAt; +private final @Nullable Instant expiresAt; + +protected AbstractOAuth2Token(String tokenValue, @Nullable Instant issuedAt, @Nullable Instant expiresAt) { + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; +} +``` + +### HTTP Protocol Patterns + +OAuth2 responses have optional fields: + +```java +// Access token is required +OAuth2AccessToken getAccessToken(); + +// Refresh token is optional +@Nullable OAuth2RefreshToken getRefreshToken(); + +// Additional parameters may be empty but never null +Map getAdditionalParameters(); // Empty map if none +``` + +## Cross-Module Impact & Test Fixes + +The null safety changes in `oauth2-core` module affect dependent modules due to stricter nullability contracts: + +### oauth2-resource-server Module + +**File:** `BearerTokenAuthenticationTests.java` + +**Issue:** Test `getNameWhenHasNoSubjectThenReturnsNull()` expected `null` from `getName()`, but the implementation returns `""` (empty string). + +**Fix:** Update test assertion to expect empty string: + +```java +@Test +public void getNameWhenHasNoSubjectThenReturnsNull() { + OAuth2AuthenticatedPrincipal principal = new DefaultOAuth2AuthenticatedPrincipal( + Collections.singletonMap("claim", "value"), null); + BearerTokenAuthentication authenticated = new BearerTokenAuthentication(principal, this.token, null); + assertThat(authenticated.getName()).isEqualTo(""); // Changed from isNull() +} +``` + +**Rationale:** +- The `AuthenticatedPrincipal` interface contract states `getName()` should "Never return null" +- `DefaultOAuth2AuthenticatedPrincipal` falls back to empty string when both the provided name and "sub" attribute are null +- This design is required to support OAuth 2.0 Token Introspection (RFC 7662), where "sub" is optional + +### Verification Steps + +After completing oauth2-core implementation: + +1. **Build oauth2-core module:** + ```bash + ./gradlew :spring-security-oauth2-core:build + ``` + +2. **Build dependent modules:** + ```bash + ./gradlew :spring-security-oauth2-client:build + ./gradlew :spring-security-oauth2-resource-server:build + ``` + +3. **Verify all tests pass:** + - oauth2-core: All tests pass + - oauth2-client: All 1365 tests pass + - oauth2-resource-server: All 327 tests pass + +## Success Criteria + +1. **Gradle build runs with `security-nullability` plugin enabled** + - Enable plugin for oauth2-core module + - Verify NullAway runs during compilation + - First build will reveal ~29 violations to fix + +2. **All package-info.java files have `@NullMarked`** + - Verify all 13 package-info.java files exist + - Add missing @NullMarked annotations (7 needed) + - Verify no compilation errors + +3. **All Spring Framework `@Nullable` migrated to JSpecify** + - Migrate 8 files currently using org.springframework.lang.Nullable + - Update import statements + - Verify no compilation warnings + +4. **Zero NullAway violations after migration** + - Fix all violations discovered during build (~29 violations expected) + - Common violations include: + - Builder initialization patterns + - Nullable dereferencing in equals/hashCode methods + - Return type mismatches in converters + - Null checks needed before dereferencing + - Verify build passes with zero errors + - **Only 5 @SuppressWarnings("NullAway") allowed in ClaimAccessor for intentional NPE behavior** + +5. **All public API methods have proper nullability documentation** + - Add/update Javadoc for nullable return types (completed inline with annotations) + - Add/update Javadoc for nullable parameters + - Verify Javadoc accuracy + +6. **All tests pass** + - Run oauth2-core module tests: `./gradlew :spring-security-oauth2-core:test` + - Run dependent module tests (oauth2-client, oauth2-resource-server) + - Fix any test failures related to null safety changes + - Verify DefaultOAuth2AuthenticatedPrincipal empty string fallback works correctly + +7. **Checkstyle and formatting validation** + - Run checkstyle: `./gradlew :spring-security-oauth2-core:checkstyleMain :spring-security-oauth2-core:checkstyleTest` + - Common issues to watch for: + - Remove any remaining Spring `@Nullable` imports (banned by checkstyle) + - Remove unused JSpecify `@Nullable` imports + - Remove redundant `@NonNull` annotations (not needed in `@NullMarked` packages) + - Run formatter: `./gradlew :spring-security-oauth2-core:format` + - Verify full build: `./gradlew :spring-security-oauth2-core:build` + +8. **Code review & validation** + - Review all changes for correctness + - Run full test suite across all oauth2 modules + - Validate no behavioral changes + +## References + +- **Implementation Plan:** [`.cursor/rules/null-safety/jspecify-implementation-plan.mdc`](.cursor/rules/null-safety/jspecify-implementation-plan.mdc) +- **Core Module Patterns:** [`.cursor/rules/null-safety/jspecify-core-module.mdc`](.cursor/rules/null-safety/jspecify-core-module.mdc) +- **Web Module Patterns:** [`.cursor/rules/null-safety/jspecify-web-module.mdc`](.cursor/rules/null-safety/jspecify-web-module.mdc) +- **OAuth 2.0 Spec:** https://tools.ietf.org/html/rfc6749 +- **OIDC Spec:** https://openid.net/specs/openid-connect-core-1_0.html +- **JSpecify Docs:** https://jspecify.dev/ diff --git a/.cursor/rules/null-safety/jspecify-spring-core-module.mdc b/.cursor/rules/null-safety/jspecify-spring-core-module.mdc new file mode 100644 index 00000000000..829af1a3135 --- /dev/null +++ b/.cursor/rules/null-safety/jspecify-spring-core-module.mdc @@ -0,0 +1,348 @@ +--- +description: JSpecify null safety implementation patterns and best practices from Spring Framework's spring-core module +alwaysApply: false +--- + +# JSpecify Null Safety Implementation in Spring Framework's spring-core Module + +Based on analysis of the Spring Framework's spring-core module, this document provides comprehensive patterns and best practices for JSpecify null safety implementation. + +## Overview + +Spring Framework 7.0+ has adopted JSpecify annotations for null safety throughout the spring-core module. This implementation provides build-time null safety checking while maintaining runtime compatibility with Kotlin's null safety features and supporting various annotation styles. + +--- + +## Key Implementation Patterns + +### 1. Package-Level `@NullMarked` Annotation + +Spring Framework applies `@NullMarked` at the **package level** via `package-info.java` files, establishing that all types within the package are non-null by default: + +```java +@NullMarked +package org.springframework.core; + +import org.jspecify.annotations.NullMarked; +``` + +This is applied consistently across all sub-packages: + +```java +@NullMarked +package org.springframework.core.io; + +import org.jspecify.annotations.NullMarked; +``` + +**Best Practice:** Package-level `@NullMarked` reduces annotation noise by making non-null the default, requiring explicit marking only for nullable types. + +--- + +### 2. Explicit `@Nullable` for Nullable Types + +When a return type, parameter, or field can be null, Spring explicitly marks it with `@Nullable`: + +```java +public interface AttributeAccessor { + @Nullable Object getAttribute(String name); + + void setAttribute(String name, @Nullable Object value); + + @Nullable Object removeAttribute(String name); +} +``` + +**Pattern:** `@Nullable` is used for: +- Return types that may return null +- Parameters that accept null values +- Fields that may be null + +--- + +### 3. Type Use Annotations + +Spring Framework applies nullability annotations at the **type use** level, including on generics and array components: + +```java +public class ResolvableType { + private volatile ResolvableType @Nullable [] interfaces; + + private volatile @Nullable Class resolved; + + public @Nullable Class [] resolveGenerics() { + // Returns array where elements can be null + } + + private @Nullable ResolvableType componentType; +} +``` + +**Advanced Pattern:** Annotations can be placed on: +- Array components: `Type @Nullable []` (nullable array elements) +- Array itself: `@Nullable Type[]` (nullable array reference) +- Generic type arguments: `Map` + +--- + +### 4. Contract Annotations for Validation + +Spring combines JSpecify with custom `@Contract` annotations for flow-sensitive type checking: + +```java +public abstract class Assert { + @Contract("null, _ -> fail") + public static void notNull(@Nullable Object object, String message) { + if (object == null) { + throw new IllegalArgumentException(message); + } + } + + @Contract("false, _ -> fail") + public static void isTrue(boolean expression, String message) { + if (!expression) { + throw new IllegalArgumentException(message); + } + } +} +``` + +**Best Practice:** The `@Contract` annotation helps static analyzers understand that certain code paths never return normally (fail), enabling better null safety analysis. + +--- + +### 5. Runtime Nullness Utility + +Spring provides a comprehensive `Nullness` enum and utility class for **runtime nullness detection**: + +```java +public enum Nullness { + UNSPECIFIED, // Java default for non-primitive types + NULLABLE, // Can include null (@Nullable) + NON_NULL // Will not include null (@NullMarked) +} +``` + +This utility: +- Detects JSpecify annotations (`@NullMarked`, `@Nullable`, `@NonNull`, `@NullUnmarked`) +- Supports Kotlin null safety introspection +- Handles package/class/element-level annotation hierarchy +- Recognizes any `@Nullable` annotation regardless of package + +**Implementation Example:** + +```java +public static Nullness forMethodReturnType(Method method) { + if (KOTLIN_REFLECT_PRESENT && KotlinDetector.isKotlinType(method.getDeclaringClass())) { + return KotlinDelegate.forMethodReturnType(method); + } + return (hasNullableAnnotation(method) ? Nullness.NULLABLE : + jSpecifyNullness(method, method.getDeclaringClass(), method.getAnnotatedReturnType())); +} +``` + +--- + +### 6. Hierarchical Annotation Resolution + +Spring implements a sophisticated hierarchy for nullness annotations: + +```java +private static Nullness jSpecifyNullness( + AnnotatedElement annotatedElement, Class declaringClass, AnnotatedType annotatedType) { + + // 1. Primitive types are always non-null (except void) + if (annotatedType.getType() instanceof Class clazz && clazz.isPrimitive()) { + return (clazz != void.class ? Nullness.NON_NULL : Nullness.UNSPECIFIED); + } + + // 2. Type-level annotations + if (annotatedType.isAnnotationPresent(Nullable.class)) { + return Nullness.NULLABLE; + } + if (annotatedType.isAnnotationPresent(NonNull.class)) { + return Nullness.NON_NULL; + } + + Nullness nullness = Nullness.UNSPECIFIED; + + // 3. Package level + if (declaringPackage.isAnnotationPresent(NullMarked.class)) { + nullness = Nullness.NON_NULL; + } + + // 4. Class level + if (declaringClass.isAnnotationPresent(NullMarked.class)) { + nullness = Nullness.NON_NULL; + } + else if (declaringClass.isAnnotationPresent(NullUnmarked.class)) { + nullness = Nullness.UNSPECIFIED; + } + + // 5. Element level + if (annotatedElement.isAnnotationPresent(NullMarked.class)) { + nullness = Nullness.NON_NULL; + } + else if (annotatedElement.isAnnotationPresent(NullUnmarked.class)) { + nullness = Nullness.UNSPECIFIED; + } + + return nullness; +} +``` + +**Resolution Order:** +1. Type use annotations (highest priority) +2. Element-level annotations +3. Class-level annotations +4. Package-level annotations (lowest priority) + +--- + +### 7. Kotlin Interoperability + +Spring Framework provides first-class support for Kotlin null safety through dedicated delegation: + +```java +private static class KotlinDelegate { + public static Nullness forMethodReturnType(Method method) { + KFunction function = ReflectJvmMapping.getKotlinFunction(method); + if (function != null) { + KType type = function.getReturnType(); + return (type.isMarkedNullable() ? Nullness.NULLABLE : Nullness.NON_NULL); + } + // Fallback to property getter detection + return Nullness.UNSPECIFIED; + } +} +``` + +**Pattern:** Spring uses conditional compilation with Kotlin reflection to: +- Detect Kotlin nullable types (`Type?`) +- Support suspend functions +- Handle property getters/setters +- Respect Kotlin's optional parameters + +--- + +### 8. Flexible `@Nullable` Detection + +Spring accepts **any** `@Nullable` annotation, regardless of package: + +```java +private static boolean hasNullableAnnotation(AnnotatedElement element) { + for (Annotation annotation : element.getDeclaredAnnotations()) { + if ("Nullable".equals(annotation.annotationType().getSimpleName())) { + return true; + } + } + return false; +} +``` + +**Compatibility:** This supports: +- `org.jspecify.annotations.Nullable` +- Spring's legacy `org.springframework.lang.Nullable` +- JSR-305 `javax.annotation.Nullable` +- Any other `@Nullable` annotation + +--- + +### 9. Generic Type Handling + +Spring properly handles nullability in complex generic scenarios: + +```java +public class MethodParameter { + public boolean isOptional() { + return (getParameterType() == Optional.class || + Nullness.forMethodParameter(this) == Nullness.NULLABLE || + (KOTLIN_REFLECT_PRESENT && + KotlinDetector.isKotlinType(getContainingClass()) && + KotlinDelegate.isOptional(this))); + } +} +``` + +**Pattern:** Combines: +- `Optional` detection +- JSpecify nullability +- Kotlin nullable/optional parameter detection + +--- + +### 10. Documentation and API Design + +Spring documents nullability expectations in Javadoc: + +```java +/** + * Return the value of the attribute identified by {@code name}. + * Return {@code null} if the attribute doesn't exist. + * @param name the unique attribute key + * @return the current value of the attribute, if any + */ +@Nullable Object getAttribute(String name); +``` + +**Best Practice:** Javadoc explicitly states when null is returned and under what conditions. + +--- + +## Best Practices from Spring Framework + +### 1. Default to Non-Null +- Use `@NullMarked` at package level +- Only annotate nullable types with `@Nullable` +- Reduces visual noise in the codebase + +### 2. Consistent Annotation Placement +- Return types: before the return type +- Parameters: before the parameter type +- Fields: before the field type +- Type use: immediately before the specific type component + +### 3. Runtime Support +- Provide runtime utilities (`Nullness` enum) for frameworks that need reflection +- Support multiple annotation styles for compatibility +- Bridge JSpecify and Kotlin null safety + +### 4. Build-Time Validation +- Use NullAway or similar tools during build +- Integrate with CI/CD pipelines +- Fail builds on null safety violations + +### 5. Migration Strategy +- Start with package-level `@NullMarked` +- Add `@Nullable` incrementally +- Use `@NullUnmarked` for legacy code sections during migration + +### 6. Interoperability +- Support Kotlin null safety out of the box +- Accept any `@Nullable` annotation for compatibility +- Provide consistent runtime behavior across JVM languages + +--- + +## Architecture Decisions + +1. **Package-level default:** Minimizes annotation overhead while providing safety +2. **Runtime utilities:** Enables framework code to respect nullability at runtime +3. **Kotlin integration:** First-class support for Kotlin's null safety model +4. **Hierarchical resolution:** Clear precedence rules for annotation conflicts +5. **Backward compatibility:** Gradual adoption without breaking existing code + +--- + +## Summary + +Spring Framework's JSpecify implementation demonstrates enterprise-grade null safety patterns: + +- **Comprehensive coverage** across the entire spring-core module +- **Runtime-aware** with the `Nullness` utility for reflection-heavy frameworks +- **Kotlin-friendly** with dedicated interoperability support +- **Flexible** accepting multiple annotation styles +- **Well-documented** with clear Javadoc conventions +- **Build-integrated** using NullAway for compile-time checks + +This implementation serves as an excellent reference for adopting JSpecify in large, complex Java codebases with diverse client requirements. diff --git a/.cursor/rules/null-safety/jspecify-spring-web-module.mdc b/.cursor/rules/null-safety/jspecify-spring-web-module.mdc new file mode 100644 index 00000000000..cfab0acc42f --- /dev/null +++ b/.cursor/rules/null-safety/jspecify-spring-web-module.mdc @@ -0,0 +1,499 @@ +--- +description: JSpecify null safety implementation patterns and best practices from Spring Framework's spring-web module +alwaysApply: false +--- + +# JSpecify Null Safety Implementation in Spring Framework's spring-web Module + +Based on comprehensive analysis of the Spring Framework's spring-web module source code, this document details the implementation patterns and best practices for JSpecify null safety in web-layer components. + +--- + +## Overview + +Spring Framework's spring-web module has adopted JSpecify annotations (`org.jspecify.annotations`) for null safety throughout its codebase, providing build-time null safety checking while maintaining runtime compatibility with Kotlin and supporting various HTTP client/server abstractions. + +The spring-web module extends the patterns established in spring-core with web-specific conventions for HTTP requests, responses, headers, and reactive web handling. + +--- + +## Key Implementation Patterns + +### 1. Consistent Package-Level `@NullMarked` Annotation + +**Pattern:** Every package in spring-web uses `@NullMarked` at the package level via `package-info.java` files. + +```java +@NullMarked +package org.springframework.web; + +import org.jspecify.annotations.NullMarked; +``` + +**Coverage across packages:** +- `org.springframework.web` - Core web interfaces +- `org.springframework.web.bind` - Data binding functionality +- `org.springframework.web.bind.annotation` - Controller annotations +- `org.springframework.web.client` - HTTP client support (RestClient, RestTemplate) +- `org.springframework.web.context` - Web application contexts +- `org.springframework.web.filter` - Filter implementations +- `org.springframework.web.method` - Handler method infrastructure +- `org.springframework.web.multipart` - Multipart file upload support +- `org.springframework.web.server` - Reactive web server support +- `org.springframework.web.util` - Web utilities + +**Best Practice:** This establishes non-null as the default for all types, parameters, return values, and fields within each package. + +--- + +### 2. Explicit `@Nullable` for Optional Values + +**Pattern:** Methods returning optional values are explicitly marked with `@Nullable`. + +**Example from `RequestAttributes`:** + +```java +public interface RequestAttributes { + @Nullable Object getAttribute(String name, int scope); + + @Nullable Object resolveReference(String key); +} +``` + +**Example from `MultipartFile`:** + +```java +public interface MultipartFile extends InputStreamSource { + @Nullable String getOriginalFilename(); + + @Nullable String getContentType(); +} +``` + +**Convention:** `@Nullable` is used for: +- Return types that may legitimately return null +- Parameters that accept null values +- Fields that may be null during the lifecycle of the object + +--- + +### 3. Type Use Annotations on Generics + +**Pattern:** Nullability annotations are applied at the type-use level, including within generic type parameters. + +**Example from `ErrorResponse`:** + +```java +public interface ErrorResponse { + default Object @Nullable [] getDetailMessageArguments() { + return null; + } + + default Object @Nullable [] getDetailMessageArguments( + MessageSource messageSource, Locale locale) { + return getDetailMessageArguments(); + } +} +``` + +**Example from `UriComponentsBuilder`:** + +```java +public class UriComponentsBuilder implements UriBuilder, Cloneable { + private @Nullable String scheme; + private @Nullable String ssp; + private @Nullable String userInfo; + private @Nullable String host; + private @Nullable String port; + private @Nullable String fragment; +} +``` + +**Advanced Pattern:** Annotations on: +- Generic type arguments: `Object @Nullable []` +- Field declarations: `private @Nullable String host` +- Method return types: `public @Nullable String getFirst(String headerName)` + +--- + +### 4. HTTP Headers Nullability Pattern + +**Pattern:** HTTP header accessors consistently return `@Nullable` for optional header values and empty collections for missing multi-valued headers. + +**From `HttpHeaders` class:** + +```java +public class HttpHeaders implements Serializable { + public @Nullable String getFirst(String headerName) { + return this.headers.getFirst(headerName); + } + + public @Nullable MediaType getContentType() { + String value = getFirst(CONTENT_TYPE); + return (StringUtils.hasLength(value) ? MediaType.parseMediaType(value) : null); + } + + public @Nullable URI getLocation() { + String value = getFirst(LOCATION); + return (value != null ? URI.create(value) : null); + } + + public @Nullable String getOrigin() { + return getFirst(ORIGIN); + } + + public @Nullable String getCacheControl() { + return getFieldValues(CACHE_CONTROL); + } +} +``` + +**Convention:** +- Single-valued header getters return `@Nullable String` or `@Nullable T` +- Multi-valued header getters return **empty collections** (never null) +- Setters accept `@Nullable` parameters and remove headers when null is passed + +--- + +### 5. Builder Pattern with Nullability + +**Pattern:** Fluent builders use `@Nullable` parameters for optional configuration and return non-null builder instances. + +**From `RestClient.Builder`:** + +```java +interface Builder { + Builder scheme(@Nullable String scheme); + + Builder userInfo(@Nullable String userInfo); + + Builder host(@Nullable String host); + + Builder port(@Nullable String port); + + Builder replacePath(@Nullable String path); + + Builder fragment(@Nullable String fragment); + + Builder queryParam(String name, @Nullable Object... values); + + // Always returns non-null + RestClient build(); +} +``` + +**Pattern:** Builders mark optional configuration parameters as `@Nullable` but always return non-null builder instances for method chaining. + +--- + +### 6. Functional Interface Nullability + +**Pattern:** Functional interfaces explicitly declare nullability for parameters and return types. + +**From `RestClient.ResponseSpec`:** + +```java +interface ResponseSpec { + @Nullable T body(Class bodyType); + + T requiredBody(Class bodyType); + + @Nullable T body(ParameterizedTypeReference bodyType); + + T requiredBody(ParameterizedTypeReference bodyType); +} +``` + +**Pattern Distinction:** +- Methods with `@Nullable` return types can return null (e.g., when no body exists) +- Methods named with `required*` prefix **never return null** and throw `IllegalStateException` instead + +--- + +### 7. Generic Type Parameters with `@NonNull` + +**Pattern:** Use `@NonNull` on generic type parameters to indicate that a generic method/interface guarantees non-null values. + +**From `RestClient`:** + +```java +@FunctionalInterface +interface RequiredValueExchangeFunction<@NonNull T> extends ExchangeFunction<@NonNull T> { + @Override + T exchange(HttpRequest clientRequest, ConvertibleClientHttpResponse clientResponse) + throws IOException; +} +``` + +**Best Practice:** When a functional interface or generic type is specifically designed to never return null, use `@NonNull` on the type parameter itself to communicate this contract. + +--- + +### 8. Array Type Nullability + +**Pattern:** Distinguish between nullable array references and nullable array elements. + +**From `UriComponentsBuilder`:** + +```java +private static final Object[] EMPTY_VALUES = new Object[0]; + +public UriComponentsBuilder queryParam(String name, @Nullable Object... values) { + // varargs parameter that accepts null values +} +``` + +**Pattern clarification:** +- `@Nullable Object[]` - The array reference can be null +- `Object @Nullable []` - Array elements can be null +- `@Nullable Object @Nullable []` - Both array and elements can be null + +--- + +### 9. Setter/Remover Pattern for Optional Values + +**Pattern:** Setters for optional configuration accept `@Nullable` and remove the value when null is passed. + +**From `HttpHeaders`:** + +```java +public void setContentType(@Nullable MediaType mediaType) { + if (mediaType != null) { + Assert.isTrue(!mediaType.isWildcardType(), + "Content-Type cannot contain wildcard type '*'"); + Assert.isTrue(!mediaType.isWildcardSubtype(), + "Content-Type cannot contain wildcard subtype '*'"); + set(CONTENT_TYPE, mediaType.toString()); + } + else { + remove(CONTENT_TYPE); + } +} + +private void setOrRemove(String headerName, @Nullable String headerValue) { + if (headerValue != null) { + set(headerName, headerValue); + } + else { + remove(headerName); + } +} +``` + +**Best Practice:** When a setter accepts `@Nullable`, it should either: +- Set the value if non-null +- Remove/clear the value if null + +This provides a convenient API for optional configuration. + +--- + +### 10. Documentation Alignment + +**Pattern:** Javadoc explicitly documents when and why null is returned. + +**From `MultipartFile`:** + +```java +/** + * Return the original filename in the client's filesystem. + * ... + * @return the original filename, or the empty String if no file has been chosen + * in the multipart form, or {@code null} if not defined or not available + */ +@Nullable String getOriginalFilename(); +``` + +**From `HttpHeaders`:** + +```java +/** + * Return the {@linkplain MediaType media type} of the body, as specified + * by the {@code Content-Type} header. + * Returns {@code null} when the {@code Content-Type} header is not set. + * @throws InvalidMediaTypeException if the media type value cannot be parsed + */ +public @Nullable MediaType getContentType() { + // implementation +} +``` + +**Best Practice:** Javadoc should: +- State when null is returned +- Describe the conditions that lead to null +- Clarify semantic differences (e.g., empty string vs null vs not present) + +--- + +## Web-Specific Nullability Patterns + +### 11. Request/Response Attribute Handling + +**Pattern:** Attributes may not exist, so getters return `@Nullable`. + +```java +public interface RequestAttributes { + @Nullable Object getAttribute(String name, int scope); + + void setAttribute(String name, Object value, int scope); + + @Nullable Object resolveReference(String key); +} +``` + +**Convention:** +- Getters: `@Nullable` (attribute may not exist) +- Setters: Non-null value parameter (cannot set to null, use remove instead) + +--- + +### 12. Multipart File Upload Nullability + +**Pattern:** File metadata may be absent or browser-dependent. + +```java +public interface MultipartFile extends InputStreamSource { + // Always present - the form parameter name + String getName(); + + // May be null - browser-dependent + @Nullable String getOriginalFilename(); + + // May be null - if not specified + @Nullable String getContentType(); +} +``` + +**Web-specific consideration:** Client-provided metadata (filename, content type) can be absent and should always be treated as `@Nullable`. + +--- + +### 13. URI Component Nullability + +**Pattern:** URI components are optional and modeled as `@Nullable` fields. + +```java +public class UriComponentsBuilder implements UriBuilder, Cloneable { + private @Nullable String scheme; + private @Nullable String ssp; + private @Nullable String userInfo; + private @Nullable String host; + private @Nullable String port; + private @Nullable String fragment; +} +``` + +**Convention:** Each URI component can be absent, so all fields are `@Nullable`. + +--- + +### 14. HTTP Client Response Handling + +**Pattern:** Response bodies may be absent, requiring explicit nullability or "required" variants. + +```java +interface ResponseSpec { + // May return null if no body + @Nullable T body(Class bodyType); + + // Throws if no body (non-null guarantee) + T requiredBody(Class bodyType); + + // Entity always present (has status/headers even without body) + ResponseEntity toEntity(Class bodyType); + + // Body-less entity (no generic type) + ResponseEntity toBodilessEntity(); +} +``` + +**Best Practice:** Provide both nullable and required variants: +- `@Nullable T method()` - returns null if absent +- `T requiredMethod()` - throws exception if absent + +--- + +## Best Practices from Spring Web + +### 1. Default to Non-Null at Package Level + +✅ Use `@NullMarked` in every `package-info.java` +✅ Only mark nullable types explicitly with `@Nullable` +✅ Reduces annotation noise throughout the codebase + +### 2. Consistent Getter/Setter Patterns + +✅ **Getters:** `@Nullable` when value may not exist +✅ **Setters:** Accept `@Nullable` to support removal semantics +✅ **Collections:** Return empty collections (never null) from getters + +### 3. HTTP-Specific Conventions + +✅ **Headers:** Single-valued headers return `@Nullable`, multi-valued return empty list +✅ **Metadata:** Client-provided data (filenames, content types) is always `@Nullable` +✅ **Optional Bodies:** Provide both `@Nullable` and `required` variants + +### 4. Builder API Safety + +✅ **Optional config:** Accept `@Nullable` parameters +✅ **Chaining:** Always return non-null builder instance +✅ **Terminal operations:** Clearly document whether result can be null + +### 5. Functional Interface Clarity + +✅ Mark return types `@Nullable` if implementations may return null +✅ Use `@NonNull` on generic parameters for guaranteed non-null returns +✅ Name methods with `required` prefix when null is not permitted + +### 6. Array Type Precision + +✅ Use `Type @Nullable []` for nullable array elements +✅ Use `@Nullable Type[]` for nullable array reference +✅ Document which dimension is nullable in complex scenarios + +### 7. Documentation Standards + +✅ Javadoc must state when null is returned +✅ Describe conditions leading to null +✅ Clarify semantic distinctions (null vs empty vs not present) + +--- + +## Architecture Decisions + +1. **Package-level default:** Minimizes boilerplate while providing safety +2. **HTTP conventions:** Aligns with HTTP semantics (optional headers, bodies, metadata) +3. **Builder pattern:** Supports fluent configuration with nullable options +4. **Required variants:** Provides choice between null-safe and exception-throwing APIs +5. **Collection semantics:** Empty collections preferred over null for multi-valued data +6. **Client-provided data:** Always treated as potentially absent (`@Nullable`) + +--- + +## Comparison with Spring Core + +| Aspect | spring-core | spring-web | +|--------|-------------|------------| +| **Package annotation** | `@NullMarked` everywhere | `@NullMarked` everywhere | +| **Runtime support** | `Nullness` enum + utilities | Inherits from spring-core | +| **Kotlin support** | First-class integration | Inherits from spring-core | +| **Domain specifics** | General-purpose utilities | HTTP headers, requests, responses | +| **Collection handling** | Mixed patterns | **Consistent: empty > null** | +| **Optional values** | `@Nullable` | **`@Nullable` + `required*()` variants** | +| **Metadata handling** | Framework-controlled | **Client-provided = `@Nullable`** | + +--- + +## Summary + +Spring Framework's spring-web module demonstrates enterprise-grade null safety patterns tailored for web applications: + +- **Comprehensive coverage** with `@NullMarked` across all packages +- **HTTP-aware conventions** for headers, bodies, and client-provided metadata +- **Dual API design** offering both `@Nullable` and `required` variants +- **Collection consistency** preferring empty collections over null +- **Builder safety** supporting optional configuration with null removal semantics +- **Well-documented** with clear Javadoc about nullability expectations +- **Framework-aligned** extending patterns from spring-core + +This implementation serves as an excellent reference for adopting JSpecify in web-layer code, providing patterns specifically suited for HTTP clients, servers, and web application contexts. diff --git a/.cursor/rules/null-safety/jspecify-web-module.mdc b/.cursor/rules/null-safety/jspecify-web-module.mdc new file mode 100644 index 00000000000..485c6dfd494 --- /dev/null +++ b/.cursor/rules/null-safety/jspecify-web-module.mdc @@ -0,0 +1,637 @@ +--- +description: JSpecify null safety implementation patterns and best practices for the Spring Security @web module +alwaysApply: false +--- + +# JSpecify Null Safety Implementation - @web Module + +## Overview + +The Spring Security **@web module** has adopted **JSpecify** for null safety annotations following the same patterns established in the @core module. The web module includes **55+ packages** marked with `@NullMarked` and contains **737+ usages** of `@Nullable` annotations across 257 files, providing compile-time null safety guarantees for web-related security components. + +## Build Configuration + +### Gradle Plugin Setup + +**Plugin Declaration**: `web/spring-security-web.gradle` + +```gradle +plugins { + id 'io.spring.convention.spring-module' + id 'security-nullability' + id 'javadoc-warnings-error' + id 'test-compile-target-jdk25' +} +``` + +The web module uses the **same `security-nullability` plugin** as the core module, ensuring consistent null safety enforcement across the codebase. + +### Checkstyle Enforcement + +The web module inherits the same Checkstyle configuration from `etc/checkstyle/checkstyle.xml` that enforces: +- **Only JSpecify annotations** are allowed (`org.jspecify.annotations.*`) +- Rejects all other nullability frameworks (JSR-305, JetBrains, etc.) + +## Implementation Patterns + +### 1. Package-Level `@NullMarked` + +**Default Non-Null Context**: All web packages use `@NullMarked` to establish a non-null default. + +**Example**: `web/src/main/java/org/springframework/security/web/package-info.java` + +```java +@NullMarked +package org.springframework.security.web; + +import org.jspecify.annotations.NullMarked; +``` + +**Key Packages** (55+ total): +- `org.springframework.security.web` - Root package +- `org.springframework.security.web.authentication` - Authentication filters +- `org.springframework.security.web.authentication.logout` - Logout handlers +- `org.springframework.security.web.authentication.rememberme` - Remember-me services +- `org.springframework.security.web.csrf` - CSRF protection +- `org.springframework.security.web.savedrequest` - Saved request handling +- `org.springframework.security.web.server.*` - Reactive WebFlux support +- `org.springframework.security.web.method.annotation` - Method argument resolvers +- `org.springframework.security.web.servletapi` - Servlet API integration +- And many more subpackages... + +### 2. Servlet Filter Method Return Types + +**Pattern**: Filter methods that can return null use `@Nullable` on return types. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilter.java` + +```java +/** + * @return the authenticated user token, or null if authentication is incomplete. + */ +public @Nullable Authentication attemptAuthentication(HttpServletRequest request, + HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + Authentication authentication = this.authenticationConverter.convert(request); + if (authentication == null) { + return null; + } + Authentication result = this.authenticationManager.authenticate(authentication); + if (result == null) { + throw new ServletException("AuthenticationManager should not return null Authentication object."); + } + return result; +} +``` + +### 3. Interface Method Contracts + +**Pattern**: Interface methods declare nullability contracts that implementations must follow. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/AuthenticationConverter.java` + +```java +public interface AuthenticationConverter { + @Nullable Authentication convert(HttpServletRequest request); +} +``` + +**Example**: `web/src/main/java/org/springframework/security/web/savedrequest/RequestCache.java` + +```java +public interface RequestCache { + @Nullable SavedRequest getRequest(HttpServletRequest request, HttpServletResponse response); + + @Nullable HttpServletRequest getMatchingRequest(HttpServletRequest request, + HttpServletResponse response); +} +``` + +**Example**: `web/src/main/java/org/springframework/security/web/csrf/CsrfTokenRepository.java` + +```java +public interface CsrfTokenRepository { + void saveToken(@Nullable CsrfToken token, HttpServletRequest request, + HttpServletResponse response); + + @Nullable CsrfToken loadToken(HttpServletRequest request); +} +``` + +### 4. Pre-Authentication Filter Patterns + +**Pattern**: Pre-authentication filters that extract credentials from headers/requests use `@Nullable` extensively. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/preauth/RequestHeaderAuthenticationFilter.java` + +```java +public class RequestHeaderAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter { + + private String principalRequestHeader = "SM_USER"; + private @Nullable String credentialsRequestHeader; + + @Override + protected @Nullable Object getPreAuthenticatedPrincipal(HttpServletRequest request) { + String principal = request.getHeader(this.principalRequestHeader); + if (principal == null && this.exceptionIfHeaderMissing) { + throw new PreAuthenticatedCredentialsNotFoundException( + this.principalRequestHeader + " header not found in request."); + } + return principal; + } + + @Override + protected @Nullable Object getPreAuthenticatedCredentials(HttpServletRequest request) { + if (this.credentialsRequestHeader != null) { + return request.getHeader(this.credentialsRequestHeader); + } + return "N/A"; + } +} +``` + +### 5. Saved Request Handling + +**Pattern**: Complex DTOs with multiple nullable fields representing HTTP request state. + +**Example**: `web/src/main/java/org/springframework/security/web/savedrequest/DefaultSavedRequest.java` + +```java +public class DefaultSavedRequest implements SavedRequest { + + private final @Nullable String contextPath; + private final String method; + private final @Nullable String pathInfo; + private final @Nullable String queryString; + private final String requestURI; + private final @Nullable String requestURL; + private final String scheme; + private final String serverName; + private final @Nullable String servletPath; + private final int serverPort; + private final @Nullable String matchingRequestParameterName; + + public DefaultSavedRequest(HttpServletRequest request, + @Nullable String matchingRequestParameterName) { + // ... initialization + this.contextPath = request.getContextPath(); + this.servletPath = request.getServletPath(); + this.matchingRequestParameterName = matchingRequestParameterName; + } +} +``` + +### 6. Builder Pattern with Nullable Fields + +**Pattern**: Builders for complex objects use `@Nullable` for optional configuration. + +**Example**: `web/src/main/java/org/springframework/security/web/savedrequest/DefaultSavedRequest.Builder` + +```java +public static class Builder { + + private @Nullable List cookies = null; + private @Nullable List locales = null; + private @Nullable String contextPath; + private @Nullable String method; + private @Nullable String pathInfo; + private @Nullable String queryString; + private @Nullable String requestURI; + private @Nullable String requestURL; + private @Nullable String scheme; + private @Nullable String serverName; + private @Nullable String servletPath; + + public Builder setQueryString(@Nullable String queryString) { + this.queryString = queryString; + return this; + } + + public DefaultSavedRequest build() { + return new DefaultSavedRequest(this); + } +} +``` + +### 7. Array Type Annotations in Utility Methods + +**Pattern**: JSpecify type-use annotations on array return types for parsing utilities. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/www/DigestAuthUtils.java` + +```java +final class DigestAuthUtils { + + // Returns a nullable array of nullable strings + static String @Nullable [] splitIgnoringQuotes(String str, char separatorChar) { + if (str == null) { + return null; + } + // ... parsing logic + return list.toArray(new String[0]); + } + + // Returns a nullable array of non-null strings + static String @Nullable [] split(String toSplit, String delimiter) { + int offset = toSplit.indexOf(delimiter); + if (offset < 0) { + return null; + } + return new String[] { beforeDelimiter, afterDelimiter }; + } + + // Returns a nullable map with parameters + static @Nullable Map splitEachArrayElementAndCreateMap( + String @Nullable [] array, String delimiter, String removeCharacters) { + if ((array == null) || (array.length == 0)) { + return null; + } + Map map = new HashMap<>(); + // ... processing + return map; + } +} +``` + +### 8. Method Argument Resolvers + +**Pattern**: Spring MVC/WebFlux argument resolvers that may return null. + +**Example**: `web/src/main/java/org/springframework/security/web/method/annotation/AuthenticationPrincipalArgumentResolver.java` + +```java +public final class AuthenticationPrincipalArgumentResolver + implements HandlerMethodArgumentResolver { + + private @Nullable BeanResolver beanResolver; + + @Override + public @Nullable Object resolveArgument(MethodParameter parameter, + @Nullable ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + @Nullable WebDataBinderFactory binderFactory) { + Authentication authentication = this.securityContextHolderStrategy + .getContext().getAuthentication(); + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + // ... resolution logic + return principal; + } +} +``` + +### 9. Servlet API Integration with Nullable Fields + +**Pattern**: Factory classes that integrate with Servlet 3 APIs use nullable for optional components. + +**Example**: `web/src/main/java/org/springframework/security/web/servletapi/HttpServlet3RequestFactory.java` + +```java +final class HttpServlet3RequestFactory implements HttpServletRequestFactory { + + private @Nullable AuthenticationEntryPoint authenticationEntryPoint; + private @Nullable AuthenticationManager authenticationManager; + private @Nullable List logoutHandlers; + + void setAuthenticationEntryPoint( + @Nullable AuthenticationEntryPoint authenticationEntryPoint) { + this.authenticationEntryPoint = authenticationEntryPoint; + } + + void setAuthenticationManager(@Nullable AuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + } + + void setLogoutHandlers(@Nullable List logoutHandlers) { + this.logoutHandlers = logoutHandlers; + } +} +``` + +### 10. Reactive (WebFlux) Patterns + +**Pattern**: Reactive interfaces use `Mono` to represent absence instead of `@Nullable` for reactive streams, but still use `@Nullable` for synchronous operations. + +**Example**: `web/src/main/java/org/springframework/security/web/server/authentication/ServerAuthenticationConverter.java` + +```java +@FunctionalInterface +public interface ServerAuthenticationConverter { + // Returns Mono instead of @Nullable for reactive absence + Mono convert(ServerWebExchange exchange); +} +``` + +**Example**: `web/src/main/java/org/springframework/security/web/server/savedrequest/ServerRequestCache.java` + +```java +public interface ServerRequestCache { + Mono saveRequest(ServerWebExchange exchange); + Mono getRedirectUri(ServerWebExchange exchange); + Mono removeMatchingRequest(ServerWebExchange exchange); +} +``` + +**Key Insight**: Reactive types (`Mono`/`Flux`) handle absence through their type system, so `@Nullable` is **not needed** for return values. However, nullable parameters in reactive code still use `@Nullable`. + +### 11. @Contract Annotations for Complex Logic + +**Pattern**: Use Spring's `@Contract` annotation alongside `@Nullable` for specifying behavior with null inputs. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilter.java` + +```java +@Contract("null, _ -> false") +private boolean shouldPerformMfa(@Nullable Authentication current, + Authentication authenticationResult) { + if (!this.mfaEnabled) { + return false; + } + if (current == null || !current.isAuthenticated()) { + return false; + } + return current.getName().equals(authenticationResult.getName()); +} +``` + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/www/BasicAuthenticationFilter.java` + +```java +@Contract("null, _ -> false") +private boolean authenticationIsRequired(@Nullable Authentication existingAuth, + UsernamePasswordAuthenticationToken newAuth) { + if (existingAuth == null || !existingAuth.isAuthenticated()) { + return true; + } + return !existingAuth.getName().equals(newAuth.getName()); +} +``` + +### 12. Cookie and Header Configuration + +**Pattern**: Cookie/header configuration classes with nullable optional settings. + +**Example**: `web/src/main/java/org/springframework/security/web/csrf/CookieCsrfTokenRepository.java` + +```java +public final class CookieCsrfTokenRepository implements CsrfTokenRepository { + + private @Nullable String cookiePath; + private @Nullable String cookieDomain; + private @Nullable Boolean secure; + + @Override + public void saveToken(@Nullable CsrfToken token, HttpServletRequest request, + HttpServletResponse response) { + String tokenValue = (token != null) ? token.getToken() : ""; + + ResponseCookie.ResponseCookieBuilder cookieBuilder = + ResponseCookie.from(this.cookieName, tokenValue) + .secure((this.secure != null) ? this.secure : request.isSecure()) + .path(StringUtils.hasLength(this.cookiePath) + ? this.cookiePath + : this.getRequestContext(request)) + .domain(this.cookieDomain); + } +} +``` + +### 13. Login Page Generation + +**Pattern**: UI filters with nullable configuration for optional URLs and settings. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java` + +```java +public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { + + private @Nullable String loginPageUrl; + private @Nullable String logoutSuccessUrl; + private @Nullable String failureUrl; + private @Nullable String authenticationUrl; + + // Configuration setters accept nullable values + public void setLoginPageUrl(@Nullable String loginPageUrl) { + this.loginPageUrl = loginPageUrl; + } +} +``` + +### 14. Static Method Fields for Reflection + +**Pattern**: Static nullable fields used in reflection-based utilities. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/preauth/websphere/DefaultWASUsernameAndGroupsExtractor.java` + +```java +final class DefaultWASUsernameAndGroupsExtractor + implements WASUsernameAndGroupsExtractor { + + private static @Nullable Method getRunAsSubject = null; + private static @Nullable Method getGroupsForUser = null; + private static @Nullable Method getSecurityName = null; + private static @Nullable Method narrow = null; + private static @Nullable Class wsCredentialClass = null; + + private static @Nullable String getSecurityName(final Subject subject) { + String userSecurityName = null; + if (subject != null) { + Object credential = subject.getPublicCredentials(getWSCredentialClass()) + .iterator().next(); + if (credential != null) { + userSecurityName = (String) invokeMethod(getSecurityNameMethod(), credential); + } + } + return userSecurityName; + } +} +``` + +### 15. @SuppressWarnings for NullAway Limitations + +**Pattern**: Strategic use of `@SuppressWarnings("NullAway")` for known tool limitations. + +**Common Cases**: + +1. **Initialization patterns** - Fields initialized in `afterPropertiesSet()`: + +```java +public abstract class AbstractPreAuthenticatedProcessingFilter + extends GenericFilterBean { + + @SuppressWarnings("NullAway.Init") + private AuthenticationManager authenticationManager; + + @Override + public void afterPropertiesSet() { + Assert.notNull(this.authenticationManager, + "An AuthenticationManager must be set"); + } +} +``` + +2. **Constructor dataflow analysis limitations**: + +```java +@SuppressWarnings("NullAway") // Dataflow analysis limitation +public LogoutFilter(LogoutSuccessHandler logoutSuccessHandler, + LogoutHandler... handlers) { + this.handler = new CompositeLogoutHandler(handlers); + Assert.notNull(logoutSuccessHandler, "logoutSuccessHandler cannot be null"); + this.logoutSuccessHandler = logoutSuccessHandler; + setFilterProcessesUrl("/logout"); +} +``` + +3. **Reactive operator limitations**: + +```java +@Override +@SuppressWarnings("NullAway") // https://github.com/uber/NullAway/issues/1290 +public Mono resolveArgument(MethodParameter parameter, + BindingContext bindingContext, ServerWebExchange exchange) { + return ReactiveSecurityContextHolder.getContext() + .mapNotNull(SecurityContext::getAuthentication) + .flatMap((authentication) -> { + // NullAway doesn't understand mapNotNull guarantees + return resolvePrincipal(parameter, authentication.getPrincipal()); + }); +} +``` + +### 16. Delegating Entry Points + +**Pattern**: Delegation classes that select from multiple nullable or non-nullable options. + +**Example**: `web/src/main/java/org/springframework/security/web/authentication/DelegatingAuthenticationEntryPoint.java` + +```java +public class DelegatingAuthenticationEntryPoint + implements AuthenticationEntryPoint, InitializingBean { + + private final List> entryPoints; + + @SuppressWarnings("NullAway.Init") + private AuthenticationEntryPoint defaultEntryPoint; + + @Override + public void afterPropertiesSet() { + Assert.notNull(this.defaultEntryPoint, + "defaultEntryPoint must be specified"); + } +} +``` + +## Web Module-Specific Patterns + +### 1. HttpServletRequest Integration + +The web module extensively uses `@Nullable` for methods that extract data from HTTP requests: + +- **Header extraction** - may return null if header not present +- **Parameter extraction** - may return null if parameter not present +- **Cookie extraction** - may return null if cookie not present +- **Session attributes** - may return null if not set + +### 2. Filter Chain Processing + +Filters that can short-circuit authentication: + +- Return `@Nullable Authentication` if authentication is incomplete +- Use `@Contract("null, _ -> false")` for MFA and authentication checks + +### 3. SavedRequest Patterns + +Saved requests preserve HTTP request state with many optional components: + +- Context path, servlet path, path info all nullable +- Query string nullable +- Matching request parameter name nullable + +### 4. Reactive vs Servlet Duality + +The web module supports both: + +- **Servlet API** (`org.springframework.security.web.*`) - uses `@Nullable` extensively +- **Reactive WebFlux** (`org.springframework.security.web.server.*`) - uses `Mono` for absence + +## Best Practices Summary + +### ✅ DO + +1. **Apply `@NullMarked` at package level** - All 55+ web packages follow this +2. **Use `@Nullable` for optional HTTP components** - Headers, parameters, cookies +3. **Use `@Nullable` for converter interfaces** - `AuthenticationConverter`, etc. +4. **Use `Mono` for reactive absence** - Don't use `@Nullable` with reactive return types +5. **Use `@Contract` for null-checking methods** - Especially in filter logic +6. **Suppress NullAway strategically** - Document the reason with issue links +7. **Keep nullable fields private** - Expose through getter methods +8. **Use builders for complex DTOs** - `DefaultSavedRequest.Builder` + +### ❌ DON'T + +1. **Don't use `@Nullable` with reactive return types** - Use `Mono.empty()` instead +2. **Don't forget constructor parameters** - They need `@Nullable` too +3. **Don't mix nullable with Optional** - Pick one pattern and stick to it +4. **Don't suppress NullAway without explanation** - Include issue links + +## Coverage Statistics + +**In the @web module**: +- **55+ packages** are `@NullMarked` +- **737+ usages** of `@Nullable` annotations across 257 files +- **15 strategic suppressions** of NullAway for tool limitations +- **100% consistency** with JSpecify (enforced by Checkstyle) + +## Comparison with @core Module + +| Aspect | @core Module | @web Module | +|--------|-------------|-------------| +| Packages | 40+ | 55+ | +| @Nullable Usages | 600+ | 737+ | +| Build Plugin | `security-nullability` | `security-nullability` | +| Checkstyle | JSpecify only | JSpecify only | +| Reactive Support | Limited (Mono/Flux in some areas) | Extensive (full WebFlux support) | +| Array Annotations | Yes | Yes (especially in parsing utilities) | +| Builder Pattern | Yes | Yes (especially SavedRequest) | +| @Contract Usage | Yes | Yes (filter logic) | + +## Key Files Reference + +### Configuration +- **Build Config**: `web/spring-security-web.gradle` +- **Root Package**: `web/src/main/java/org/springframework/security/web/package-info.java` + +### Core Interfaces +- **AuthenticationConverter**: `web/src/main/java/org/springframework/security/web/authentication/AuthenticationConverter.java` +- **RequestCache**: `web/src/main/java/org/springframework/security/web/savedrequest/RequestCache.java` +- **CsrfTokenRepository**: `web/src/main/java/org/springframework/security/web/csrf/CsrfTokenRepository.java` + +### Filter Examples +- **AbstractAuthenticationProcessingFilter**: `web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilter.java` +- **AbstractPreAuthenticatedProcessingFilter**: `web/src/main/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilter.java` +- **RequestHeaderAuthenticationFilter**: `web/src/main/java/org/springframework/security/web/authentication/preauth/RequestHeaderAuthenticationFilter.java` + +### Complex Patterns +- **DefaultSavedRequest**: `web/src/main/java/org/springframework/security/web/savedrequest/DefaultSavedRequest.java` +- **DigestAuthUtils**: `web/src/main/java/org/springframework/security/web/authentication/www/DigestAuthUtils.java` +- **CookieCsrfTokenRepository**: `web/src/main/java/org/springframework/security/web/csrf/CookieCsrfTokenRepository.java` + +### Reactive Examples +- **ServerAuthenticationConverter**: `web/src/main/java/org/springframework/security/web/server/authentication/ServerAuthenticationConverter.java` +- **ServerRequestCache**: `web/src/main/java/org/springframework/security/web/server/savedrequest/ServerRequestCache.java` + +## Conclusion + +The Spring Security @web module demonstrates **production-grade null safety** using JSpecify annotations with: + +- **Comprehensive coverage** across all web-related security components +- **Consistent patterns** matching the @core module approach +- **Web-specific adaptations** for servlet and reactive environments +- **Strategic handling** of optional HTTP components (headers, cookies, parameters) +- **Proper distinction** between servlet (`@Nullable`) and reactive (`Mono.empty()`) patterns +- **Build-time enforcement** through Checkstyle and the nullability plugin + +The implementation serves as a reference for applying null safety to web frameworks that must handle numerous optional HTTP components while maintaining both blocking servlet and reactive WebFlux APIs. diff --git a/etc/checkstyle/checkstyle-suppressions.xml b/etc/checkstyle/checkstyle-suppressions.xml index b8b2861854d..8e191101bdb 100644 --- a/etc/checkstyle/checkstyle-suppressions.xml +++ b/etc/checkstyle/checkstyle-suppressions.xml @@ -82,9 +82,6 @@ - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d27b51a2cbc..540fa2dfa18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ org-springframework = "7.0.3" com-password4j = "1.8.4" [libraries] -ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.26" +ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.27" com-fasterxml-jackson-jackson-bom = "com.fasterxml.jackson:jackson-bom:2.20.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" diff --git a/oauth2/oauth2-core/spring-security-oauth2-core.gradle b/oauth2/oauth2-core/spring-security-oauth2-core.gradle index a3c3a2f4e9a..43f7c114eb9 100644 --- a/oauth2/oauth2-core/spring-security-oauth2-core.gradle +++ b/oauth2/oauth2-core/spring-security-oauth2-core.gradle @@ -1,5 +1,6 @@ plugins { id 'compile-warnings-error' + id 'security-nullability' } apply plugin: 'io.spring.convention.spring-module' diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2Token.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2Token.java index 2182f9a1cd0..d06b30aa95e 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2Token.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AbstractOAuth2Token.java @@ -19,7 +19,8 @@ import java.io.Serializable; import java.time.Instant; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; + import org.springframework.util.Assert; /** @@ -37,9 +38,9 @@ public abstract class AbstractOAuth2Token implements OAuth2Token, Serializable { private final String tokenValue; - private final Instant issuedAt; + private final @Nullable Instant issuedAt; - private final Instant expiresAt; + private final @Nullable Instant expiresAt; /** * Sub-class constructor. @@ -78,8 +79,7 @@ public String getTokenValue() { * Returns the time at which the token was issued. * @return the time the token was issued or {@code null} */ - @Nullable - public Instant getIssuedAt() { + public @Nullable Instant getIssuedAt() { return this.issuedAt; } @@ -87,8 +87,7 @@ public Instant getIssuedAt() { * Returns the expiration time on or after which the token MUST NOT be accepted. * @return the token expiration time or {@code null} */ - @Nullable - public Instant getExpiresAt() { + public @Nullable Instant getExpiresAt() { return this.expiresAt; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java index a42c0893e62..84d451fb72b 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClaimAccessor.java @@ -21,6 +21,8 @@ import java.util.List; import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.TypeDescriptor; import org.springframework.security.oauth2.core.converter.ClaimConversionService; import org.springframework.util.Assert; @@ -44,11 +46,11 @@ public interface ClaimAccessor { * type {@code T}. * @param claim the name of the claim * @param the type of the claim value - * @return the claim value + * @return the claim value, or {@code null} if the claim does not exist * @since 5.2 */ @SuppressWarnings("unchecked") - default T getClaim(String claim) { + default @Nullable T getClaim(String claim) { return !hasClaim(claim) ? null : (T) getClaims().get(claim); } @@ -71,7 +73,7 @@ default boolean hasClaim(String claim) { * @return the claim value or {@code null} if it does not exist or is equal to * {@code null} */ - default String getClaimAsString(String claim) { + default @Nullable String getClaimAsString(String claim) { return !hasClaim(claim) ? null : ClaimConversionService.getSharedInstance().convert(getClaims().get(claim), String.class); } @@ -85,7 +87,8 @@ default String getClaimAsString(String claim) { * {@code Boolean} * @throws NullPointerException if the claim value is {@code null} */ - default Boolean getClaimAsBoolean(String claim) { + @SuppressWarnings("NullAway") + default @Nullable Boolean getClaimAsBoolean(String claim) { if (!hasClaim(claim)) { return null; } @@ -100,8 +103,12 @@ default Boolean getClaimAsBoolean(String claim) { * Returns the claim value as an {@code Instant} or {@code null} if it does not exist. * @param claim the name of the claim * @return the claim value or {@code null} if it does not exist + * @throws IllegalArgumentException if the claim value cannot be converted to an + * {@code Instant} + * @throws NullPointerException if the claim value is {@code null} */ - default Instant getClaimAsInstant(String claim) { + @SuppressWarnings("NullAway") + default @Nullable Instant getClaimAsInstant(String claim) { if (!hasClaim(claim)) { return null; } @@ -116,8 +123,12 @@ default Instant getClaimAsInstant(String claim) { * Returns the claim value as an {@code URL} or {@code null} if it does not exist. * @param claim the name of the claim * @return the claim value or {@code null} if it does not exist + * @throws IllegalArgumentException if the claim value cannot be converted to a + * {@code URL} + * @throws NullPointerException if the claim value is {@code null} */ - default URL getClaimAsURL(String claim) { + @SuppressWarnings("NullAway") + default @Nullable URL getClaimAsURL(String claim) { if (!hasClaim(claim)) { return null; } @@ -137,8 +148,8 @@ default URL getClaimAsURL(String claim) { * {@code Map} * @throws NullPointerException if the claim value is {@code null} */ - @SuppressWarnings("unchecked") - default Map getClaimAsMap(String claim) { + @SuppressWarnings({ "unchecked", "NullAway" }) + default @Nullable Map getClaimAsMap(String claim) { if (!hasClaim(claim)) { return null; } @@ -162,8 +173,8 @@ default Map getClaimAsMap(String claim) { * {@code List} * @throws NullPointerException if the claim value is {@code null} */ - @SuppressWarnings("unchecked") - default List getClaimAsStringList(String claim) { + @SuppressWarnings({ "unchecked", "NullAway" }) + default @Nullable List getClaimAsStringList(String claim) { if (!hasClaim(claim)) { return null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClientAuthenticationMethod.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClientAuthenticationMethod.java index 6255f13b8bd..cdb13b3a4f2 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClientAuthenticationMethod.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/ClientAuthenticationMethod.java @@ -18,7 +18,6 @@ import java.io.Serializable; -import org.springframework.lang.NonNull; import org.springframework.util.Assert; /** @@ -105,7 +104,6 @@ static ClientAuthenticationMethod[] methods() { * constant, if any * @since 6.5 */ - @NonNull public static ClientAuthenticationMethod valueOf(String method) { for (ClientAuthenticationMethod m : methods()) { if (m.getValue().equals(method)) { diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DefaultOAuth2AuthenticatedPrincipal.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DefaultOAuth2AuthenticatedPrincipal.java index 6de624f6332..5adc48aaf70 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DefaultOAuth2AuthenticatedPrincipal.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/DefaultOAuth2AuthenticatedPrincipal.java @@ -22,6 +22,8 @@ import java.util.Collections; import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.util.Assert; @@ -58,17 +60,21 @@ public DefaultOAuth2AuthenticatedPrincipal(Map attributes, /** * Constructs an {@code DefaultOAuth2AuthenticatedPrincipal} using the provided * parameters. - * @param name the name attached to the OAuth 2.0 token + * @param name the name attached to the OAuth 2.0 token, may be {@code null} * @param attributes the attributes of the OAuth 2.0 token - * @param authorities the authorities of the OAuth 2.0 token + * @param authorities the authorities of the OAuth 2.0 token, may be {@code null} */ - public DefaultOAuth2AuthenticatedPrincipal(String name, Map attributes, - Collection authorities) { + public DefaultOAuth2AuthenticatedPrincipal(@Nullable String name, Map attributes, + @Nullable Collection authorities) { Assert.notEmpty(attributes, "attributes cannot be empty"); this.attributes = Collections.unmodifiableMap(attributes); this.authorities = (authorities != null) ? Collections.unmodifiableCollection(authorities) : AuthorityUtils.NO_AUTHORITIES; - this.name = (name != null) ? name : (String) this.attributes.get("sub"); + // Ensure name is never null - use 'sub' attribute as fallback, then empty string + // This satisfies AuthenticatedPrincipal.getName() contract which never returns + // null + String resolvedName = (name != null) ? name : (String) this.attributes.get("sub"); + this.name = (resolvedName != null) ? resolvedName : ""; } /** diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java index 9174a44654c..ef0dbe4ccf2 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AccessToken.java @@ -22,6 +22,8 @@ import java.util.Collections; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.util.Assert; /** @@ -52,11 +54,12 @@ public class OAuth2AccessToken extends AbstractOAuth2Token { * Constructs an {@code OAuth2AccessToken} using the provided parameters. * @param tokenType the token type * @param tokenValue the token value - * @param issuedAt the time at which the token was issued + * @param issuedAt the time at which the token was issued, may be {@code null} * @param expiresAt the expiration time on or after which the token MUST NOT be - * accepted + * accepted, may be {@code null} */ - public OAuth2AccessToken(TokenType tokenType, String tokenValue, Instant issuedAt, Instant expiresAt) { + public OAuth2AccessToken(TokenType tokenType, String tokenValue, @Nullable Instant issuedAt, + @Nullable Instant expiresAt) { this(tokenType, tokenValue, issuedAt, expiresAt, Collections.emptySet()); } @@ -64,13 +67,13 @@ public OAuth2AccessToken(TokenType tokenType, String tokenValue, Instant issuedA * Constructs an {@code OAuth2AccessToken} using the provided parameters. * @param tokenType the token type * @param tokenValue the token value - * @param issuedAt the time at which the token was issued + * @param issuedAt the time at which the token was issued, may be {@code null} * @param expiresAt the expiration time on or after which the token MUST NOT be - * accepted + * accepted, may be {@code null} * @param scopes the scope(s) associated to the token */ - public OAuth2AccessToken(TokenType tokenType, String tokenValue, Instant issuedAt, Instant expiresAt, - Set scopes) { + public OAuth2AccessToken(TokenType tokenType, String tokenValue, @Nullable Instant issuedAt, + @Nullable Instant expiresAt, Set scopes) { super(tokenValue, issuedAt, expiresAt); Assert.notNull(tokenType, "tokenType cannot be null"); this.tokenType = tokenType; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.java index 61b718ce95b..2a23f08efa4 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.java @@ -19,7 +19,8 @@ import java.util.Collection; import java.util.Map; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; + import org.springframework.security.core.AuthenticatedPrincipal; import org.springframework.security.core.GrantedAuthority; @@ -38,9 +39,8 @@ public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal { * @param the type of the attribute * @return the attribute or {@code null} otherwise */ - @Nullable @SuppressWarnings("unchecked") - default A getAttribute(String name) { + default @Nullable A getAttribute(String name) { return (A) getAttributes().get(name); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java index 394c9d7f3fe..8793c81f1d6 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2AuthenticationException.java @@ -18,6 +18,8 @@ import java.io.Serial; +import org.jspecify.annotations.Nullable; + import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.util.Assert; @@ -77,22 +79,25 @@ public OAuth2AuthenticationException(OAuth2Error error, Throwable cause) { /** * Constructs an {@code OAuth2AuthenticationException} using the provided parameters. * @param error the {@link OAuth2Error OAuth 2.0 Error} - * @param message the detail message + * @param message the detail message, may be {@code null} */ - public OAuth2AuthenticationException(OAuth2Error error, String message) { + public OAuth2AuthenticationException(OAuth2Error error, @Nullable String message) { this(error, message, null); } /** * Constructs an {@code OAuth2AuthenticationException} using the provided parameters. * @param error the {@link OAuth2Error OAuth 2.0 Error} - * @param message the detail message - * @param cause the root cause + * @param message the detail message, may be {@code null} + * @param cause the root cause, may be {@code null} */ - public OAuth2AuthenticationException(OAuth2Error error, String message, Throwable cause) { - super(message, cause); + public OAuth2AuthenticationException(OAuth2Error error, @Nullable String message, @Nullable Throwable cause) { + super(message); Assert.notNull(error, "error cannot be null"); this.error = error; + if (cause != null) { + initCause(cause); + } } /** diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Error.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Error.java index 5aaedebef99..f4e6587c200 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Error.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Error.java @@ -18,6 +18,8 @@ import java.io.Serializable; +import org.jspecify.annotations.Nullable; + import org.springframework.util.Assert; /** @@ -41,9 +43,9 @@ public class OAuth2Error implements Serializable { private final String errorCode; - private final String description; + private final @Nullable String description; - private final String uri; + private final @Nullable String uri; /** * Constructs an {@code OAuth2Error} using the provided parameters. @@ -56,10 +58,10 @@ public OAuth2Error(String errorCode) { /** * Constructs an {@code OAuth2Error} using the provided parameters. * @param errorCode the error code - * @param description the error description - * @param uri the error uri + * @param description the error description, may be {@code null} + * @param uri the error uri, may be {@code null} */ - public OAuth2Error(String errorCode, String description, String uri) { + public OAuth2Error(String errorCode, @Nullable String description, @Nullable String uri) { Assert.hasText(errorCode, "errorCode cannot be empty"); this.errorCode = errorCode; this.description = description; @@ -76,17 +78,17 @@ public final String getErrorCode() { /** * Returns the error description. - * @return the error description + * @return the error description, or {@code null} if not available */ - public final String getDescription() { + public final @Nullable String getDescription() { return this.description; } /** * Returns the error uri. - * @return the error uri + * @return the error uri, or {@code null} if not available */ - public final String getUri() { + public final @Nullable String getUri() { return this.uri; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2RefreshToken.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2RefreshToken.java index 2f04b823e9c..85e9a1739b3 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2RefreshToken.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2RefreshToken.java @@ -19,6 +19,8 @@ import java.io.Serial; import java.time.Instant; +import org.jspecify.annotations.Nullable; + /** * An implementation of an {@link AbstractOAuth2Token} representing an OAuth 2.0 Refresh * Token. @@ -43,20 +45,20 @@ public class OAuth2RefreshToken extends AbstractOAuth2Token { /** * Constructs an {@code OAuth2RefreshToken} using the provided parameters. * @param tokenValue the token value - * @param issuedAt the time at which the token was issued + * @param issuedAt the time at which the token was issued, may be {@code null} */ - public OAuth2RefreshToken(String tokenValue, Instant issuedAt) { + public OAuth2RefreshToken(String tokenValue, @Nullable Instant issuedAt) { this(tokenValue, issuedAt, null); } /** * Constructs an {@code OAuth2RefreshToken} using the provided parameters. * @param tokenValue the token value - * @param issuedAt the time at which the token was issued - * @param expiresAt the time at which the token expires + * @param issuedAt the time at which the token was issued, may be {@code null} + * @param expiresAt the time at which the token expires, may be {@code null} * @since 5.5 */ - public OAuth2RefreshToken(String tokenValue, Instant issuedAt, Instant expiresAt) { + public OAuth2RefreshToken(String tokenValue, @Nullable Instant issuedAt, @Nullable Instant expiresAt) { super(tokenValue, issuedAt, expiresAt); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Token.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Token.java index 00da44931f0..9f263f4b7fa 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Token.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2Token.java @@ -18,7 +18,7 @@ import java.time.Instant; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * Core interface representing an OAuth 2.0 Token. @@ -39,8 +39,7 @@ public interface OAuth2Token { * Returns the time at which the token was issued. * @return the time the token was issued or {@code null} */ - @Nullable - default Instant getIssuedAt() { + default @Nullable Instant getIssuedAt() { return null; } @@ -48,8 +47,7 @@ default Instant getIssuedAt() { * Returns the expiration time on or after which the token MUST NOT be accepted. * @return the token expiration time or {@code null} */ - @Nullable - default Instant getExpiresAt() { + default @Nullable Instant getExpiresAt() { return null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaimAccessor.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaimAccessor.java index adf8e80ed98..b68550ed890 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaimAccessor.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaimAccessor.java @@ -20,7 +20,7 @@ import java.time.Instant; import java.util.List; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * A {@link ClaimAccessor} for the "claims" that may be contained in the @@ -45,105 +45,106 @@ default boolean isActive() { /** * Returns a human-readable identifier {@code (username)} for the resource owner that - * authorized the token + * authorized the token, or {@code null} if it does not exist. * @return a human-readable identifier for the resource owner that authorized the - * token + * token, or {@code null} if it does not exist */ - @Nullable - default String getUsername() { + default @Nullable String getUsername() { return getClaimAsString(OAuth2TokenIntrospectionClaimNames.USERNAME); } /** - * Returns the client identifier {@code (client_id)} for the token - * @return the client identifier for the token + * Returns the client identifier {@code (client_id)} for the token, or {@code null} if + * it does not exist. + * @return the client identifier for the token, or {@code null} if it does not exist */ - @Nullable - default String getClientId() { + default @Nullable String getClientId() { return getClaimAsString(OAuth2TokenIntrospectionClaimNames.CLIENT_ID); } /** - * Returns the scopes {@code (scope)} associated with the token - * @return the scopes associated with the token + * Returns the scopes {@code (scope)} associated with the token, or {@code null} if it + * does not exist. + * @return the scopes associated with the token, or {@code null} if it does not exist */ - @Nullable - default List getScopes() { + default @Nullable List getScopes() { return getClaimAsStringList(OAuth2TokenIntrospectionClaimNames.SCOPE); } /** - * Returns the type of the token {@code (token_type)}, for example {@code bearer}. - * @return the type of the token, for example {@code bearer}. + * Returns the type of the token {@code (token_type)}, for example {@code bearer}, or + * {@code null} if it does not exist. + * @return the type of the token, for example {@code bearer}, or {@code null} if it + * does not exist */ - @Nullable - default String getTokenType() { + default @Nullable String getTokenType() { return getClaimAsString(OAuth2TokenIntrospectionClaimNames.TOKEN_TYPE); } /** - * Returns a timestamp {@code (exp)} indicating when the token expires - * @return a timestamp indicating when the token expires + * Returns a timestamp {@code (exp)} indicating when the token expires, or + * {@code null} if it does not exist. + * @return a timestamp indicating when the token expires, or {@code null} if it does + * not exist */ - @Nullable - default Instant getExpiresAt() { + default @Nullable Instant getExpiresAt() { return getClaimAsInstant(OAuth2TokenIntrospectionClaimNames.EXP); } /** - * Returns a timestamp {@code (iat)} indicating when the token was issued - * @return a timestamp indicating when the token was issued + * Returns a timestamp {@code (iat)} indicating when the token was issued, or + * {@code null} if it does not exist. + * @return a timestamp indicating when the token was issued, or {@code null} if it + * does not exist */ - @Nullable - default Instant getIssuedAt() { + default @Nullable Instant getIssuedAt() { return getClaimAsInstant(OAuth2TokenIntrospectionClaimNames.IAT); } /** * Returns a timestamp {@code (nbf)} indicating when the token is not to be used - * before - * @return a timestamp indicating when the token is not to be used before + * before, or {@code null} if it does not exist. + * @return a timestamp indicating when the token is not to be used before, or + * {@code null} if it does not exist */ - @Nullable - default Instant getNotBefore() { + default @Nullable Instant getNotBefore() { return getClaimAsInstant(OAuth2TokenIntrospectionClaimNames.NBF); } /** * Returns usually a machine-readable identifier {@code (sub)} of the resource owner - * who authorized the token + * who authorized the token, or {@code null} if it does not exist. * @return usually a machine-readable identifier of the resource owner who authorized - * the token + * the token, or {@code null} if it does not exist */ - @Nullable - default String getSubject() { + default @Nullable String getSubject() { return getClaimAsString(OAuth2TokenIntrospectionClaimNames.SUB); } /** - * Returns the intended audience {@code (aud)} for the token - * @return the intended audience for the token + * Returns the intended audience {@code (aud)} for the token, or {@code null} if it + * does not exist. + * @return the intended audience for the token, or {@code null} if it does not exist */ - @Nullable - default List getAudience() { + default @Nullable List getAudience() { return getClaimAsStringList(OAuth2TokenIntrospectionClaimNames.AUD); } /** - * Returns the issuer {@code (iss)} of the token - * @return the issuer of the token + * Returns the issuer {@code (iss)} of the token, or {@code null} if it does not + * exist. + * @return the issuer of the token, or {@code null} if it does not exist */ - @Nullable - default URL getIssuer() { + default @Nullable URL getIssuer() { return getClaimAsURL(OAuth2TokenIntrospectionClaimNames.ISS); } /** - * Returns the identifier {@code (jti)} for the token - * @return the identifier for the token + * Returns the identifier {@code (jti)} for the token, or {@code null} if it does not + * exist. + * @return the identifier for the token, or {@code null} if it does not exist */ - @Nullable - default String getId() { + default @Nullable String getId() { return getClaimAsString(OAuth2TokenIntrospectionClaimNames.JTI); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/package-info.java new file mode 100644 index 00000000000..7afef534669 --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/authorization/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Support classes that provide OAuth 2.0 authorization managers. + */ +@NullMarked +package org.springframework.security.oauth2.core.authorization; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java index 9ca8ecb93f0..9594dbe58fc 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ClaimConversionService.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.core.converter; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.converter.ConverterRegistry; import org.springframework.core.convert.support.GenericConversionService; @@ -32,7 +34,7 @@ */ public final class ClaimConversionService extends GenericConversionService { - private static volatile ClaimConversionService sharedInstance; + private static volatile @Nullable ClaimConversionService sharedInstance; private ClaimConversionService() { addConverters(this); diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java index a86350ab057..ce9d19fb004 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToBooleanConverter.java @@ -19,6 +19,8 @@ import java.util.Collections; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.GenericConverter; @@ -34,7 +36,7 @@ public Set getConvertibleTypes() { } @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { if (source == null) { return null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java index 8f1d9f1a916..ea7f1cc951a 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToInstantConverter.java @@ -21,6 +21,8 @@ import java.util.Date; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.GenericConverter; @@ -36,7 +38,7 @@ public Set getConvertibleTypes() { } @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { if (source == null) { return null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java index 055ec755196..fdd2a4a09ba 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToListStringConverter.java @@ -23,6 +23,8 @@ import java.util.List; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalGenericConverter; import org.springframework.util.ClassUtils; @@ -49,7 +51,7 @@ public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { } @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { if (source == null) { return null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java index b2bbcb12e1d..23dc633aea0 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToMapStringObjectConverter.java @@ -21,6 +21,8 @@ import java.util.Map; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalGenericConverter; @@ -37,12 +39,13 @@ public Set getConvertibleTypes() { @Override public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { + TypeDescriptor mapKeyTypeDescriptor = targetType.getMapKeyTypeDescriptor(); return targetType.getElementTypeDescriptor() == null - || targetType.getMapKeyTypeDescriptor().getType().equals(String.class); + || (mapKeyTypeDescriptor != null && mapKeyTypeDescriptor.getType().equals(String.class)); } @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { if (source == null) { return null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java index 4f1fd43c6cd..3c415063a43 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToStringConverter.java @@ -19,6 +19,8 @@ import java.util.Collections; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.GenericConverter; @@ -34,7 +36,7 @@ public Set getConvertibleTypes() { } @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { return (source != null) ? source.toString() : null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java index c9ee6511f58..46e3a1809ed 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/ObjectToURLConverter.java @@ -21,6 +21,8 @@ import java.util.Collections; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.GenericConverter; @@ -36,7 +38,7 @@ public Set getConvertibleTypes() { } @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { if (source == null) { return null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/package-info.java new file mode 100644 index 00000000000..24fed93719b --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/converter/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Support classes that provide claim type converters for OAuth 2.0 and OpenID Connect. + */ +@NullMarked +package org.springframework.security.oauth2.core.converter; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/DefaultMapOAuth2AccessTokenResponseConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/DefaultMapOAuth2AccessTokenResponseConverter.java index 52cc9ae096d..52a2e0c062e 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/DefaultMapOAuth2AccessTokenResponseConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/DefaultMapOAuth2AccessTokenResponseConverter.java @@ -23,6 +23,8 @@ import java.util.Map; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.converter.Converter; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.util.StringUtils; @@ -44,6 +46,9 @@ public final class DefaultMapOAuth2AccessTokenResponseConverter @Override public OAuth2AccessTokenResponse convert(Map source) { String accessToken = getParameterValue(source, OAuth2ParameterNames.ACCESS_TOKEN); + if (accessToken == null) { + throw new IllegalArgumentException("Missing required parameter: " + OAuth2ParameterNames.ACCESS_TOKEN); + } OAuth2AccessToken.TokenType accessTokenType = getAccessTokenType(source); long expiresIn = getExpiresIn(source); Set scopes = getScopes(source); @@ -65,7 +70,8 @@ public OAuth2AccessTokenResponse convert(Map source) { // @formatter:on } - private static OAuth2AccessToken.TokenType getAccessTokenType(Map tokenResponseParameters) { + private static OAuth2AccessToken.@Nullable TokenType getAccessTokenType( + Map tokenResponseParameters) { if (OAuth2AccessToken.TokenType.BEARER.getValue() .equalsIgnoreCase(getParameterValue(tokenResponseParameters, OAuth2ParameterNames.TOKEN_TYPE))) { return OAuth2AccessToken.TokenType.BEARER; @@ -89,7 +95,8 @@ private static Set getScopes(Map tokenResponseParameters return Collections.emptySet(); } - private static String getParameterValue(Map tokenResponseParameters, String parameterName) { + private static @Nullable String getParameterValue(Map tokenResponseParameters, + String parameterName) { Object obj = tokenResponseParameters.get(parameterName); return (obj != null) ? obj.toString() : null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AccessTokenResponse.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AccessTokenResponse.java index 4e0fe2e2765..3a9b56c70f2 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AccessTokenResponse.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AccessTokenResponse.java @@ -21,9 +21,11 @@ import java.util.Map; import java.util.Set; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; + import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -39,11 +41,11 @@ */ public final class OAuth2AccessTokenResponse { - private OAuth2AccessToken accessToken; + private @Nullable OAuth2AccessToken accessToken; - private OAuth2RefreshToken refreshToken; + private @Nullable OAuth2RefreshToken refreshToken; - private Map additionalParameters; + private @Nullable Map additionalParameters; private OAuth2AccessTokenResponse() { } @@ -53,12 +55,14 @@ private OAuth2AccessTokenResponse() { * @return the {@link OAuth2AccessToken} */ public OAuth2AccessToken getAccessToken() { + Assert.notNull(this.accessToken, "accessToken cannot be null"); return this.accessToken; } /** * Returns the {@link OAuth2RefreshToken Refresh Token}. - * @return the {@link OAuth2RefreshToken} + * @return the {@link OAuth2RefreshToken}, or {@code null} if not present in the + * response * @since 5.1 */ public @Nullable OAuth2RefreshToken getRefreshToken() { @@ -71,6 +75,7 @@ public OAuth2AccessToken getAccessToken() { * empty. */ public Map getAdditionalParameters() { + Assert.notNull(this.additionalParameters, "additionalParameters cannot be null"); return this.additionalParameters; } @@ -99,19 +104,19 @@ public static final class Builder { private String tokenValue; - private OAuth2AccessToken.TokenType tokenType; + private OAuth2AccessToken.@Nullable TokenType tokenType; - private Instant issuedAt; + private @Nullable Instant issuedAt; - private Instant expiresAt; + private @Nullable Instant expiresAt; private long expiresIn; - private Set scopes; + private @Nullable Set scopes; - private String refreshToken; + private @Nullable String refreshToken; - private Map additionalParameters; + private @Nullable Map additionalParameters; private Builder(OAuth2AccessTokenResponse response) { OAuth2AccessToken accessToken = response.getAccessToken(); @@ -131,10 +136,10 @@ private Builder(String tokenValue) { /** * Sets the {@link OAuth2AccessToken.TokenType token type}. - * @param tokenType the type of token issued + * @param tokenType the type of token issued, may be {@code null} * @return the {@link Builder} */ - public Builder tokenType(OAuth2AccessToken.TokenType tokenType) { + public Builder tokenType(OAuth2AccessToken.@Nullable TokenType tokenType) { this.tokenType = tokenType; return this; } @@ -152,30 +157,32 @@ public Builder expiresIn(long expiresIn) { /** * Sets the scope(s) associated to the access token. - * @param scopes the scope(s) associated to the access token. + * @param scopes the scope(s) associated to the access token, may be {@code null} * @return the {@link Builder} */ - public Builder scopes(Set scopes) { + public Builder scopes(@Nullable Set scopes) { this.scopes = scopes; return this; } /** * Sets the refresh token associated to the access token. - * @param refreshToken the refresh token associated to the access token. + * @param refreshToken the refresh token associated to the access token, may be + * {@code null} * @return the {@link Builder} */ - public Builder refreshToken(String refreshToken) { + public Builder refreshToken(@Nullable String refreshToken) { this.refreshToken = refreshToken; return this; } /** * Sets the additional parameters returned in the response. - * @param additionalParameters the additional parameters returned in the response + * @param additionalParameters the additional parameters returned in the response, + * may be {@code null} * @return the {@link Builder} */ - public Builder additionalParameters(Map additionalParameters) { + public Builder additionalParameters(@Nullable Map additionalParameters) { this.additionalParameters = additionalParameters; return this; } @@ -185,11 +192,14 @@ public Builder additionalParameters(Map additionalParameters) { * @return a {@link OAuth2AccessTokenResponse} */ public OAuth2AccessTokenResponse build() { + Assert.notNull(this.tokenType, "tokenType cannot be null"); Instant issuedAt = getIssuedAt(); Instant expiresAt = getExpiresAt(); + // Convert nullable scopes to non-null for constructor + Set scopesToUse = (this.scopes != null) ? this.scopes : Collections.emptySet(); OAuth2AccessTokenResponse accessTokenResponse = new OAuth2AccessTokenResponse(); accessTokenResponse.accessToken = new OAuth2AccessToken(this.tokenType, this.tokenValue, issuedAt, - expiresAt, this.scopes); + expiresAt, scopesToUse); if (StringUtils.hasText(this.refreshToken)) { accessTokenResponse.refreshToken = new OAuth2RefreshToken(this.refreshToken, issuedAt); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java index b9b48f443f2..5de190b0cae 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationRequest.java @@ -30,6 +30,8 @@ import java.util.function.Consumer; import java.util.function.Function; +import org.jspecify.annotations.Nullable; + import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; @@ -65,11 +67,11 @@ public class OAuth2AuthorizationRequest implements Serializable { private final String clientId; - private final String redirectUri; + private final @Nullable String redirectUri; private final Set scopes; - private final String state; + private final @Nullable String state; private final Map additionalParameters; @@ -78,6 +80,8 @@ public class OAuth2AuthorizationRequest implements Serializable { private final Map attributes; protected OAuth2AuthorizationRequest(AbstractBuilder builder) { + Assert.notNull(builder.authorizationUri, "authorizationUri cannot be null"); + Assert.notNull(builder.clientId, "clientId cannot be null"); Assert.hasText(builder.authorizationUri, "authorizationUri cannot be empty"); Assert.hasText(builder.clientId, "clientId cannot be empty"); this.authorizationUri = builder.authorizationUri; @@ -89,8 +93,9 @@ protected OAuth2AuthorizationRequest(AbstractBuilder builder) { CollectionUtils.isEmpty(builder.scopes) ? Collections.emptySet() : new LinkedHashSet<>(builder.scopes)); this.state = builder.state; this.additionalParameters = Collections.unmodifiableMap(builder.additionalParameters); - this.authorizationRequestUri = StringUtils.hasText(builder.authorizationRequestUri) - ? builder.authorizationRequestUri : builder.buildAuthorizationRequestUri(); + String builderAuthorizationRequestUri = builder.authorizationRequestUri; + this.authorizationRequestUri = StringUtils.hasText(builderAuthorizationRequestUri) + ? builderAuthorizationRequestUri : builder.buildAuthorizationRequestUri(); this.attributes = Collections.unmodifiableMap(builder.attributes); } @@ -127,10 +132,10 @@ public String getClientId() { } /** - * Returns the uri for the redirection endpoint. - * @return the uri for the redirection endpoint + * Returns the uri for the redirection endpoint, or {@code null} if not present. + * @return the uri for the redirection endpoint, or {@code null} */ - public String getRedirectUri() { + public @Nullable String getRedirectUri() { return this.redirectUri; } @@ -143,10 +148,10 @@ public Set getScopes() { } /** - * Returns the state. - * @return the state + * Returns the state, or {@code null} if not present. + * @return the state, or {@code null} */ - public String getState() { + public @Nullable String getState() { return this.state; } @@ -177,7 +182,7 @@ public Map getAttributes() { * @since 5.2 */ @SuppressWarnings("unchecked") - public T getAttribute(String name) { + public @Nullable T getAttribute(String name) { return (T) this.getAttributes().get(name); } @@ -277,19 +282,19 @@ public OAuth2AuthorizationRequest build() { */ protected abstract static class AbstractBuilder> { - private String authorizationUri; + private @Nullable String authorizationUri; private final AuthorizationGrantType authorizationGrantType = AuthorizationGrantType.AUTHORIZATION_CODE; private final OAuth2AuthorizationResponseType responseType = OAuth2AuthorizationResponseType.CODE; - private String clientId; + private @Nullable String clientId; - private String redirectUri; + private @Nullable String redirectUri; - private Set scopes; + private @Nullable Set scopes; - private String state; + private @Nullable String state; private Map additionalParameters = new LinkedHashMap<>(); @@ -298,7 +303,7 @@ protected abstract static class AbstractBuilder attributes = new LinkedHashMap<>(); - private String authorizationRequestUri; + private @Nullable String authorizationRequestUri; private Function authorizationRequestUriFunction = (builder) -> builder.build(); @@ -341,20 +346,20 @@ public B clientId(String clientId) { /** * Sets the uri for the redirection endpoint. - * @param redirectUri the uri for the redirection endpoint + * @param redirectUri the uri for the redirection endpoint, may be {@code null} * @return the {@link AbstractBuilder} */ - public B redirectUri(String redirectUri) { + public B redirectUri(@Nullable String redirectUri) { this.redirectUri = redirectUri; return getThis(); } /** * Sets the scope(s). - * @param scope the scope(s) + * @param scope the scope(s), may be {@code null} * @return the {@link AbstractBuilder} */ - public B scope(String... scope) { + public B scope(@Nullable String... scope) { if (scope != null && scope.length > 0) { return scopes(new LinkedHashSet<>(Arrays.asList(scope))); } @@ -363,20 +368,20 @@ public B scope(String... scope) { /** * Sets the scope(s). - * @param scopes the scope(s) + * @param scopes the scope(s), may be {@code null} * @return the {@link AbstractBuilder} */ - public B scopes(Set scopes) { + public B scopes(@Nullable Set scopes) { this.scopes = scopes; return getThis(); } /** * Sets the state. - * @param state the state + * @param state the state, may be {@code null} * @return the {@link AbstractBuilder} */ - public B state(String state) { + public B state(@Nullable String state) { this.state = state; return getThis(); } @@ -502,6 +507,7 @@ else if (v != null && v.getClass().isArray()) { queryParams.set(key, encodeQueryParam(String.valueOf(v))); } }); + Assert.notNull(this.authorizationUri, "authorizationUri cannot be null"); UriBuilder uriBuilder = this.uriBuilderFactory.uriString(this.authorizationUri).queryParams(queryParams); return this.authorizationRequestUriFunction.apply(uriBuilder).toString(); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java index e47fd37e774..d557a9a319d 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2AuthorizationResponse.java @@ -19,6 +19,8 @@ import java.io.Serial; import java.io.Serializable; +import org.jspecify.annotations.Nullable; + import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -39,13 +41,13 @@ public final class OAuth2AuthorizationResponse implements Serializable { @Serial private static final long serialVersionUID = 620L; - private String redirectUri; + private @Nullable String redirectUri; - private String state; + private @Nullable String state; - private String code; + private @Nullable String code; - private OAuth2Error error; + private @Nullable OAuth2Error error; private OAuth2AuthorizationResponse() { } @@ -55,22 +57,24 @@ private OAuth2AuthorizationResponse() { * @return the uri where the response was redirected to */ public String getRedirectUri() { + Assert.notNull(this.redirectUri, "redirectUri cannot be null"); return this.redirectUri; } /** - * Returns the state. - * @return the state + * Returns the state, or {@code null} if not present. + * @return the state, or {@code null} */ - public String getState() { + public @Nullable String getState() { return this.state; } /** - * Returns the authorization code. - * @return the authorization code + * Returns the authorization code, or {@code null} if the response is an error + * response. + * @return the authorization code, or {@code null} */ - public String getCode() { + public @Nullable String getCode() { return this.code; } @@ -80,7 +84,7 @@ public String getCode() { * @return the {@link OAuth2Error} if the Authorization Request failed, otherwise * {@code null} */ - public OAuth2Error getError() { + public @Nullable OAuth2Error getError() { return this.error; } @@ -127,17 +131,17 @@ public static Builder error(String errorCode) { */ public static final class Builder { - private String redirectUri; + private @Nullable String redirectUri; - private String state; + private @Nullable String state; - private String code; + private @Nullable String code; - private String errorCode; + private @Nullable String errorCode; - private String errorDescription; + private @Nullable String errorDescription; - private String errorUri; + private @Nullable String errorUri; private Builder() { } @@ -218,6 +222,8 @@ public OAuth2AuthorizationResponse build() { authorizationResponse.code = this.code; } else { + Assert.notNull(this.errorCode, "errorCode cannot be null when code is not present"); + Assert.hasText(this.errorCode, "errorCode cannot be empty when code is not present"); authorizationResponse.error = new OAuth2Error(this.errorCode, this.errorDescription, this.errorUri); } return authorizationResponse; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java index 18ca78d7701..da2ce841107 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java @@ -21,6 +21,8 @@ import java.util.Collections; import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.util.Assert; @@ -38,17 +40,17 @@ */ public final class OAuth2DeviceAuthorizationResponse { - private OAuth2DeviceCode deviceCode; + private @Nullable OAuth2DeviceCode deviceCode; - private OAuth2UserCode userCode; + private @Nullable OAuth2UserCode userCode; - private String verificationUri; + private @Nullable String verificationUri; - private String verificationUriComplete; + private @Nullable String verificationUriComplete; private long interval; - private Map additionalParameters; + private @Nullable Map additionalParameters; private OAuth2DeviceAuthorizationResponse() { } @@ -58,6 +60,7 @@ private OAuth2DeviceAuthorizationResponse() { * @return the {@link OAuth2DeviceCode} */ public OAuth2DeviceCode getDeviceCode() { + Assert.notNull(this.deviceCode, "deviceCode cannot be null"); return this.deviceCode; } @@ -66,6 +69,7 @@ public OAuth2DeviceCode getDeviceCode() { * @return the {@link OAuth2UserCode} */ public OAuth2UserCode getUserCode() { + Assert.notNull(this.userCode, "userCode cannot be null"); return this.userCode; } @@ -74,14 +78,16 @@ public OAuth2UserCode getUserCode() { * @return the end-user verification URI */ public String getVerificationUri() { + Assert.notNull(this.verificationUri, "verificationUri cannot be null"); return this.verificationUri; } /** - * Returns the end-user verification URI that includes the user code. - * @return the end-user verification URI that includes the user code + * Returns the end-user verification URI that includes the user code, or {@code null} + * if not present. + * @return the end-user verification URI that includes the user code, or {@code null} */ - public String getVerificationUriComplete() { + public @Nullable String getVerificationUriComplete() { return this.verificationUriComplete; } @@ -100,6 +106,7 @@ public long getInterval() { * empty. */ public Map getAdditionalParameters() { + Assert.notNull(this.additionalParameters, "additionalParameters cannot be null"); return this.additionalParameters; } @@ -138,15 +145,15 @@ public static final class Builder { private final String userCode; - private String verificationUri; + private @Nullable String verificationUri; - private String verificationUriComplete; + private @Nullable String verificationUriComplete; private long expiresIn; private long interval; - private Map additionalParameters; + private @Nullable Map additionalParameters; private Builder(OAuth2DeviceCode deviceCode, OAuth2UserCode userCode) { this.deviceCode = deviceCode.getTokenValue(); @@ -172,10 +179,10 @@ public Builder verificationUri(String verificationUri) { /** * Sets the end-user verification URI that includes the user code. * @param verificationUriComplete the end-user verification URI that includes the - * user code + * user code, may be {@code null} * @return the {@link Builder} */ - public Builder verificationUriComplete(String verificationUriComplete) { + public Builder verificationUriComplete(@Nullable String verificationUriComplete) { this.verificationUriComplete = verificationUriComplete; return this; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/package-info.java index 595dadafdb3..6649cabdfb0 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/package-info.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/package-info.java @@ -18,4 +18,7 @@ * Support classes that model the OAuth 2.0 Request and Response messages from the * Authorization Endpoint and Token Endpoint. */ +@NullMarked package org.springframework.security.oauth2.core.endpoint; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java index fca64a7667a..1e2d4a92529 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/HttpMessageConverters.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.core.http.converter; +import org.jspecify.annotations.Nullable; + import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.GsonHttpMessageConverter; @@ -54,7 +56,7 @@ private HttpMessageConverters() { } @SuppressWarnings("removal") - static GenericHttpMessageConverter getJsonMessageConverter() { + static @Nullable GenericHttpMessageConverter getJsonMessageConverter() { if (jacksonPresent) { return new GenericHttpMessageConverterAdapter<>(new JacksonJsonHttpMessageConverter()); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java index f431dea5a38..ae8d24e5b69 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2AccessTokenResponseHttpMessageConverter.java @@ -52,8 +52,7 @@ public class OAuth2AccessTokenResponseHttpMessageConverter private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { }; - private final GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters - .getJsonMessageConverter(); + private final GenericHttpMessageConverter jsonMessageConverter; private Converter, OAuth2AccessTokenResponse> accessTokenResponseConverter = new DefaultMapOAuth2AccessTokenResponseConverter(); @@ -61,6 +60,9 @@ public class OAuth2AccessTokenResponseHttpMessageConverter public OAuth2AccessTokenResponseHttpMessageConverter() { super(DEFAULT_CHARSET, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + GenericHttpMessageConverter converter = HttpMessageConverters.getJsonMessageConverter(); + Assert.notNull(converter, "Unable to locate a supported JSON message converter"); + this.jsonMessageConverter = converter; } @Override diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java index 7be1205b7fa..2ad881bf858 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java @@ -25,6 +25,8 @@ import java.util.Map; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpInputMessage; @@ -56,13 +58,18 @@ public class OAuth2DeviceAuthorizationResponseHttpMessageConverter private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { }; - private final GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters - .getJsonMessageConverter(); + private final GenericHttpMessageConverter jsonMessageConverter; private Converter, OAuth2DeviceAuthorizationResponse> deviceAuthorizationResponseConverter = new DefaultMapOAuth2DeviceAuthorizationResponseConverter(); private Converter> deviceAuthorizationResponseParametersConverter = new DefaultOAuth2DeviceAuthorizationResponseMapConverter(); + public OAuth2DeviceAuthorizationResponseHttpMessageConverter() { + GenericHttpMessageConverter converter = HttpMessageConverters.getJsonMessageConverter(); + Assert.notNull(converter, "Unable to locate a supported JSON message converter"); + this.jsonMessageConverter = converter; + } + @Override protected boolean supports(Class clazz) { return OAuth2DeviceAuthorizationResponse.class.isAssignableFrom(clazz); @@ -139,8 +146,11 @@ private static final class DefaultMapOAuth2DeviceAuthorizationResponseConverter @Override public OAuth2DeviceAuthorizationResponse convert(Map parameters) { String deviceCode = getParameterValue(parameters, OAuth2ParameterNames.DEVICE_CODE); + Assert.notNull(deviceCode, "Missing required parameter: " + OAuth2ParameterNames.DEVICE_CODE); String userCode = getParameterValue(parameters, OAuth2ParameterNames.USER_CODE); + Assert.notNull(userCode, "Missing required parameter: " + OAuth2ParameterNames.USER_CODE); String verificationUri = getParameterValue(parameters, OAuth2ParameterNames.VERIFICATION_URI); + Assert.notNull(verificationUri, "Missing required parameter: " + OAuth2ParameterNames.VERIFICATION_URI); String verificationUriComplete = getParameterValue(parameters, OAuth2ParameterNames.VERIFICATION_URI_COMPLETE); long expiresIn = getParameterValue(parameters, OAuth2ParameterNames.EXPIRES_IN, 0L); @@ -162,7 +172,7 @@ public OAuth2DeviceAuthorizationResponse convert(Map parameters) // @formatter:on } - private static String getParameterValue(Map parameters, String parameterName) { + private static @Nullable String getParameterValue(Map parameters, String parameterName) { Object obj = parameters.get(parameterName); return (obj != null) ? obj.toString() : null; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java index 70e0556bf3c..eb364f63e4d 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2ErrorHttpMessageConverter.java @@ -52,8 +52,7 @@ public class OAuth2ErrorHttpMessageConverter extends AbstractHttpMessageConverte private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { }; - private final GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters - .getJsonMessageConverter(); + private final GenericHttpMessageConverter jsonMessageConverter; protected Converter, OAuth2Error> errorConverter = new OAuth2ErrorConverter(); @@ -61,6 +60,9 @@ public class OAuth2ErrorHttpMessageConverter extends AbstractHttpMessageConverte public OAuth2ErrorHttpMessageConverter() { super(DEFAULT_CHARSET, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + GenericHttpMessageConverter converter = HttpMessageConverters.getJsonMessageConverter(); + Assert.notNull(converter, "Unable to locate a supported JSON message converter"); + this.jsonMessageConverter = converter; } @Override @@ -133,6 +135,7 @@ private static class OAuth2ErrorConverter implements Converter parameters) { String errorCode = parameters.get(OAuth2ParameterNames.ERROR); + Assert.hasText(errorCode, "errorCode cannot be empty"); String errorDescription = parameters.get(OAuth2ParameterNames.ERROR_DESCRIPTION); String errorUri = parameters.get(OAuth2ParameterNames.ERROR_URI); return new OAuth2Error(errorCode, errorDescription, errorUri); diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/package-info.java new file mode 100644 index 00000000000..a3f7b61aa28 --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * HTTP message converters for OAuth 2.0 and OpenID Connect protocol messages. + */ +@NullMarked +package org.springframework.security.oauth2.core.http.converter; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/package-info.java new file mode 100644 index 00000000000..cf38bd61a4a --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Support classes that provide HTTP message conversion for OAuth 2.0 and OpenID Connect. + */ +@NullMarked +package org.springframework.security.oauth2.core.http; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/AddressStandardClaim.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/AddressStandardClaim.java index 6329e5f1603..a4ba432996f 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/AddressStandardClaim.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/AddressStandardClaim.java @@ -16,6 +16,8 @@ package org.springframework.security.oauth2.core.oidc; +import org.jspecify.annotations.Nullable; + /** * The Address Claim represents a physical mailing address defined by the OpenID Connect * Core 1.0 specification that can be returned either in the UserInfo Response or the ID @@ -34,40 +36,43 @@ public interface AddressStandardClaim { /** - * Returns the full mailing address, formatted for display. - * @return the full mailing address + * Returns the full mailing address, formatted for display, or {@code null} if it does + * not exist. + * @return the full mailing address, or {@code null} if it does not exist */ - String getFormatted(); + @Nullable String getFormatted(); /** * Returns the full street address, which may include house number, street name, P.O. - * Box, etc. - * @return the full street address + * Box, etc., or {@code null} if it does not exist. + * @return the full street address, or {@code null} if it does not exist */ - String getStreetAddress(); + @Nullable String getStreetAddress(); /** - * Returns the city or locality. - * @return the city or locality + * Returns the city or locality, or {@code null} if it does not exist. + * @return the city or locality, or {@code null} if it does not exist */ - String getLocality(); + @Nullable String getLocality(); /** - * Returns the state, province, prefecture, or region. - * @return the state, province, prefecture, or region + * Returns the state, province, prefecture, or region, or {@code null} if it does not + * exist. + * @return the state, province, prefecture, or region, or {@code null} if it does not + * exist */ - String getRegion(); + @Nullable String getRegion(); /** - * Returns the zip code or postal code. - * @return the zip code or postal code + * Returns the zip code or postal code, or {@code null} if it does not exist. + * @return the zip code or postal code, or {@code null} if it does not exist */ - String getPostalCode(); + @Nullable String getPostalCode(); /** - * Returns the country. - * @return the country + * Returns the country, or {@code null} if it does not exist. + * @return the country, or {@code null} if it does not exist */ - String getCountry(); + @Nullable String getCountry(); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/DefaultAddressStandardClaim.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/DefaultAddressStandardClaim.java index 8cecca5c154..28360753bd1 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/DefaultAddressStandardClaim.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/DefaultAddressStandardClaim.java @@ -18,6 +18,8 @@ import java.util.Map; +import org.jspecify.annotations.Nullable; + /** * The default implementation of an {@link AddressStandardClaim Address Claim}. * @@ -27,48 +29,48 @@ */ public final class DefaultAddressStandardClaim implements AddressStandardClaim { - private String formatted; + private @Nullable String formatted; - private String streetAddress; + private @Nullable String streetAddress; - private String locality; + private @Nullable String locality; - private String region; + private @Nullable String region; - private String postalCode; + private @Nullable String postalCode; - private String country; + private @Nullable String country; private DefaultAddressStandardClaim() { } @Override - public String getFormatted() { + public @Nullable String getFormatted() { return this.formatted; } @Override - public String getStreetAddress() { + public @Nullable String getStreetAddress() { return this.streetAddress; } @Override - public String getLocality() { + public @Nullable String getLocality() { return this.locality; } @Override - public String getRegion() { + public @Nullable String getRegion() { return this.region; } @Override - public String getPostalCode() { + public @Nullable String getPostalCode() { return this.postalCode; } @Override - public String getCountry() { + public @Nullable String getCountry() { return this.country; } @@ -131,17 +133,17 @@ public static class Builder { private static final String COUNTRY_FIELD_NAME = "country"; - private String formatted; + private @Nullable String formatted; - private String streetAddress; + private @Nullable String streetAddress; - private String locality; + private @Nullable String locality; - private String region; + private @Nullable String region; - private String postalCode; + private @Nullable String postalCode; - private String country; + private @Nullable String country; /** * Default constructor. @@ -165,10 +167,10 @@ public Builder(Map addressFields) { /** * Sets the full mailing address, formatted for display. - * @param formatted the full mailing address + * @param formatted the full mailing address, may be {@code null} * @return the {@link Builder} */ - public Builder formatted(String formatted) { + public Builder formatted(@Nullable String formatted) { this.formatted = formatted; return this; } @@ -176,50 +178,50 @@ public Builder formatted(String formatted) { /** * Sets the full street address, which may include house number, street name, P.O. * Box, etc. - * @param streetAddress the full street address + * @param streetAddress the full street address, may be {@code null} * @return the {@link Builder} */ - public Builder streetAddress(String streetAddress) { + public Builder streetAddress(@Nullable String streetAddress) { this.streetAddress = streetAddress; return this; } /** * Sets the city or locality. - * @param locality the city or locality + * @param locality the city or locality, may be {@code null} * @return the {@link Builder} */ - public Builder locality(String locality) { + public Builder locality(@Nullable String locality) { this.locality = locality; return this; } /** * Sets the state, province, prefecture, or region. - * @param region the state, province, prefecture, or region + * @param region the state, province, prefecture, or region, may be {@code null} * @return the {@link Builder} */ - public Builder region(String region) { + public Builder region(@Nullable String region) { this.region = region; return this; } /** * Sets the zip code or postal code. - * @param postalCode the zip code or postal code + * @param postalCode the zip code or postal code, may be {@code null} * @return the {@link Builder} */ - public Builder postalCode(String postalCode) { + public Builder postalCode(@Nullable String postalCode) { this.postalCode = postalCode; return this; } /** * Sets the country. - * @param country the country + * @param country the country, may be {@code null} * @return the {@link Builder} */ - public Builder country(String country) { + public Builder country(@Nullable String country) { this.country = country; return this; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/IdTokenClaimAccessor.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/IdTokenClaimAccessor.java index a75a13b4f15..572ef75c099 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/IdTokenClaimAccessor.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/IdTokenClaimAccessor.java @@ -20,6 +20,8 @@ import java.time.Instant; import java.util.List; +import org.jspecify.annotations.Nullable; + import org.springframework.security.oauth2.core.ClaimAccessor; /** @@ -43,101 +45,117 @@ public interface IdTokenClaimAccessor extends StandardClaimAccessor { /** - * Returns the Issuer identifier {@code (iss)}. - * @return the Issuer identifier + * Returns the Issuer identifier {@code (iss)}, or {@code null} if it does not exist. + * @return the Issuer identifier, or {@code null} if it does not exist */ - default URL getIssuer() { + default @Nullable URL getIssuer() { return this.getClaimAsURL(IdTokenClaimNames.ISS); } /** - * Returns the Subject identifier {@code (sub)}. - * @return the Subject identifier + * Returns the Subject identifier {@code (sub)}, or {@code null} if it does not exist. + * @return the Subject identifier, or {@code null} if it does not exist */ @Override - default String getSubject() { + default @Nullable String getSubject() { return this.getClaimAsString(IdTokenClaimNames.SUB); } /** - * Returns the Audience(s) {@code (aud)} that this ID Token is intended for. - * @return the Audience(s) that this ID Token is intended for + * Returns the Audience(s) {@code (aud)} that this ID Token is intended for, or + * {@code null} if it does not exist. + * @return the Audience(s) that this ID Token is intended for, or {@code null} if it + * does not exist */ - default List getAudience() { + default @Nullable List getAudience() { return this.getClaimAsStringList(IdTokenClaimNames.AUD); } /** * Returns the Expiration time {@code (exp)} on or after which the ID Token MUST NOT - * be accepted. - * @return the Expiration time on or after which the ID Token MUST NOT be accepted + * be accepted, or {@code null} if it does not exist. + * @return the Expiration time on or after which the ID Token MUST NOT be accepted, or + * {@code null} if it does not exist */ - default Instant getExpiresAt() { + default @Nullable Instant getExpiresAt() { return this.getClaimAsInstant(IdTokenClaimNames.EXP); } /** - * Returns the time at which the ID Token was issued {@code (iat)}. - * @return the time at which the ID Token was issued + * Returns the time at which the ID Token was issued {@code (iat)}, or {@code null} if + * it does not exist. + * @return the time at which the ID Token was issued, or {@code null} if it does not + * exist */ - default Instant getIssuedAt() { + default @Nullable Instant getIssuedAt() { return this.getClaimAsInstant(IdTokenClaimNames.IAT); } /** - * Returns the time when the End-User authentication occurred {@code (auth_time)}. - * @return the time when the End-User authentication occurred + * Returns the time when the End-User authentication occurred {@code (auth_time)}, or + * {@code null} if it does not exist. + * @return the time when the End-User authentication occurred, or {@code null} if it + * does not exist */ - default Instant getAuthenticatedAt() { + default @Nullable Instant getAuthenticatedAt() { return this.getClaimAsInstant(IdTokenClaimNames.AUTH_TIME); } /** * Returns a {@code String} value {@code (nonce)} used to associate a Client session - * with an ID Token, and to mitigate replay attacks. - * @return the nonce used to associate a Client session with an ID Token + * with an ID Token, and to mitigate replay attacks, or {@code null} if it does not + * exist. + * @return the nonce used to associate a Client session with an ID Token, or + * {@code null} if it does not exist */ - default String getNonce() { + default @Nullable String getNonce() { return this.getClaimAsString(IdTokenClaimNames.NONCE); } /** - * Returns the Authentication Context Class Reference {@code (acr)}. - * @return the Authentication Context Class Reference + * Returns the Authentication Context Class Reference {@code (acr)}, or {@code null} + * if it does not exist. + * @return the Authentication Context Class Reference, or {@code null} if it does not + * exist */ - default String getAuthenticationContextClass() { + default @Nullable String getAuthenticationContextClass() { return this.getClaimAsString(IdTokenClaimNames.ACR); } /** - * Returns the Authentication Methods References {@code (amr)}. - * @return the Authentication Methods References + * Returns the Authentication Methods References {@code (amr)}, or {@code null} if it + * does not exist. + * @return the Authentication Methods References, or {@code null} if it does not exist */ - default List getAuthenticationMethods() { + default @Nullable List getAuthenticationMethods() { return this.getClaimAsStringList(IdTokenClaimNames.AMR); } /** - * Returns the Authorized party {@code (azp)} to which the ID Token was issued. - * @return the Authorized party to which the ID Token was issued + * Returns the Authorized party {@code (azp)} to which the ID Token was issued, or + * {@code null} if it does not exist. + * @return the Authorized party to which the ID Token was issued, or {@code null} if + * it does not exist */ - default String getAuthorizedParty() { + default @Nullable String getAuthorizedParty() { return this.getClaimAsString(IdTokenClaimNames.AZP); } /** - * Returns the Access Token hash value {@code (at_hash)}. - * @return the Access Token hash value + * Returns the Access Token hash value {@code (at_hash)}, or {@code null} if it does + * not exist. + * @return the Access Token hash value, or {@code null} if it does not exist */ - default String getAccessTokenHash() { + default @Nullable String getAccessTokenHash() { return this.getClaimAsString(IdTokenClaimNames.AT_HASH); } /** - * Returns the Authorization Code hash value {@code (c_hash)}. - * @return the Authorization Code hash value + * Returns the Authorization Code hash value {@code (c_hash)}, or {@code null} if it + * does not exist. + * @return the Authorization Code hash value, or {@code null} if it does not exist */ - default String getAuthorizationCodeHash() { + default @Nullable String getAuthorizationCodeHash() { return this.getClaimAsString(IdTokenClaimNames.C_HASH); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java index bff451f999c..c3da042a0f4 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/OidcIdToken.java @@ -25,6 +25,8 @@ import java.util.Map; import java.util.function.Consumer; +import org.jspecify.annotations.Nullable; + import org.springframework.security.oauth2.core.AbstractOAuth2Token; import org.springframework.util.Assert; @@ -57,12 +59,14 @@ public class OidcIdToken extends AbstractOAuth2Token implements IdTokenClaimAcce /** * Constructs a {@code OidcIdToken} using the provided parameters. * @param tokenValue the ID Token value - * @param issuedAt the time at which the ID Token was issued {@code (iat)} + * @param issuedAt the time at which the ID Token was issued {@code (iat)}, may be + * {@code null} * @param expiresAt the expiration time {@code (exp)} on or after which the ID Token - * MUST NOT be accepted + * MUST NOT be accepted, may be {@code null} * @param claims the claims about the authentication of the End-User */ - public OidcIdToken(String tokenValue, Instant issuedAt, Instant expiresAt, Map claims) { + public OidcIdToken(String tokenValue, @Nullable Instant issuedAt, @Nullable Instant expiresAt, + Map claims) { super(tokenValue, issuedAt, expiresAt); Assert.notEmpty(claims, "claims cannot be empty"); this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims)); @@ -251,7 +255,7 @@ public OidcIdToken build() { return new OidcIdToken(this.tokenValue, iat, exp, this.claims); } - private Instant toInstant(Object timestamp) { + private @Nullable Instant toInstant(@Nullable Object timestamp) { if (timestamp != null) { Assert.isInstanceOf(Instant.class, timestamp, "timestamps must be of type Instant"); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/StandardClaimAccessor.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/StandardClaimAccessor.java index 08163d7ca4c..d27638b3a73 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/StandardClaimAccessor.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/StandardClaimAccessor.java @@ -19,6 +19,8 @@ import java.time.Instant; import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.security.oauth2.core.ClaimAccessor; import org.springframework.util.CollectionUtils; @@ -41,152 +43,166 @@ public interface StandardClaimAccessor extends ClaimAccessor { /** - * Returns the Subject identifier {@code (sub)}. - * @return the Subject identifier + * Returns the Subject identifier {@code (sub)}, or {@code null} if it does not exist. + * @return the Subject identifier, or {@code null} if it does not exist */ - default String getSubject() { + default @Nullable String getSubject() { return this.getClaimAsString(StandardClaimNames.SUB); } /** - * Returns the user's full name {@code (name)} in displayable form. - * @return the user's full name + * Returns the user's full name {@code (name)} in displayable form, or {@code null} if + * it does not exist. + * @return the user's full name, or {@code null} if it does not exist */ - default String getFullName() { + default @Nullable String getFullName() { return this.getClaimAsString(StandardClaimNames.NAME); } /** - * Returns the user's given name(s) or first name(s) {@code (given_name)}. - * @return the user's given name(s) + * Returns the user's given name(s) or first name(s) {@code (given_name)}, or + * {@code null} if it does not exist. + * @return the user's given name(s), or {@code null} if it does not exist */ - default String getGivenName() { + default @Nullable String getGivenName() { return this.getClaimAsString(StandardClaimNames.GIVEN_NAME); } /** - * Returns the user's surname(s) or last name(s) {@code (family_name)}. - * @return the user's family names(s) + * Returns the user's surname(s) or last name(s) {@code (family_name)}, or + * {@code null} if it does not exist. + * @return the user's family names(s), or {@code null} if it does not exist */ - default String getFamilyName() { + default @Nullable String getFamilyName() { return this.getClaimAsString(StandardClaimNames.FAMILY_NAME); } /** - * Returns the user's middle name(s) {@code (middle_name)}. - * @return the user's middle name(s) + * Returns the user's middle name(s) {@code (middle_name)}, or {@code null} if it does + * not exist. + * @return the user's middle name(s), or {@code null} if it does not exist */ - default String getMiddleName() { + default @Nullable String getMiddleName() { return this.getClaimAsString(StandardClaimNames.MIDDLE_NAME); } /** * Returns the user's nick name {@code (nickname)} that may or may not be the same as - * the {@code (given_name)}. - * @return the user's nick name + * the {@code (given_name)}, or {@code null} if it does not exist. + * @return the user's nick name, or {@code null} if it does not exist */ - default String getNickName() { + default @Nullable String getNickName() { return this.getClaimAsString(StandardClaimNames.NICKNAME); } /** * Returns the preferred username {@code (preferred_username)} that the user wishes to - * be referred to. - * @return the user's preferred user name + * be referred to, or {@code null} if it does not exist. + * @return the user's preferred user name, or {@code null} if it does not exist */ - default String getPreferredUsername() { + default @Nullable String getPreferredUsername() { return this.getClaimAsString(StandardClaimNames.PREFERRED_USERNAME); } /** - * Returns the URL of the user's profile page {@code (profile)}. - * @return the URL of the user's profile page + * Returns the URL of the user's profile page {@code (profile)}, or {@code null} if it + * does not exist. + * @return the URL of the user's profile page, or {@code null} if it does not exist */ - default String getProfile() { + default @Nullable String getProfile() { return this.getClaimAsString(StandardClaimNames.PROFILE); } /** - * Returns the URL of the user's profile picture {@code (picture)}. - * @return the URL of the user's profile picture + * Returns the URL of the user's profile picture {@code (picture)}, or {@code null} if + * it does not exist. + * @return the URL of the user's profile picture, or {@code null} if it does not exist */ - default String getPicture() { + default @Nullable String getPicture() { return this.getClaimAsString(StandardClaimNames.PICTURE); } /** - * Returns the URL of the user's web page or blog {@code (website)}. - * @return the URL of the user's web page or blog + * Returns the URL of the user's web page or blog {@code (website)}, or {@code null} + * if it does not exist. + * @return the URL of the user's web page or blog, or {@code null} if it does not + * exist */ - default String getWebsite() { + default @Nullable String getWebsite() { return this.getClaimAsString(StandardClaimNames.WEBSITE); } /** - * Returns the user's preferred e-mail address {@code (email)}. - * @return the user's preferred e-mail address + * Returns the user's preferred e-mail address {@code (email)}, or {@code null} if it + * does not exist. + * @return the user's preferred e-mail address, or {@code null} if it does not exist */ - default String getEmail() { + default @Nullable String getEmail() { return this.getClaimAsString(StandardClaimNames.EMAIL); } /** * Returns {@code true} if the user's e-mail address has been verified - * {@code (email_verified)}, otherwise {@code false}. + * {@code (email_verified)}, otherwise {@code false}, or {@code null} if it does not + * exist. * @return {@code true} if the user's e-mail address has been verified, otherwise - * {@code false} + * {@code false}, or {@code null} if it does not exist */ - default Boolean getEmailVerified() { + default @Nullable Boolean getEmailVerified() { return this.getClaimAsBoolean(StandardClaimNames.EMAIL_VERIFIED); } /** - * Returns the user's gender {@code (gender)}. - * @return the user's gender + * Returns the user's gender {@code (gender)}, or {@code null} if it does not exist. + * @return the user's gender, or {@code null} if it does not exist */ - default String getGender() { + default @Nullable String getGender() { return this.getClaimAsString(StandardClaimNames.GENDER); } /** - * Returns the user's birth date {@code (birthdate)}. - * @return the user's birth date + * Returns the user's birth date {@code (birthdate)}, or {@code null} if it does not + * exist. + * @return the user's birth date, or {@code null} if it does not exist */ - default String getBirthdate() { + default @Nullable String getBirthdate() { return this.getClaimAsString(StandardClaimNames.BIRTHDATE); } /** - * Returns the user's time zone {@code (zoneinfo)}. - * @return the user's time zone + * Returns the user's time zone {@code (zoneinfo)}, or {@code null} if it does not + * exist. + * @return the user's time zone, or {@code null} if it does not exist */ - default String getZoneInfo() { + default @Nullable String getZoneInfo() { return this.getClaimAsString(StandardClaimNames.ZONEINFO); } /** - * Returns the user's locale {@code (locale)}. - * @return the user's locale + * Returns the user's locale {@code (locale)}, or {@code null} if it does not exist. + * @return the user's locale, or {@code null} if it does not exist */ - default String getLocale() { + default @Nullable String getLocale() { return this.getClaimAsString(StandardClaimNames.LOCALE); } /** - * Returns the user's preferred phone number {@code (phone_number)}. - * @return the user's preferred phone number + * Returns the user's preferred phone number {@code (phone_number)}, or {@code null} + * if it does not exist. + * @return the user's preferred phone number, or {@code null} if it does not exist */ - default String getPhoneNumber() { + default @Nullable String getPhoneNumber() { return this.getClaimAsString(StandardClaimNames.PHONE_NUMBER); } /** * Returns {@code true} if the user's phone number has been verified - * {@code (phone_number_verified)}, otherwise {@code false}. + * {@code (phone_number_verified)}, otherwise {@code false}, or {@code null} if it + * does not exist. * @return {@code true} if the user's phone number has been verified, otherwise - * {@code false} + * {@code false}, or {@code null} if it does not exist */ - default Boolean getPhoneNumberVerified() { + default @Nullable Boolean getPhoneNumberVerified() { return this.getClaimAsBoolean(StandardClaimNames.PHONE_NUMBER_VERIFIED); } @@ -201,10 +217,12 @@ default AddressStandardClaim getAddress() { } /** - * Returns the time the user's information was last updated {@code (updated_at)}. - * @return the time the user's information was last updated + * Returns the time the user's information was last updated {@code (updated_at)}, or + * {@code null} if it does not exist. + * @return the time the user's information was last updated, or {@code null} if it + * does not exist */ - default Instant getUpdatedAt() { + default @Nullable Instant getUpdatedAt() { return this.getClaimAsInstant(StandardClaimNames.UPDATED_AT); } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/endpoint/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/endpoint/package-info.java index 7b73d843a70..3aa82bf1c39 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/endpoint/package-info.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/endpoint/package-info.java @@ -18,4 +18,7 @@ * Support classes that model the OpenID Connect Core 1.0 Request and Response messages * from the Authorization Endpoint and Token Endpoint. */ +@NullMarked package org.springframework.security.oauth2.core.oidc.endpoint; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/package-info.java index 4b48e438b44..8cb1d1892fa 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/package-info.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/package-info.java @@ -17,4 +17,7 @@ /** * Core classes and interfaces providing support for OpenID Connect Core 1.0. */ +@NullMarked package org.springframework.security.oauth2.core.oidc; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java index 3b99e3e829b..6e1284fdbfb 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java @@ -20,6 +20,8 @@ import java.util.Collection; import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -48,52 +50,52 @@ public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser { private final OidcIdToken idToken; - private final OidcUserInfo userInfo; + private final @Nullable OidcUserInfo userInfo; /** * Constructs a {@code DefaultOidcUser} using the provided parameters. - * @param authorities the authorities granted to the user + * @param authorities the authorities granted to the user, may be {@code null} * @param idToken the {@link OidcIdToken ID Token} containing claims about the user */ - public DefaultOidcUser(Collection authorities, OidcIdToken idToken) { + public DefaultOidcUser(@Nullable Collection authorities, OidcIdToken idToken) { this(authorities, idToken, IdTokenClaimNames.SUB); } /** * Constructs a {@code DefaultOidcUser} using the provided parameters. - * @param authorities the authorities granted to the user + * @param authorities the authorities granted to the user, may be {@code null} * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param nameAttributeKey the key used to access the user's "name" from * {@link #getAttributes()} */ - public DefaultOidcUser(Collection authorities, OidcIdToken idToken, + public DefaultOidcUser(@Nullable Collection authorities, OidcIdToken idToken, String nameAttributeKey) { this(authorities, idToken, null, nameAttributeKey); } /** * Constructs a {@code DefaultOidcUser} using the provided parameters. - * @param authorities the authorities granted to the user + * @param authorities the authorities granted to the user, may be {@code null} * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} */ - public DefaultOidcUser(Collection authorities, OidcIdToken idToken, - OidcUserInfo userInfo) { + public DefaultOidcUser(@Nullable Collection authorities, OidcIdToken idToken, + @Nullable OidcUserInfo userInfo) { this(authorities, idToken, userInfo, IdTokenClaimNames.SUB); } /** * Constructs a {@code DefaultOidcUser} using the provided parameters. - * @param authorities the authorities granted to the user + * @param authorities the authorities granted to the user, may be {@code null} * @param idToken the {@link OidcIdToken ID Token} containing claims about the user * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} * @param nameAttributeKey the key used to access the user's "name" from * {@link #getAttributes()} */ - public DefaultOidcUser(Collection authorities, OidcIdToken idToken, - OidcUserInfo userInfo, String nameAttributeKey) { + public DefaultOidcUser(@Nullable Collection authorities, OidcIdToken idToken, + @Nullable OidcUserInfo userInfo, String nameAttributeKey) { super(authorities, OidcUserAuthority.collectClaims(idToken, userInfo), nameAttributeKey); this.idToken = idToken; this.userInfo = userInfo; @@ -110,7 +112,7 @@ public OidcIdToken getIdToken() { } @Override - public OidcUserInfo getUserInfo() { + public @Nullable OidcUserInfo getUserInfo() { return this.userInfo; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUser.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUser.java index b795215a404..01e87e22cb5 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUser.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUser.java @@ -18,6 +18,8 @@ import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.security.core.AuthenticatedPrincipal; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.oidc.IdTokenClaimAccessor; @@ -65,10 +67,11 @@ public interface OidcUser extends OAuth2User, IdTokenClaimAccessor { Map getClaims(); /** - * Returns the {@link OidcUserInfo UserInfo} containing claims about the user. - * @return the {@link OidcUserInfo} containing claims about the user. + * Returns the {@link OidcUserInfo UserInfo} containing claims about the user, or + * {@code null} if not present. + * @return the {@link OidcUserInfo} containing claims about the user, or {@code null} */ - OidcUserInfo getUserInfo(); + @Nullable OidcUserInfo getUserInfo(); /** * Returns the {@link OidcIdToken ID Token} containing claims about the user. diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java index 5e0f4fa0b2e..3b6acab6e79 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java @@ -19,8 +19,10 @@ import java.io.Serial; import java.util.HashMap; import java.util.Map; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; -import org.springframework.lang.Nullable; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -42,7 +44,7 @@ public class OidcUserAuthority extends OAuth2UserAuthority { private final OidcIdToken idToken; - private final OidcUserInfo userInfo; + private final @Nullable OidcUserInfo userInfo; /** * Constructs a {@code OidcUserAuthority} using the provided parameters. @@ -59,7 +61,7 @@ public OidcUserAuthority(OidcIdToken idToken) { * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} */ - public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) { + public OidcUserAuthority(OidcIdToken idToken, @Nullable OidcUserInfo userInfo) { this("OIDC_USER", idToken, userInfo); } @@ -70,10 +72,11 @@ public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo) { * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} * @param userNameAttributeName the attribute name used to access the user's name from - * the attributes + * the attributes, may be {@code null} * @since 6.4 */ - public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable String userNameAttributeName) { + public OidcUserAuthority(OidcIdToken idToken, @Nullable OidcUserInfo userInfo, + @Nullable String userNameAttributeName) { this("OIDC_USER", idToken, userInfo, userNameAttributeName); } @@ -84,7 +87,7 @@ public OidcUserAuthority(OidcIdToken idToken, OidcUserInfo userInfo, @Nullable S * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} */ - public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo) { + public OidcUserAuthority(String authority, OidcIdToken idToken, @Nullable OidcUserInfo userInfo) { this(authority, idToken, userInfo, IdTokenClaimNames.SUB); } @@ -95,10 +98,10 @@ public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo use * @param userInfo the {@link OidcUserInfo UserInfo} containing claims about the user, * may be {@code null} * @param userNameAttributeName the attribute name used to access the user's name from - * the attributes + * the attributes, may be {@code null} * @since 6.4 */ - public OidcUserAuthority(String authority, OidcIdToken idToken, OidcUserInfo userInfo, + public OidcUserAuthority(String authority, OidcIdToken idToken, @Nullable OidcUserInfo userInfo, @Nullable String userNameAttributeName) { super(authority, collectClaims(idToken, userInfo), userNameAttributeName); this.idToken = idToken; @@ -118,7 +121,7 @@ public OidcIdToken getIdToken() { * {@code null}. * @return the {@link OidcUserInfo} containing claims about the user, or {@code null} */ - public OidcUserInfo getUserInfo() { + public @Nullable OidcUserInfo getUserInfo() { return this.userInfo; } @@ -137,8 +140,7 @@ public boolean equals(Object obj) { if (!this.getIdToken().equals(that.getIdToken())) { return false; } - return (this.getUserInfo() != null) ? this.getUserInfo().equals(that.getUserInfo()) - : that.getUserInfo() == null; + return Objects.equals(this.getUserInfo(), that.getUserInfo()); } @Override @@ -149,7 +151,7 @@ public int hashCode() { return result; } - static Map collectClaims(OidcIdToken idToken, OidcUserInfo userInfo) { + static Map collectClaims(OidcIdToken idToken, @Nullable OidcUserInfo userInfo) { Assert.notNull(idToken, "idToken cannot be null"); Map claims = new HashMap<>(); if (userInfo != null) { diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/package-info.java index e4146d8563b..040dcb5ef68 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/package-info.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/package-info.java @@ -18,4 +18,7 @@ * Provides a model for an OpenID Connect Core 1.0 representation of a user * {@code Principal}. */ +@NullMarked package org.springframework.security.oauth2.core.oidc.user; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/package-info.java index 3021617c099..aa7c3520d58 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/package-info.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/package-info.java @@ -18,4 +18,7 @@ * Core classes and interfaces providing support for the OAuth 2.0 Authorization * Framework. */ +@NullMarked package org.springframework.security.oauth2.core; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java index a8d09ec7aa2..27d1e9e7d7d 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/DefaultOAuth2User.java @@ -27,6 +27,8 @@ import java.util.SortedSet; import java.util.TreeSet; +import org.jspecify.annotations.Nullable; + import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.util.Assert; @@ -59,18 +61,17 @@ public class DefaultOAuth2User implements OAuth2User, Serializable { /** * Constructs a {@code DefaultOAuth2User} using the provided parameters. - * @param authorities the authorities granted to the user + * @param authorities the authorities granted to the user, may be {@code null} * @param attributes the attributes about the user * @param nameAttributeKey the key used to access the user's "name" from * {@link #getAttributes()} */ - public DefaultOAuth2User(Collection authorities, Map attributes, - String nameAttributeKey) { + public DefaultOAuth2User(@Nullable Collection authorities, + Map attributes, String nameAttributeKey) { Assert.notEmpty(attributes, "attributes cannot be empty"); Assert.hasText(nameAttributeKey, "nameAttributeKey cannot be empty"); Assert.notNull(attributes.get(nameAttributeKey), "Attribute value for '" + nameAttributeKey + "' cannot be null"); - this.authorities = (authorities != null) ? Collections.unmodifiableSet(new LinkedHashSet<>(this.sortAuthorities(authorities))) : Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES)); @@ -80,7 +81,9 @@ public DefaultOAuth2User(Collection authorities, Map @Override public String getName() { - return this.getAttribute(this.nameAttributeKey).toString(); + Object nameAttributeValue = this.getAttribute(this.nameAttributeKey); + Assert.notNull(nameAttributeValue, "name attribute value cannot be null"); + return nameAttributeValue.toString(); } @Override diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java index 5a94b825d50..7d4a5751a24 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/OAuth2UserAuthority.java @@ -22,7 +22,8 @@ import java.util.Map; import java.util.Objects; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; + import org.springframework.security.core.GrantedAuthority; import org.springframework.util.Assert; @@ -41,7 +42,7 @@ public class OAuth2UserAuthority implements GrantedAuthority { private final Map attributes; - private final String userNameAttributeName; + private final @Nullable String userNameAttributeName; /** * Constructs a {@code OAuth2UserAuthority} using the provided parameters and defaults @@ -78,10 +79,11 @@ public OAuth2UserAuthority(String authority, Map attributes) { * @param authority the authority granted to the user * @param attributes the attributes about the user * @param userNameAttributeName the attribute name used to access the user's name from - * the attributes + * the attributes, may be {@code null} * @since 6.4 */ - public OAuth2UserAuthority(String authority, Map attributes, String userNameAttributeName) { + public OAuth2UserAuthority(String authority, Map attributes, + @Nullable String userNameAttributeName) { Assert.hasText(authority, "authority cannot be empty"); Assert.notEmpty(attributes, "attributes cannot be empty"); this.authority = authority; @@ -104,11 +106,11 @@ public Map getAttributes() { /** * Returns the attribute name used to access the user's name from the attributes. - * @return the attribute name used to access the user's name from the attributes + * @return the attribute name used to access the user's name from the attributes, or + * {@code null} if not available * @since 6.4 */ - @Nullable - public String getUserNameAttributeName() { + public @Nullable String getUserNameAttributeName() { return this.userNameAttributeName; } @@ -137,8 +139,9 @@ public boolean equals(Object obj) { } } else { - Object thatValue = convertURLIfNecessary(thatAttributes.get(key)); - if (!value.equals(thatValue)) { + Object thatValue = thatAttributes.get(key); + Object convertedThatValue = convertURLIfNecessary(thatValue); + if (!value.equals(convertedThatValue)) { return false; } } @@ -165,9 +168,10 @@ public String toString() { /** * @return {@code URL} converted to a string since {@code URL} shouldn't be used for - * equality/hashCode. For other instances the value is returned as is. + * equality/hashCode. For other instances the value is returned as is (including + * null). */ - private static Object convertURLIfNecessary(Object value) { + private static @Nullable Object convertURLIfNecessary(@Nullable Object value) { return (value instanceof URL) ? ((URL) value).toExternalForm() : value; } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/package-info.java index d0c4a130794..48ca8909c90 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/package-info.java +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/user/package-info.java @@ -17,4 +17,7 @@ /** * Provides a model for an OAuth 2.0 representation of a user {@code Principal}. */ +@NullMarked package org.springframework.security.oauth2.core.user; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/package-info.java new file mode 100644 index 00000000000..ae9c62758ea --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Web support classes for OAuth 2.0 and OpenID Connect. + */ +@NullMarked +package org.springframework.security.oauth2.core.web; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/function/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/function/package-info.java new file mode 100644 index 00000000000..e94035ff55f --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/function/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Reactive functional web support classes for OAuth 2.0 and OpenID Connect. + */ +@NullMarked +package org.springframework.security.oauth2.core.web.reactive.function; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/package-info.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/package-info.java new file mode 100644 index 00000000000..9b69c6968cc --- /dev/null +++ b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/web/reactive/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ + +/** + * Reactive web support classes for OAuth 2.0 and OpenID Connect. + */ +@NullMarked +package org.springframework.security.oauth2.core.web.reactive; + +import org.jspecify.annotations.NullMarked; diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthenticationTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthenticationTests.java index d1fef9d1f8b..9ae84282d1d 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthenticationTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/BearerTokenAuthenticationTests.java @@ -76,11 +76,11 @@ public void getNameWhenConfiguredInConstructorThenReturnsName() { } @Test - public void getNameWhenHasNoSubjectThenReturnsNull() { + public void getNameWhenHasNoSubjectThenReturnsEmptyString() { OAuth2AuthenticatedPrincipal principal = new DefaultOAuth2AuthenticatedPrincipal( Collections.singletonMap("claim", "value"), null); BearerTokenAuthentication authenticated = new BearerTokenAuthentication(principal, this.token, null); - assertThat(authenticated.getName()).isNull(); + assertThat(authenticated.getName()).isEmpty(); } @Test