From c3640070d8e56d4156633160bce570f866d52e1d Mon Sep 17 00:00:00 2001 From: Canitas Date: Fri, 10 Jul 2026 21:52:12 -0700 Subject: [PATCH 1/3] feat(auth): add reusable step-up authorization --- CHANGELOG.md | 12 + CONTRIBUTING.md | 4 +- README.md | 415 +----------------- SECURITY.md | 2 + build.gradle.kts | 2 +- docs/README.md | 25 ++ .../0001-step-up-opaque-one-time-proofs.md | 26 ++ docs/api/java-api.md | 31 ++ docs/architecture/system-boundaries.md | 45 ++ docs/development/testing.md | 28 ++ docs/guides/authentication.md | 64 +++ docs/operations/deployment.md | 37 ++ docs/reference/configuration.md | 96 ++++ .../release-policy.md} | 7 +- docs/roadmap.md | 7 +- docs/security/security-model.md | 23 + docs/security/step-up-authorization.md | 93 ++++ docs/usage-guide.md | 253 ----------- .../autoconfigure/VigilAutoConfiguration.java | 33 ++ .../vigil/autoconfigure/VigilProperties.java | 40 +- .../vigil/stepup/CaffeineStepUpStore.java | 74 ++++ .../vigil/stepup/StepUpAuthorization.java | 17 + .../stepup/StepUpAuthorizationProof.java | 8 + .../stepup/StepUpAuthorizationRequest.java | 14 + .../stepup/StepUpAuthorizationService.java | 178 ++++++++ .../vigil/stepup/StepUpChallenge.java | 17 + .../vigil/stepup/StepUpChallengeRequest.java | 32 ++ .../vigil/stepup/StepUpCredential.java | 11 + .../stepup/StepUpCredentialVerifier.java | 10 + .../vigil/stepup/StepUpException.java | 27 ++ .../sequelcore/vigil/stepup/StepUpMethod.java | 10 + .../sequelcore/vigil/stepup/StepUpStore.java | 22 + .../vigil/stepup/pin/PinCredential.java | 37 ++ .../vigil/stepup/pin/PinCredentialRecord.java | 6 + .../vigil/stepup/pin/PinCredentialStore.java | 11 + .../pin/PinStepUpCredentialVerifier.java | 33 ++ .../vigil/stepup/pin/VigilPinService.java | 82 ++++ .../VigilAutoConfigurationTest.java | 1 + .../StepUpAuthorizationServiceTest.java | 148 +++++++ 39 files changed, 1328 insertions(+), 653 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/adr/0001-step-up-opaque-one-time-proofs.md create mode 100644 docs/api/java-api.md create mode 100644 docs/architecture/system-boundaries.md create mode 100644 docs/development/testing.md create mode 100644 docs/guides/authentication.md create mode 100644 docs/operations/deployment.md create mode 100644 docs/reference/configuration.md rename docs/{release.md => releases/release-policy.md} (94%) create mode 100644 docs/security/security-model.md create mode 100644 docs/security/step-up-authorization.md delete mode 100644 docs/usage-guide.md create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/CaffeineStepUpStore.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorization.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationProof.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationRequest.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationService.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallenge.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallengeRequest.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredential.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredentialVerifier.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpException.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpMethod.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/StepUpStore.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredential.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialRecord.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialStore.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/pin/PinStepUpCredentialVerifier.java create mode 100644 src/main/java/io/github/sequelcore/vigil/stepup/pin/VigilPinService.java create mode 100644 src/test/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationServiceTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e1986..7f01bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ include migration notes. ## Unreleased +- Added generic step-up authorization: short-lived, opaque, one-time proofs bound to tenant, + audience, purpose, current actor, authorizing actor, method, and audit ID without mutating the + current session. +- Added extensible credential verifier and shared-state SPIs, plus personal numeric PIN support + with BCrypt hashing, configurable policy, rotation/revocation helpers, lockout reuse, and + single-node Caffeine defaults. +- Added step-up integration, security, migration, and multi-instance deployment documentation. +- Reorganized the public documentation around a canonical index, architecture, API contracts, + security, operations, development verification, and release guidance. +- Consolidated duplicated integration and configuration prose into a concise package README, a + task-focused authentication guide, and one configuration reference. + - Certified Vigil for Spring Boot 4.1.x, Spring Framework 7.x, Spring Security 7.1.x, Java 25, Gradle 9.1.x, and Jackson 3. - Updated build tooling to Spring Boot 4.1.0, Java 25 toolchains, Gradle 9.1.0, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0369b0f..a6d3aac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ Contributions are welcome when they keep Vigil small, explicit, tested, and secu ## Development Requirements -- Java 21. -- Spring Boot 3.5.x compatibility. +- Java 25. +- Spring Boot 4.1.x compatibility. - Use the Gradle wrapper, not a system Gradle requirement. ## Quality Gates diff --git a/README.md b/README.md index ede878a..e981694 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,16 @@ # Vigil -JWT authentication for Spring Boot. Security by default. +JWT authentication infrastructure for Spring Boot applications. [![Maven Central](https://img.shields.io/maven-central/v/io.github.sequelcore/vigil-spring-boot-starter.svg)](https://central.sonatype.com/artifact/io.github.sequelcore/vigil-spring-boot-starter) -Status: Spring Boot 4.1 certification candidate. Version `7.0.0` is the current repository version. Vigil is used by Sequel applications, but public consumers should pin exact versions and review release notes before upgrades. +Vigil provides JWT lifecycle, request authentication, cookie helpers, tenant consistency, revocation, reset tokens, and reusable step-up credential verification. Applications retain ownership of users, login routes, credentials, recovery delivery, and business authorization. ## Compatibility -Vigil `7.0.0` is the Spring Boot 4.1.x / Java 25 certification line. +Vigil `7.0.x` supports Java 25, Spring Boot 4.1.x, Spring Framework 7.x, Spring Security 7.1.x, Gradle 9.1.x, and Jackson 3. Vigil `6.0.x` was the final Java 21 / Spring Boot 3.5 line. -| Component | Supported line | -|-----------|----------------| -| Java | 25 | -| Spring Boot | 4.1.x | -| Spring Framework | 7.x through Spring Boot | -| Spring Security | 7.1.x through Spring Boot | -| Gradle wrapper | 9.1.x | -| JSON support | Jackson 3 (`tools.jackson`) | - -Vigil `7.0.x` is the active supported platform line. Vigil `6.0.x` was the -final Spring Boot 3.5.x / Java 21 line and is not supported for Spring Boot -4.1 consumers. Do not add compatibility shims between the two lines; Boot 4 -moves to Jackson 3 and modular test/client support. - -The only intentional public API break in `7.0.0` is the JSON mapper type used -by `VigilAuthenticationEntryPoint`: Spring Boot 4 defaults to Jackson 3, so the -constructor now accepts `tools.jackson.databind.ObjectMapper` instead of -Jackson 2's `com.fasterxml.jackson.databind.ObjectMapper`. - -## Scope - -Vigil handles token lifecycle, not user lifecycle. - -| Vigil owns | Application owns | -|------------|------------------| -| Access and refresh token generation | User storage and lookup | -| JWT validation and refresh rotation | Credential validation | -| HTTP-only cookie helpers | Login, registration, and account endpoints | -| Bearer token and cookie extraction | User domain model and authorization rules | -| Spring Security authentication filter | `SecurityFilterChain` route policy | -| Token and subject blacklisting | Email, SMS, MFA, and recovery delivery | -| Tenant context validation | Tenant ownership model | -| Guest session token hooks | Guest/session persistence | -| RS256 signing and JWKS endpoint | OAuth/OIDC authorization server duties | - -Vigil is not an OAuth authorization server, OpenID Connect provider, user management system, or hosted identity product. - -## Documentation - -- [Usage guide](docs/usage-guide.md) -- [Release policy](docs/release.md) -- [Roadmap](docs/roadmap.md) -- [Changelog](CHANGELOG.md) -- [Contributing](CONTRIBUTING.md) -- [Security](SECURITY.md) +`7.0.0` is the current published release; `7.1.0-SNAPSHOT` contains the next planned minor release. ## Install @@ -64,370 +20,47 @@ dependencies { } ``` -Vigil expects the application to provide Spring Web and Spring Security. - -## Spring Security Integration - -Vigil auto-configures a `VigilAuthenticationFilter` bean. The application must add it to its Spring Security filter chain and keep authorization rules in application code. - -```java -@Configuration -class SecurityConfig { - - @Bean - SecurityFilterChain securityFilterChain( - HttpSecurity http, - VigilAuthenticationFilter vigilAuthenticationFilter, - VigilAuthenticationEntryPoint vigilAuthenticationEntryPoint, - VigilProperties vigilProperties) throws Exception { - - String[] ignoredPaths = vigilProperties.filter().ignoredPaths().toArray(String[]::new); - String[] publicPaths = vigilProperties.filter().publicPaths().toArray(String[]::new); - - http.csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .exceptionHandling(exceptions -> - exceptions.authenticationEntryPoint(vigilAuthenticationEntryPoint)) - .authorizeHttpRequests(auth -> { - if (ignoredPaths.length > 0) { - auth.requestMatchers(ignoredPaths).permitAll(); - } - if (publicPaths.length > 0) { - auth.requestMatchers(publicPaths).permitAll(); - } - auth.requestMatchers("/.well-known/jwks.json").permitAll() - .anyRequest().authenticated(); - }) - .addFilterBefore( - vigilAuthenticationFilter, - UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } -} -``` - -Place the filter inside Spring Security's filter chain. Spring Security clears `SecurityContextHolder` when the security chain completes; adding Vigil as an unmanaged servlet filter is not the supported integration path. - -`ignored-paths` only means "skip Vigil"; it does not grant Spring Security access by itself. Permit health, actuator, JWKS, or other anonymous routes in `authorizeHttpRequests` when they should be reachable without authentication. - -## Configure - -### HS256 - -Use HS256 only when every service that has the secret is trusted to sign tokens. - -```yaml -vigil: - jwt: - secret: ${JWT_SECRET} # Required for HS256, min 32 characters - issuer: ${JWT_ISSUER} - audience: ${JWT_AUDIENCE} - access-ttl: 15m - refresh-ttl: 7d - - auth: - realm: my-api - - cookie: - secure: true - http-only: true - same-site: Lax - profiles: - default: - access-token-name: access_token - refresh-token-name: refresh_token - - filter: - ignored-paths: - - /actuator/** - - /health - public-paths: - - /auth/** - - /public/** -``` - -### RS256 And JWKS +The application supplies Spring Web, Spring Security, JWT signing configuration, and its `SecurityFilterChain`. -Use RS256 when one service signs tokens and other services only need to verify them. +## Minimal integration ```yaml vigil: jwt: - algorithm: RS256 - rsa-private-key: ${RSA_PRIVATE_KEY} - rsa-public-key: ${RSA_PUBLIC_KEY} - rsa-public-keys: - - ${PREVIOUS_RSA_PUBLIC_KEY} - issuer: my-auth-service + secret: ${JWT_SECRET} + issuer: my-service audience: my-api - access-ttl: 15m - refresh-ttl: 7d - clock-skew: 30s -``` - -When `algorithm: RS256` is active: - -- `/.well-known/jwks.json` is registered automatically. -- The JWKS endpoint exposes the public key only. -- Every signed token includes a deterministic `kid` header. -- Additional `rsa-public-keys` are verification-only keys for rotation. -- The HS256 `secret` property is ignored. - -Key generation: - -```bash -openssl genrsa -out vigil-private.pem 2048 -openssl rsa -in vigil-private.pem -pubout -out vigil-public.pem -``` - -`rsa-private-key` and `rsa-public-key` accept `file:/absolute/path.pem`, `classpath:path.pem`, or inline PEM content from a secrets manager. - -During RS256 rotation, deploy the new private/public key pair as `rsa-private-key` and `rsa-public-key`, and keep previous public keys in `rsa-public-keys` until all tokens signed by the previous private key have expired. - -## Usage - -### Web Clients - -Tokens are stored in HTTP-only cookies. Set `secure: true` in production and use HTTPS. - -```java -@RestController -@RequiredArgsConstructor -class AuthController { - - private final VigilAuthService authService; - private final VigilPasswordService passwordService; - private final UserRepository userRepository; - - @PostMapping("/auth/login") - AuthResult login(@RequestBody LoginRequest request, HttpServletResponse response) { - User user = userRepository.findByEmail(request.email()) - .filter(candidate -> passwordService.matches( - request.password(), - candidate.passwordHash())) - .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); - - return authService.login( - response, - user.email(), - Map.of("userId", user.id(), "roles", user.roles())); - } - - @PostMapping("/auth/refresh") - AuthResult refresh(HttpServletRequest request, HttpServletResponse response) { - return authService.refresh(request, response); - } - - @PostMapping("/auth/logout") - void logout(HttpServletRequest request, HttpServletResponse response) { - authService.logout(request, response); - } -} ``` -### Native Apps And APIs - -Tokens are returned in the response body. Native clients should store them in platform secure storage. - ```java -@PostMapping("/auth/login") -AuthResult login(@RequestBody LoginRequest request) { - User user = validateCredentials(request); - return authService.login(user.email(), Map.of("userId", user.id(), "roles", user.roles())); -} - -@PostMapping("/auth/refresh") -AuthResult refresh(@RequestBody RefreshRequest request) { - return authService.refresh(request.refreshToken()); -} - -@PostMapping("/auth/logout") -void logout(@RequestBody LogoutRequest request) { - authService.logout(request.accessToken(), request.refreshToken()); -} +http.addFilterBefore(vigilAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); ``` -## Filter Behavior +The application must still configure route authorization. `ignored-paths` skips Vigil processing; it does not grant anonymous access. -| Path type | Credentials | Behavior | -|-----------|-------------|----------| -| Ignored | Any | Skip all Vigil processing | -| Public | None | Continue anonymous | -| Public | Valid | Authenticate and continue | -| Public | Invalid | Continue anonymous after hook | -| Protected | None | Leave unauthenticated for Spring Security 401 | -| Protected | Valid | Authenticate and continue | -| Protected | Invalid | Leave unauthenticated for Spring Security 401 | - -`ignored-paths` bypass tenant extraction, token parsing, session lookup, and context populators. `public-paths` still run Vigil so public endpoints can optionally see authenticated users. - -## Token Lifecycle - -Refresh rotation is enabled through `VigilAuthService`. When a refresh token is rotated, the old token is stored with a grace period so retrying the same refresh request can return the same new tokens during network races. - -```yaml -vigil: - blacklist: - max-size: 10000 - ttl: 24h - grace-period: 30s # Clamped to max 60s -``` - -The default blacklist backend is in-memory Caffeine for single-instance deployments. In multi-instance deployments, expose a shared `VigilBlacklistBackend` bean backed by Redis, a database, or another shared store. Vigil auto-configuration wraps that backend with the configured rotation grace period. - -```java -@Bean -VigilBlacklistBackend sharedBlacklistBackend(MyRedisClient redis) { - return new MyRedisBlacklistBackend(redis); -} -``` - -## Multi-Portal Cookies - -```yaml -vigil: - cookie: - profiles: - staff: - access-token-name: staff_access_token - refresh-token-name: staff_refresh_token - customer: - access-token-name: customer_access_token - refresh-token-name: customer_refresh_token - filter: - profile-paths: - staff: - - /api/console/** - customer: - - /api/customer/** -``` - -```java -authService.login(response, staff.email(), "staff", staffClaims); -authService.login(response, customer.email(), "customer", customerClaims); -``` - -## Multi-Tenant Requests - -```yaml -vigil: - tenant: - enabled: true - header-name: X-Tenant-ID -``` - -If a token has a `tenantId` claim and the request has `X-Tenant-ID`, Vigil rejects the authentication when they differ. If only the token has a tenant, Vigil uses the token tenant for the request context. - -```java -UUID tenantId = VigilTenantContext.requireTenant(); -``` - -## Custom Request Context - -```java -@Component -class UserContextPopulator implements VigilContextPopulator { - - @Override - public void populate(HttpServletRequest request, VigilTokenClaims claims) { - UserContext.set( - claims == null ? null : claims.getString("userId").orElse(null), - claims == null ? List.of() : claims.getStringList("roles")); - } - - @Override - public void clear() { - UserContext.clear(); - } -} -``` - -## Passwords And Reset Tokens - -Vigil provides BCrypt hashing, password strength feedback, and single-use reset tokens. The application still owns account lookup, new password validation policy, and email/SMS delivery. - -```java -PasswordStrength strength = passwordService.strength(newPassword); -if (!strength.isAcceptable()) { - throw new ValidationException(strength.feedback().toString()); -} - -String token = resetTokenService.generate(user.email()); -String subject = resetTokenService.validateAndConsume(token); -authService.invalidateAllSessions(subject); -``` - -## Configuration Reference - -```yaml -vigil: - jwt: - secret: ${JWT_SECRET} - algorithm: HS256 - rsa-private-key: ${RSA_PRIVATE_KEY} - rsa-public-key: ${RSA_PUBLIC_KEY} - rsa-public-keys: [] - access-ttl: 15m - refresh-ttl: 7d - issuer: my-auth-service - audience: my-api - clock-skew: 0s - - auth: - realm: my-api - - cookie: - secure: true - http-only: true - same-site: Lax - profiles: - default: - access-token-name: access_token - refresh-token-name: refresh_token - - password: - strength: 12 - - blacklist: - max-size: 10000 - ttl: 24h - grace-period: 30s - - protection: - max-attempts: 5 - lock-duration: 15m - - session: - enabled: false - cookie-name: session_token - ttl: 30m - - tenant: - enabled: false - header-name: X-Tenant-ID +## Documentation - filter: - ignored-paths: [] - public-paths: [] - profile-paths: {} +Start at the [documentation index](docs/README.md). - reset: - ttl: 1h -``` +- [Authentication guide](docs/guides/authentication.md) +- [Configuration reference](docs/reference/configuration.md) +- [System boundaries](docs/architecture/system-boundaries.md) +- [Security model](docs/security/security-model.md) +- [Step-up authorization](docs/security/step-up-authorization.md) +- [Deployment and operations](docs/operations/deployment.md) +- [Java API contract](docs/api/java-api.md) +- [Testing and verification](docs/development/testing.md) +- [Release policy](docs/releases/release-policy.md) -## Verify +## Verification ```bat -gradlew.bat clean check --no-daemon gradlew.bat qualityCheck --no-daemon -gradlew.bat publishToMavenLocal --no-daemon ``` -## Release Policy +## Security and contribution -Publishing is manual through the release workflow. A publish operation requires an exact `v` ref, explicit `publish ` confirmation, scoped release secrets, and the protected `release` environment. Do not publish from local machines. +Read [SECURITY.md](SECURITY.md) for private vulnerability reporting and [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. ## License diff --git a/SECURITY.md b/SECURITY.md index ca0c42b..311f8a8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,6 +30,8 @@ Vigil must: - keep cookie defaults production-safe (`HttpOnly`, `Secure`, and explicit `SameSite`); - keep user lifecycle and authorization decisions in the application; - use an application-provided shared `VigilBlacklistBackend` bean for multi-instance revocation; +- consume step-up proofs atomically and bind them to tenant, audience, purpose, and TTL; +- never log PINs, PIN hashes, or step-up proof values; - keep JWKS public-key exposure separate from private signing keys. ## Disclosure diff --git a/build.gradle.kts b/build.gradle.kts index be3e9fc..ba12332 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "io.github.sequelcore" -version = "7.0.0" +version = "7.1.0-SNAPSHOT" val hasSigningConfiguration = providers.gradleProperty("signingInMemoryKey").isPresent || providers.gradleProperty("signing.secretKeyRingFile").isPresent diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..71f6c4b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,25 @@ +# Vigil documentation + +Vigil is a Spring Boot starter for application-owned JWT authentication. It provides token lifecycle, request authentication, cookie helpers, tenant consistency, revocation, reset tokens, and step-up credential verification. It does not own users, product authorization, or identity-delivery flows. + +## Start here + +- [Authentication guide](guides/authentication.md) — configure JWTs, cookies, Spring Security, tenants, and reset tokens. +- [Configuration reference](reference/configuration.md) — every `vigil.*` property and its defaults. +- [System boundaries](architecture/system-boundaries.md) — understand ownership and extension points before integrating. +- [Java API contract](api/java-api.md) — public services, SPIs, and compatibility expectations. +- [Step-up proof decision](adr/0001-step-up-opaque-one-time-proofs.md) — why approvals are opaque and server-consumed. + +## Security and operations + +- [Security model](security/security-model.md) — security controls, threat assumptions, and secret-handling rules. +- [Step-up authorization](security/step-up-authorization.md) — one-time authorization proofs and personal PIN integration. +- [Deployment and operations](operations/deployment.md) — production configuration, key rotation, and multi-instance requirements. + +## Development and releases + +- [Testing and verification](development/testing.md) — local gates and integration coverage. +- [Release policy](releases/release-policy.md) — compatibility, versioning, and publication controls. +- [Roadmap](roadmap.md) — supported scope and deliberately deferred work. + +The root [README](../README.md) is the concise package landing page. This index is the canonical navigation surface for repository documentation. diff --git a/docs/adr/0001-step-up-opaque-one-time-proofs.md b/docs/adr/0001-step-up-opaque-one-time-proofs.md new file mode 100644 index 0000000..9472365 --- /dev/null +++ b/docs/adr/0001-step-up-opaque-one-time-proofs.md @@ -0,0 +1,26 @@ +# ADR 0001: Use opaque, one-time proofs for step-up authorization + +**Status:** Accepted +**Date:** 2026-07-10 + +## Context + +An application may need a recently verified second actor to approve a sensitive action while preserving the current actor's session. The evidence must be scoped to the application operation, expire quickly, and resist replay across routes and service instances. + +Embedding approval claims in the current actor's access token would mutate or conflate identities. A self-contained signed approval JWT would still require shared replay state to guarantee one-time use and would expose authorization metadata to clients. + +## Decision + +Vigil issues a 256-bit opaque proof after a successful step-up credential verification. It stores only the SHA-256 digest and authorization evidence server-side. The business backend consumes the proof atomically with an exact tenant, audience, and purpose binding. + +The API remains credential-neutral through `StepUpCredentialVerifier`. PIN is the first verifier; applications provide personal PIN storage through `PinCredentialStore`. Vigil does not evaluate the business policy that decides whether an approval is required or sufficient. + +## Consequences + +- A proof cannot be independently validated offline; the consuming backend needs access to the same `StepUpStore`. +- Multi-instance deployments must provide a shared store with atomic consume semantics. +- The current actor's session, cookie, token, and `SecurityContext` remain unchanged. +- The consuming application can record both actors and Vigil's `auditId` with its business decision. +- Future authenticators such as passkeys can reuse the same proof lifecycle without changing product integrations. + +See [step-up authorization](../security/step-up-authorization.md) for integration and security details. diff --git a/docs/api/java-api.md b/docs/api/java-api.md new file mode 100644 index 0000000..0de84c6 --- /dev/null +++ b/docs/api/java-api.md @@ -0,0 +1,31 @@ +# Java API contract + +This guide names Vigil's public integration contracts. It is not generated Javadoc; public type Javadoc remains the source for exact method signatures. + +## Authentication services + +| Type | Consumer responsibility | +| --- | --- | +| `VigilAuthService` | Validate application credentials before issuing, refreshing, or revoking tokens. | +| `VigilTokenService` | Use only when a lower-level token integration is genuinely needed. | +| `VigilResetTokenService` | Deliver the reset token and update the user's password after successful consumption. | +| `VigilPasswordService` | Store its returned hash; own password policy and user persistence. | +| `VigilTenantContext` | Read the validated request tenant. | + +## Step-up authorization + +`StepUpAuthorizationService` implements this sequence: + +1. `createChallenge(StepUpChallengeRequest)` binds intent to a current actor, tenant, audience, purpose, allowed methods, and self-authorization policy. +2. `authorize(challengeId, authorizingActorId, credential)` verifies a credential without changing the current session and returns an opaque proof. +3. `consume(proof, StepUpAuthorizationRequest)` atomically checks the binding and consumes the proof exactly once. + +The returned `StepUpAuthorization` is evidence, not a business decision. See the full [step-up contract](../security/step-up-authorization.md). + +## Configuration + +All configuration uses `vigil.*`. The canonical properties, defaults, and validation notes are in the [configuration reference](../reference/configuration.md). + +## Error handling + +Use Vigil's typed exceptions to map failures at an application HTTP boundary; do not expose their causes or credential data. `VigilAuthException` represents token and reset failures. `StepUpException` represents challenge, credential, lockout, proof, and binding failures. The application owns HTTP status, error-body format, localization, and audit logging. diff --git a/docs/architecture/system-boundaries.md b/docs/architecture/system-boundaries.md new file mode 100644 index 0000000..c6601a3 --- /dev/null +++ b/docs/architecture/system-boundaries.md @@ -0,0 +1,45 @@ +# System boundaries + +## Purpose + +Vigil is authentication infrastructure for Spring Boot applications. It standardizes secure token handling while preserving the application's ownership of users and business authorization. + +## Ownership + +| Vigil owns | Application owns | +| --- | --- | +| JWT generation, validation, signing-key handling, JWKS, refresh rotation, and revocation | User persistence, lookup, registration, and credential enrollment UI | +| Cookie and bearer-token extraction | Login, logout, reset, and recovery routes | +| Request authentication filter and tenant consistency | `SecurityFilterChain` authorization rules and tenant membership | +| Password hashing helpers and reset-token lifecycle | Password policy, recovery delivery, and account recovery UX | +| Step-up challenge/proof lifecycle and credential-verifier SPI | Business approval policy, roles, limits, segregation of duties, and audit decision | + +Vigil is not an OAuth authorization server, OpenID Connect provider, hosted identity platform, user-management system, or business authorization engine. + +## Runtime shape + +```text +Client + -> application route / SecurityFilterChain + -> VigilAuthenticationFilter (token/cookie extraction, validation, tenant consistency) + -> application controller and authorization policy + -> VigilAuthService / VigilResetTokenService / StepUpAuthorizationService + -> application user and business persistence +``` + +Applications add `VigilAuthenticationFilter` inside Spring Security's filter chain. The filter establishes authentication from a validated token; it does not authorize a route. Controllers and services must continue to enforce application permissions. + +## Extension points + +| Extension point | Use it for | Do not use it for | +| --- | --- | --- | +| `VigilBlacklistBackend` | shared revocation and refresh-rotation state | application session or user storage | +| `VigilContextPopulator` | copying validated claims into request context | granting business permissions | +| `VigilSessionProvider` | application-owned guest/session lookup | replacing token validation | +| `StepUpStore` | atomic shared step-up challenge/proof state | storing PINs or users | +| `StepUpCredentialVerifier` | a credential method such as PIN or a future passkey | product-specific approval logic | +| `PinCredentialStore` | tenant-scoped personal PIN hashes | raw PIN storage | + +## Compatibility boundary + +The public types under `io.github.sequelcore.vigil`, `vigil.*` configuration, documented HTTP behavior, and published artifact coordinates are compatibility surfaces. Details and release policy are defined in [the release policy](../releases/release-policy.md). diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 0000000..5041ee0 --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,28 @@ +# Testing and verification + +## Required local gate + +Run from the repository root: + +```bat +gradlew.bat qualityCheck --no-daemon +gradlew.bat build --no-daemon +``` + +`qualityCheck` runs formatting, Checkstyle, unit/integration tests, and the JaCoCo coverage threshold. Run focused tests first while iterating: + +```bat +gradlew.bat test --tests io.github.sequelcore.vigil.stepup.StepUpAuthorizationServiceTest --no-daemon +``` + +## Behavioral coverage + +Changes must prove the behavior at the owning boundary: + +- token parsing and signing: algorithm, claims, issuer, audience, expiration, and rotation; +- filter behavior: protected, anonymous, ignored, invalid-token, and tenant-mismatch paths; +- shared-state behavior: blacklist, reset tokens, sessions, and replay-sensitive state; +- step-up: actor separation, credential failure/lockout, tenant/audience/purpose binding, expiry, and one-time consumption; +- configuration: invalid security settings fail fast at startup. + +Use application integration tests for application-owned routes and user persistence. Vigil tests do not replace product authorization or user-lifecycle tests. diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md new file mode 100644 index 0000000..93febfb --- /dev/null +++ b/docs/guides/authentication.md @@ -0,0 +1,64 @@ +# Authentication guide + +Use this guide to connect Vigil to an application's login routes and Spring Security chain. Before integrating, read the [system boundaries](../architecture/system-boundaries.md): Vigil issues and validates tokens, while the application validates credentials and authorizes its own routes. + +## 1. Configure token signing + +Start with a JWT signing mode and an explicit issuer and audience: + +```yaml +vigil: + jwt: + secret: ${JWT_SECRET} + issuer: my-service + audience: my-api +``` + +Use RS256 when verifiers must not hold signing authority. The complete property set, defaults, and key-source formats are in the [configuration reference](../reference/configuration.md). + +## 2. Install the authentication filter + +Vigil auto-configures `VigilAuthenticationFilter`. Add it inside the application's `SecurityFilterChain` and keep route policy in the application: + +```java +@Bean +SecurityFilterChain securityFilterChain( + HttpSecurity http, VigilAuthenticationFilter vigilAuthenticationFilter) throws Exception { + return http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/auth/**").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(vigilAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); +} +``` + +`ignored-paths` bypasses Vigil entirely. `public-paths` permits an anonymous request while making a valid existing authentication available to the application. Neither setting replaces `authorizeHttpRequests`. + +## 3. Issue tokens after application credential validation + +For browser clients, validate the credential in the application and let Vigil write HTTP-only cookies: + +```java +AuthResult login(LoginRequest request, HttpServletResponse response) { + User user = users.findByEmail(request.email()) + .filter(candidate -> passwords.matches(request.password(), candidate.passwordHash())) + .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); + + return authService.login(response, user.id().toString(), Map.of("tenantId", user.tenantId())); +} +``` + +For native clients and APIs, call `authService.login(subject, claims)` and return the `AuthResult` through the application's own route. Store native tokens in platform secure storage. + +## 4. Select the relevant follow-up guide + +| Need | Canonical document | +| --- | --- | +| Browser cookies, native tokens, refresh, logout, tenants, reset tokens | [Configuration reference](../reference/configuration.md) and public API Javadoc | +| Key rotation, JWKS, clusters, and operational checks | [Deployment and operations](../operations/deployment.md) | +| Security assumptions and incident response | [Security model](../security/security-model.md) | +| Additional approval without changing the current session | [Step-up authorization](../security/step-up-authorization.md) | +| Contract and extension points | [Java API contract](../api/java-api.md) | + +Run the checks in [testing and verification](../development/testing.md) before release. diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md new file mode 100644 index 0000000..0b99179 --- /dev/null +++ b/docs/operations/deployment.md @@ -0,0 +1,37 @@ +# Deployment and operations + +## Production baseline + +- Run the supported Java and Spring Boot line stated in the root README. +- Terminate or pass through TLS so browser cookies can be `Secure`. +- Supply JWT keys from a secret manager or protected runtime environment; never commit them. +- Set a specific JWT issuer and audience when services have a stable trust boundary. +- Use short access-token TTLs and a refresh TTL appropriate to the application risk profile. + +## Signing modes and rotation + +HS256 is suitable only when every service holding the secret is authorized to sign. Use RS256 when verifiers must not hold the private key. RS256 exposes `/.well-known/jwks.json`; during rotation, deploy the new private/public pair and keep previous public keys in `rsa-public-keys` until all tokens signed by them expire. + +## Stateful features in a cluster + +The built-in Caffeine implementations are single-node defaults. + +| Feature | Production cluster requirement | +| --- | --- | +| blacklist and refresh rotation | provide a shared `VigilBlacklistBackend` | +| step-up authorization | provide a shared `StepUpStore` with atomic challenge/proof consumption | +| failed-attempt protection | place a shared rate limiter or equivalent protection in front of the application when node-local lockout is insufficient | + +Do not use eventually consistent state for one-time proof consumption. A proof must be consumed at most once across all nodes. + +## Operational checks + +Before deploying a configuration change: + +1. Validate the Spring context starts with the intended JWT algorithm and keys. +2. Exercise a protected route, an anonymous route, refresh rotation, and logout/revocation. +3. For RS256, fetch and validate the JWKS endpoint from a verifier environment. +4. For tenant-aware routes, test matching and mismatched tenant headers. +5. For step-up, test success, invalid credential, expired proof, binding mismatch, and concurrent replay. + +Run the repository verification commands in [testing and verification](../development/testing.md) before a release. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..94e0d07 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,96 @@ +# Configuration reference + +This is the canonical reference for `vigil.*` properties. Values below reflect `VigilProperties`; omitted optional sections use the stated defaults. + +## Required JWT configuration + +Configure exactly one signing mode. + +```yaml +vigil: + jwt: + algorithm: HS256 # default + secret: ${JWT_SECRET} # required for HS256; at least 32 characters + access-ttl: 15m + refresh-ttl: 7d + issuer: my-service # optional + audience: my-api # optional + clock-skew: 0s # maximum 5m +``` + +For RS256, replace the HS256 secret with the active private/public pair. Additional public keys only verify existing tokens during key rotation. + +```yaml +vigil: + jwt: + algorithm: RS256 + rsa-private-key: ${RSA_PRIVATE_KEY} + rsa-public-key: ${RSA_PUBLIC_KEY} + rsa-public-keys: [] +``` + +`rsa-private-key`, `rsa-public-key`, and `rsa-public-keys` accept inline PEM, `classpath:` or `file:` locations. RS256 automatically registers `/.well-known/jwks.json`. + +## Optional configuration + +```yaml +vigil: + auth: + realm: app + + cookie: + secure: true + http-only: true + same-site: Lax + profiles: + default: + access-token-name: access_token + refresh-token-name: refresh_token + + password: + strength: 12 + + blacklist: + max-size: 10000 + ttl: 24h + grace-period: 30s # maximum 60s + + protection: + max-attempts: 5 + lock-duration: 15m + max-size: 10000 + + tenant: + enabled: false + header-name: X-Tenant-ID + + filter: + ignored-paths: [] + public-paths: [] + profile-paths: {} + + session: + enabled: false + cookie-name: session_token + ttl: 30m + + reset: + ttl: 1h + + step-up: + challenge-ttl: 2m + proof-ttl: 5m + max-size: 10000 + pin: + min-length: 6 + max-length: 12 + bcrypt-strength: 12 + reject-common-patterns: true +``` + +## Validation and deployment notes + +- Invalid JWT signing configuration fails at application startup. +- `secure: true` requires HTTPS for browser clients. +- The built-in blacklist, protection, and step-up stores are node-local. See [deployment and operations](../operations/deployment.md) before running more than one instance. +- Configuration only controls authentication infrastructure. Route authorization, user lifecycle, credential lookup, and business approval policy remain application-owned. diff --git a/docs/release.md b/docs/releases/release-policy.md similarity index 94% rename from docs/release.md rename to docs/releases/release-policy.md index 030be9d..edaa182 100644 --- a/docs/release.md +++ b/docs/releases/release-policy.md @@ -1,8 +1,9 @@ # Release Policy Vigil is published as a Spring Boot starter for JWT authentication. Version -`7.0.0` is the current repository version and Spring Boot 4.1 certification -baseline. +`7.0.0` is the current published release and Spring Boot 4.1 certification +baseline. Version `7.1.0-SNAPSHOT` is the development version for the next +minor release; it must not be published as a final artifact. Vigil is used by Sequel applications, but public consumers should pin exact versions, read release notes, and review migration notes before upgrades. @@ -75,7 +76,7 @@ fixtures, generated reports, examples, or build output. Before a public release: 1. `README.md` documents the current version and supported integration path. -2. `docs/usage-guide.md` reflects current behavior and configuration. +2. The relevant guide reflects current behavior and `docs/reference/configuration.md` reflects every configuration change. 3. `CHANGELOG.md` contains user-visible changes. 4. Public API or configuration changes have migration notes. 5. Architecture changes are documented in the relevant public guide or roadmap. diff --git a/docs/roadmap.md b/docs/roadmap.md index ad9d21a..b299e6b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -22,6 +22,7 @@ current supported scope and future candidates. - Tenant context validation. - Guest session hooks. - BCrypt password helpers and single-use reset tokens. +- Step-up authorization with generic credential verifiers, one-time proofs, and personal PIN support. Vigil does not claim OAuth authorization-server support, OIDC provider support, hosted identity management, user storage, credential validation ownership, MFA @@ -44,6 +45,8 @@ need tests and docs when they change architecture or public contracts. Every completed roadmap slice must update: - `CHANGELOG.md` for user-visible changes; -- `docs/usage-guide.md` when behavior or configuration changes; -- `docs/release.md` when release policy changes; +- `docs/guides/authentication.md` when the application integration path changes; +- `docs/reference/configuration.md` when a `vigil.*` property or default changes; +- `docs/security/step-up-authorization.md` when the step-up boundary changes; +- `docs/releases/release-policy.md` when release policy changes; - the relevant public guide when the architecture boundary changes. diff --git a/docs/security/security-model.md b/docs/security/security-model.md new file mode 100644 index 0000000..5199d95 --- /dev/null +++ b/docs/security/security-model.md @@ -0,0 +1,23 @@ +# Security model + +## Security invariants + +- Validate signed JWTs, configured issuer, and configured audience before trusting claims. +- Keep HS256 secrets at least 256 bits and limit signing authority to trusted services. +- Prefer RS256 and JWKS where multiple services verify tokens but should not sign them. +- Use HTTP-only, Secure cookies over HTTPS for browser flows. +- Keep access tokens short-lived; use bounded clock skew only for known clock drift. +- Treat user storage, credential verification, business authorization, and recovery delivery as application responsibilities. +- Never log raw tokens, keys, PINs, credential hashes, reset tokens, or step-up proof values. + +## Trust assumptions + +Vigil trusts application-supplied credential validation, user lookup, and shared state adapters. A custom `VigilBlacklistBackend` or `StepUpStore` is security-sensitive infrastructure: it must preserve TTLs and perform the documented read/consume operations atomically across application nodes. + +## Step-up controls + +Step-up proofs are opaque, 256-bit random values. Vigil stores a SHA-256 digest, binds the resulting authorization to a tenant, audience, purpose, identities, method, timestamps, and audit ID, and deletes it on successful consumption. Details are in [step-up authorization](step-up-authorization.md). + +## Incident response + +Follow the repository [security policy](../../SECURITY.md) for private vulnerability reporting. For signing-key compromise, rotate signing material, retain only verification keys needed for the maximum token lifetime, revoke affected sessions where the application can identify them, and publish a patched release through the guarded release workflow. diff --git a/docs/security/step-up-authorization.md b/docs/security/step-up-authorization.md new file mode 100644 index 0000000..421a608 --- /dev/null +++ b/docs/security/step-up-authorization.md @@ -0,0 +1,93 @@ +# Step-up authorization + +This is Vigil's canonical guide for the reusable step-up authorization contract. Start with the [system boundaries](../architecture/system-boundaries.md) when deciding whether a responsibility belongs in Vigil or the consuming application. + +Vigil's step-up API verifies a second (or, when the application explicitly allows it, the same) actor in an already-authenticated flow. It never creates, replaces, refreshes, or clears the current actor's session. + +The feature is intentionally generic: Vigil verifies a credential and produces evidence; the consuming application decides which business operation needs that evidence and evaluates its roles, permissions, limits, and separation-of-duties rules. + +## Flow + +1. The application's backend obtains the current actor and tenant from its authenticated request. +2. It calls `createChallenge`, binding an audience and purpose such as `admit-api` and `refund`. +3. The local UI collects the authorizing actor identifier and credential, then submits them once to the application's backend. +4. The backend calls `authorize` and returns the opaque proof to the UI. No Vigil session is altered. +5. The business backend calls `consume` with the expected tenant, audience, and purpose immediately before its state transition. + +`consume` is one-time: successful consumption atomically deletes the proof. The returned `StepUpAuthorization` records the current actor, authorizing actor, method, timestamps, and a correlation-safe `auditId`. + +```java +StepUpChallenge challenge = stepUp.createChallenge(new StepUpChallengeRequest( + currentActor.id(), tenantId, "admit-api", "refund", false, Set.of(StepUpMethod.PIN))); + +try (PinCredential pin = new PinCredential(request.pinCharacters())) { + StepUpAuthorizationProof proof = stepUp.authorize(challenge.id(), request.supervisorId(), pin); + // Return proof.value() only to the local flow; never log or persist it client-side. +} + +StepUpAuthorization approval = stepUp.consume( + request.proof(), new StepUpAuthorizationRequest(tenantId, "admit-api", "refund")); +// Application now evaluates its own supervisor permissions and performs the refund transaction. +``` + +Vigil has no HTTP controller for this flow. Applications own their routes, request DTOs, CSRF posture, business authorization, and audit decision. This prevents POS or product vocabulary from entering the starter. + +## PINs + +Implement `PinCredentialStore` with the application's tenant-scoped user persistence, then register a `PinStepUpCredentialVerifier` bean. `VigilPinService` enrolls, rotates, revokes, validates, and BCrypt-hashes personal numeric PINs; only `PinCredentialRecord.hash` is stored. + +```java +@Bean +PinStepUpCredentialVerifier pinVerifier(PinCredentialStore store, VigilPinService pins) { + return new PinStepUpCredentialVerifier(store, pins); +} +``` + +PIN configuration is deliberately separate from password policy: + +```yaml +vigil: + step-up: + challenge-ttl: 2m + proof-ttl: 5m + max-size: 10000 + pin: + min-length: 6 + max-length: 12 + bcrypt-strength: 12 + reject-common-patterns: true +``` + +Do not deserialize a PIN into an immutable `String` if the application can avoid it. Use a short-lived `char[]`, construct `PinCredential`, and close it as shown. Vigil never logs credentials, proof values, hashes, or verification exceptions. + +## Operations and multi-instance deployments + +The default `CaffeineStepUpStore` is suitable only for a single application instance. A cluster must provide one shared `StepUpStore` whose challenge and proof consumption are atomic across nodes. Store only the SHA-256 digest of the opaque proof, preserve expiry, and retain a used-proof marker through the proof TTL to return `PROOF_ALREADY_USED` deterministically. + +The existing `VigilProtectionService` applies failed-attempt counters and lockout per `{tenant}:step-up:{authorizingActor}`. For clustered brute-force protection, applications should already replace or front this in-memory mechanism with shared rate limiting; otherwise lockouts are node-local. + +## Threat model + +| Threat | Control | +| --- | --- | +| Proof replay | 256-bit opaque proof, server-side digest storage, atomic one-time consumption | +| Cross-service reuse | exact tenant, audience, and purpose binding | +| Stale approval | independent short challenge and proof TTLs | +| PIN guessing | BCrypt, configurable numeric policy, failed-attempt counter and lockout | +| Session substitution | no cookie, JWT, or `SecurityContext` mutation by this API | +| Secret exposure | no PIN/hash/proof logging; close `PinCredential` promptly | +| Distributed races | required shared store with atomic consume for multi-instance deployments | + +The design follows the principle behind audience-restricted, replay-resistant tokens in [RFC 9700](https://www.rfc-editor.org/info/rfc9700/) and NIST's requirement to rate-limit low-entropy activation secrets in [SP 800-63B-4](https://pages.nist.gov/800-63-4/sp800-63b.html). A future credential verifier can support origin-bound public-key credentials such as [WebAuthn](https://www.w3.org/TR/webauthn-3/); WebAuthn is not implemented by Vigil today. + +## Migration + +Replace product endpoints that directly compare a supervisor PIN with this sequence: + +1. Move the personal PIN hash to tenant-scoped application storage behind `PinCredentialStore`. +2. Enroll or rotate it through `VigilPinService`; revoke by writing a revoked record. +3. Replace direct comparison with `createChallenge` and `authorize`. +4. Require `consume` inside the business transaction before the sensitive transition. +5. Record the returned `auditId` and both actor IDs in the application's business audit trail. + +Do not accept a user ID, role, or boolean approval flag in place of a consumed proof, and do not make a proof reusable for a batch of actions. diff --git a/docs/usage-guide.md b/docs/usage-guide.md deleted file mode 100644 index fd796bc..0000000 --- a/docs/usage-guide.md +++ /dev/null @@ -1,253 +0,0 @@ -# Usage Guide - -Vigil is for Spring applications that want explicit JWT authentication without -turning authentication into a user-management framework. It owns token -lifecycle, request authentication, cookies, reset tokens, tenant context, guest -session hooks, and blacklist-backed revocation. - -Vigil does not own user storage, credential validation, registration flows, -authorization policy, OAuth authorization-server endpoints, OIDC provider -behavior, email delivery, SMS delivery, or MFA orchestration. - -## Supported Platform - -Vigil `7.0.x` supports Spring Boot 4.1.x, Spring Framework 7.x, Spring Security -7.1.x, Java 25, Gradle 9.1.x, and Jackson 3. Consumers still own their -`SecurityFilterChain` and product authentication endpoints. - -Vigil `6.0.x` was the final Spring Boot 3.5.x / Java 21 line. Do not mix the -Boot 3.5 line with Vigil `7.0.x`, and do not add compatibility shims between -the two platform lines. Spring Boot 4 moves JSON support to Jackson 3 -`tools.jackson` packages. - -## Choosing The Client Path - -Use cookie-based flows for browser clients that can rely on HTTP-only cookies: - -```java -AuthResult result = authService.login( - response, - user.email(), - Map.of("userId", user.id(), "roles", user.roles())); -``` - -The application validates credentials before calling Vigil. Vigil issues the -tokens, writes cookies through the configured cookie profile, and applies the -configured access and refresh TTLs. - -Use token-body flows for native applications, CLIs, service clients, and APIs -that should receive tokens in the response body: - -```java -AuthResult result = authService.login( - user.email(), - Map.of("userId", user.id(), "roles", user.roles())); -``` - -Native clients should store returned tokens in platform secure storage. Browser -clients should prefer HTTP-only cookies unless the application has a deliberate -reason to expose bearer tokens to JavaScript. - -## Spring Security Wiring - -Vigil auto-configures a `VigilAuthenticationFilter` bean. The application still -owns the `SecurityFilterChain` and must place the filter inside that chain: - -```java -http.addFilterBefore( - vigilAuthenticationFilter, - UsernamePasswordAuthenticationFilter.class); -``` - -`ignored-paths` skip Vigil processing only. They do not grant Spring Security -access by themselves. Permit anonymous routes in `authorizeHttpRequests` when -health, login, registration, public content, or JWKS endpoints should be -reachable without authentication. - -Use `public-paths` for endpoints that may be accessed anonymously but should -still see an authenticated context when valid credentials are present. - -## Signing Mode - -Use HS256 when a single application signs and verifies tokens, and every holder -of the secret is trusted to sign: - -```yaml -vigil: - jwt: - secret: ${JWT_SECRET} - access-ttl: 15m - refresh-ttl: 7d -``` - -The HS256 secret must be at least 32 characters. Configuration fails fast when -the secret is missing or too short. - -Use RS256 when one service signs tokens and other services only verify them: - -```yaml -vigil: - jwt: - algorithm: RS256 - rsa-private-key: ${RSA_PRIVATE_KEY} - rsa-public-key: ${RSA_PUBLIC_KEY} - rsa-public-keys: - - ${PREVIOUS_RSA_PUBLIC_KEY} -``` - -When RS256 is active, Vigil publishes `/.well-known/jwks.json` and signs tokens -with a deterministic `kid` header. `rsa-public-keys` are verification-only keys -for rotation; they do not sign new tokens. - -During key rotation, deploy the new private/public pair as `rsa-private-key` -and `rsa-public-key`, then keep previous public keys in `rsa-public-keys` until -all tokens signed by the previous private key have expired. - -## Token Lifetime And Clock Skew - -Keep access tokens short-lived and refresh tokens longer-lived: - -```yaml -vigil: - jwt: - access-ttl: 15m - refresh-ttl: 7d - clock-skew: 0s -``` - -`clock-skew` defaults to zero and is capped at five minutes. Use it only to -absorb known clock drift between trusted systems. Do not use clock skew to -extend token lifetime. - -## Refresh Rotation And Revocation - -Refresh rotation is handled by `VigilAuthService`. When a refresh token is -rotated, the previous token enters a short grace period so a retried request can -receive the same new token pair: - -```yaml -vigil: - blacklist: - max-size: 10000 - ttl: 24h - grace-period: 30s -``` - -The default backend is Caffeine and is appropriate for single-instance -deployments. Multi-instance deployments should expose a shared -`VigilBlacklistBackend` bean: - -```java -@Bean -VigilBlacklistBackend sharedBlacklistBackend(MyRedisClient redis) { - return new MyRedisBlacklistBackend(redis); -} -``` - -Vigil auto-configuration detects the bean and wraps it with the configured -rotation grace period. The backend must preserve token blacklist entries, -rotation entries, and subject invalidation timestamps across application -instances. - -## Multi-Portal Cookies - -Use cookie profiles when one application serves different client surfaces: - -```yaml -vigil: - cookie: - profiles: - staff: - access-token-name: staff_access_token - refresh-token-name: staff_refresh_token - customer: - access-token-name: customer_access_token - refresh-token-name: customer_refresh_token - filter: - profile-paths: - staff: ["/api/admin/**", "/api/staff/**"] - customer: ["/api/customer/**"] -``` - -The route profile decides which cookie names are used for extraction and -writing. Security settings such as `secure`, `http-only`, and `same-site` are -shared across profiles. - -## Tenant Context - -Enable tenant validation when requests carry a tenant header: - -```yaml -vigil: - tenant: - enabled: true - header-name: X-Tenant-ID -``` - -If both token and request contain a tenant, Vigil rejects authentication when -they differ. If only the token contains a tenant, Vigil uses the token tenant -for request context. - -Application authorization still owns tenant membership and resource ownership. -Vigil validates consistency; it does not decide whether a user may access a -tenant resource. - -## Custom Context - -Implement `VigilContextPopulator` to copy validated claims into application -context: - -```java -@Component -class UserContextPopulator implements VigilContextPopulator { - - @Override - public void populate(HttpServletRequest request, VigilTokenClaims claims) { - UserContext.set( - claims == null ? null : claims.getString("userId").orElse(null), - claims == null ? List.of() : claims.getStringList("roles")); - } - - @Override - public void clear() { - UserContext.clear(); - } -} -``` - -Keep business authorization checks outside the populator. The populator should -copy request context, not grant access. - -## Reset Tokens - -Vigil reset tokens are single-use JWTs. The application still owns account -lookup, email or SMS delivery, password policy, and reset UX. - -```java -String token = resetTokenService.generate(user.email()); -String subject = resetTokenService.validateAndConsume(token); -authService.invalidateAllSessions(subject); -``` - -Consume reset tokens before accepting a new password. After a successful -password change, invalidate existing sessions for the subject. - -## Testing Integrations - -Test authentication at the adapter boundary: - -- login endpoints validate credentials before calling `VigilAuthService`; -- refresh endpoints reject blacklisted or expired refresh tokens; -- logout endpoints blacklist both access and refresh tokens; -- protected routes require Spring Security authentication; -- public routes behave correctly with no credentials, valid credentials, and - invalid credentials; -- multi-tenant routes reject tenant mismatches. - -Run the project gate before release: - -```bash -gradlew.bat clean check --no-daemon -gradlew.bat qualityCheck --no-daemon -gradlew.bat publishToMavenLocal --no-daemon -``` diff --git a/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfiguration.java b/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfiguration.java index b99466a..aa2e253 100644 --- a/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfiguration.java +++ b/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfiguration.java @@ -19,6 +19,11 @@ import io.github.sequelcore.vigil.protection.VigilProtectionService; import io.github.sequelcore.vigil.session.VigilSessionProvider; import io.github.sequelcore.vigil.session.VigilSessionService; +import io.github.sequelcore.vigil.stepup.CaffeineStepUpStore; +import io.github.sequelcore.vigil.stepup.StepUpAuthorizationService; +import io.github.sequelcore.vigil.stepup.StepUpCredentialVerifier; +import io.github.sequelcore.vigil.stepup.StepUpStore; +import io.github.sequelcore.vigil.stepup.pin.VigilPinService; import io.github.sequelcore.vigil.tenant.VigilTenantService; import java.util.ArrayList; import java.util.List; @@ -157,6 +162,34 @@ public VigilProtectionService vigilProtectionService(VigilProperties properties) return new VigilProtectionService(properties.protection()); } + /** Creates the default single-node store for step-up challenges and proofs. */ + @Bean + @ConditionalOnMissingBean + public StepUpStore stepUpStore(VigilProperties properties) { + VigilProperties.StepUp config = properties.stepUp(); + return new CaffeineStepUpStore(config.maxSize(), config.challengeTtl().plus(config.proofTtl())); + } + + /** Creates the PIN policy and BCrypt hashing service. */ + @Bean + @ConditionalOnMissingBean + public VigilPinService vigilPinService(VigilProperties properties) { + return new VigilPinService(properties.stepUp().pin()); + } + + /** + * Creates the generic step-up authorization service. Credential verifiers are application SPIs. + */ + @Bean + @ConditionalOnMissingBean + public StepUpAuthorizationService stepUpAuthorizationService( + VigilProperties properties, + StepUpStore store, + VigilProtectionService protectionService, + List verifiers) { + return new StepUpAuthorizationService(properties.stepUp(), store, protectionService, verifiers); + } + /** * Creates the high-level auth service. * diff --git a/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilProperties.java b/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilProperties.java index 64d7fdb..fe74439 100644 --- a/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilProperties.java +++ b/src/main/java/io/github/sequelcore/vigil/autoconfigure/VigilProperties.java @@ -33,7 +33,8 @@ public record VigilProperties( Filter filter, Session session, Reset reset, - Auth auth) { + Auth auth, + StepUp stepUp) { /** * Applies defaults when configuration sections are omitted. @@ -82,6 +83,9 @@ public record VigilProperties( if (auth == null) { auth = new Auth(null); } + if (stepUp == null) { + stepUp = new StepUp(Duration.ofMinutes(2), Duration.ofMinutes(5), 10000, null); + } } /** @@ -448,4 +452,38 @@ public record Auth(String realm) { } } } + + /** Step-up authorization configuration. */ + public record StepUp(Duration challengeTtl, Duration proofTtl, int maxSize, Pin pin) { + public StepUp { + if (challengeTtl == null || challengeTtl.isNegative() || challengeTtl.isZero()) { + challengeTtl = Duration.ofMinutes(2); + } + if (proofTtl == null || proofTtl.isNegative() || proofTtl.isZero()) { + proofTtl = Duration.ofMinutes(5); + } + if (maxSize <= 0) { + maxSize = 10000; + } + if (pin == null) { + pin = new Pin(6, 12, 12, true); + } + } + + /** Numeric PIN policy. */ + public record Pin( + int minLength, int maxLength, int bcryptStrength, boolean rejectCommonPatterns) { + public Pin { + if (minLength < 4) { + minLength = 6; + } + if (maxLength < minLength || maxLength > 128) { + maxLength = 12; + } + if (bcryptStrength < 4 || bcryptStrength > 31) { + bcryptStrength = 12; + } + } + } + } } diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/CaffeineStepUpStore.java b/src/main/java/io/github/sequelcore/vigil/stepup/CaffeineStepUpStore.java new file mode 100644 index 0000000..8c467b9 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/CaffeineStepUpStore.java @@ -0,0 +1,74 @@ +package io.github.sequelcore.vigil.stepup; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; + +/** Single-node default store. It deliberately stores only a digest of opaque proofs. */ +public final class CaffeineStepUpStore implements StepUpStore { + private final Cache challenges; + private final Cache proofs; + private final Cache usedProofs; + + public CaffeineStepUpStore(long maxSize, Duration maximumTtl) { + challenges = Caffeine.newBuilder().maximumSize(maxSize).expireAfterWrite(maximumTtl).build(); + proofs = Caffeine.newBuilder().maximumSize(maxSize).expireAfterWrite(maximumTtl).build(); + usedProofs = Caffeine.newBuilder().maximumSize(maxSize).expireAfterWrite(maximumTtl).build(); + } + + @Override + public void saveChallenge(StepUpChallenge challenge) { + challenges.put(challenge.id(), challenge); + } + + @Override + public Optional consumeChallenge(UUID challengeId, Instant now) { + StepUpChallenge challenge = challenges.asMap().remove(challengeId); + return challenge == null || !now.isBefore(challenge.expiresAt()) + ? Optional.empty() + : Optional.of(challenge); + } + + @Override + public void saveProof(String proofHash, StepUpAuthorization authorization) { + proofs.put(proofHash, authorization); + } + + @Override + public Optional consumeProof( + String proofHash, Predicate expectedBinding, Instant now) { + AtomicReference consumed = new AtomicReference<>(); + proofs + .asMap() + .computeIfPresent( + proofHash, + (key, authorization) -> { + if (!now.isBefore(authorization.expiresAt())) { + return null; + } + if (!expectedBinding.test(authorization)) { + return authorization; + } + consumed.set(authorization); + usedProofs.put(proofHash, Boolean.TRUE); + return null; + }); + return Optional.ofNullable(consumed.get()); + } + + @Override + public boolean hasProof(String proofHash, Instant now) { + StepUpAuthorization authorization = proofs.getIfPresent(proofHash); + return authorization != null && now.isBefore(authorization.expiresAt()); + } + + @Override + public boolean wasProofUsed(String proofHash) { + return usedProofs.getIfPresent(proofHash) != null; + } +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorization.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorization.java new file mode 100644 index 0000000..f49e891 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorization.java @@ -0,0 +1,17 @@ +package io.github.sequelcore.vigil.stepup; + +import java.time.Instant; +import java.util.UUID; + +/** Verified authorization evidence, returned only after a proof is consumed. */ +public record StepUpAuthorization( + UUID authorizationId, + String currentActorId, + String authorizingActorId, + UUID tenantId, + String audience, + String purpose, + StepUpMethod method, + Instant issuedAt, + Instant expiresAt, + UUID auditId) {} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationProof.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationProof.java new file mode 100644 index 0000000..641a1e4 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationProof.java @@ -0,0 +1,8 @@ +package io.github.sequelcore.vigil.stepup; + +import java.time.Instant; +import java.util.UUID; + +/** Opaque bearer proof. Send it once to the business backend and never persist it in a client. */ +public record StepUpAuthorizationProof( + UUID authorizationId, String value, Instant expiresAt, UUID auditId) {} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationRequest.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationRequest.java new file mode 100644 index 0000000..e01dd3c --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationRequest.java @@ -0,0 +1,14 @@ +package io.github.sequelcore.vigil.stepup; + +import java.util.Objects; +import java.util.UUID; + +/** Expected binding when a business backend consumes a proof. */ +public record StepUpAuthorizationRequest(UUID tenantId, String audience, String purpose) { + public StepUpAuthorizationRequest { + Objects.requireNonNull(tenantId, "tenantId is required"); + if (audience == null || audience.isBlank() || purpose == null || purpose.isBlank()) { + throw new IllegalArgumentException("audience and purpose are required"); + } + } +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationService.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationService.java new file mode 100644 index 0000000..2fcd4c7 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationService.java @@ -0,0 +1,178 @@ +package io.github.sequelcore.vigil.stepup; + +import io.github.sequelcore.vigil.autoconfigure.VigilProperties; +import io.github.sequelcore.vigil.protection.VigilProtectionService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Creates and consumes generic, one-time step-up authorization evidence without altering a session. + */ +public final class StepUpAuthorizationService { + private static final SecureRandom RANDOM = new SecureRandom(); + + private final VigilProperties.StepUp config; + private final StepUpStore store; + private final VigilProtectionService protectionService; + private final Map verifiers; + private final Clock clock; + + public StepUpAuthorizationService( + VigilProperties.StepUp config, + StepUpStore store, + VigilProtectionService protectionService, + java.util.List verifiers) { + this(config, store, protectionService, verifiers, Clock.systemUTC()); + } + + StepUpAuthorizationService( + VigilProperties.StepUp config, + StepUpStore store, + VigilProtectionService protectionService, + java.util.List verifiers, + Clock clock) { + this.config = config; + this.store = store; + this.protectionService = protectionService; + this.clock = clock; + this.verifiers = + verifiers.stream() + .collect( + Collectors.toUnmodifiableMap( + StepUpCredentialVerifier::method, verifier -> verifier)); + } + + /** Creates a short-lived, non-secret challenge bound to the current actor and business intent. */ + public StepUpChallenge createChallenge(StepUpChallengeRequest request) { + Instant issuedAt = Instant.now(clock); + StepUpChallenge challenge = + new StepUpChallenge( + UUID.randomUUID(), + request.currentActorId(), + request.tenantId(), + request.audience(), + request.purpose(), + request.allowSelfAuthorization(), + request.allowedMethods(), + issuedAt, + issuedAt.plus(config.challengeTtl())); + store.saveChallenge(challenge); + return challenge; + } + + /** + * Verifies one credential submission and returns an opaque proof. This method never changes the + * current Spring Security authentication or writes cookies/tokens. + */ + public StepUpAuthorizationProof authorize( + UUID challengeId, String authorizingActorId, StepUpCredential credential) { + Instant now = Instant.now(clock); + StepUpChallenge challenge = + store + .consumeChallenge(challengeId, now) + .orElseThrow( + () -> + new StepUpException( + StepUpException.Code.CHALLENGE_EXPIRED, "Challenge expired")); + try (credential) { + if (!challenge.allowedMethods().contains(credential.method())) { + throw new StepUpException(StepUpException.Code.METHOD_NOT_ALLOWED, "Method is not allowed"); + } + if (!challenge.allowSelfAuthorization() + && challenge.currentActorId().equals(authorizingActorId)) { + throw new StepUpException( + StepUpException.Code.SELF_AUTHORIZATION_NOT_ALLOWED, + "Self-authorization is not allowed"); + } + String protectionKey = challenge.tenantId() + ":step-up:" + authorizingActorId; + if (protectionService.isLocked(protectionKey)) { + throw new StepUpException(StepUpException.Code.ACTOR_LOCKED, "Authorizing actor is locked"); + } + StepUpCredentialVerifier verifier = verifiers.get(credential.method()); + if (verifier == null + || !verifier.verify(challenge.tenantId(), authorizingActorId, credential)) { + protectionService.recordFailedAttempt(protectionKey); + throw new StepUpException(StepUpException.Code.CREDENTIAL_INVALID, "Credential is invalid"); + } + protectionService.recordSuccessfulLogin(protectionKey); + UUID authorizationId = UUID.randomUUID(); + UUID auditId = UUID.randomUUID(); + StepUpAuthorization authorization = + new StepUpAuthorization( + authorizationId, + challenge.currentActorId(), + authorizingActorId, + challenge.tenantId(), + challenge.audience(), + challenge.purpose(), + credential.method(), + now, + now.plus(config.proofTtl()), + auditId); + String value = randomProof(); + store.saveProof(hash(value), authorization); + return new StepUpAuthorizationProof( + authorizationId, value, authorization.expiresAt(), auditId); + } + } + + /** Atomically verifies the expected binding and consumes the proof exactly once. */ + public StepUpAuthorization consume( + String proof, StepUpAuthorizationRequest expectedAuthorization) { + String proofHash = hash(proof); + StepUpAuthorization authorization = + store + .consumeProof( + proofHash, + candidate -> bindingMatches(candidate, expectedAuthorization), + Instant.now(clock)) + .orElse(null); + if (authorization == null) { + if (store.wasProofUsed(proofHash)) { + throw new StepUpException( + StepUpException.Code.PROOF_ALREADY_USED, "Proof has already been used"); + } + if (store.hasProof(proofHash, Instant.now(clock))) { + throw new StepUpException( + StepUpException.Code.BINDING_MISMATCH, "Proof binding does not match"); + } + throw new StepUpException(StepUpException.Code.PROOF_INVALID, "Proof is invalid or expired"); + } + return authorization; + } + + private static boolean bindingMatches( + StepUpAuthorization authorization, StepUpAuthorizationRequest expectedAuthorization) { + return authorization.tenantId().equals(expectedAuthorization.tenantId()) + && authorization.audience().equals(expectedAuthorization.audience()) + && authorization.purpose().equals(expectedAuthorization.purpose()); + } + + private static String randomProof() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String hash(String value) { + if (value == null || value.isBlank()) { + throw new StepUpException(StepUpException.Code.PROOF_INVALID, "Proof is invalid"); + } + try { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString( + MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallenge.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallenge.java new file mode 100644 index 0000000..45b9383 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallenge.java @@ -0,0 +1,17 @@ +package io.github.sequelcore.vigil.stepup; + +import java.time.Instant; +import java.util.Set; +import java.util.UUID; + +/** Public, non-secret handle for a single step-up authorization ceremony. */ +public record StepUpChallenge( + UUID id, + String currentActorId, + UUID tenantId, + String audience, + String purpose, + boolean allowSelfAuthorization, + Set allowedMethods, + Instant issuedAt, + Instant expiresAt) {} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallengeRequest.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallengeRequest.java new file mode 100644 index 0000000..6f9247e --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpChallengeRequest.java @@ -0,0 +1,32 @@ +package io.github.sequelcore.vigil.stepup; + +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** Declares the security context to which a step-up challenge is bound. */ +public record StepUpChallengeRequest( + String currentActorId, + UUID tenantId, + String audience, + String purpose, + boolean allowSelfAuthorization, + Set allowedMethods) { + + public StepUpChallengeRequest { + requireText(currentActorId, "currentActorId"); + Objects.requireNonNull(tenantId, "tenantId is required"); + requireText(audience, "audience"); + requireText(purpose, "purpose"); + allowedMethods = allowedMethods == null ? Set.of() : Set.copyOf(allowedMethods); + if (allowedMethods.isEmpty()) { + throw new IllegalArgumentException("allowedMethods must not be empty"); + } + } + + private static void requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " is required"); + } + } +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredential.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredential.java new file mode 100644 index 0000000..f8208e3 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredential.java @@ -0,0 +1,11 @@ +package io.github.sequelcore.vigil.stepup; + +/** + * A credential submitted during a challenge. Implementations must clear secret material promptly. + */ +public interface StepUpCredential extends AutoCloseable { + StepUpMethod method(); + + @Override + void close(); +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredentialVerifier.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredentialVerifier.java new file mode 100644 index 0000000..e942f0d --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpCredentialVerifier.java @@ -0,0 +1,10 @@ +package io.github.sequelcore.vigil.stepup; + +import java.util.UUID; + +/** Application-extensible verifier for a particular step-up credential method. */ +public interface StepUpCredentialVerifier { + StepUpMethod method(); + + boolean verify(UUID tenantId, String actorId, StepUpCredential credential); +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpException.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpException.java new file mode 100644 index 0000000..2ff7ffd --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpException.java @@ -0,0 +1,27 @@ +package io.github.sequelcore.vigil.stepup; + +/** Stable, secret-free failures from the step-up authorization boundary. */ +public class StepUpException extends RuntimeException { + private final Code code; + + public StepUpException(Code code, String message) { + super(message); + this.code = code; + } + + public Code getCode() { + return code; + } + + public enum Code { + CHALLENGE_EXPIRED, + CREDENTIAL_INVALID, + ACTOR_LOCKED, + METHOD_NOT_ALLOWED, + SELF_AUTHORIZATION_NOT_ALLOWED, + PROOF_INVALID, + PROOF_EXPIRED, + PROOF_ALREADY_USED, + BINDING_MISMATCH + } +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpMethod.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpMethod.java new file mode 100644 index 0000000..826c7d2 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpMethod.java @@ -0,0 +1,10 @@ +package io.github.sequelcore.vigil.stepup; + +/** Authentication method used to approve a short-lived step-up authorization. */ +public enum StepUpMethod { + PIN, + PASSWORD, + PASSKEY, + NFC, + OTP +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/StepUpStore.java b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpStore.java new file mode 100644 index 0000000..63211d6 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/StepUpStore.java @@ -0,0 +1,22 @@ +package io.github.sequelcore.vigil.stepup; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Predicate; + +/** Shared-state SPI. Production clusters must provide an atomic distributed implementation. */ +public interface StepUpStore { + void saveChallenge(StepUpChallenge challenge); + + Optional consumeChallenge(UUID challengeId, Instant now); + + void saveProof(String proofHash, StepUpAuthorization authorization); + + Optional consumeProof( + String proofHash, Predicate expectedBinding, Instant now); + + boolean hasProof(String proofHash, Instant now); + + boolean wasProofUsed(String proofHash); +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredential.java b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredential.java new file mode 100644 index 0000000..bf224e8 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredential.java @@ -0,0 +1,37 @@ +package io.github.sequelcore.vigil.stepup.pin; + +import io.github.sequelcore.vigil.stepup.StepUpCredential; +import io.github.sequelcore.vigil.stepup.StepUpMethod; +import java.util.Arrays; + +/** Numeric PIN credential whose mutable character buffer is wiped after verification. */ +public final class PinCredential implements StepUpCredential { + private char[] value; + + public PinCredential(char[] value) { + if (value == null || value.length == 0) { + throw new IllegalArgumentException("PIN is required"); + } + this.value = value.clone(); + } + + @Override + public StepUpMethod method() { + return StepUpMethod.PIN; + } + + char[] value() { + if (value == null) { + throw new IllegalStateException("PIN has already been cleared"); + } + return value; + } + + @Override + public void close() { + if (value != null) { + Arrays.fill(value, '\0'); + value = null; + } + } +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialRecord.java b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialRecord.java new file mode 100644 index 0000000..d2f4c24 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialRecord.java @@ -0,0 +1,6 @@ +package io.github.sequelcore.vigil.stepup.pin; + +import java.time.Instant; + +/** Stored PIN metadata. The hash must be BCrypt; raw PINs must never be persisted. */ +public record PinCredentialRecord(String hash, Instant rotatedAt, boolean revoked) {} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialStore.java b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialStore.java new file mode 100644 index 0000000..c79d247 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinCredentialStore.java @@ -0,0 +1,11 @@ +package io.github.sequelcore.vigil.stepup.pin; + +import java.util.Optional; +import java.util.UUID; + +/** Application persistence SPI for personal PINs. Vigil intentionally does not own user storage. */ +public interface PinCredentialStore { + Optional find(UUID tenantId, String actorId); + + void save(UUID tenantId, String actorId, PinCredentialRecord credential); +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinStepUpCredentialVerifier.java b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinStepUpCredentialVerifier.java new file mode 100644 index 0000000..33b4bc9 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/pin/PinStepUpCredentialVerifier.java @@ -0,0 +1,33 @@ +package io.github.sequelcore.vigil.stepup.pin; + +import io.github.sequelcore.vigil.stepup.StepUpCredential; +import io.github.sequelcore.vigil.stepup.StepUpCredentialVerifier; +import io.github.sequelcore.vigil.stepup.StepUpMethod; +import java.util.UUID; + +/** PIN verifier backed by application-owned credential storage. */ +public final class PinStepUpCredentialVerifier implements StepUpCredentialVerifier { + private final PinCredentialStore store; + private final VigilPinService pinService; + + public PinStepUpCredentialVerifier(PinCredentialStore store, VigilPinService pinService) { + this.store = store; + this.pinService = pinService; + } + + @Override + public StepUpMethod method() { + return StepUpMethod.PIN; + } + + @Override + public boolean verify(UUID tenantId, String actorId, StepUpCredential credential) { + if (!(credential instanceof PinCredential pin)) { + return false; + } + return store + .find(tenantId, actorId) + .map(record -> pinService.matches(pin, record)) + .orElse(false); + } +} diff --git a/src/main/java/io/github/sequelcore/vigil/stepup/pin/VigilPinService.java b/src/main/java/io/github/sequelcore/vigil/stepup/pin/VigilPinService.java new file mode 100644 index 0000000..bcd5bb7 --- /dev/null +++ b/src/main/java/io/github/sequelcore/vigil/stepup/pin/VigilPinService.java @@ -0,0 +1,82 @@ +package io.github.sequelcore.vigil.stepup.pin; + +import io.github.sequelcore.vigil.autoconfigure.VigilProperties; +import java.time.Clock; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** Hashes, validates, rotates, and revokes personal numeric PINs. */ +public final class VigilPinService { + private static final Pattern NUMERIC = Pattern.compile("[0-9]+"); + + private final VigilProperties.StepUp.Pin config; + private final BCryptPasswordEncoder encoder; + private final Clock clock; + + public VigilPinService(VigilProperties.StepUp.Pin config) { + this(config, Clock.systemUTC()); + } + + VigilPinService(VigilProperties.StepUp.Pin config, Clock clock) { + this.config = config; + this.clock = clock; + this.encoder = new BCryptPasswordEncoder(config.bcryptStrength()); + } + + public void enroll(PinCredentialStore store, UUID tenantId, String actorId, PinCredential pin) { + save(store, tenantId, actorId, pin, false); + } + + public void rotate(PinCredentialStore store, UUID tenantId, String actorId, PinCredential pin) { + save(store, tenantId, actorId, pin, false); + } + + public void revoke(PinCredentialStore store, UUID tenantId, String actorId) { + store.save(tenantId, actorId, new PinCredentialRecord("", Instant.now(clock), true)); + } + + public boolean matches(PinCredential pin, PinCredentialRecord record) { + return record != null + && !record.revoked() + && encoder.matches(new String(pin.value()), record.hash()); + } + + private void save( + PinCredentialStore store, UUID tenantId, String actorId, PinCredential pin, boolean revoked) { + String raw = new String(pin.value()); + validate(raw); + store.save( + tenantId, + actorId, + new PinCredentialRecord(encoder.encode(raw), Instant.now(clock), revoked)); + } + + private void validate(String pin) { + if (!NUMERIC.matcher(pin).matches() + || pin.length() < config.minLength() + || pin.length() > config.maxLength()) { + throw new IllegalArgumentException( + "PIN does not satisfy the configured numeric length policy"); + } + if (config.rejectCommonPatterns() && isCommonPattern(pin)) { + throw new IllegalArgumentException("PIN is too easy to guess"); + } + } + + private boolean isCommonPattern(String pin) { + if (new HashSet(pin.chars().mapToObj(c -> (char) c).toList()).size() == 1) { + return true; + } + boolean ascending = true; + boolean descending = true; + for (int index = 1; index < pin.length(); index++) { + ascending &= pin.charAt(index) == pin.charAt(index - 1) + 1; + descending &= pin.charAt(index) == pin.charAt(index - 1) - 1; + } + return ascending || descending || Set.of("000000", "111111", "123456", "654321").contains(pin); + } +} diff --git a/src/test/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfigurationTest.java b/src/test/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfigurationTest.java index 23bd199..86a4eae 100644 --- a/src/test/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfigurationTest.java +++ b/src/test/java/io/github/sequelcore/vigil/autoconfigure/VigilAutoConfigurationTest.java @@ -137,6 +137,7 @@ private static VigilProperties properties() { null, null, null, + null, null); } diff --git a/src/test/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationServiceTest.java b/src/test/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationServiceTest.java new file mode 100644 index 0000000..343c69c --- /dev/null +++ b/src/test/java/io/github/sequelcore/vigil/stepup/StepUpAuthorizationServiceTest.java @@ -0,0 +1,148 @@ +package io.github.sequelcore.vigil.stepup; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.github.sequelcore.vigil.autoconfigure.VigilProperties; +import io.github.sequelcore.vigil.protection.VigilProtectionService; +import io.github.sequelcore.vigil.stepup.pin.PinCredential; +import io.github.sequelcore.vigil.stepup.pin.PinCredentialRecord; +import io.github.sequelcore.vigil.stepup.pin.PinCredentialStore; +import io.github.sequelcore.vigil.stepup.pin.PinStepUpCredentialVerifier; +import io.github.sequelcore.vigil.stepup.pin.VigilPinService; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StepUpAuthorizationServiceTest { + private static final UUID TENANT = UUID.randomUUID(); + private StepUpAuthorizationService service; + private VigilPinService pins; + private InMemoryPinStore pinStore; + + @BeforeEach + void setUp() { + VigilProperties.StepUp config = + new VigilProperties.StepUp(Duration.ofMinutes(2), Duration.ofMinutes(5), 100, null); + pins = new VigilPinService(config.pin()); + pinStore = new InMemoryPinStore(); + service = + new StepUpAuthorizationService( + config, + new CaffeineStepUpStore(100, Duration.ofMinutes(10)), + new VigilProtectionService( + new VigilProperties.Protection(3, Duration.ofMinutes(1), 100)), + java.util.List.of(new PinStepUpCredentialVerifier(pinStore, pins))); + try (PinCredential pin = new PinCredential("482915".toCharArray())) { + pins.enroll(pinStore, TENANT, "supervisor", pin); + } + } + + @Test + void authorizesASecondActorAndConsumesTheProofOnlyOnce() { + StepUpChallenge challenge = challenge(false); + StepUpAuthorizationProof proof; + try (PinCredential pin = new PinCredential("482915".toCharArray())) { + proof = service.authorize(challenge.id(), "supervisor", pin); + } + + StepUpAuthorization authorization = + service.consume( + proof.value(), new StepUpAuthorizationRequest(TENANT, "admit-api", "refund")); + + assertThat(authorization.currentActorId()).isEqualTo("cashier"); + assertThat(authorization.authorizingActorId()).isEqualTo("supervisor"); + assertThat(authorization.method()).isEqualTo(StepUpMethod.PIN); + assertThatThrownBy( + () -> + service.consume( + proof.value(), new StepUpAuthorizationRequest(TENANT, "admit-api", "refund"))) + .isInstanceOf(StepUpException.class) + .extracting(exception -> ((StepUpException) exception).getCode()) + .isEqualTo(StepUpException.Code.PROOF_ALREADY_USED); + } + + @Test + void rejectsSelfAuthorizationWhenPolicyDisallowsIt() { + StepUpChallenge challenge = challenge(false); + try (PinCredential pin = new PinCredential("482915".toCharArray())) { + assertThatThrownBy(() -> service.authorize(challenge.id(), "cashier", pin)) + .isInstanceOf(StepUpException.class) + .extracting(exception -> ((StepUpException) exception).getCode()) + .isEqualTo(StepUpException.Code.SELF_AUTHORIZATION_NOT_ALLOWED); + } + } + + @Test + void rejectsAWrongPurposeWithoutConsumingTheProof() { + StepUpChallenge challenge = challenge(false); + StepUpAuthorizationProof proof; + try (PinCredential pin = new PinCredential("482915".toCharArray())) { + proof = service.authorize(challenge.id(), "supervisor", pin); + } + + assertThatThrownBy( + () -> + service.consume( + proof.value(), new StepUpAuthorizationRequest(TENANT, "admit-api", "cancel"))) + .isInstanceOf(StepUpException.class) + .extracting(exception -> ((StepUpException) exception).getCode()) + .isEqualTo(StepUpException.Code.BINDING_MISMATCH); + assertThat( + service.consume( + proof.value(), new StepUpAuthorizationRequest(TENANT, "admit-api", "refund"))) + .isNotNull(); + } + + @Test + void locksTheAuthorizingActorAfterRepeatedInvalidPins() { + for (int attempt = 0; attempt < 3; attempt++) { + StepUpChallenge challenge = challenge(false); + try (PinCredential pin = new PinCredential("000000".toCharArray())) { + assertThatThrownBy(() -> service.authorize(challenge.id(), "supervisor", pin)) + .isInstanceOf(StepUpException.class); + } + } + StepUpChallenge challenge = challenge(false); + try (PinCredential pin = new PinCredential("482915".toCharArray())) { + assertThatThrownBy(() -> service.authorize(challenge.id(), "supervisor", pin)) + .isInstanceOf(StepUpException.class) + .extracting(exception -> ((StepUpException) exception).getCode()) + .isEqualTo(StepUpException.Code.ACTOR_LOCKED); + } + } + + private StepUpChallenge challenge(boolean allowSelfAuthorization) { + return service.createChallenge( + new StepUpChallengeRequest( + "cashier", + TENANT, + "admit-api", + "refund", + allowSelfAuthorization, + Set.of(StepUpMethod.PIN))); + } + + private static final class InMemoryPinStore implements PinCredentialStore { + private final Map values = new HashMap<>(); + + @Override + public Optional find(UUID tenantId, String actorId) { + return Optional.ofNullable(values.get(key(tenantId, actorId))); + } + + @Override + public void save(UUID tenantId, String actorId, PinCredentialRecord credential) { + values.put(key(tenantId, actorId), credential); + } + + private String key(UUID tenantId, String actorId) { + return tenantId + ":" + actorId; + } + } +} From 39a77fee32c66b4785002048c8150beb7e80eb37 Mon Sep 17 00:00:00 2001 From: Canitas Date: Fri, 10 Jul 2026 21:52:42 -0700 Subject: [PATCH 2/3] chore: ignore generated Kiln projections --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 0320c23..33dc248 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,9 @@ Thumbs.db *.local .kiln/ +# Kiln-generated native harness projections +.claude/ +AGENTS.md + # Test output test-output/ From 0f188ad94c87ff5a795875ca13d9588fd960d6f6 Mon Sep 17 00:00:00 2001 From: Canitas Date: Fri, 10 Jul 2026 21:55:50 -0700 Subject: [PATCH 3/3] chore(release): prepare 7.1.0 --- CHANGELOG.md | 26 ++++++++++++++------------ README.md | 2 +- build.gradle.kts | 2 +- docs/releases/release-policy.md | 4 +--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f01bf0..b2e13e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,6 @@ include migration notes. ## Unreleased -- Added generic step-up authorization: short-lived, opaque, one-time proofs bound to tenant, - audience, purpose, current actor, authorizing actor, method, and audit ID without mutating the - current session. -- Added extensible credential verifier and shared-state SPIs, plus personal numeric PIN support - with BCrypt hashing, configurable policy, rotation/revocation helpers, lockout reuse, and - single-node Caffeine defaults. -- Added step-up integration, security, migration, and multi-instance deployment documentation. -- Reorganized the public documentation around a canonical index, architecture, API contracts, - security, operations, development verification, and release guidance. -- Consolidated duplicated integration and configuration prose into a concise package README, a - task-focused authentication guide, and one configuration reference. - - Certified Vigil for Spring Boot 4.1.x, Spring Framework 7.x, Spring Security 7.1.x, Java 25, Gradle 9.1.x, and Jackson 3. - Updated build tooling to Spring Boot 4.1.0, Java 25 toolchains, Gradle 9.1.0, @@ -34,6 +22,20 @@ include migration notes. `6.0.x` was the final Spring Boot 3.5 / Java 21 line, not a backward-compatibility target for `7.0.x`. +## 7.1.0 - 2026-07-10 + +- Added generic step-up authorization: short-lived, opaque, one-time proofs bound to tenant, + audience, purpose, current actor, authorizing actor, method, and audit ID without mutating the + current session. +- Added extensible credential verifier and shared-state SPIs, plus personal numeric PIN support + with BCrypt hashing, configurable policy, rotation/revocation helpers, lockout reuse, and + single-node Caffeine defaults. +- Added step-up integration, security, migration, and multi-instance deployment documentation. +- Reorganized the public documentation around a canonical index, architecture, API contracts, + security, operations, development verification, and release guidance. +- Consolidated duplicated integration and configuration prose into a concise package README, a + task-focused authentication guide, and one configuration reference. + ## 6.0.0 - 2026-06-05 - Hardened public release documentation with security, contribution, usage, diff --git a/README.md b/README.md index e981694..c0f716a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Vigil provides JWT lifecycle, request authentication, cookie helpers, tenant con Vigil `7.0.x` supports Java 25, Spring Boot 4.1.x, Spring Framework 7.x, Spring Security 7.1.x, Gradle 9.1.x, and Jackson 3. Vigil `6.0.x` was the final Java 21 / Spring Boot 3.5 line. -`7.0.0` is the current published release; `7.1.0-SNAPSHOT` contains the next planned minor release. +`7.1.0` is the current release line. Public consumers should pin an exact version and review the release notes before upgrading. ## Install diff --git a/build.gradle.kts b/build.gradle.kts index ba12332..9bdf8c6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "io.github.sequelcore" -version = "7.1.0-SNAPSHOT" +version = "7.1.0" val hasSigningConfiguration = providers.gradleProperty("signingInMemoryKey").isPresent || providers.gradleProperty("signing.secretKeyRingFile").isPresent diff --git a/docs/releases/release-policy.md b/docs/releases/release-policy.md index edaa182..c87f98d 100644 --- a/docs/releases/release-policy.md +++ b/docs/releases/release-policy.md @@ -1,9 +1,7 @@ # Release Policy Vigil is published as a Spring Boot starter for JWT authentication. Version -`7.0.0` is the current published release and Spring Boot 4.1 certification -baseline. Version `7.1.0-SNAPSHOT` is the development version for the next -minor release; it must not be published as a final artifact. +`7.1.0` is the current release line and Spring Boot 4.1 certification baseline. Vigil is used by Sequel applications, but public consumers should pin exact versions, read release notes, and review migration notes before upgrades.